Compare commits

..

2 Commits

Author SHA1 Message Date
NewSoupVi
cd46f9acd9 oop 2025-10-25 22:51:14 +02:00
NewSoupVi
a07ff112d0 WebHost: Pin Flask-Compress to 1.18 for all versions of Python 2025-10-25 22:49:15 +02:00
722 changed files with 14655 additions and 90158 deletions

View File

@@ -2,15 +2,11 @@
"include": [
"../BizHawkClient.py",
"../Patch.py",
"../rule_builder/cached_world.py",
"../rule_builder/options.py",
"../rule_builder/rules.py",
"../test/param.py",
"../test/general/test_groups.py",
"../test/general/test_helpers.py",
"../test/general/test_memory.py",
"../test/general/test_names.py",
"../test/general/test_rule_builder.py",
"../test/multiworld/__init__.py",
"../test/multiworld/test_multiworlds.py",
"../test/netutils/__init__.py",

View File

@@ -1,5 +1,4 @@
# This workflow will build a release-like distribution when manually dispatched:
# a Windows x64 7zip, a Windows x64 Installer, a Linux AppImage and a Linux binary .tar.gz.
# This workflow will build a release-like distribution when manually dispatched
name: Build
@@ -24,11 +23,10 @@ env:
ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated.
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation
id-token: 'write'
@@ -51,7 +49,7 @@ jobs:
run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.7.0 --allow-downgrade
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build
run: |
python -m pip install --upgrade pip
@@ -143,9 +141,9 @@ jobs:
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract

View File

@@ -11,7 +11,7 @@ on:
- "!.github/workflows/**"
- ".github/workflows/docker.yml"
branches:
- "main"
- "*"
tags:
- "v?[0-9]+.[0-9]+.[0-9]*"
workflow_dispatch:

View File

@@ -11,11 +11,10 @@ env:
ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated.
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation
id-token: 'write'
@@ -128,9 +127,9 @@ jobs:
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract

View File

@@ -59,7 +59,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r ci-requirements.txt
pip install pytest pytest-subtests pytest-xdist
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
python Launcher.py --update_settings # make sure host.yaml exists for tests
- name: Unittests

3
.gitignore vendored
View File

@@ -63,10 +63,7 @@ Output Logs/
/installdelete.iss
/data/user.kv
/datapackage
/datapackage_export.json
/custom_worlds
# stubgen output
/out/
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build APWorlds" type="PythonConfigurationType" factoryName="Python">
<configuration default="false" name="Build APWorld" type="PythonConfigurationType" factoryName="Python">
<module name="Archipelago" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
@@ -12,8 +12,8 @@
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/Launcher.py" />
<option name="PARAMETERS" value="&quot;Build APWorlds&quot;" />
<option name="SCRIPT_NAME" value="$ContentRoot$/Launcher.py" />
<option name="PARAMETERS" value="\&quot;Build APWorlds\&quot;" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />

View File

@@ -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
from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple,
Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload)
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload)
import dataclasses
from typing_extensions import NotRequired, TypedDict
@@ -22,7 +22,6 @@ import Utils
if TYPE_CHECKING:
from entrance_rando import ERPlacementState
from rule_builder.rules import Rule
from worlds import AutoWorld
@@ -86,7 +85,7 @@ class MultiWorld():
local_items: Dict[int, Options.LocalItems]
non_local_items: Dict[int, Options.NonLocalItems]
progression_balancing: Dict[int, Options.ProgressionBalancing]
completion_condition: Dict[int, CollectionRule]
completion_condition: Dict[int, Callable[[CollectionState], bool]]
indirect_connections: Dict[Region, Set[Entrance]]
exclude_locations: Dict[int, Options.ExcludeLocations]
priority_locations: Dict[int, Options.PriorityLocations]
@@ -767,7 +766,7 @@ class CollectionState():
else:
self._update_reachable_regions_auto_indirect_conditions(player, queue)
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque[Entrance]):
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
# run BFS on all connections, and keep track of those blocked by missing items
@@ -785,16 +784,13 @@ class CollectionState():
blocked_connections.update(new_region.exits)
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
self.multiworld.worlds[player].reached_region(self, new_region)
# Retry connections if the new region can unblock them
entrances = self.multiworld.indirect_connections.get(new_region)
if entrances is not None:
relevant_entrances = entrances.intersection(blocked_connections)
relevant_entrances.difference_update(queue)
queue.extend(relevant_entrances)
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
if new_entrance in blocked_connections and new_entrance not in queue:
queue.append(new_entrance)
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque[Entrance]):
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
new_connection: bool = True
@@ -816,7 +812,6 @@ class CollectionState():
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
new_connection = True
self.multiworld.worlds[player].reached_region(self, new_region)
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
queue.extend(blocked_connections)
@@ -1174,17 +1169,13 @@ class CollectionState():
self.prog_items[player][item] = count
CollectionRule = Callable[[CollectionState], bool]
DEFAULT_COLLECTION_RULE: CollectionRule = staticmethod(lambda state: True)
class EntranceType(IntEnum):
ONE_WAY = 1
TWO_WAY = 2
class Entrance:
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
hide_path: bool = False
player: int
name: str
@@ -1371,7 +1362,7 @@ class Region:
self,
location_name: str,
item_name: str | None = None,
rule: CollectionRule | Rule[Any] | None = None,
rule: Callable[[CollectionState], bool] | None = None,
location_type: type[Location] | None = None,
item_type: type[Item] | None = None,
show_in_spoiler: bool = True,
@@ -1399,7 +1390,7 @@ class Region:
event_location = location_type(self.player, location_name, None, self)
event_location.show_in_spoiler = show_in_spoiler
if rule is not None:
self.multiworld.worlds[self.player].set_rule(event_location, rule)
event_location.access_rule = rule
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
@@ -1410,7 +1401,7 @@ class Region:
return event_item
def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[CollectionRule | Rule[Any]] = None) -> Entrance:
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
"""
Connects this Region to another Region, placing the provided rule on the connection.
@@ -1418,8 +1409,8 @@ class Region:
:param name: name of the connection being created
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
if rule is not None:
self.multiworld.worlds[self.player].set_rule(exit_, rule)
if rule:
exit_.access_rule = rule
exit_.connect(connecting_region)
return exit_
@@ -1444,7 +1435,7 @@ class Region:
return entrance
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
rules: Mapping[str, CollectionRule | Rule[Any]] | None = None) -> List[Entrance]:
rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]:
"""
Connects current region to regions in exit dictionary. Passed region names must exist first.
@@ -1483,7 +1474,7 @@ class Location:
show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
item: Optional[Item] = None
@@ -1560,7 +1551,7 @@ class ItemClassification(IntFlag):
skip_balancing = 0b01000
""" 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) """
@@ -1568,13 +1559,13 @@ class ItemClassification(IntFlag):
deprioritized = 0b10000
""" Should technically never occur on its own.
Will not be considered for priority locations,
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
Should be used for items that would feel bad for the player to find on a priority location.
Usually, these are items that are plentiful or insignificant. """
progression_deprioritized_skip_balancing = 0b11001
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
these items often want both flags. """
progression_skip_balancing = 0b01001 # only progression gets balanced
@@ -1730,10 +1721,9 @@ class Spoiler:
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
location.item.name, location.item.player, location.name, location.player) for location in
sphere_candidates])
if not multiworld.has_beaten_game(state):
raise RuntimeError("During playthrough generation, the game was determined to be unbeatable. "
"Something went terribly wrong here. "
f"Unreachable progression items: {sphere_candidates}")
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]):
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
f'Something went terribly wrong here.')
else:
self.unreachables = sphere_candidates
break

13
CommonClient.py Executable file → Normal file
View File

@@ -24,7 +24,7 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor, mark_raw
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
from Utils import gui_enabled, Version, stream_input, async_start
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
import ssl
@@ -35,6 +35,9 @@ if typing.TYPE_CHECKING:
logger = logging.getLogger("Client")
# without terminal, we have to use gui mode
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
@Utils.cache_argsless
def get_ssl_context():
@@ -62,8 +65,6 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_exit(self) -> bool:
"""Close connections and client"""
if self.ctx.ui:
self.ctx.ui.stop()
self.ctx.exit_event.set()
return True
@@ -322,7 +323,7 @@ class CommonContext:
hint_cost: int | None
"""Current Hint Cost per Hint from the server"""
hint_points: int | None
"""Current available Hint Points from the server"""
"""Current avaliable Hint Points from the server"""
player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)"""
@@ -571,10 +572,6 @@ class CommonContext:
return print_json_packet.get("type", "") == "ItemSend" \
and not self.slot_concerns_self(print_json_packet["receiving"]) \
and not self.slot_concerns_self(print_json_packet["item"].player)
def is_connection_change(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out connection changes."""
return print_json_packet.get("type", "") in ["Join","Part"]
def on_print(self, args: dict):
logger.info(args["text"])

View File

@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
def mystery_argparse(argv: list[str] | None = None):
from settings import get_settings
settings = get_settings()
defaults = settings.generator
@@ -68,7 +68,7 @@ def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando = PlandoOptions.from_option_string(args.plando)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
return args
@@ -119,9 +119,9 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
meta_weights = None
player_id: int = 1
player_files: dict[int, str] = {}
player_errors: list[str] = []
player_id = 1
player_files = {}
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 \
@@ -135,13 +135,9 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
weights_for_file.append(yaml)
weights_cache[fname] = tuple(weights_for_file)
except Exception as e:
logging.exception(f"Exception reading weights in file {fname}")
player_errors.append(
f"{len(player_errors) + 1}. "
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
)
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
# sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
@@ -156,10 +152,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
args.multi = max(player_id - 1, args.multi)
if args.multi == 0:
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
raise ValueError(
"No individual player files found and number of players is 0. "
"Provide individual player files or specify the number of players via host.yaml or --multi."
@@ -169,10 +161,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
f"{seed_name} Seed {seed} with plando: {args.plando}")
if not weights_cache:
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.")
@@ -183,6 +171,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
args.name = {}
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
for fname, yamls in weights_cache.items()}
if meta_weights:
for category_name, category_dict in meta_weights.items():
for key in category_dict:
@@ -197,93 +189,50 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
yaml[category][key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
elif key == "triggers":
if "triggers" not in yaml[category_name]:
yaml[category_name][key] = []
for trigger in option:
yaml[category_name][key].append(trigger)
else:
yaml[category_name][key] = option
settings_cache: dict[str, tuple[argparse.Namespace, ...] | None] = {fname: None for fname in weights_cache}
if args.sameoptions:
for fname, yamls in weights_cache.items():
try:
settings_cache[fname] = tuple(roll_settings(yaml, args.plando) for yaml in yamls)
except Exception as e:
logging.exception(f"Exception reading settings in file {fname}")
player_errors.append(
f"{len(player_errors) + 1}. "
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
)
# Exit early here to avoid throwing the same errors again later
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
player_path_cache: dict[int, str] = {}
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter: Counter[str] = Counter()
name_counter = Counter()
args.player_options = {}
player = 1
while player <= args.multi:
path = player_path_cache[player]
if not path:
player_errors.append(f'No weights specified for player {player}')
player += 1
continue
for doc_index, yaml in enumerate(weights_cache[path]):
name = yaml.get("name")
if path:
try:
# Use the cached settings object if it exists, otherwise roll settings within the try-catch
# Invariant: settings_cache[path] and weights_cache[path] have the same length
cached = settings_cache[path]
settings_object: argparse.Namespace = (cached[doc_index] if cached else roll_settings(yaml, args.plando))
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings:
for k, v in vars(settingsObject).items():
if v is not None:
try:
getattr(args, k)[player] = v
except AttributeError:
setattr(args, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
for k, v in vars(settings_object).items():
if v is not None:
try:
getattr(args, k)[player] = v
except AttributeError:
setattr(args, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
# name was not specified
if player not in args.name:
if path == args.weights_file_path:
# weights file, so we need to make the name unique
args.name[player] = f"Player{player}"
else:
# use the filename
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
# name was not specified
if player not in args.name:
if path == args.weights_file_path:
# weights file, so we need to make the name unique
args.name[player] = f"Player{player}"
else:
# use the filename
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
player += 1
except Exception as e:
logging.exception(f"Exception reading settings in file {path} document #{doc_index + 1} "
f"(name: {args.name.get(player, name)})")
player_errors.append(
f"{len(player_errors) + 1}. "
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
player += 1
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
else:
raise RuntimeError(f'No weights specified for player {player}')
if len(set(name.lower() for name in args.name.values())) != len(args.name):
player_errors.append(
f"{len(player_errors) + 1}. "
f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}"
)
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}")
return args, seed
@@ -362,7 +311,7 @@ class SafeFormatter(string.Formatter):
return kwargs.get(key, "{" + key + "}")
def handle_name(name: str, player: int, name_counter: Counter[str]):
def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
@@ -393,9 +342,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
elif isinstance(new_value, list):
cleaned_value.extend(new_value)
elif isinstance(new_value, dict):
counter_value = Counter(cleaned_value)
counter_value.update(new_value)
cleaned_value = dict(counter_value)
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
else:
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
@@ -409,18 +356,13 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
for element in new_value:
cleaned_value.remove(element)
elif isinstance(new_value, dict):
counter_value = Counter(cleaned_value)
counter_value.subtract(new_value)
cleaned_value = dict(counter_value)
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
else:
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
else:
# Options starting with + and - may modify values in-place, and new_weights may be shared by multiple slots
# using the same .yaml, so ensure that the new value is a copy.
cleaned_value = copy.deepcopy(new_weights[option])
cleaned_weights[option_name] = cleaned_value
cleaned_weights[option_name] = new_weights[option]
new_options = set(cleaned_weights) - set(weights)
weights.update(cleaned_weights)
if new_options:
@@ -443,8 +385,6 @@ def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
if option_key == "triggers":
return category_dict[option_key]
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
@@ -500,7 +440,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
return weights
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type[Options.Option], plando_options: PlandoOptions):
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
try:
if option_key in game_weights:
if not option.supports_weighting:

View File

@@ -31,10 +31,6 @@ import settings
import Utils
from Utils import (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
@@ -79,17 +75,12 @@ def open_patch():
launch([*exe, file], component.cli)
def generate_yamls(*args):
def generate_yamls():
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)
open_folder(target)
def browse_files():
@@ -222,17 +213,12 @@ def launch(exe, in_terminal=False):
def create_shortcut(button: Any, component: Component) -> None:
from pyshortcuts import make_shortcut
env = os.environ
if "APPIMAGE" in env:
script = env["ARGV0"]
wkdir = None # defaults to ~ on Linux
else:
script = sys.argv[0]
wkdir = Utils.local_path()
script = sys.argv[0]
wkdir = Utils.local_path()
script = f"{script} \"{component.display_name}\""
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
startmenu=False, terminal=False, working_dir=wkdir, noexe=Utils.is_frozen())
startmenu=False, terminal=False, working_dir=wkdir)
button.menu.dismiss()
@@ -497,6 +483,7 @@ def main(args: argparse.Namespace | dict | None = None):
if __name__ == '__main__':
init_logging('Launcher')
multiprocessing.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser(

View File

@@ -207,9 +207,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
else:
logger.info("Progression balancing skipped.")
AutoWorld.call_all(multiworld, "finalize_multiworld")
AutoWorld.call_all(multiworld, "pre_output")
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
multiworld.random.passthrough = False
@@ -329,7 +326,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
if current_sphere:
spheres.append(dict(current_sphere))
multidata: NetUtils.MultiData = {
multidata: NetUtils.MultiData | bytes = {
"slot_data": slot_data,
"slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
@@ -353,11 +350,11 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
for key in ("slot_data", "er_hint_data"):
multidata[key] = convert_to_base_types(multidata[key])
serialized_multidata = zlib.compress(restricted_dumps(multidata), 9)
multidata = zlib.compress(restricted_dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([3])) # version of format
f.write(serialized_multidata)
f.write(multidata)
output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result():

View File

@@ -5,16 +5,15 @@ import multiprocessing
import warnings
if sys.platform in ("win32", "darwin") and not (3, 11, 9) <= sys.version_info < (3, 14, 0):
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9):
# Official micro version updates. This should match the number in docs/running from source.md.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. "
"Official 3.11.9 through 3.13.x is supported.")
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13):
# There are known security issues, but no easy way to install fixed versions on Windows for testing.
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
elif not (3, 11, 0) <= sys.version_info < (3, 14, 0):
elif sys.version_info < (3, 11, 0):
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0 through 3.13.x is supported.")
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool(

View File

@@ -21,7 +21,6 @@ import time
import typing
import weakref
import zlib
from signal import SIGINT, SIGTERM, signal
import ModuleUpdate
@@ -70,12 +69,6 @@ def remove_from_list(container, value):
def pop_from_container(container, value):
if isinstance(container, list) and isinstance(value, int) and len(container) <= value:
return container
if isinstance(container, dict) and value not in container:
return container
try:
container.pop(value)
except ValueError:
@@ -497,11 +490,10 @@ class Context:
self.read_data = {}
# there might be a better place to put this.
race_mode = decoded_obj.get("race_mode", 0)
self.read_data["race_mode"] = lambda: race_mode
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
f"however this server is of version {version_tuple}")
self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
@@ -919,6 +911,12 @@ async def server(websocket: "ServerConnection", path: str = "/", ctx: Context =
async def on_client_connected(ctx: Context, client: Client):
players = []
for team, clients in ctx.clients.items():
for slot, connected_clients in clients.items():
if connected_clients:
name = ctx.player_names[team, slot]
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago")
await ctx.send_msgs(client, [{
@@ -1303,13 +1301,6 @@ class CommandMeta(type):
commands.update(base.commands)
commands.update({command_name[5:]: method for command_name, method in attrs.items() if
command_name.startswith("_cmd_")})
for command_name, method in commands.items():
# wrap async def functions so they run on default asyncio loop
if inspect.iscoroutinefunction(method):
def _wrapper(self, *args, _method=method, **kwargs):
return async_start(_method(self, *args, **kwargs))
functools.update_wrapper(_wrapper, method)
commands[command_name] = _wrapper
return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
@@ -1373,10 +1364,7 @@ class CommandProcessor(metaclass=CommandMeta):
argname += "=" + parameter.default
argtext += argname
argtext += " "
method_doc = inspect.getdoc(method)
if method_doc is None:
method_doc = "(missing help text)"
doctext = "\n ".join(method_doc.split("\n"))
doctext = '\n '.join(inspect.getdoc(method).split('\n'))
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
return s
@@ -2572,8 +2560,6 @@ async def console(ctx: Context):
input_text = await queue.get()
queue.task_done()
ctx.commandprocessor(input_text)
except asyncio.exceptions.CancelledError:
ctx.logger.info("ConsoleTask cancelled")
except:
import traceback
traceback.print_exc()
@@ -2740,26 +2726,6 @@ async def main(args: argparse.Namespace):
console_task = asyncio.create_task(console(ctx))
if ctx.auto_shutdown:
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task]))
def stop():
try:
for remove_signal in [SIGINT, SIGTERM]:
asyncio.get_event_loop().remove_signal_handler(remove_signal)
except NotImplementedError:
pass
ctx.commandprocessor._cmd_exit()
def shutdown(signum, frame):
stop()
try:
for sig in [SIGINT, SIGTERM]:
asyncio.get_event_loop().add_signal_handler(sig, stop)
except NotImplementedError:
# add_signal_handler is only implemented for UNIX platforms
for sig in [SIGINT, SIGTERM]:
signal(sig, shutdown)
await ctx.exit_event.wait()
console_task.cancel()
if ctx.shutdown_task:

View File

@@ -24,39 +24,6 @@ if typing.TYPE_CHECKING:
import pathlib
_RANDOM_OPTS = [
"random", "random-low", "random-middle", "random-high",
"random-range-low-<min>-<max>", "random-range-middle-<min>-<max>",
"random-range-high-<min>-<max>", "random-range-<min>-<max>",
]
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
"""
Integer triangular distribution for `lower` inclusive to `end` inclusive.
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
"""
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
# when a != b, so ensure the result is never more than `end`.
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
def random_weighted_range(text: str, range_start: int, range_end: int):
if text == "random-low":
return triangular(range_start, range_end, 0.0)
elif text == "random-high":
return triangular(range_start, range_end, 1.0)
elif text == "random-middle":
return triangular(range_start, range_end)
elif text == "random":
return random.randint(range_start, range_end)
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: {', '.join(_RANDOM_OPTS)}.")
def roll_percentage(percentage: int | float) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
@@ -450,12 +417,10 @@ class Toggle(NumericOption):
def from_text(cls, text: str) -> Toggle:
if text == "random":
return cls(random.choice(list(cls.name_lookup)))
elif text.lower() in {"off", "0", "false", "none", "null", "no", "disabled"}:
elif text.lower() in {"off", "0", "false", "none", "null", "no"}:
return cls(0)
elif text.lower() in {"on", "1", "true", "yes", "enabled"}:
return cls(1)
else:
raise OptionError(f"Option {cls.__name__} does not support a value of {text}")
return cls(1)
@classmethod
def from_any(cls, data: typing.Any):
@@ -558,9 +523,9 @@ class Choice(NumericOption):
class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
value: str | int
value: typing.Union[str, int]
def __init__(self, value: str | int):
def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, int), \
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
self.value = value
@@ -581,7 +546,7 @@ class TextChoice(Choice):
return cls(text)
@classmethod
def get_option_name(cls, value: str | int) -> str:
def get_option_name(cls, value: T) -> str:
if isinstance(value, str):
return value
return super().get_option_name(value)
@@ -748,39 +713,33 @@ class Range(NumericOption):
# these are the conditions where "true" and "false" make sense
if text == "true":
return cls.from_any(cls.default)
# "false"
return cls(0)
try:
num = int(text)
except ValueError:
# text is not a number
# Handle conditionally acceptable values here rather than in the f-string
default = ""
truefalse = ""
if hasattr(cls, "default"):
default = ", default"
if cls.range_start == 0 and cls.default != 0:
truefalse = ", \"true\", \"false\""
raise Exception(f"Invalid range value {text!r}. Acceptable values are: "
f"<int>{default}, high, low{truefalse}, "
f"{', '.join(cls._RANDOM_OPTS)}.")
return cls(num)
else: # "false"
return cls(0)
return cls(int(text))
@classmethod
def weighted_range(cls, text) -> Range:
if text.startswith("random-range-"):
if text == "random-low":
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
elif text == "random-high":
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
elif text == "random-middle":
return cls(cls.triangular(cls.range_start, cls.range_end))
elif text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.range_start, cls.range_end))
else:
return cls(random_weighted_range(text, cls.range_start, cls.range_end))
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
@classmethod
def custom_range(cls, text) -> Range:
textsplit = text.split("-")
try:
random_range = [int(textsplit[-2]), int(textsplit[-1])]
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
random_range.sort()
@@ -788,9 +747,14 @@ class Range(NumericOption):
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if textsplit[2] in ("low", "middle", "high"):
return cls(random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", *random_range))
return cls(random_weighted_range("random", *random_range))
if text.startswith("random-range-low"):
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
elif text.startswith("random-range-middle"):
return cls(cls.triangular(random_range[0], random_range[1]))
elif text.startswith("random-range-high"):
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
else:
return cls(random.randint(random_range[0], random_range[1]))
@classmethod
def from_any(cls, data: typing.Any) -> Range:
@@ -805,6 +769,18 @@ class Range(NumericOption):
def __str__(self) -> str:
return str(self.value)
@staticmethod
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
"""
Integer triangular distribution for `lower` inclusive to `end` inclusive.
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
"""
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
# when a != b, so ensure the result is never more than `end`.
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
class NamedRange(Range):
special_range_names: typing.Dict[str, int] = {}
@@ -894,7 +870,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
def __iter__(self) -> typing.Iterator[typing.Any]:
return self.value.__iter__()
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default = {}
supports_weighting = False
@@ -909,8 +885,7 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
@classmethod
def get_option_name(cls, value):
def get_option_name(self, value):
return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any:
@@ -990,8 +965,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
return cls(data)
return cls.from_text(str(data))
@classmethod
def get_option_name(cls, value):
def get_option_name(self, value):
return ", ".join(map(str, value))
def __contains__(self, item):
@@ -1001,19 +975,13 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
default = frozenset()
supports_weighting = False
random_str: str | None
def __init__(self, value: typing.Iterable[str], random_str: str | None = None):
def __init__(self, value: typing.Iterable[str]):
self.value = set(deepcopy(value))
self.random_str = random_str
super(OptionSet, self).__init__()
@classmethod
def from_text(cls, text: str):
check_text = text.lower().split(",")
if ((cls.valid_keys or cls.verify_item_name or cls.verify_location_name)
and len(check_text) == 1 and check_text[0].startswith("random")):
return cls((), check_text[0])
return cls([option.strip() for option in text.split(",")])
@classmethod
@@ -1022,37 +990,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
return cls(data)
return cls.from_text(str(data))
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
if self.random_str and not self.value:
choice_list = sorted(self.valid_keys)
if self.verify_item_name:
choice_list.extend(sorted(world.item_names))
if self.verify_location_name:
choice_list.extend(sorted(world.location_names))
if self.random_str.startswith("random-range-"):
textsplit = self.random_str.split("-")
try:
random_range = [int(textsplit[-2]), int(textsplit[-1])]
except ValueError:
raise ValueError(f"Invalid random range {self.random_str} for option {self.__class__.__name__} "
f"for player {player_name}")
random_range.sort()
if random_range[0] < 0 or random_range[1] > len(choice_list):
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"0-{len(choice_list)} for option {self.__class__.__name__} for player {player_name}")
if textsplit[2] in ("low", "middle", "high"):
choice_count = random_weighted_range(f"{textsplit[0]}-{textsplit[2]}",
random_range[0], random_range[1])
else:
choice_count = random_weighted_range("random", random_range[0], random_range[1])
else:
choice_count = random_weighted_range(self.random_str, 0, len(choice_list))
self.value = set(random.sample(choice_list, k=choice_count))
super(Option, self).verify(world, player_name, plando_options)
@classmethod
def get_option_name(cls, value):
def get_option_name(self, value):
return ", ".join(sorted(value))
def __contains__(self, item):
@@ -1080,8 +1018,6 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
supports_weighting = False
display_name = "Plando Texts"
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
self.value = list(deepcopy(value))
super().__init__()
@@ -1208,8 +1144,6 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
entrances: typing.ClassVar[typing.AbstractSet[str]]
exits: typing.ClassVar[typing.AbstractSet[str]]
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
duplicate_exits: bool = False
"""Whether or not exits should be allowed to be duplicate."""
@@ -1501,7 +1435,6 @@ class DeathLink(Toggle):
class ItemLinks(OptionList):
"""Share part of your item pool with other players."""
display_name = "Item Links"
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
rich_text_doc = True
default = []
schema = Schema([
@@ -1586,7 +1519,6 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
default = ()
supports_weighting = False
display_name = "Plando Items"
visibility = Visibility.template | Visibility.spoiler
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))
@@ -1697,7 +1629,7 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
def __len__(self) -> int:
return len(self.value)
class Removed(FreeText):
"""This Option has been Removed."""
rich_text_doc = True
@@ -1783,10 +1715,8 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
from Utils import local_path, __version__
full_path: str
preset_folder = os.path.join(target_folder, "Presets")
os.makedirs(target_folder, exist_ok=True)
os.makedirs(preset_folder, exist_ok=True)
# clean out old
for file in os.listdir(target_folder):
@@ -1794,30 +1724,18 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path)
for file in os.listdir(preset_folder):
full_path = os.path.join(preset_folder, file)
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path)
def dictify_range(option: Range, option_val: int | str):
data = {option_val: 50}
for sub_option in ["random", "random-low", "random-high",
f"random-range-{option.range_start}-{option.range_end}"]:
if sub_option != option_val:
def dictify_range(option: Range):
data = {option.default: 50}
for sub_option in ["random", "random-low", "random-high"]:
if sub_option != option.default:
data[sub_option] = 0
notes = {
"random-low": "random value weighted towards lower values",
"random-high": "random value weighted towards higher values",
f"random-range-{option.range_start}-{option.range_end}": f"random value between "
f"{option.range_start} and {option.range_end}"
}
notes = {}
for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}"
if number in data:
data[name] = data[number]
del data[number]
elif name in data:
pass
else:
data[name] = 0
@@ -1833,27 +1751,20 @@ 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:
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)
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,
)
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res)
def dump_player_options(multiworld: MultiWorld) -> None:

View File

@@ -1,708 +0,0 @@
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
ToggleButton, MarkupDropdown, ResizableTextField)
from kivy.clock import Clock
from kivy.uix.behaviors.button import ButtonBehavior
from kivymd.uix.behaviors import RotateBehavior
from kivymd.uix.anchorlayout import MDAnchorLayout
from kivymd.uix.expansionpanel import MDExpansionPanel, MDExpansionPanelContent, MDExpansionPanelHeader
from kivymd.uix.list import MDListItem, MDListItemTrailingIcon, MDListItemSupportingText
from kivymd.uix.slider import MDSlider
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.button import MDButton, MDButtonText, MDIconButton
from kivymd.uix.dialog import MDDialog
from kivy.core.text.markup import MarkupLabel
from kivy.utils import escape_markup
from kivy.lang.builder import Builder
from kivy.properties import ObjectProperty
from textwrap import dedent
from copy import deepcopy
import Utils
import typing
import webbrowser
import re
from urllib.parse import urlparse
from worlds.AutoWorld import AutoWorldRegister, World
from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList,
OptionCounter, Visibility)
def validate_url(x):
try:
result = urlparse(x)
return all([result.scheme, result.netloc])
except AttributeError:
return False
def filter_tooltip(tooltip):
if tooltip is None:
tooltip = "No tooltip available."
tooltip = dedent(tooltip).strip().replace("\n", "<br>").replace("&", "&amp;") \
.replace("[", "&bl;").replace("]", "&br;")
tooltip = re.sub(r"\*\*(.+?)\*\*", r"[b]\g<1>[/b]", tooltip)
tooltip = re.sub(r"\*(.+?)\*", r"[i]\g<1>[/i]", tooltip)
return escape_markup(tooltip)
def option_can_be_randomized(option: typing.Type[Option]):
# most options can be randomized, so we should just check for those that cannot
if not option.supports_weighting:
return False
elif issubclass(option, FreeText) and not issubclass(option, TextChoice):
return False
return True
def check_random(value: typing.Any):
if not isinstance(value, str):
return value # cannot be random if evaluated
if value.startswith("random-"):
return "random"
return value
class TrailingPressedIconButton(ButtonBehavior, RotateBehavior, MDListItemTrailingIcon):
pass
class WorldButton(ToggleButton):
world_cls: typing.Type[World]
class VisualRange(MDBoxLayout):
option: typing.Type[Range]
name: str
tag: MDLabel = ObjectProperty(None)
slider: MDSlider = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[Range], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
def update_points(*update_args):
pass
self.slider._update_points = update_points
class VisualChoice(MDButton):
option: typing.Type[Choice]
name: str
text: MDButtonText = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[Choice], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
class VisualNamedRange(MDBoxLayout):
option: typing.Type[NamedRange]
name: str
range: VisualRange = ObjectProperty(None)
choice: MDButton = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[NamedRange], name: str, range_widget: VisualRange, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
self.range = range_widget
self.add_widget(self.range)
class VisualFreeText(ResizableTextField):
option: typing.Type[FreeText] | typing.Type[TextChoice]
name: str
def __init__(self, *args, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
class VisualTextChoice(MDBoxLayout):
option: typing.Type[TextChoice]
name: str
choice: VisualChoice = ObjectProperty(None)
text: VisualFreeText = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[TextChoice], name: str, choice: VisualChoice,
text: VisualFreeText, **kwargs):
self.option = option
self.name = name
super(MDBoxLayout, self).__init__(*args, **kwargs)
self.choice = choice
self.text = text
self.add_widget(self.choice)
self.add_widget(self.text)
class VisualToggle(MDBoxLayout):
button: MDIconButton = ObjectProperty(None)
option: typing.Type[Toggle]
name: str
def __init__(self, *args, option: typing.Type[Toggle], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
class CounterItemValue(ResizableTextField):
pat = re.compile('[^0-9]')
def insert_text(self, substring, from_undo=False):
return super().insert_text(re.sub(self.pat, "", substring), from_undo=from_undo)
class VisualListSetCounter(MDDialog):
button: MDIconButton = ObjectProperty(None)
option: typing.Type[OptionSet] | typing.Type[OptionList] | typing.Type[OptionCounter]
scrollbox: ScrollBox = ObjectProperty(None)
add: MDIconButton = ObjectProperty(None)
save: MDButton = ObjectProperty(None)
input: ResizableTextField = ObjectProperty(None)
dropdown: MDDropdownMenu
valid_keys: typing.Iterable[str]
def __init__(self, *args, option: typing.Type[OptionSet] | typing.Type[OptionList],
name: str, valid_keys: typing.Iterable[str], **kwargs):
self.option = option
self.name = name
self.valid_keys = valid_keys
super().__init__(*args, **kwargs)
self.dropdown = MarkupDropdown(caller=self.input, border_margin=dp(2),
width=self.input.width, position="bottom")
self.input.bind(text=self.on_text)
self.input.bind(on_text_validate=self.validate_add)
def validate_add(self, instance):
if self.valid_keys:
if self.input.text not in self.valid_keys:
MDSnackbar(MDSnackbarText(text="Item must be a valid key for this option."), y=dp(24),
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
return
if not issubclass(self.option, OptionList):
if any(self.input.text == child.text.text for child in self.scrollbox.layout.children):
MDSnackbar(MDSnackbarText(text="This value is already in the set."), y=dp(24),
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
return
self.add_set_item(self.input.text)
self.input.set_text(self.input, "")
def remove_item(self, button: MDIconButton):
list_item = button.parent
self.scrollbox.layout.remove_widget(list_item)
def add_set_item(self, key: str, value: int | None = None):
text = MDListItemSupportingText(text=key, id="value")
if issubclass(self.option, OptionCounter):
value_txt = CounterItemValue(text=str(value) if value else "1")
item = MDListItem(text,
value_txt,
MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
item.value = value_txt
else:
item = MDListItem(text, MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
item.text = text
self.scrollbox.layout.add_widget(item)
def on_text(self, instance, value):
if not self.valid_keys:
return
if len(value) >= 3:
self.dropdown.items.clear()
def on_press(txt):
split_text = MarkupLabel(text=txt, markup=True).markup
self.input.set_text(self.input, "".join(text_frag for text_frag in split_text
if not text_frag.startswith("[")))
self.input.focus = True
self.dropdown.dismiss()
lowered = value.lower()
for item_name in self.valid_keys:
try:
index = item_name.lower().index(lowered)
except ValueError:
pass # substring not found
else:
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=text: on_press(txt),
"markup": True
})
if not self.dropdown.parent:
self.dropdown.open()
else:
self.dropdown.dismiss()
class OptionsCreator(ThemedApp):
base_title: str = "Archipelago Options Creator"
container: ContainerLayout
main_layout: MainLayout
scrollbox: ScrollBox
main_panel: MainLayout
player_options: MainLayout
option_layout: MainLayout
name_input: ResizableTextField
game_label: MDLabel
current_game: str
options: typing.Dict[str, typing.Any]
def __init__(self):
self.title = self.base_title + " " + Utils.__version__
self.icon = r"data/icon.png"
self.current_game = ""
self.options = {}
super().__init__()
@staticmethod
def show_result_snack(text: str) -> None:
MDSnackbar(MDSnackbarText(text=text), y=dp(24), pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
def on_export_result(self, text: str | None) -> None:
self.container.disabled = False
if text is not None:
Clock.schedule_once(lambda _: self.show_result_snack(text), 0)
def export_options_background(self, options: dict[str, typing.Any]) -> None:
try:
file_name = Utils.save_filename("Export Options File As...", [("YAML", [".yaml"])],
Utils.get_file_safe_name(f"{self.name_input.text}.yaml"))
except Exception:
self.on_export_result("Could not open dialog. Already open?")
raise
if not file_name:
self.on_export_result(None) # No file selected. No need to show a message for this.
return
try:
with open(file_name, 'w') as f:
f.write(Utils.dump(options, sort_keys=False))
f.close()
self.on_export_result("File saved successfully.")
except Exception:
self.on_export_result("Could not save file.")
raise
def export_options(self, button: Widget) -> None:
if 0 < len(self.name_input.text) < 17 and self.current_game:
import threading
options = {
"name": self.name_input.text,
"description": f"YAML generated by Archipelago {Utils.__version__}.",
"game": self.current_game,
self.current_game: {k: check_random(v) for k, v in self.options.items()}
}
threading.Thread(target=self.export_options_background, args=(options,), daemon=True).start()
self.container.disabled = True
elif not self.name_input.text:
self.show_result_snack("Name must not be empty.")
elif not self.current_game:
self.show_result_snack("You must select a game to play.")
else:
self.show_result_snack("Name cannot be longer than 16 characters.")
def create_range(self, option: typing.Type[Range], name: str, bind=True):
def update_text(range_box: VisualRange):
self.options[name] = int(range_box.slider.value)
range_box.tag.text = str(int(range_box.slider.value))
return
box = VisualRange(option=option, name=name)
if bind:
box.slider.bind(value=lambda _, _1: update_text(box))
self.options[name] = option.default
return box
def create_named_range(self, option: typing.Type[NamedRange], name: str):
def set_to_custom(range_box: VisualNamedRange):
range_box.range.tag.text = str(int(range_box.range.slider.value))
if range_box.range.slider.value in option.special_range_names.values():
value = next(key for key, val in option.special_range_names.items()
if val == range_box.range.slider.value)
self.options[name] = value
set_button_text(box.choice, value.title())
else:
self.options[name] = int(range_box.range.slider.value)
set_button_text(range_box.choice, "Custom")
def set_button_text(button: MDButton, text: str):
button.text.text = text
def set_value(text: str, range_box: VisualNamedRange):
range_box.range.slider.value = min(max(option.special_range_names[text.lower()], option.range_start),
option.range_end)
range_box.range.tag.text = str(option.special_range_names[text.lower()])
set_button_text(range_box.choice, text)
self.options[name] = text.lower()
range_box.range.slider.dropdown.dismiss()
def open_dropdown(button):
# for some reason this fixes an issue causing some to not open
box.range.slider.dropdown.open()
box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name, bind=False))
default: int | str = option.default
if default in option.special_range_names:
# value can get mismatched in this case
box.range.slider.value = min(max(option.special_range_names[default], option.range_start),
option.range_end)
box.range.tag.text = str(int(box.range.slider.value))
elif default in option.special_range_names.values():
# better visual
default = next(key for key, val in option.special_range_names.items() if val == option.default)
set_button_text(box.choice, default.title())
box.range.slider.bind(value=lambda _, _2: set_to_custom(box))
items = [
{
"text": choice.title(),
"on_release": lambda text=choice.title(): set_value(text, box)
}
for choice in option.special_range_names
]
box.range.slider.dropdown = MDDropdownMenu(caller=box.choice, items=items)
box.choice.bind(on_release=open_dropdown)
self.options[name] = default
return box
def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str):
text = VisualFreeText(option=option, name=name)
def set_value(instance):
self.options[name] = instance.text
text.bind(on_text_validate=set_value)
return text
def create_choice(self, option: typing.Type[Choice], name: str):
def set_button_text(button: VisualChoice, text: str):
button.text.text = text
def set_value(text, value):
set_button_text(main_button, text)
self.options[name] = value
dropdown.dismiss()
def open_dropdown(button):
# for some reason this fixes an issue causing some to not open
dropdown.open()
default_string = isinstance(option.default, str)
main_button = VisualChoice(option=option, name=name)
main_button.bind(on_release=open_dropdown)
items = [
{
"text": option.get_option_name(choice),
"on_release": lambda val=choice: set_value(option.get_option_name(val), option.name_lookup[val])
}
for choice in option.name_lookup
]
dropdown = MDDropdownMenu(caller=main_button, items=items)
self.options[name] = option.name_lookup[option.default] if not default_string else option.default
return main_button
def create_text_choice(self, option: typing.Type[TextChoice], name: str):
def set_button_text(button: MDButton, text: str):
for child in button.children:
if isinstance(child, MDButtonText):
child.text = text
box = VisualTextChoice(option=option, name=name, choice=self.create_choice(option, name),
text=self.create_free_text(option, name))
def set_value(instance):
set_button_text(box.choice, "Custom")
self.options[name] = instance.text
box.text.bind(on_text_validate=set_value)
return box
def create_toggle(self, option: typing.Type[Toggle], name: str) -> Widget:
def set_value(instance: MDIconButton):
if instance.icon == "checkbox-outline":
instance.icon = "checkbox-blank-outline"
else:
instance.icon = "checkbox-outline"
self.options[name] = bool(not self.options[name])
self.options[name] = bool(option.default)
checkbox = VisualToggle(option=option, name=name)
checkbox.button.bind(on_release=set_value)
return checkbox
def create_popup(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | typing.Type[OptionCounter],
name: str, world: typing.Type[World]):
valid_keys = sorted(option.valid_keys)
if option.verify_item_name:
valid_keys += list(world.item_name_to_id.keys())
if option.convert_name_groups:
valid_keys += list(world.item_name_groups.keys())
if option.verify_location_name:
valid_keys += list(world.location_name_to_id.keys())
if option.convert_name_groups:
valid_keys += list(world.location_name_groups.keys())
if not issubclass(option, OptionCounter):
def apply_changes(button):
self.options[name].clear()
for list_item in dialog.scrollbox.layout.children:
self.options[name].append(getattr(list_item.text, "text"))
dialog.dismiss()
else:
def apply_changes(button):
self.options[name].clear()
for list_item in dialog.scrollbox.layout.children:
self.options[name][getattr(list_item.text, "text")] = int(getattr(list_item.value, "text"))
dialog.dismiss()
dialog = VisualListSetCounter(option=option, name=name, valid_keys=valid_keys)
dialog.ids.container.spacing = dp(30)
dialog.scrollbox.layout.theme_bg_color = "Custom"
dialog.scrollbox.layout.md_bg_color = self.theme_cls.surfaceContainerLowColor
dialog.scrollbox.layout.spacing = dp(5)
dialog.scrollbox.layout.padding = [0, dp(5), 0, 0]
if issubclass(option, OptionCounter):
for value in sorted(self.options[name]):
dialog.add_set_item(value, self.options[name].get(value, None))
else:
for value in sorted(self.options[name]):
dialog.add_set_item(value)
dialog.save.bind(on_release=apply_changes)
dialog.open()
def create_option_set_list_counter(self, option: typing.Type[OptionList] | typing.Type[OptionSet] |
typing.Type[OptionCounter], name: str, world: typing.Type[World]):
main_button = MDButton(MDButtonText(text="Edit"), on_release=lambda x: self.create_popup(option, name, world))
if name not in self.options:
# convert from non-mutable to mutable
# We use list syntax even for sets, set behavior is enforced through GUI
if issubclass(option, OptionCounter):
self.options[name] = deepcopy(option.default)
else:
self.options[name] = sorted(option.default)
return main_button
def create_option(self, option: typing.Type[Option], name: str, world: typing.Type[World]) -> Widget:
option_base = MDBoxLayout(orientation="vertical", size_hint_y=None, padding=[0, 0, dp(5), dp(5)])
tooltip = filter_tooltip(option.__doc__)
option_label = TooltipLabel(text=f"[ref=0|{tooltip}]{getattr(option, 'display_name', name)}")
label_box = MDBoxLayout(orientation="horizontal")
label_anchor = MDAnchorLayout(anchor_x="right", anchor_y="center")
label_anchor.add_widget(option_label)
label_box.add_widget(label_anchor)
option_base.add_widget(label_box)
if issubclass(option, NamedRange):
option_base.add_widget(self.create_named_range(option, name))
elif issubclass(option, Range):
option_base.add_widget(self.create_range(option, name))
elif issubclass(option, Toggle):
option_base.add_widget(self.create_toggle(option, name))
elif issubclass(option, TextChoice):
option_base.add_widget(self.create_text_choice(option, name))
elif issubclass(option, Choice):
option_base.add_widget(self.create_choice(option, name))
elif issubclass(option, FreeText):
option_base.add_widget(self.create_free_text(option, name))
elif any(issubclass(option, cls) for cls in (OptionSet, OptionList, OptionCounter)):
option_base.add_widget(self.create_option_set_list_counter(option, name, world))
else:
option_base.add_widget(MDLabel(text="This option isn't supported by the option creator.\n"
"Please edit your yaml manually to set this option."))
if option_can_be_randomized(option):
def randomize_option(instance: Widget, value: str):
value = value == "down"
if value:
self.options[name] = "random-" + str(self.options[name])
else:
self.options[name] = self.options[name].replace("random-", "")
if self.options[name].isnumeric():
self.options[name] = int(self.options[name])
elif self.options[name] in ("True", "False"):
self.options[name] = self.options[name] == "True"
base_object = instance.parent.parent
label_object = instance.parent
for child in base_object.children:
if child is not label_object:
child.disabled = value
default_random = option.default == "random"
random_toggle = ToggleButton(MDButtonText(text="Random?"), size_hint_x=None, width=dp(100),
state="down" if default_random else "normal")
random_toggle.bind(state=randomize_option)
label_box.add_widget(random_toggle)
if default_random:
randomize_option(random_toggle, "down")
return option_base
def create_options_panel(self, world_button: WorldButton):
self.option_layout.clear_widgets()
self.options.clear()
cls: typing.Type[World] = world_button.world_cls
self.current_game = cls.game
if not cls.web.options_page:
self.current_game = "None"
return
elif isinstance(cls.web.options_page, str):
self.current_game = "None"
if validate_url(cls.web.options_page):
webbrowser.open(cls.web.options_page)
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
world_button.state = "normal"
else:
# attach onto archipelago.gg and see if we pass
new_url = "https://archipelago.gg/" + cls.web.options_page
if validate_url(new_url):
webbrowser.open(new_url)
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24),
pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
else:
MDSnackbar(MDSnackbarText(text="Invalid options page, please report to world developer."), y=dp(24),
pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
world_button.state = "normal"
# else just fall through
else:
expansion_box = ScrollBox()
expansion_box.layout.orientation = "vertical"
expansion_box.layout.spacing = dp(3)
expansion_box.scroll_type = ["bars"]
expansion_box.do_scroll_x = False
group_names = ["Game Options", *(group.name for group in cls.web.option_groups)]
groups = {name: [] for name in group_names}
for name, option in cls.options_dataclass.type_hints.items():
group = next((group.name for group in cls.web.option_groups if option in group.options), "Game Options")
groups[group].append((name, option))
for group, options in groups.items():
options = [(name, option) for name, option in options
if name and option.visibility & Visibility.simple_ui]
if not options:
continue # Game Options can be empty if every other option is in another group
# Can also have an option group of options that should not render on simple ui
group_item = MDExpansionPanel(size_hint_y=None)
group_header = MDExpansionPanelHeader(MDListItem(MDListItemSupportingText(text=group),
TrailingPressedIconButton(icon="chevron-right",
on_release=lambda x,
item=group_item:
self.tap_expansion_chevron(
item, x)),
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
theme_bg_color="Custom",
on_release=lambda x, item=group_item:
self.tap_expansion_chevron(item, x)))
group_content = MDExpansionPanelContent(orientation="vertical", theme_bg_color="Custom",
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
padding=[dp(12), dp(100), dp(12), 0],
spacing=dp(3))
group_item.add_widget(group_header)
group_item.add_widget(group_content)
group_box = ScrollBox()
group_box.layout.orientation = "vertical"
group_box.layout.spacing = dp(3)
for name, option in options:
group_content.add_widget(self.create_option(option, name, cls))
expansion_box.layout.add_widget(group_item)
self.option_layout.add_widget(expansion_box)
self.game_label.text = f"Game: {self.current_game}"
@staticmethod
def tap_expansion_chevron(panel: MDExpansionPanel, chevron: TrailingPressedIconButton | MDListItem):
if isinstance(chevron, MDListItem):
chevron = next((child for child in chevron.ids.trailing_container.children
if isinstance(child, TrailingPressedIconButton)), None)
panel.open() if not panel.is_open else panel.close()
if chevron:
panel.set_chevron_down(
chevron
) if not panel.is_open else panel.set_chevron_up(chevron)
def build(self):
self.set_colors()
self.options = {}
self.container = Builder.load_file(Utils.local_path("data/optionscreator.kv"))
self.root = self.container
self.main_layout = self.container.ids.main
self.scrollbox = self.container.ids.scrollbox
def world_button_action(world_btn: WorldButton):
if self.current_game != world_btn.world_cls.game:
old_button = next((button for button in self.scrollbox.layout.children
if button.world_cls.game == self.current_game), None)
if old_button:
old_button.state = "normal"
else:
world_btn.state = "down"
self.create_options_panel(world_btn)
for world, cls in sorted(AutoWorldRegister.world_types.items(), key=lambda x: x[0]):
if cls.hidden:
continue
world_text = MDButtonText(text=world, size_hint_y=None, width=dp(150),
pos_hint={"x": 0.03, "center_y": 0.5})
world_text.text_size = (world_text.width, None)
world_text.bind(width=lambda *x, text=world_text: text.setter('text_size')(text, (text.width, None)),
texture_size=lambda *x, text=world_text: text.setter("height")(text,
world_text.texture_size[1]))
world_button = WorldButton(world_text, size_hint_x=None, width=dp(150), theme_width="Custom",
radius=(dp(5), dp(5), dp(5), dp(5)))
world_button.bind(on_release=world_button_action)
world_button.world_cls = cls
self.scrollbox.layout.add_widget(world_button)
self.main_panel = self.container.ids.player_layout
self.player_options = self.container.ids.player_options
self.game_label = self.container.ids.game
self.name_input = self.container.ids.player_name
self.option_layout = self.container.ids.options
def set_height(instance, value):
instance.height = value[1]
self.game_label.bind(texture_size=set_height)
# 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
# from kivy.core.window import Window
# create_console(Window, self.container)
return self.container
def launch():
OptionsCreator().run()
if __name__ == "__main__":
Utils.init_logging("OptionsCreator")
launch()

View File

@@ -82,9 +82,6 @@ Currently, the following games are supported:
* Paint
* Celeste (Open World)
* Choo-Choo Charles
* APQuest
* Satisfactory
* EarthBound
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

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import os
import sys
import time
import asyncio
import typing
import bsdiff4
@@ -16,9 +15,6 @@ from CommonClient import CommonContext, server_loop, \
gui_enabled, ClientCommandProcessor, logger, get_base_parser
from Utils import async_start
# Heartbeat for position sharing via bounces, in seconds
UNDERTALE_STATUS_INTERVAL = 30.0
UNDERTALE_ONLINE_TIMEOUT = 60.0
class UndertaleCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx):
@@ -113,11 +109,6 @@ class UndertaleContext(CommonContext):
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
# self.save_game_folder: files go in this path to pass data between us and the actual game
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
self.last_sent_position: typing.Optional[tuple] = None
self.last_room: typing.Optional[str] = None
self.last_status_write: float = 0.0
self.other_undertale_status: dict[int, dict] = {}
def patch_game(self):
with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
@@ -228,9 +219,6 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral",
str(ctx.slot)+" RoutesDone pacifist",
str(ctx.slot)+" RoutesDone genocide"]}])
if any(info.game == "Undertale" and slot != ctx.slot
for slot, info in ctx.slot_info.items()):
ctx.set_notify("undertale_room_status")
if args["slot_data"]["only_flakes"]:
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
f.close()
@@ -275,12 +263,6 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]:
if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None:
ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"]
if "undertale_room_status" in args["keys"] and args["keys"]["undertale_room_status"]:
status = args["keys"]["undertale_room_status"]
ctx.other_undertale_status = {
int(key): val for key, val in status.items()
if int(key) != ctx.slot
}
elif cmd == "SetReply":
if args["value"] is not None:
if str(ctx.slot)+" RoutesDone pacifist" == args["key"]:
@@ -289,19 +271,17 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
ctx.completed_routes["genocide"] = args["value"]
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
ctx.completed_routes["neutral"] = args["value"]
if args.get("key") == "undertale_room_status" and args.get("value"):
ctx.other_undertale_status = {
int(key): val for key, val in args["value"].items()
if int(key) != ctx.slot
}
elif cmd == "ReceivedItems":
start_index = args["index"]
if start_index == 0:
ctx.items_received = []
elif start_index != len(ctx.items_received):
await ctx.check_locations(ctx.locations_checked)
await ctx.send_msgs([{"cmd": "Sync"}])
sync_msg = [{"cmd": "Sync"}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks",
"locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
if start_index == len(ctx.items_received):
counter = -1
placedWeapon = 0
@@ -388,8 +368,9 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
f.close()
elif cmd == "Bounced":
data = args.get("data", {})
if "x" in data and "room" in data:
tags = args.get("tags", [])
if "Online" in tags:
data = args.get("data", {})
if data["player"] != ctx.slot and data["player"] is not None:
filename = f"FRISK" + str(data["player"]) + ".playerspot"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
@@ -400,63 +381,21 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
async def multi_watcher(ctx: UndertaleContext):
while not ctx.exit_event.is_set():
if "Online" in ctx.tags and any(
info.game == "Undertale" and slot != ctx.slot
for slot, info in ctx.slot_info.items()):
now = time.time()
path = ctx.save_game_folder
for root, dirs, files in os.walk(path):
for file in files:
if "spots.mine" in file:
with open(os.path.join(root, file), "r") as mine:
this_x = mine.readline()
this_y = mine.readline()
this_room = mine.readline()
this_sprite = mine.readline()
this_frame = mine.readline()
if this_room != ctx.last_room or \
now - ctx.last_status_write >= UNDERTALE_STATUS_INTERVAL:
ctx.last_room = this_room
ctx.last_status_write = now
await ctx.send_msgs([{
"cmd": "Set",
"key": "undertale_room_status",
"default": {},
"want_reply": False,
"operations": [{"operation": "update",
"value": {str(ctx.slot): {"room": this_room,
"time": now}}}]
}])
# If player was visible but timed out (heartbeat) or left the room, remove them.
for slot, entry in ctx.other_undertale_status.items():
if entry.get("room") != this_room or \
now - entry.get("time", now) > UNDERTALE_ONLINE_TIMEOUT:
playerspot = os.path.join(ctx.save_game_folder,
f"FRISK{slot}.playerspot")
if os.path.exists(playerspot):
os.remove(playerspot)
current_position = (this_x, this_y, this_room, this_sprite, this_frame)
if current_position == ctx.last_sent_position:
continue
# Empty status dict = no data yet → send to bootstrap.
online_in_room = any(
entry.get("room") == this_room and
now - entry.get("time", now) <= UNDERTALE_ONLINE_TIMEOUT
for entry in ctx.other_undertale_status.values()
)
if ctx.other_undertale_status and not online_in_room:
continue
message = [{"cmd": "Bounce", "games": ["Undertale"],
"data": {"player": ctx.slot, "x": this_x, "y": this_y,
"room": this_room, "spr": this_sprite,
"frm": this_frame}}]
await ctx.send_msgs(message)
ctx.last_sent_position = current_position
path = ctx.save_game_folder
for root, dirs, files in os.walk(path):
for file in files:
if "spots.mine" in file and "Online" in ctx.tags:
with open(os.path.join(root, file), "r") as mine:
this_x = mine.readline()
this_y = mine.readline()
this_room = mine.readline()
this_sprite = mine.readline()
this_frame = mine.readline()
mine.close()
message = [{"cmd": "Bounce", "tags": ["Online"],
"data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room,
"spr": this_sprite, "frm": this_frame}}]
await ctx.send_msgs(message)
await asyncio.sleep(0.1)
@@ -470,9 +409,10 @@ async def game_watcher(ctx: UndertaleContext):
for file in files:
if ".item" in file:
os.remove(os.path.join(root, file))
await ctx.check_locations(ctx.locations_checked)
await ctx.send_msgs([{"cmd": "Sync"}])
sync_msg = [{"cmd": "Sync"}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False
if ctx.got_deathlink:
ctx.got_deathlink = False
@@ -507,7 +447,7 @@ async def game_watcher(ctx: UndertaleContext):
for l in lines:
sending = sending+[(int(l.rstrip('\n')))+12000]
finally:
await ctx.check_locations(sending)
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}])
if "victory" in file and str(ctx.route) in file:
victory = True
if ".playerspot" in file and "Online" not in ctx.tags:

220
Utils.py
View File

@@ -22,8 +22,6 @@ from settings import Settings, get_settings
from time import sleep
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
from yaml import load, load_all, dump
from pathspec import PathSpec, GitIgnoreSpec
from typing_extensions import deprecated
try:
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
@@ -50,7 +48,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.6.7"
__version__ = "0.6.4"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -316,9 +314,12 @@ def get_public_ipv6() -> str:
return ip
@deprecated("Utils.get_options() is deprecated. Use the settings API instead.")
OptionsType = Settings # TODO: remove when removing get_options
def get_options() -> Settings:
deprecate("Utils.get_options() is deprecated. Use the settings API instead.")
# TODO: switch to Utils.deprecate after 0.4.4
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
return get_settings()
@@ -390,14 +391,6 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
logging.debug(f"Could not store data package: {e}")
def read_apignore(filename: str | pathlib.Path) -> PathSpec | None:
try:
with open(filename) as ignore_file:
return GitIgnoreSpec.from_lines(ignore_file)
except FileNotFoundError:
return None
def get_default_adjuster_settings(game_name: str) -> Namespace:
import LttPAdjuster
adjuster_settings = Namespace()
@@ -762,11 +755,6 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(open_filename(*args))
def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
res.put(save_filename(*args))
def _run_for_stdout(*args: str):
env = os.environ
if "LD_LIBRARY_PATH" in env:
@@ -813,62 +801,8 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
try:
return tkinter.filedialog.askopenfilename(
title=title,
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None,
)
finally:
root.destroy()
def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file save dialog for {title}.")
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return _run_for_stdout(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
# fall back to tk
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because save_filename was used for "{title}".')
raise e
else:
if is_macos and is_kivy_running():
# on macOS, mixing kivy and tk does not work, so spawn a new process
# FIXME: performance of this is pretty bad, and we should (also) look into alternatives
from multiprocessing import Process, Queue
res: "Queue[typing.Optional[str]]" = Queue()
Process(target=_mp_save_filename, args=(res, title, filetypes, suggest)).start()
return res.get()
try:
root = tkinter.Tk()
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
try:
return tkinter.filedialog.asksaveasfilename(
title=title,
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None,
)
finally:
root.destroy()
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None)
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
@@ -916,13 +850,6 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def messagebox(title: str, text: str, error: bool = False) -> None:
if not gui_enabled:
if error:
logging.error(f"{title}: {text}")
else:
logging.info(f"{title}: {text}")
return
if is_kivy_running():
from kvui import MessageBox
MessageBox(title, text, error).open()
@@ -958,9 +885,6 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
root.update()
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
"""Checks if the user wanted no GUI mode and has a terminal to use it with."""
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: Union[str, Dict[str, Any]]) -> str:
@@ -1005,7 +929,6 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
def deprecate(message: str, add_stacklevels: int = 0):
"""also use typing_extensions.deprecated wherever you use this"""
if __debug__:
raise Exception(message)
warnings.warn(message, stacklevel=2 + add_stacklevels)
@@ -1070,7 +993,6 @@ def _extend_freeze_support() -> None:
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
@deprecated("Use multiprocessing.freeze_support() instead")
def freeze_support() -> None:
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
import multiprocessing
@@ -1082,18 +1004,9 @@ def freeze_support() -> None:
_extend_freeze_support()
def visualize_regions(
root_region: Region,
file_name: str,
*,
show_entrance_names: bool = False,
show_locations: bool = True,
show_other_regions: bool = True,
linetype_ortho: bool = True,
regions_to_highlight: set[Region] | None = None,
entrance_highlighting: dict[int, int] | None = None,
detail_other_regions: bool = False,
auto_assign_colors: bool = False) -> None:
def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
"""Visualize the layout of a world as a PlantUML diagram.
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
@@ -1110,13 +1023,6 @@ def visualize_regions(
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
:param entrance_highlighting: a mapping from your world's entrance randomization groups to RGB values, used to color
your entrances
:param detail_other_regions: (default False) If enabled, will fully visualize regions that aren't reachable
from root_region.
:param auto_assign_colors: (default False) If enabled, will automatically assign random colors to entrances of the
same randomization group. Uses entrance_highlighting first, and only picks random colors for entrance groups
not found in the passed-in map
Example usage in World code:
from Utils import visualize_regions
@@ -1142,34 +1048,6 @@ def visualize_regions(
regions: typing.Deque[Region] = deque((root_region,))
multiworld: MultiWorld = root_region.multiworld
colors_used: set[int] = set()
if entrance_highlighting:
for color in entrance_highlighting.values():
# filter the colors to their most-significant bits to avoid too similar colors
colors_used.add(color & 0xF0F0F0)
else:
# assign an empty dict to not crash later
# the parameter is optional for ease of use when you don't care about colors
entrance_highlighting = {}
def select_color(group: int) -> int:
# specifically spacing color indexes by three different prime numbers (3, 5, 7) for the RGB components to avoid
# obvious cyclical color patterns
COLOR_INDEX_SPACING: int = 0x357
new_color_index: int = (group * COLOR_INDEX_SPACING) % 0x1000
new_color = ((new_color_index & 0xF00) << 12) + \
((new_color_index & 0xF0) << 8) + \
((new_color_index & 0xF) << 4)
while new_color in colors_used:
# while this is technically unbounded, expected collisions are low. There are 4095 possible colors
# and worlds are unlikely to get to anywhere close to that many entrance groups
# intentionally not using multiworld.random to not affect output when debugging with this tool
new_color_index += COLOR_INDEX_SPACING
new_color = ((new_color_index & 0xF00) << 12) + \
((new_color_index & 0xF0) << 8) + \
((new_color_index & 0xF) << 4)
return new_color
def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
name = obj.name
if isinstance(obj, Item):
@@ -1189,28 +1067,18 @@ def visualize_regions(
def visualize_exits(region: Region) -> None:
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:
if show_entrance_names:
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"{color_code}")
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"")
else:
try:
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"{color_code}")
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"{color_code}")
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"")
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"")
except ValueError:
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"{color_code}")
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"")
else:
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\" {color_code}")
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"{color_code}")
for entrance in region.entrances:
color_code: str = ""
if entrance.randomization_group in entrance_highlighting:
color_code = f" #{entrance_highlighting[entrance.randomization_group]:0>6X}"
if not entrance.parent_region:
uml.append(f"circle \"unconnected entrance:\\n{fmt(entrance)}\"{color_code}")
uml.append(f"\"unconnected entrance:\\n{fmt(entrance)}\" --> \"{fmt(region)}\"{color_code}")
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"")
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"")
def visualize_locations(region: Region) -> None:
any_lock = any(location.locked for location in region.locations)
@@ -1231,27 +1099,9 @@ def visualize_regions(
if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
uml.append("package \"other regions\" <<Cloud>> {")
for region in other_regions:
if detail_other_regions:
visualize_region(region)
else:
uml.append(f"class \"{fmt(region)}\"")
uml.append(f"class \"{fmt(region)}\"")
uml.append("}")
if auto_assign_colors:
all_entrances: list[Entrance] = []
for region in multiworld.get_regions(root_region.player):
all_entrances.extend(region.entrances)
all_entrances.extend(region.exits)
all_groups: list[int] = sorted(set([entrance.randomization_group for entrance in all_entrances]))
for group in all_groups:
if group not in entrance_highlighting:
if len(colors_used) >= 0x1000:
# on the off chance someone makes 4096 different entrance groups, don't cycle forever
break
new_color: int = select_color(group)
entrance_highlighting[group] = new_color
colors_used.add(new_color)
uml.append("@startuml")
uml.append("hide circle")
uml.append("hide empty members")
@@ -1262,7 +1112,7 @@ def visualize_regions(
seen.add(current_region)
visualize_region(current_region)
regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
if show_other_regions or detail_other_regions:
if show_other_regions:
visualize_other_regions()
uml.append("@enduml")
@@ -1326,35 +1176,3 @@ class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
t.start()
self._threads.add(t)
# NOTE: don't add to _threads_queues so we don't block on shutdown
def get_full_typename(t: type) -> str:
"""Returns the full qualified name of a type, including its module (if not builtins)."""
module = t.__module__
if module and module != "builtins":
return f"{module}.{t.__qualname__}"
return t.__qualname__
def get_all_causes(ex: Exception) -> str:
"""Return a string describing the recursive causes of this exception.
:param ex: The exception to be described.
:return A multiline string starting with the initial exception on the first line and each resulting exception
on subsequent lines with progressive indentation.
For example:
```
Exception: Invalid value 'bad'.
Which caused: Options.OptionError: Error generating option
Which caused: ValueError: File bad.yaml is invalid.
```
"""
cause = ex
causes = [f"{get_full_typename(type(ex))}: {ex}"]
while cause := cause.__cause__:
causes.append(f"{get_full_typename(type(cause))}: {cause}")
top = causes[-1]
others = "".join(f"\n{' ' * (i + 1)}Which caused: {c}" for i, c in enumerate(reversed(causes[:-1])))
return f"{top}{others}"

View File

@@ -20,8 +20,7 @@ if typing.TYPE_CHECKING:
Utils.local_path.cached_path = os.path.dirname(__file__)
settings.no_gui = True
configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath):
# fall back to config.yaml in user_path if config does not exist in cwd to match settings.py
if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml'))

View File

@@ -1,20 +1,46 @@
# WebHost
## Asset License
The image files used in the page design were specifically designed for archipelago.gg and are **not** covered by the top
level LICENSE.
See individual LICENSE files in `./static/static/**`.
You are only allowed to use them for personal use, testing and development.
If the site is reachable over the internet, have a robots.txt in place (see `ASSET_RIGHTS` in `config.yaml`)
and do not promote it publicly. Alternatively replace or remove the assets.
## Contribution Guidelines
**Thank you for your interest in contributing to the Archipelago website!**
Much of the content on the website is generated automatically, but there are some things
that need a personal touch. For those things, we rely on contributions from both the core
team and the community. The current primary maintainer of the website is Farrak Kilhn.
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
Pages should preferably be rendered on the server side with Jinja. Features should work with noscript if feasible.
Design changes have to fit the overall design.
### Small Changes
Little changes like adding a button or a couple new select elements are perfectly fine.
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
you build a new page which needs two side by side tables, and you need to write a CSS file
specific to your page, that is perfectly reasonable.
Introduction of JS dependencies should first be discussed on Discord or in a draft PR.
### Content Additions
Once you develop a new feature or add new content the website, make a pull request. It will
be reviewed by the community and there will probably be some discussion around it. Depending
on the size of the feature, and if new styles are required, there may be an additional step
before the PR is accepted wherein Farrak works with the designer to implement styles.
See also [docs/style.md](/docs/style.md) for the style guide.
### Restrictions on Style Changes
A professional designer is paid to develop the styles and assets for the Archipelago website.
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
change site styles are rejected. Please note this applies to code which changes the overall
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
behind these restrictions is to maintain a curated feel for the design of the site. If
any PR affects the overall feel of the site but includes additive changes, there will
likely be a conversation about how to implement those changes without compromising the
curated site style. It is therefore worth noting there are a couple files which, if
changed in your pull request, will cause it to draw additional scrutiny.
These closely guarded files are:
- `globalStyles.css`
- `islandFooter.css`
- `landing.css`
- `markdown.css`
- `tooltip.css`
### Site Themes
There are several themes available for game pages. It is possible to request a new theme in
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
are not free, and take some time to create. Farrak works closely with the designer to implement
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
good chance it will become a reality.

View File

@@ -23,17 +23,6 @@ app.jinja_env.filters['any'] = any
app.jinja_env.filters['all'] = all
app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
# overwrites of flask default config
app.config["DEBUG"] = False
app.config["PORT"] = 80
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
app.config["SESSION_PERMANENT"] = True
app.config["MAX_FORM_MEMORY_SIZE"] = 2 * 1024 * 1024 # 2 MB, needed for large option pages such as SC2
# custom config
app.config["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
@@ -41,12 +30,19 @@ 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["DEBUG"] = False
app.config["PORT"] = 80
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
# 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
# memory limit for generator processes in bytes
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent
# archipelago.gg uses gunicorn + nginx; ignoring this option

View File

@@ -2,20 +2,10 @@
from typing import List, Tuple
from flask import Blueprint
from flask_cors import CORS
from ..models import Seed, Slot
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
cors = CORS(api_endpoints, resources={
r"/api/datapackage/*": {"origins": "*"},
r"/api/datapackage": {"origins": "*"},
r"/api/datapackage_checksum/*": {"origins": "*"},
r"/api/room_status/*": {"origins": "*"},
r"/api/tracker/*": {"origins": "*"},
r"/api/static_tracker/*": {"origins": "*"},
r"/api/slot_data_tracker/*": {"origins": "*"}
})
def get_players(seed: Seed) -> List[Tuple[str, str]]:

View File

@@ -58,12 +58,6 @@ class PlayerLocationsTotal(TypedDict):
total_locations: int
class PlayerGame(TypedDict):
team: int
player: int
game: str
@api_endpoints.route("/tracker/<suuid:tracker>")
@cache.memoize(timeout=60)
def tracker_data(tracker: UUID) -> dict[str, Any]:
@@ -86,8 +80,7 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
"""Slot aliases of all players."""
for team, players in all_players.items():
for player in players:
player_aliases.append(
{"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
player_items_received: list[PlayerItemsReceived] = []
"""Items received by each player."""
@@ -101,8 +94,7 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
for team, players in all_players.items():
for player in players:
player_checks_done.append(
{"team": team, "player": player,
"locations": sorted(tracker_data.get_player_checked_locations(team, player))})
{"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
total_checks_done: list[TeamTotalChecks] = [
{"team": team, "checks_done": checks_done}
@@ -152,8 +144,7 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
"""The current client status for each player."""
for team, players in all_players.items():
for player in players:
player_status.append(
{"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
player_status.append({"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
return {
"aliases": player_aliases,
@@ -216,20 +207,12 @@ def static_tracker_data(tracker: UUID) -> dict[str, Any]:
player_locations_total.append(
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
player_game: list[PlayerGame] = []
"""The played game per player slot."""
for team, players in all_players.items():
for player in players:
player_game.append({"team": team, "player": player, "game": tracker_data.get_player_game(player)})
return {
"groups": groups,
"datapackage": tracker_data._multidata["datapackage"],
"player_locations_total": player_locations_total,
"player_game": player_game,
}
# It should be exceedingly rare that slot data is needed, so it's separated out.
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)

View File

@@ -89,24 +89,19 @@ class WebHostContext(Context):
setattr(self, key, value)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
async def listen_to_db_commands(self):
def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self)
while not self.exit_event.is_set():
await self.main_loop.run_in_executor(None, self._process_db_commands, cmdprocessor)
try:
await asyncio.wait_for(self.exit_event.wait(), 5)
except asyncio.TimeoutError:
pass
def _process_db_commands(self, cmdprocessor):
with db_session:
commands = select(command for command in Command if command.room.id == self.room_id)
if commands:
for command in commands:
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete()
commit()
with db_session:
commands = select(command for command in Command if command.room.id == self.room_id)
if commands:
for command in commands:
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete()
commit()
del commands
time.sleep(5)
@db_session
def load(self, room_id: int):
@@ -161,9 +156,9 @@ class WebHostContext(Context):
with db_session:
savegame_data = Room.get(id=self.room_id).multisave
if savegame_data:
self.set_save(restricted_loads(savegame_data))
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self._start_async_saving(atexit_save=False)
asyncio.create_task(self.listen_to_db_commands())
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session
def _save(self, exit_save: bool = False) -> bool:
@@ -234,17 +229,6 @@ def set_up_logging(room_id) -> logging.Logger:
return logger
def tear_down_logging(room_id):
"""Close logging handling for a room."""
logger_name = f"RoomLogger {room_id}"
if logger_name in logging.Logger.manager.loggerDict:
logger = logging.getLogger(logger_name)
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
del logging.Logger.manager.loggerDict[logger_name]
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, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
@@ -341,7 +325,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
except (KeyboardInterrupt, SystemExit):
if ctx.saving:
ctx._save(True)
ctx._save()
setattr(asyncio.current_task(), "save", None)
except Exception as e:
with db_session:
@@ -352,25 +336,19 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
raise
else:
if ctx.saving:
ctx._save(True)
ctx._save()
setattr(asyncio.current_task(), "save", None)
finally:
try:
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
if ctx.server and hasattr(ctx.server, "ws_server"):
ctx.server.ws_server.close()
await ctx.server.ws_server.wait_closed()
with db_session:
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
del room
tear_down_logging(room_id)
logging.info(f"Shutting down room {room_id} on {name}.")
finally:
await asyncio.sleep(5)

View File

@@ -1,7 +1,5 @@
import datetime
import os
import warnings
from enum import StrEnum
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
import jinja2.exceptions
@@ -15,25 +13,11 @@ from .markdown import render_markdown
from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted
class WebWorldTheme(StrEnum):
DIRT = "dirt"
GRASS = "grass"
GRASS_FLOWERS = "grassFlowers"
ICE = "ice"
JUNGLE = "jungle"
OCEAN = "ocean"
PARTY_TIME = "partyTime"
STONE = "stone"
def get_world_theme(game_name: str) -> str:
if game_name not in AutoWorldRegister.world_types:
return "grass"
chosen_theme = AutoWorldRegister.world_types[game_name].web.theme
available_themes = [theme.value for theme in WebWorldTheme]
if chosen_theme not in available_themes:
warnings.warn(f"Theme '{chosen_theme}' for {game_name} not valid, switching to default 'grass' theme.")
return "grass"
return chosen_theme
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
def get_visible_worlds() -> dict[str, type(World)]:
@@ -128,13 +112,8 @@ def tutorial_landing():
"authors": tutorial.authors,
"language": tutorial.language
}
worlds = dict(
title_sorted(
worlds.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game
)
)
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)

View File

@@ -13,7 +13,6 @@ from Utils import local_path
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
from .generate import get_meta
from .misc import get_world_theme
def create() -> None:
@@ -23,6 +22,12 @@ def create() -> None:
Options.generate_yaml_templates(yaml_folder)
def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
world = AutoWorldRegister.world_types[world_name]
if world.hidden or world.web.options_page is False:

View File

@@ -6,7 +6,6 @@ waitress>=3.0.2
Flask-Caching>=2.3.0
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
Flask-Limiter>=3.12
Flask-Cors>=6.0.2
bokeh>=3.6.3
markupsafe>=3.0.2
setproctitle>=1.3.5

View File

@@ -23,7 +23,7 @@ players to rely upon each other to complete their game.
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
players to randomize any of the supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
Here is a list of our [Supported Games](/games).
Here is a list of our [Supported Games](https://archipelago.gg/games).
## Can I generate a single-player game with Archipelago?
@@ -33,7 +33,7 @@ play, open the Settings Page, pick your settings, and click Generate Game.
## How do I get started?
We have a [Getting Started](/tutorial/Archipelago/setup/en) guide that will help you get the
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
including multiple games, and hosting multiworlds on the website for ease and convenience.
@@ -57,7 +57,7 @@ their multiworld.
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
in that game belonging to other players are sent out automatically. This allows other players to continue to play
uninterrupted. Here is a list of all of our [Server Commands](/tutorial/Archipelago/commands/en).
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en).
## What happens if an item is placed somewhere it is impossible to get?

View File

@@ -241,9 +241,12 @@ input[type="checkbox"]{
}
/* Hidden items */
.hidden-class:not(:has(.f:not(.unacquired))), .hidden-item{
.hidden-class:not(:has(img.acquired)){
display: none;
}
.hidden-item:not(.acquired){
display:none;
}
/* Keys */
#keys ol, #keys ul{

View File

@@ -55,9 +55,6 @@
{{ OptionTitle(option_name, option) }}
<div class="named-range-container">
<select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% if option.default not in option.special_range_names.values() %}
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
{% endif %}
{% for key, val in option.special_range_names.items() %}
{% if option.default == val %}
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
@@ -97,9 +94,6 @@
<div class="text-choice-container">
<div class="text-choice-wrapper">
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% if option.default not in option.options.values() %}
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
{% endif %}
{% for id, name in option.name_lookup.items()|sort %}
{% if name != "random" %}
{% if option.default == id %}

File diff suppressed because it is too large Load Diff

View File

@@ -959,7 +959,7 @@ if "Timespinner" in network_data_package["games"]:
timespinner_location_ids = {
"Present": list(range(1337000, 1337085)),
"Past": list(range(1337086, 1337157)) + list(range(1337159, 1337175)),
"Past": list(range(1337086, 1337175)),
"Ancient Pyramid": [
1337236,
1337246, 1337247, 1337248, 1337249]
@@ -1228,7 +1228,7 @@ if "Starcraft 2" in network_data_package["games"]:
def render_Starcraft2_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
SC2WOL_ITEM_ID_OFFSET = 1000
SC2HOTS_ITEM_ID_OFFSET = 2000
SC2LOTV_ITEM_ID_OFFSET = 3000
SC2LOTV_ITEM_ID_OFFSET = 2000
SC2_KEY_ITEM_ID_OFFSET = 4000
NCO_LOCATION_ID_LOW = 20004500
NCO_LOCATION_ID_HIGH = NCO_LOCATION_ID_LOW + 1000

View File

@@ -289,7 +289,7 @@ async def nes_sync_task(ctx: ZeldaContext):
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate "
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)

View File

@@ -1,2 +0,0 @@
pytest>=9.0.1,<10 # this includes subtests support
pytest-xdist>=3.8.0

View File

@@ -1,13 +0,0 @@
# This file specifies patterns that are ignored by default for any world built with the "Build APWorlds" component.
# These patterns can be overriden by a world-specific .apignore using !-prefixed patterns for negation.
# Auto-created folders
__MACOSX
.DS_Store
__pycache__
# Unneeded files
/archipelago.json
/.apignore
/.git
/.gitignore

View File

@@ -224,7 +224,6 @@
height: self.content.texture_size[1] + 80
<ScrollBox>:
layout: layout
box_height: dp(100)
bar_width: "12dp"
scroll_wheel_distance: 40
do_scroll_x: False
@@ -235,11 +234,4 @@
orientation: "vertical"
spacing: 10
size_hint_y: None
height: max(self.minimum_height, root.box_height)
<MessageBoxLabel>:
valign: "middle"
halign: "center"
text_size: self.width, None
height: self.texture_size[1]
height: self.minimum_height

View File

@@ -28,7 +28,7 @@
name: Player{number}
# Used to describe your yaml. Useful if you have multiple files.
description: {{ yaml_dump("%s Preset for %s" % (preset_name, game)) if preset_name else yaml_dump("Default %s Template" % game) }}
description: {{ yaml_dump("Default %s Template" % game) }}
game: {{ yaml_dump(game) }}
requires:
@@ -38,11 +38,11 @@ requires:
{{ yaml_dump(game) }}: {{ world_version }} # Version of the world required for this yaml to work as expected.
{%- endif %}
{%- macro range_option(option, option_val) %}
{%- macro range_option(option) %}
# You can define additional values between the minimum and maximum values.
# Minimum value is {{ option.range_start }}
# Maximum value is {{ option.range_end }}
{%- set data, notes = dictify_range(option, option_val) %}
{%- set data, notes = dictify_range(option) %}
{%- for entry, default in data.items() %}
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
{%- endfor -%}
@@ -56,10 +56,6 @@ requires:
{%- for option_key, option in group_options.items() %}
{{ option_key }}:
{%- set option_val = option.default %}
{%- if option_key in preset %}
{%- set option_val = preset[option_key] %}
{%- endif -%}
{%- if option.__doc__ %}
# {{ cleandoc(option.__doc__)
| trim
@@ -73,25 +69,25 @@ requires:
{%- endif -%}
{%- if option.range_start is defined and option.range_start is number %}
{{- range_option(option, option_val) -}}
{{- range_option(option) -}}
{%- elif option.options -%}
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
{{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option_val or sub_option_name == option_val %}50{% else %}0{% endif %}
{{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
{%- endfor -%}
{%- if option.name_lookup[option_val] not in option.options and option_val not in option.options %}
{{ yaml_dump(option_val) }}: 50
{%- if option.name_lookup[option.default] not in option.options %}
{{ yaml_dump(option.default) }}: 50
{%- endif -%}
{%- elif option_val is string %}
{{ yaml_dump(option_val) }}: 50
{%- elif option.default is string %}
{{ yaml_dump(option.default) }}: 50
{%- elif option_val is iterable and option_val is not mapping %}
{{ option_val | list }}
{%- elif option.default is iterable and option.default is not mapping %}
{{ option.default | list }}
{%- else %}
{{ yaml_dump(option_val) | indent(4, first=false) }}
{{ yaml_dump(option.default) | indent(4, first=false) }}
{%- endif -%}
{{ "\n" }}
{%- endfor %}

View File

@@ -1,174 +0,0 @@
<VisualRange>:
id: this
spacing: 15
orientation: "horizontal"
slider: slider
tag: tag
MDLabel:
id: tag
text: str(this.option.default) if not isinstance(this.option.default, str) else str(this.option.range_start)
MDSlider:
id: slider
min: this.option.range_start
max: this.option.range_end
value: min(max(this.option.default, this.option.range_start), this.option.range_end) if not isinstance(this.option.default, str) else this.option.range_start
step: 1
step_point_size: 0
MDSliderHandle:
MDSliderValueLabel:
<VisualChoice>:
id: this
text: text
MDButtonText:
id: text
text: this.option.get_option_name(this.option.default if not isinstance(this.option.default, str) else list(this.option.options.values())[0])
theme_text_color: "Primary"
<VisualNamedRange>:
id: this
orientation: "horizontal"
spacing: "10dp"
padding: (0, 0, "10dp", 0)
choice: choice
MDButton:
id: choice
text: text
MDButtonText:
id: text
text: this.option.default.title() if this.option.default in this.option.special_range_names else "Custom"
<VisualFreeText>:
multiline: False
font_size: "15sp"
text: self.option.default if isinstance(self.option.default, str) else ""
theme_height: "Custom"
height: "30dp"
<VisualTextChoice>:
id: this
orientation: "horizontal"
spacing: "5dp"
padding: (0, 0, "10dp", 0)
<VisualToggle>:
id: this
button: button
MDIconButton:
id: button
icon: "checkbox-outline" if this.option.default else "checkbox-blank-outline"
<VisualListSetEntry@ResizableTextField>:
height: "20dp"
<CounterItemValue>:
height: "30dp"
<VisualListSetCounter>:
id: this
scrollbox: scrollbox
add: add
save: save
input: input
focus_behavior: False
MDDialogHeadlineText:
text: getattr(this.option, "display_name", this.name)
MDDialogSupportingText:
text: "Add or Remove Entries"
MDDialogContentContainer:
orientation: "vertical"
spacing: 10
MDBoxLayout:
orientation: "horizontal"
VisualListSetEntry:
id: input
height: "20dp"
MDIconButton:
id: add
icon: "plus"
theme_height: "Custom"
height: "20dp"
on_press: root.validate_add(input)
ScrollBox:
id: scrollbox
size_hint_y: None
adapt_minimum: False
MDButton:
id: save
MDButtonText:
text: "Save Changes"
ContainerLayout:
md_bg_color: app.theme_cls.backgroundColor
MainLayout:
id: main
cols: 3
padding: 3, 5, 0, 3
spacing: "2dp"
ScrollBox:
id: scrollbox
size_hint_x: None
width: "150dp"
MDDivider:
orientation: "vertical"
width: "4dp"
MainLayout:
id: player_layout
rows: 2
spacing: "20dp"
MDBoxLayout:
id: player_options
orientation: "horizontal"
height: "75dp"
size_hint_y: None
padding: ["10dp", "30dp", "10dp", 0]
spacing: "10dp"
ResizableTextField:
id: player_name
multiline: False
MDTextFieldHintText:
text: "Player Name"
MDTextFieldMaxLengthText:
max_text_length: 16
MDBoxLayout:
orientation: "vertical"
spacing: "15dp"
MDLabel:
id: game
text: "Game: None"
pos_hint: {"center_x": 0.5, "center_y": 0.5}
MDButton:
pos_hint: {"center_x": 0.5, "center_y": 0.5}
on_press: app.export_options(self)
theme_width: "Custom"
size_hint_y: 1
size_hint_x: 1
MDButtonText:
pos_hint: {"center_x": 0.5, "center_y": 0.5}
text: "Export Options"
MainLayout:
cols: 1
id: options

View File

@@ -8,7 +8,3 @@ SELFLAUNCH: false
# Host Address. This is the address encoded into the patch that will be used for client auto-connect.
# Set as your local IP (192.168.x.x) to serve over LAN.
HOST_ADDRESS: localhost
# Asset redistribution rights. If true, the host affirms they have been given explicit permission to redistribute
# the proprietary assets in WebHostLib
#ASSET_RIGHTS: false

View File

@@ -15,10 +15,6 @@
# A Link to the Past
/worlds/alttp/ @Berserker66
# APQuest
# NewSoupVi is acting maintainer, but world belongs to core with the exception of the music
/worlds/apquest/ @NewSoupVi
# Sudoku (APSudoku)
/worlds/apsudoku/ @EmilyV99
@@ -70,9 +66,6 @@
# DOOM II
/worlds/doom_ii/ @Daivuk @KScl
# EarthBound
/worlds/earthbound/ @PinkSwitch
# Factorio
/worlds/factorio/ @Berserker66
@@ -179,12 +172,8 @@
# Sonic Adventure 2 Battle
/worlds/sa2b/ @PoryGone @RaspberrySpace
# Satisfactory
/worlds/satisfactory/ @Jarno458 @budak7273
# Starcraft 2
# Note: @Ziktofel acts as a mentor
/worlds/sc2/ @MatthewMarinets @Snarkie @SirChuckOfTheChuckles
/worlds/sc2/ @Ziktofel
# Super Metroid
/worlds/sm/ @lordlou

View File

@@ -17,8 +17,7 @@ it will not be detailed here.
The client is an intermediary program between the game and the Archipelago server. This can either be a direct
modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it
must fulfill a few requirements in order to function as expected. Libraries for most modern languages and the spec for
various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document. Additional help with specific game
engines and rom formats can be found in the #ap-modding-help channel in the [Discord](https://archipelago.gg/discord).
various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document.
### Hard Requirements
@@ -140,8 +139,8 @@ if possible.
* An implementation of
[get_filler_item_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L473)
* By default, this function chooses any item name from `item_name_to_id`, which may include items you consider
"non-repeatable".
* By default, this function chooses any item name from `item_name_to_id`, so you want to limit it to only the true
filler items.
* An `options_dataclass` defining the options players have available to them
* This should be accompanied by a type hint for `options` with the same class name
* A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220)

View File

@@ -41,18 +41,16 @@ There are also the following optional fields:
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
["Build APWorlds" launcher component](#build-apworlds-launcher-component),
["Build apworlds" launcher component](#build-apworlds-launcher-component),
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
### "Build APWorlds" Launcher Component
### "Build apworlds" Launcher Component
In the Archipelago Launcher, there is a "Build APWorlds" component that will package all world folders to `.apworld`,
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`,
and add `archipelago.json` manifest files to them.
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
The `archipelago.json` file in each .apworld will automatically include the appropriate
`version` and `compatible_version`.
The component can also be called from the command line to allow for specifying a certain list of worlds to build.
For example, running `Launcher.py "Build APWorlds" -- "Game Name"` will build only the game called `Game Name`.
`version` and `compatible_version`.
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
So, a world folder with an `archipelago.json` that looks like this:
@@ -81,26 +79,10 @@ will be packaged into an `.apworld` with a manifest file inside of it that looks
This is the recommended workflow for packaging your world to an `.apworld`.
### .apignore Exclusions
## Extra Data
By default, any additional files inside of the world folder will be packaged into the resulting `.apworld` archive and
can then be read by the world. However, if there are any other files that aren't needed in the resulting `.apworld`, you
can automatically prevent the build component from including them by specifying them in a file called `.apignore` inside
the root of the world folder.
The zip can contain arbitrary files in addition what was specified above.
The `.apignore` file selects files in the same way as the `.gitignore` format with patterns separated by line describing
which files to ignore. For example, an `.apignore` like this:
```gitignore
*.iso
scripts/
!scripts/needed.py
```
would ignore any `.iso` files and anything in the scripts folder except for `scripts/needed.py`.
Some exclusions are made by default for all worlds such as `__pycache__` folders. These are listed in the
`GLOBAL.apignore` file inside of the `data` directory.
## Caveats

View File

@@ -6,49 +6,6 @@ including [Contributing](contributing.md), [Adding Games](<adding games.md>), an
---
### I've never added a game to Archipelago before. Should I start with the APWorld or the game client?
Strictly speaking, this is a false dichotomy: we do *not* recommend doing 100% of client work before the APWorld,
or 100% of APWorld work before the client. It's important to iterate on both parts and test them together.
However, the early iterations tend to be very similar for most games,
so the typical recommendation for first-time AP developers is:
- Start with a proof-of-concept for [the game client](adding%20games.md#client)
- Figure out how to interface with the game. Whether that means "modding" the game, or patching a ROM file,
or developing a separate client program that edits the game's memory, or some other technique.
- Figure out how to give items and detect locations in the actual game. Not every item and location,
just one of each major type (e.g. opening a chest vs completing a sidequest) to prove all the items and locations
you want can actually be implemented.
- Figure out how to make a websocket connection to an AP server, possibly using a client library (see [Network Protocol](<network%20protocol.md>).
To make absolutely sure this part works, you may want to test the connection by generating a multiworld
with a different game, then making your client temporarily pretend to be that other game.
- Next, make a "trivial" APWorld, i.e. an APWorld that always generates the same items and locations
- If you've never done this before, likely the fastest approach is to copy-paste [APQuest](<../worlds/apquest>), and read the many
comments in there until you understand how to edit the items and locations.
- Then you can do your first "end-to-end test": generate a multiworld using your APWorld, [run a local server](<running%20from%20source.md>)
to host it, connect to that local server from your game client, actually check a location in the game,
and finally make sure the client successfully sent that location check to the AP server
as well as received an item from it.
That's about where general recommendations end. What you should do next will depend entirely on your game
(e.g. implement more items, write down logic rules, add client features, prototype a tracker, etc).
If you're not sure, then this would be a good time to re-read [Adding Games](<adding%20games.md>), and [World API](<world%20api.md>).
There are a few assumptions in this recommendation worth stating explicitly, namely:
- If something you want to do is infeasible, you want to find out that it's infeasible as soon as possible, before
you write a bunch of code assuming it could be done. That's why we recommend starting with the game client.
- Getting an APWorld to generate whatever items/locations you want is always feasible, since items/locations are
little more than id numbers and name strings during generation.
- You generally want to get to an "end-to-end playable" prototype quickly. On top of all the technical challenges these
docs describe, it's also important to check that a randomizer is *fun to play*, and figure out what features would be
essential for a public release.
- A first-time world developer may or may not be deeply familiar with Archipelago, but they're almost certainly familiar
with the game they want to randomize. So judging whether your game client is working correctly might be significantly
easier than judging if your APWorld is working.
---
### My game has a restrictive start that leads to fill errors
A "restrictive start" here means having a combination of very few sphere 1 locations and potentially requiring more
@@ -183,58 +140,3 @@ So when the game itself does not follow this assumption, the options are:
- For connections, any logical regions will still need to be reachable through other, *repeatable* connections
- For locations, this may require game changes to remove the vanilla item if it affects logic
- Decide that resetting the save file is part of the game's logic, and warn players about that
---
### What are "local" vs "remote" items, and what are the pros and cons of each?
First off, these terms can be misleading. Since the whole point of a multi-game multiworld randomizer is that some items
are going to be placed in other slots (unless there's only one slot), the choice isn't really "local vs remote";
it's "mixed local/remote vs all remote". You have to get "remote items" working to be an AP implementation at all, and
it's often simpler to handle every item/location the same way, so you generally shouldn't worry about "local items"
until you've finished more critical features.
Next, "local" and "remote" items confusingly refer to multiple concepts, so it's important to clearly separate them:
- Whether an item happens to get placed in the same slot it originates from, or a different slot. I'll call these
"locally placed" and "remotely placed" items.
- Whether an AP client implements location checking for locally placed items by skipping the usual AP server roundtrip
(i.e. sending [LocationChecks](<network%20protocol.md#locationchecks>)
then receiving [ReceivedItems](<network%20protocol.md#receiveditems>)
) and directly giving the item to the player, or by doing the AP server roundtrip regardless. I'll call these
"locally implemented" items and "remotely implemented" items.
- Locally implementing items requires the AP client to know what the locally placed items were without asking an AP
server (or else you'd effectively be doing remote items with extra steps). Typically, it gets that information from
a patch file, which is one reason why games that already need a patch file are more likely to choose local items.
- If items are remotely implemented, the AP client can use [location scouts](<network%20protocol.md#LocationScouts>)
to learn what items are placed on what locations. Features that require this information are sometimes mistakenly
assumed to require locally implemented items, but location scouts work just as well as patch file data.
- [The `items_handling` bitflags in the Connect packet](<network%20protocol.md#items_handling-flags>).
AP clients with remotely implemented items will typically set all three flags, including "from your own world".
Clients with locally implemented items might set only the "from other worlds" flag.
- Whether a local items client sets the "starting inventory" flag likely depends on other details. For example, if a ROM
is being patched, and starting inventory can be added to that patch, then it makes sense to leave the flag unset.
When people talk about "local vs remote items" as a choice that world devs have to make, they mean deciding whether
your client will locally or remotely implement the items which happen to be locally placed (or make both
implementations, or let the player choose an implementation).
Theoretically, the biggest benefit of "local items" is that it allows a solo (single slot) multiworld to be played
entirely offline, with no AP server, from start to finish. This is similar to a "standalone"/non-AP randomizer,
except that you still get AP's player options, generation, etc. for free.
For some games, there are also technical constraints that make certain items easier to implement locally,
or less glitchy when implemented locally, as long as you're okay with never allowing these items to be placed remotely
(or offering the player even more options).
The main downside (besides more implementation work) is that "local items" can't support "same slot co-op".
That's when two players on two different machines connect to the same slot and play together.
This only works if both players receive all the items for that slot, including ones found by the other player,
which requires those items to be implemented remotely so the AP server can send them to all of that slot's clients.
So to recap:
- (All) remote items is often the simplest choice, since you have to implement remote items anyway.
- Remote items enable same slot co-op.
- Local items enable solo offline play.
- If you want to support both solo offline play and same slot co-op,
you might need to expose local vs remote items as an option to the player.

View File

@@ -20,7 +20,7 @@ game contributions:
It is recommended that automated github actions are turned on in your fork to have github run unit tests after
pushing.
You can turn them on here:
![Github actions example](/docs/img/github-actions-example.png)
![Github actions example](./img/github-actions-example.png)
* **When reviewing PRs, please leave a message about what was done.**
We don't have full test coverage, so manual testing can help.

View File

@@ -225,7 +225,7 @@ Sent to clients after a client requested this message be sent to them, more info
| 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 |
| data | dict | 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.
@@ -425,7 +425,7 @@ the server will forward the message to all those targets to which any one requir
| 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 |
| data | dict | 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.
@@ -647,16 +647,6 @@ class Version(NamedTuple):
build: int
```
If constructing version information as a dict for a custom client rather than as a NamedTuple built into the CommonClient, you must add the `class` key to allow Archipelago to compare version support.
```
"version": {
"class": "Version",
"build": X,
"major": Y,
"minor": Z
}
```
### SlotType
An enum representing the nature of a slot.

View File

@@ -269,8 +269,7 @@ placed on them.
### PriorityLocations
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them if any are available in
the pool. Progression items without a deprioritized flag will be used first when filling priority_locations. Progression items with
a deprioritized flag will be used next.
the pool.
### ItemLinks
Allows users to share their item pool with other players. Currently item links are per game. A link of one game between

View File

@@ -1,482 +0,0 @@
# 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:
- Rule classes that avoid all the common pitfalls
- Logic optimization
- Automatic result caching (opt-in)
- Serialization/deserialization
- Human-readable logic explanations for players
## Overview
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.
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.
```python
# In your world's create_regions method
location = MyWorldLocation(...)
self.set_rule(location, Has("A Big Gun"))
```
The rule builder comes with a number of rules by default:
- `True_`: Always returns true
- `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)
- `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
- `HasAllCounts`: Checks that the player has all of the counts for the given items
- `HasAnyCount`: Checks that the player has any of the counts for the given items
- `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
- `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:
```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`.
### Assigning rules
When assigning the rule you must use the `set_rule` helper to correctly resolve and register the rule.
```python
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`.
```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`.
You can also set a rule for your world's completion condition:
```python
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. Rules that pass the options check will be resolved as normal, and those that fail will be resolved as `False`.
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`
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.
To check if the player can reach a switch, or if they've received the switch item if switches are randomized:
```python
rule = (
Has("Red switch", options=[OptionFilter(SwitchRando, 1)])
| CanReachLocation("Red switch", options=[OptionFilter(SwitchRando, 0)])
)
```
To add an extra logic requirement on the easiest difficulty which is ignored for other difficulties:
```python
rule = (
# ...the rest of the logic
& Has("QoL item", options=[OptionFilter(Difficulty, Difficulty.option_easy)], filtered_resolution=True)
)
```
If you would like to provide option filters when reusing or composing rules, you can use the `Filtered` helper rule:
```python
common_rule = Has("A") | HasAny("B", "C")
...
rule = (
Filtered(common_rule, options=[OptionFilter(Opt, 0)]),
| Filtered(Has("X") | CanReachRegion("Y"), options=[OptionFilter(Opt, 1)]),
)
```
You can also use the & and | operators to apply options to rules:
```python
common_rule = Has("A")
easy_filter = [OptionFilter(Difficulty, Difficulty.option_easy)]
common_rule_only_on_easy = common_rule & easy_filter
common_rule_skipped_on_easy = common_rule | easy_filter
```
## Enabling caching
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.
### 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.
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):
item_mapping = {
"Currency x10": "Currency",
"Currency x50": "Currency",
"Currency x100": "Currency",
"Currency x500": "Currency",
}
```
## 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 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:
```python
@dataclasses.dataclass()
class CanGoal(Rule["MyWorld"], game="My Game"):
@override
def _instantiate(self, world: "MyWorld") -> Rule.Resolved:
# caching_enabled only needs to be passed in when your world inherits from CachedRuleBuilderWorld
return self.Resolved(world.required_mcguffins, player=world.player, caching_enabled=True)
class Resolved(Rule.Resolved):
goal: int
@override
def _evaluate(self, state: CollectionState) -> bool:
return state.has("McGuffin", self.player, count=self.goal)
@override
def item_dependencies(self) -> dict[str, set[int]]:
# this function is only required if you have caching enabled
return {"McGuffin": {id(self)}}
@override
def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]:
# this method can be overridden to display custom explanations
return [
{"type": "text", "text": "Goal with "},
{"type": "color", "color": "green" if state and self(state) else "salmon", "text": str(self.goal)},
{"type": "text", "text": " McGuffins"},
]
```
Your custom rule can also resolve to builtin rules instead of needing to define your own:
```python
@dataclasses.dataclass()
class ComplicatedFilter(Rule["MyWorld"], game="My Game"):
def _instantiate(self, world: "MyWorld") -> Rule.Resolved:
if world.some_precalculated_bool:
return Has("Item 1").resolve(world)
if world.options.some_option:
return CanReachRegion("Region 1").resolve(world)
return False_().resolve(world)
```
### 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.
```python
@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
class Resolved(Rule.Resolved):
item_name: str
@override
def item_dependencies(self) -> dict[str, set[int]]:
return {self.item_name: {id(self)}}
```
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.
```python
@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
class Resolved(Rule.Resolved):
region_name: str
@override
def region_dependencies(self) -> dict[str, set[int]]:
return {self.region_name: {id(self)}}
```
The default `CanReachLocation`, `CanReachRegion`, and `CanReachEntrance` rules define this function already.
### 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.
```python
@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
class Resolved(Rule.Resolved):
location_name: str
@override
def location_dependencies(self) -> dict[str, set[int]]:
return {self.location_name: {id(self)}}
```
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.
```python
@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
class Resolved(Rule.Resolved):
entrance_name: str
@override
def entrance_dependencies(self) -> dict[str, set[int]]:
return {self.entrance_name: {id(self)}}
```
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.
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"):
class Resolved(Rule.Resolved):
@override
def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]:
has_item = state and state.has("growth spurt", self.player)
color = "yellow"
start = "You must be "
if has_item:
start = "You are "
color = "green"
elif state is not None:
start = "You are not "
color = "salmon"
return [
{"type": "text", "text": start},
{"type": "color", "color": color, "text": "THIS"},
{"type": "text", "text": " tall to beat the game"},
]
@override
def explain_str(self, state: CollectionState | None = None) -> str:
if state is None:
return str(self)
if state.has("growth spurt", self.player):
return "You ARE this tall and can beat the game"
return "You are not THIS tall and cannot beat the game"
@override
def __str__(self) -> str:
return "You must be THIS tall to beat the 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.
- `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.
- 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.
## 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 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
{
"rule": "Has",
"options": [],
"args": {
"item_name": "Some item",
"count": 1,
},
}
```
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
{
"rule": "And",
"options": [],
"children": [
..., # each serialized rule
]
}
```
A full example is as follows:
```python
rule = And(
Has("a", options=[OptionFilter(ToggleOption, 0)]),
Or(Has("b", count=2), CanReachRegion("c"), options=[OptionFilter(ToggleOption, 1)]),
)
assert rule.to_dict() == {
"rule": "And",
"options": [],
"children": [
{
"rule": "Has",
"options": [
{
"option": "worlds.my_world.options.ToggleOption",
"value": 0,
"operator": "eq",
},
],
"args": {
"item_name": "a",
"count": 1,
},
},
{
"rule": "Or",
"options": [
{
"option": "worlds.my_world.options.ToggleOption",
"value": 1,
"operator": "eq",
},
],
"children": [
{
"rule": "Has",
"options": [],
"args": {
"item_name": "b",
"count": 2,
},
},
{
"rule": "CanReachRegion",
"options": [],
"args": {
"region_name": "c",
},
},
],
},
],
}
```
### Custom serialization
To define a different format for your custom rules, override the `to_dict` function:
```python
class BasicLogicRule(Rule, game="My Game"):
items = ("one", "two")
def to_dict(self) -> dict[str, Any]:
# Return whatever format works best for you
return {
"logic": "basic",
"items": self.items,
}
```
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"):
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self:
items = data.get("items", ())
return cls(*items)
```
## APIs
This section is provided for reference, refer to the above sections for examples.
### World API
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
- `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`
#### CachedRuleBuilderWorld Properties
The following property is only available when inheriting from `CachedRuleBuilderWorld`
- `item_mapping: dict[str, str]`: A mapping of actual item name to logical item name
### Rule API
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`
- `__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
- `__str__()`: A string describing this rule's logic without its evaluation, override to explain custom rules

View File

@@ -7,9 +7,10 @@ use that version. These steps are for developers or platforms without compiled r
## General
What you'll need:
* [Python 3.11.9 or newer but less than 3.14](https://www.python.org/downloads/), not the Windows Store version
* [Python 3.11.9 or newer](https://www.python.org/downloads/), not the Windows Store version
* On Windows, please consider only using the latest supported version in production environments since security
updates for older versions are not easily available.
* Python 3.13.x is currently the newest supported version
* pip: included in downloads from python.org, separate in many Linux distributions
* Matching C compiler
* possibly optional, read operating system specific sections
@@ -52,32 +53,6 @@ Recommended steps
Refer to [Guide to Run Archipelago from Source Code on macOS](../worlds/generic/docs/mac_en.md).
## Linux
If your Linux distribution ships a compatible Python version (see [General](#general)) and pip, you can use that,
otherwise you may need to install Python from a 3rd party. Refer to documentation of your Linux distribution.
Installing a C compiler is usually optional. The package is typically named `gcc`, sometimes another package with the
base build tools may be required, i.e. `build-essential` (Debian/Ubuntu) or `base-devel` (Arch).
After getting the source code, it is strongly recommended to create a
[venv](https://docs.python.org/3/tutorial/venv.html) (Virtual Environment)
by hand or using an IDE, such as PyCharm, because Archipelago requires specific versions of Python packages.
Run `python ModuleUpdate.py` in the project root to install packages, run `python Launcher.py` to run the Launcher.
### Building
Builds contain (almost) all dependencies to run Archipelago on any Linux distribution that is as new or newer than the
one it was built on. Beware that currently only the oldest Ubuntu LTS available in GitHub actions is supported for that.
This means the easiest way to generate a build is by running the `Build` action from GitHub actions instead of building
locally. If you still want to, e.g. for local testing, you can by running
`python setup.py build_exe` to generate a binary distribution of Archipelago in `build/`. Or to generate an AppImage
first generate the binary distribution and then run `python setup.py bdist_appimage` to populate `dist/`. You need to
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

View File

@@ -47,27 +47,21 @@
## HTML
* Indent with 4 spaces for new code.
* Indent with 2 spaces for new code.
* kebab-case for ids and classes.
* Avoid using on* attributes (onclick, etc.).
## CSS / SCSS
## CSS
* Indent with 4 spaces for new code.
* Indent with 2 spaces for new code.
* `{` on the same line as the selector.
* Space between selector and `{`.
* No space between selector and `{`.
## JS
* Indent with 4 spaces.
* Indent `case` inside `switch ` with 4 spaces.
* Prefer double quotation marks (`"`).
* Indent with 2 spaces.
* Indent `case` inside `switch ` with 2 spaces.
* Use single quotes.
* Semicolons are required after every statement.
* Use [IIFEs](https://developer.mozilla.org/docs/Glossary/IIFE) to avoid polluting global scope.
* Prefer to use [defer](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script#defer)
in script tags, which retains order of execution but does not block.
* Avoid `<script async ...` in most cases, see [async and defer](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script#async_and_defer).
* Use addEventListener.
## KV

View File

@@ -4,7 +4,7 @@ Archipelago has a rudimentary API that can be queried by endpoints. The API is a
The following API requests are formatted as: `https://<Archipelago URL>/api/<endpoint>`
The returned data will be formatted in a combination of JSON lists or dicts, with their keys or values being notated in `blocks` (if applicable)
The returned data will be formated in a combination of JSON lists or dicts, with their keys or values being notated in `blocks` (if applicable)
Current endpoints:
- Datapackage API
@@ -24,21 +24,13 @@ Current endpoints:
- [`/get_rooms`](#getrooms)
- [`/get_seeds`](#getseeds)
## API Data Caching
To reduce the strain on an Archipelago WebHost, many API endpoints will cache their data and only poll new data in timed intervals. Each endpoint has their own caching time related to the type of data being served. More dynamic data is refreshed more frequently, while static data is cached for longer.
Each API endpoint will have their "Cache timer" listed under their definition (if any).
API calls to these endpoints should not be faster than the listed timer. This will result in wasted processing for your client and (more importantly) the Archipelago WebHost, as the data will not be refreshed by the WebHost until the internal timer has elapsed.
## Datapackage Endpoints
These endpoints are used by applications to acquire a room's datapackage, and validate that they have the correct datapackage for use. Datapackages normally include, item IDs, location IDs, and name groupings, for a given room, and are essential for mapping IDs received from Archipelago to their correct items or locations.
### `/datapackage`
<a name="datapackage"></a>
Fetches the current datapackage from the WebHost.
**Cache timer: None**
You'll receive a dict named `games` that contains a named dict of every game and its data currently supported by Archipelago.
Each game will have:
- A checksum `checksum`
@@ -48,7 +40,7 @@ Each game will have:
- Location name to AP ID dict `location_name_to_id`
Example:
```json
```
{
"games": {
...
@@ -84,10 +76,7 @@ Example:
### `/datapackage/<string:checksum>`
<a name="datapackagestringchecksum"></a>
Fetches a single datapackage by checksum.
**Cache timer: None**
Fetches a single datapackage by checksum.
Returns a dict of the game's data with:
- A checksum `checksum`
- A dict of item groups `item_name_groups`
@@ -99,13 +88,10 @@ Its format will be identical to the whole-datapackage endpoint (`/datapackage`),
### `/datapackage_checksum`
<a name="datapackagechecksum"></a>
Fetches the checksums of the current static datapackages on the WebHost.
**Cache timer: None**
Fetches the checksums of the current static datapackages on the WebHost.
You'll receive a dict with `game:checksum` key-value pairs for all the current officially supported games.
Example:
```json
```
{
...
"Donkey Kong Country 3":"f90acedcd958213f483a6a4c238e2a3faf92165e",
@@ -122,7 +108,6 @@ These endpoints are used internally for the WebHost to generate games and valida
<a name="generate"></a>
Submits a game to the WebHost for generation.
**This endpoint only accepts a POST HTTP request.**
**Cache timer: None**
There are two ways to submit data for generation: With a file and with JSON.
@@ -131,7 +116,7 @@ Have your ZIP of yaml(s) or a single yaml, and submit a POST request to the `/ge
If the options are valid, you'll be returned a successful generation response. (see [Generation Response](#generation-response))
Example using the python requests library:
```python
```
file = {'file': open('Games.zip', 'rb')}
req = requests.post("https://archipelago.gg/api/generate", files=file)
```
@@ -142,7 +127,7 @@ Finally, submit a POST request to the `/generate` endpoint.
If the weighted options are valid, you'll be returned a successful generation response (see [Generation Response](#generation-response))
Example using the python requests library:
```python
```
data = {"Test":{"game": "Factorio","name": "Test","Factorio": {}},}
weights={"weights": data}
req = requests.post("https://archipelago.gg/api/generate", json=weights)
@@ -158,7 +143,7 @@ Upon successful generation, you'll be sent a JSON dict response detailing the ge
- The API status page of the generation `wait_api_url` (see [Status Endpoint](#status))
Example:
```json
```
{
"detail": "19878f16-5a58-4b76-aab7-d6bf38be9463",
"encoded": "GYePFlpYS3aqt9a_OL6UYw",
@@ -182,14 +167,12 @@ If the generation detects a issue in generation, you'll be sent a dict with two
- Detailed issue in `detail`
In the event of an unhandled server exception, you'll be provided a dict with a single key `text`:
- Exception, `Uncaught Exception: <error>` with a 500 status code
- Exception, `Uncought Exception: <error>` with a 500 status code
### `/status/<suuid:seed>`
<a name="status"></a>
Retrieves the status of the seed's generation.
**Cache timer: None**
This endpoint will return a dict with a single key-value pair. The key will always be `text`
This endpoint will return a dict with a single key-vlaue pair. The key will always be `text`
The value will tell you the status of the generation:
- Generation was completed: `Generation done` with a 201 status code
- Generation request was not found: `Generation not found` with a 404 status code
@@ -201,8 +184,6 @@ Endpoints to fetch information of the active WebHost room with the supplied room
### `/room_status/<suuid:room_id>`
<a name="roomstatus"></a>
**Cache timer: None**
Will provide a dict of room data with the following keys:
- Tracker SUUID (`tracker`)
- A list of players (`players`)
@@ -211,10 +192,10 @@ Will provide a dict of room data with the following keys:
- Last activity timestamp (`last_activity`)
- The room timeout counter (`timeout`)
- A list of downloads for files required for gameplay (`downloads`)
- Each item is a dict containing the download URL and slot (`slot`, `download`)
- Each item is a dict containings the download URL and slot (`slot`, `download`)
Example:
```json
```
{
"downloads": [
{
@@ -263,7 +244,7 @@ Example:
]
],
"timeout": 7200,
"tracker": "2gVkMQgISGScA8wsvDZg5A"
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171"
}
```
@@ -273,27 +254,17 @@ can either be viewed while on a room tracker page, or from the [room's endpoint]
### `/tracker/<suuid:tracker>`
<a name=tracker></a>
**Cache timer: 60 seconds**
Will provide a dict of tracker data with the following keys:
- A list of players current alias data (`aliases`)
- Each item containing a dict with, their alias `alias`, their player number `player`, and their team `team`
- `alias` will return `null` if there is no alias set
- A list of items each player has received as a [NetworkItem](network%20protocol.md#networkitem) (`player_items_received`)
- Each item containing a dict with, a list of NetworkItems `items`, their player number `player`, their team `team`
- Each player's current alias (`aliases`)
- Will return the name if there is none
- A list of items each player has received as a NetworkItem (`player_items_received`)
- A list of checks done by each player as a list of the location id's (`player_checks_done`)
- Each item containing a dict with, a list of checked location id's `locations`, their player number `player`, and their team `team`
- A list of the total number of checks done by all players (`total_checks_done`)
- Each item will contain a dict with, the total checks done `checks_done`, and the team `team`
- A list of [Hints](network%20protocol.md#hint) data that players have used or received (`hints`)
- Each item containing a dict containing, a list of hint data `hints`, the player number `player`, and their team `team`
- A list containing the last activity time for each player, formatted in RFC 1123 format (`activity_timers`)
- Each item containing, last activity time `time`, their player number `player`, and their team `team`
- A list containing the last connection time for each player, formatted in RFC 1123 format (`connection_timers`)
- Each item containing, the time of their last connection `time`, their player number `player`, and their team `team`
- A list of the current [ClientStatus](network%20protocol.md#clientstatus) of each player (`player_status`)
- Each item will contain, their status `status`, their player number `player`, and their team `team`
- The total number of checks done by all players (`total_checks_done`)
- Hints that players have used or received (`hints`)
- The time of last activity of each player in RFC 1123 format (`activity_timers`)
- The time of last active connection of each player in RFC 1123 format (`connection_timers`)
- The current client status of each player (`player_status`)
Example:
```json
@@ -308,12 +279,7 @@ Example:
"team": 0,
"player": 2,
"alias": "Slot_Name_2"
},
{
"team": 0,
"player": 3,
"alias": null
},
}
],
"player_items_received": [
{
@@ -412,21 +378,13 @@ Example:
### `/static_tracker/<suuid:tracker>`
<a name=statictracker></a>
**Cache timer: 300 seconds**
Will provide a dict of static tracker data with the following keys:
- A list of item_link groups and their member players (`groups`)
- Each item containing a dict with, the slot registering the group `slot`, the item_link name `name`, and a list of members `members`
- A dict of datapackage hashes for each game (`datapackage`)
- Each item is a named dict of the game's name.
- Each game contains two keys, the datapackage's checksum hash `checksum`, and the version `version`
- item_link groups and their players (`groups`)
- The datapackage hash for each game (`datapackage`)
- This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary
- A list of number of checks found vs. total checks available per player (`player_locations_total`)
- Each list item contains a dict with three keys, the total locations for that slot `total_locations`, their player number `player`, and their team `team`
- The number of checks found vs. total checks available per player (`player_locations_total`)
- Same logic as the multitracker template: found = len(player_checks_done.locations) / total = player_locations_total.total_locations (all available checks).
- The game each player is playing (`player_game`)
- Provided as a list of objects with `team`, `player`, and `game`.
Example:
```json
@@ -451,10 +409,10 @@ Example:
],
"datapackage": {
"Archipelago": {
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb"
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb",
},
"The Messenger": {
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b"
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b",
}
},
"player_locations_total": [
@@ -469,29 +427,12 @@ Example:
"total_locations": 20
}
],
"player_game": [
{
"team": 0,
"player": 1,
"game": "Archipelago"
},
{
"team": 0,
"player": 2,
"game": "The Messenger"
}
]
}
```
### `/slot_data_tracker/<suuid:tracker>`
<a name=slotdatatracker></a>
Will provide a list of each player's slot_data.
**Cache timer: 300 seconds**
Each list item will contain a dict with the player's data:
- player slot number `player`
- A named dict `slot_data` containing any set slot data for that player
Will provide a list of each player's slot_data.
Example:
```json
@@ -519,8 +460,6 @@ User endpoints can get room and seed details from the current session tokens (co
### `/get_rooms`
<a name="getrooms"></a>
Retreives a list of all rooms currently owned by the session token.
**Cache timer: None**
Each list item will contain a dict with the room's details:
- Room SUUID (`room_id`)
- Seed SUUID (`seed_id`)
@@ -531,25 +470,25 @@ Each list item will contain a dict with the room's details:
- Room tracker SUUID (`tracker`)
Example:
```json
```
[
{
"creation_time": "Fri, 18 Apr 2025 19:46:53 GMT",
"last_activity": "Fri, 18 Apr 2025 21:16:02 GMT",
"last_port": 52122,
"room_id": "0D30FgQaRcWivFsw9o8qzw",
"seed_id": "TFjiarBgTsCj5-Jbe8u33A",
"room_id": "90ae5f9b-177c-4df8-ac53-9629fc3bff7a",
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6",
"timeout": 7200,
"tracker": "52BycvJhRe6knrYH8v4bag"
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171"
},
{
"creation_time": "Fri, 18 Apr 2025 20:36:42 GMT",
"last_activity": "Fri, 18 Apr 2025 20:36:46 GMT",
"last_port": 56884,
"room_id": "LMCFchESSNyuqcY3GxkhwA",
"seed_id": "CENtJMXCTGmkIYCzjB5Csg",
"room_id": "14465c05-d08e-4d28-96bd-916f994609d8",
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb",
"timeout": 7200,
"tracker": "2gVkMQgISGScA8wsvDZg5A"
"tracker": "4e624bd8-32b6-42e4-9178-aa407f72751c"
}
]
```
@@ -557,8 +496,6 @@ Example:
### `/get_seeds`
<a name="getseeds"></a>
Retreives a list of all seeds currently owned by the session token.
**Cache timer: None**
Each item in the list will contain a dict with the seed's details:
- Seed SUUID (`seed_id`)
- Creation timestamp (`creation_time`)
@@ -566,7 +503,7 @@ Each item in the list will contain a dict with the seed's details:
- Each item in the list will contain a list of the slot name and game
Example:
```json
```
[
{
"creation_time": "Fri, 18 Apr 2025 19:46:52 GMT",
@@ -592,7 +529,7 @@ Example:
"Ocarina of Time"
]
],
"seed_id": "CENtJMXCTGmkIYCzjB5Csg"
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6"
},
{
"creation_time": "Fri, 18 Apr 2025 20:36:39 GMT",
@@ -614,7 +551,7 @@ Example:
"Archipelago"
]
],
"seed_id": "TFjiarBgTsCj5-Jbe8u33A"
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
}
]
```

View File

@@ -225,10 +225,7 @@ and has a classification. The name needs to be unique within each game and must
letter or symbol). The ID needs to be unique across all locations within the game.
Locations and items can share IDs, and locations can share IDs with other games' locations.
World-specific IDs **must** be in the range 1 to 2<sup>53</sup>-1 (the largest integer that is "[safe](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER#description)"
to store in a 64-bit float, and thus all popular programming languages can handle). IDs ≤ 0 are global and reserved.
It's **recommended** to keep your IDs in the range 1 to 2<sup>31</sup>-1,
so only 32-bit integers are needed to hold your IDs.
World-specific IDs must be in the range 1 to 2<sup>53</sup>-1; IDs ≤ 0 are global and reserved.
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
@@ -770,7 +767,6 @@ class MyGameState(LogicMixin):
new_state.mygame_defeatable_enemies = {
player: enemies.copy() for player, enemies in self.mygame_defeatable_enemies.items()
}
return new_state
```
After doing this, you can now access `state.mygame_defeatable_enemies[player]` from your access rules.

View File

@@ -525,7 +525,7 @@ def randomize_entrances(
running_time = time.perf_counter() - start_time
if running_time > 1.0:
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player}, "
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player},"
f"named {world.multiworld.player_name[world.player]}")
return er_state

View File

@@ -208,11 +208,6 @@ Root: HKCR; Subkey: "{#MyAppName}apcivvipatch"; ValueData: "
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apeb"; ValueData: "{#MyAppName}ebpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ebpatch"; ValueData: "Archipelago EarthBound Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
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: ".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: "";

23
kvui.py
View File

@@ -19,7 +19,6 @@ os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
os.environ["KIVY_NO_ARGS"] = "1"
os.environ["KIVY_LOG_ENABLE"] = "0"
os.environ["SDL_MOUSE_FOCUS_CLICKTHROUGH"] = "1"
import Utils
@@ -36,17 +35,6 @@ Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set("kivy", "exit_on_escape", "0")
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
# Workaround for Kivy issue #9226.
# caused by kivy by default using probesysfs,
# which assumes all multi touch deviecs are touch screens.
# workaround provided by Snu of the kivy commmunity c:
from kivy.utils import platform
if platform == "linux":
options = Config.options("input")
for option in options:
if Config.get("input", option) == "probesysfs":
Config.remove_option("input", option)
# Workaround for an issue where importing kivy.core.window before loading sounds
# will hang the whole application on Linux once the first sound is loaded.
# kivymd imports kivy.core.window, so we have to do this before the first kivymd import.
@@ -139,7 +127,7 @@ class ImageButton(MDIconButton):
val = kwargs.pop(kwarg, "None")
if val != "None":
image_args[kwarg.replace("image_", "")] = val
super().__init__(**kwargs)
super().__init__()
self.image = ApAsyncImage(**image_args)
def set_center(button, center):
@@ -155,7 +143,6 @@ class ImageButton(MDIconButton):
class ScrollBox(MDScrollView):
layout: MDBoxLayout = ObjectProperty(None)
box_height: int = NumericProperty(dp(100))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -166,7 +153,6 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
def __init__(self, *args, **kwargs):
super(ToggleButton, self).__init__(*args, **kwargs)
self.bind(state=self._update_bg)
self._update_bg(self, self.state)
def _update_bg(self, _, state: str):
if self.disabled:
@@ -184,7 +170,7 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
child.text_color = self.theme_cls.onPrimaryColor
child.icon_color = self.theme_cls.onPrimaryColor
else:
self.md_bg_color = self.theme_cls.surfaceContainerLowColor
self.md_bg_color = self.theme_cls.surfaceContainerLowestColor
for child in self.children:
if child.theme_text_color == "Primary":
child.theme_text_color = "Custom"
@@ -198,6 +184,7 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
class ResizableTextField(MDTextField):
"""
Resizable MDTextField that manually overrides the builtin sizing.
Note that in order to use this, the sizing must be specified from within a .kv rule.
"""
def __init__(self, *args, **kwargs):
@@ -261,7 +248,7 @@ Factory.register("HoverBehavior", HoverBehavior)
class ToolTip(MDTooltipPlain):
markup = True
pass
class ServerToolTip(ToolTip):
@@ -296,8 +283,6 @@ class TooltipLabel(HovererableLabel, MDTooltip):
def on_mouse_pos(self, window, pos):
if not self.get_root_window():
return # Abort if not displayed
if self.disabled:
return
super().on_mouse_pos(window, pos)
if self.refs and self.hovered:

View File

@@ -1,2 +0,0 @@
[mypy]
mypy_path = typings

View File

@@ -1,21 +1,17 @@
colorama>=0.4.6
websockets>=13.0.1,<14
PyYAML>=6.0.3
jellyfish>=1.2.1
PyYAML>=6.0.2
jellyfish>=1.1.3
jinja2>=3.1.6
schema>=0.7.8
schema>=0.7.7
kivy>=2.3.1
bsdiff4>=1.2.6
platformdirs>=4.5.0
certifi>=2025.11.12
cython>=3.2.1
cymem>=2.0.13
orjson>=3.11.4
typing_extensions>=4.15.0
pyshortcuts>=1.9.6
pathspec>=0.12.1
platformdirs>=4.3.6
certifi>=2025.4.26
cython>=3.0.12
cymem>=2.0.11
orjson>=3.10.15
typing_extensions>=4.12.2
pyshortcuts>=1.9.1
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
kivymd>=2.0.1.dev0
# Legacy world dependencies that custom worlds rely on
Pymem>=1.13.0

View File

@@ -1,146 +0,0 @@
from collections import defaultdict
from typing import ClassVar, cast
from typing_extensions import override
from BaseClasses import CollectionState, Item, MultiWorld, Region
from worlds.AutoWorld import LogicMixin, World
from .rules import Rule
class CachedRuleBuilderWorld(World):
"""A World subclass that provides helpers for interacting with the rule builder"""
rule_item_dependencies: dict[str, set[int]]
"""A mapping of item name to set of rule ids"""
rule_region_dependencies: dict[str, set[int]]
"""A mapping of region name to set of rule ids"""
rule_location_dependencies: dict[str, set[int]]
"""A mapping of location name to set of rule ids"""
rule_entrance_dependencies: dict[str, set[int]]
"""A mapping of entrance name to set of rule ids"""
item_mapping: ClassVar[dict[str, str]] = {}
"""A mapping of actual item name to logical item name.
Useful when there are multiple versions of a collected item but the logic only uses one. For example:
item = Item("Currency x500"), rule = Has("Currency", count=1000), item_mapping = {"Currency x500": "Currency"}"""
rule_caching_enabled: ClassVar[bool] = True
"""Flag to inform rules that the caching system for this world is enabled. It should not be overridden."""
def __init__(self, multiworld: MultiWorld, player: int) -> None:
super().__init__(multiworld, player)
self.rule_item_dependencies = defaultdict(set)
self.rule_region_dependencies = defaultdict(set)
self.rule_location_dependencies = defaultdict(set)
self.rule_entrance_dependencies = defaultdict(set)
@override
def register_rule_dependencies(self, resolved_rule: Rule.Resolved) -> None:
for item_name, rule_ids in resolved_rule.item_dependencies().items():
self.rule_item_dependencies[item_name] |= rule_ids
for region_name, rule_ids in resolved_rule.region_dependencies().items():
self.rule_region_dependencies[region_name] |= rule_ids
for location_name, rule_ids in resolved_rule.location_dependencies().items():
self.rule_location_dependencies[location_name] |= rule_ids
for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items():
self.rule_entrance_dependencies[entrance_name] |= rule_ids
def register_rule_builder_dependencies(self) -> None:
"""Register all rules that depend on locations or entrances with their dependencies"""
for location_name, rule_ids in self.rule_location_dependencies.items():
try:
location = self.get_location(location_name)
except KeyError:
continue
if not isinstance(location.access_rule, Rule.Resolved):
continue
for item_name in location.access_rule.item_dependencies():
self.rule_item_dependencies[item_name] |= rule_ids
for region_name in location.access_rule.region_dependencies():
self.rule_region_dependencies[region_name] |= rule_ids
for entrance_name, rule_ids in self.rule_entrance_dependencies.items():
try:
entrance = self.get_entrance(entrance_name)
except KeyError:
continue
if not isinstance(entrance.access_rule, Rule.Resolved):
continue
for item_name in entrance.access_rule.item_dependencies():
self.rule_item_dependencies[item_name] |= rule_ids
for region_name in entrance.access_rule.region_dependencies():
self.rule_region_dependencies[region_name] |= rule_ids
@override
def collect(self, state: CollectionState, item: Item) -> bool:
changed = super().collect(state, item)
if changed and self.rule_item_dependencies:
player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
mapped_name = self.item_mapping.get(item.name, "")
rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name]
for rule_id in rule_ids:
if player_results.get(rule_id, None) is False:
del player_results[rule_id]
return changed
@override
def remove(self, state: CollectionState, item: Item) -> bool:
changed = super().remove(state, item)
if not changed:
return changed
player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
if self.rule_item_dependencies:
mapped_name = self.item_mapping.get(item.name, "")
rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name]
for rule_id in rule_ids:
player_results.pop(rule_id, None)
# clear all region dependent caches as none can be trusted
if self.rule_region_dependencies:
for rule_ids in self.rule_region_dependencies.values():
for rule_id in rule_ids:
player_results.pop(rule_id, None)
# clear all location dependent caches as they may have lost region access
if self.rule_location_dependencies:
for rule_ids in self.rule_location_dependencies.values():
for rule_id in rule_ids:
player_results.pop(rule_id, None)
# clear all entrance dependent caches as they may have lost region access
if self.rule_entrance_dependencies:
for rule_ids in self.rule_entrance_dependencies.values():
for rule_id in rule_ids:
player_results.pop(rule_id, None)
return changed
@override
def reached_region(self, state: CollectionState, region: Region) -> None:
super().reached_region(state, region)
if self.rule_region_dependencies:
player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
for rule_id in self.rule_region_dependencies[region.name]:
player_results.pop(rule_id, None)
class CachedRuleBuilderLogicMixin(LogicMixin):
multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable]
rule_builder_cache: dict[int, dict[int, bool]] # pyright: ignore[reportUninitializedInstanceVariable]
def init_mixin(self, multiworld: "MultiWorld") -> None:
players = multiworld.get_all_ids()
self.rule_builder_cache = {player: {} for player in players}
def copy_mixin(self, new_state: "CachedRuleBuilderLogicMixin") -> "CachedRuleBuilderLogicMixin":
new_state.rule_builder_cache = {
player: player_results.copy() for player, player_results in self.rule_builder_cache.items()
}
return new_state

View File

@@ -1,91 +0,0 @@
import dataclasses
import importlib
import operator
from collections.abc import Callable, Iterable
from typing import Any, Final, Literal, Self, cast
from typing_extensions import override
from Options import CommonOptions, Option
Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains", "in"]
OPERATORS: Final[dict[Operator, Callable[..., bool]]] = {
"eq": operator.eq,
"ne": operator.ne,
"gt": operator.gt,
"lt": operator.lt,
"ge": operator.ge,
"le": operator.le,
"contains": operator.contains,
"in": operator.contains,
}
OPERATOR_STRINGS: Final[dict[Operator, str]] = {
"eq": "==",
"ne": "!=",
"gt": ">",
"lt": "<",
"ge": ">=",
"le": "<=",
}
REVERSE_OPERATORS: Final[tuple[Operator, ...]] = ("in",)
@dataclasses.dataclass(frozen=True)
class OptionFilter:
option: type[Option[Any]]
value: Any
operator: Operator = "eq"
def to_dict(self) -> dict[str, Any]:
"""Returns a JSON compatible dict representation of this option filter"""
return {
"option": f"{self.option.__module__}.{self.option.__name__}",
"value": self.value,
"operator": self.operator,
}
def check(self, options: CommonOptions) -> bool:
"""Tests the given options dataclass to see if it passes this option filter"""
option_name = next(
(name for name, cls in options.__class__.type_hints.items() if cls is self.option),
None,
)
if option_name is None:
raise ValueError(f"Cannot find option {self.option.__name__} in options class {options.__class__.__name__}")
opt = cast(Option[Any] | None, getattr(options, option_name, None))
if opt is None:
raise ValueError(f"Invalid option: {option_name}")
fn = OPERATORS[self.operator]
return fn(self.value, opt) if self.operator in REVERSE_OPERATORS else fn(opt, self.value)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""Returns a new OptionFilter instance from a dict representation"""
if "option" not in data or "value" not in data:
raise ValueError("Missing required value and/or option")
option_path = data["option"]
try:
option_mod_name, option_cls_name = option_path.rsplit(".", 1)
option_module = importlib.import_module(option_mod_name)
option = getattr(option_module, option_cls_name, None)
except (ValueError, ImportError) as e:
raise ValueError(f"Cannot parse option '{option_path}'") from e
if option is None or not issubclass(option, Option):
raise ValueError(f"Invalid option '{option_path}' returns type '{option}' instead of Option subclass")
value = data["value"]
operator = data.get("operator", "eq")
return cls(option=cast(type[Option[Any]], option), value=value, operator=operator)
@classmethod
def multiple_from_dict(cls, data: Iterable[dict[str, Any]]) -> tuple[Self, ...]:
"""Returns a tuple of OptionFilters instances from an iterable of dict representations"""
return tuple(cls.from_dict(o) for o in data)
@override
def __str__(self) -> str:
op = OPERATOR_STRINGS.get(self.operator, self.operator)
return f"{self.option.__name__} {op} {self.value}"

File diff suppressed because it is too large Load Diff

View File

@@ -394,11 +394,11 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
manifest = json.load(manifest_file)
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it "
f"World directory {world_directory} has an archipelago.json manifest file, but it"
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the "
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
else:

View File

@@ -248,7 +248,6 @@ class WorldTestBase(unittest.TestCase):
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
call_all(self.multiworld, "finalize_multiworld")
self.assertTrue(fulfills_accessibility(), "Collected all locations, but can't beat the game.")
placed_items = [loc.item for loc in self.multiworld.get_locations() if loc.item and loc.item.code]
self.assertLessEqual(len(self.multiworld.itempool), len(placed_items),

View File

@@ -1,11 +1,9 @@
import unittest
from typing import Any, Dict, Optional
from typing import Callable, Dict, Optional
from typing_extensions import override
from BaseClasses import CollectionRule, MultiWorld, Region
from rule_builder.rules import Has, Rule
from test.general import TestWorld
from BaseClasses import CollectionState, MultiWorld, Region
class TestHelpers(unittest.TestCase):
@@ -18,7 +16,6 @@ class TestHelpers(unittest.TestCase):
self.multiworld.game[self.player] = "helper_test_game"
self.multiworld.player_name = {1: "Tester"}
self.multiworld.set_seed()
self.multiworld.worlds[self.player] = TestWorld(self.multiworld, self.player)
def test_region_helpers(self) -> None:
"""Tests `Region.add_locations()` and `Region.add_exits()` have correct behavior"""
@@ -49,9 +46,8 @@ class TestHelpers(unittest.TestCase):
"TestRegion1": {"TestRegion3"}
}
exit_rules: Dict[str, CollectionRule | Rule[Any]] = {
"TestRegion1": lambda state: state.has("test_item", self.player),
"TestRegion2": Has("test_item2"),
exit_rules: Dict[str, Callable[[CollectionState], bool]] = {
"TestRegion1": lambda state: state.has("test_item", self.player)
}
self.multiworld.regions += [Region(region, self.player, self.multiworld, regions[region]) for region in regions]
@@ -78,17 +74,13 @@ class TestHelpers(unittest.TestCase):
self.assertTrue(f"{parent} -> {exit_reg}" in created_exit_names)
if exit_reg in exit_rules:
entrance_name = exit_name if exit_name else f"{parent} -> {exit_reg}"
rule = exit_rules[exit_reg]
if isinstance(rule, Rule):
self.assertEqual(rule.resolve(self.multiworld.worlds[self.player]),
self.multiworld.get_entrance(entrance_name, self.player).access_rule)
else:
self.assertEqual(rule, self.multiworld.get_entrance(entrance_name, self.player).access_rule)
self.assertEqual(exit_rules[exit_reg],
self.multiworld.get_entrance(entrance_name, self.player).access_rule)
for region, exit_set in reg_exit_set.items():
for region in reg_exit_set:
current_region = self.multiworld.get_region(region, self.player)
current_region.add_exits(exit_set)
current_region.add_exits(reg_exit_set[region])
exit_names = {_exit.name for _exit in current_region.exits}
for reg_exit in exit_set:
for reg_exit in reg_exit_set[region]:
self.assertTrue(f"{region} -> {reg_exit}" in exit_names,
f"{region} -> {reg_exit} not in {exit_names}")

View File

@@ -88,7 +88,6 @@ class TestIDs(unittest.TestCase):
multiworld = setup_solo_multiworld(world_type)
distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill")
call_all(multiworld, "finalize_multiworld")
datapackage = world_type.get_data_package_data()
for item_group, item_names in datapackage["item_name_groups"].items():
self.assertIsInstance(item_group, str,

View File

@@ -46,8 +46,6 @@ class TestImplemented(unittest.TestCase):
with self.subTest(game=game_name, seed=multiworld.seed):
distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill")
call_all(multiworld, "finalize_multiworld")
call_all(multiworld, "pre_output")
for key, data in multiworld.worlds[1].fill_slot_data().items():
self.assertIsInstance(key, str, "keys in slot data must be a string")
convert_to_base_types(data) # only put base data types into slot data
@@ -95,7 +93,6 @@ class TestImplemented(unittest.TestCase):
with self.subTest(game=game_name, seed=multiworld.seed):
distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill")
call_all(multiworld, "finalize_multiworld")
# Note: `multiworld.get_spheres()` iterates a set of locations, so the order that locations are checked
# is nondeterministic and may vary between runs with the same seed.

View File

@@ -1,6 +1,5 @@
import unittest
from argparse import Namespace
from collections import ChainMap
from typing import Type
from BaseClasses import CollectionState, MultiWorld
@@ -83,13 +82,12 @@ 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.world_types.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type)
for item in multiworld.itempool:
self.assertIn(item.name, ChainMap(world_type.item_name_to_id, archipelago.item_name_to_id))
self.assertIn(item.name, world_type.item_name_to_id)
def test_item_links(self) -> None:
"""
Tests item link creation by creating a multiworld of 2 worlds for every game and linking their items together.
@@ -123,7 +121,6 @@ class TestBase(unittest.TestCase):
call_all(multiworld, "pre_fill")
distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill")
call_all(multiworld, "finalize_multiworld")
self.assertTrue(multiworld.can_beat_game(CollectionState(multiworld)), f"seed = {multiworld.seed}")
for game_name, world_type in AutoWorldRegister.world_types.items():

View File

@@ -1,9 +1,8 @@
import unittest
from BaseClasses import PlandoOptions
from Options import Choice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts
from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister
@@ -45,19 +44,19 @@ class TestOptions(unittest.TestCase):
}],
[{
"name": "ItemLinkGroup",
"item_pool": ["Hammer", "Sword"],
"item_pool": ["Hammer", "Bow"],
"link_replacement": False,
"replacement_item": None,
}]
]
# we really need some sort of test world but generic doesn't have enough items for this
world = AutoWorldRegister.world_types["APQuest"]
world = AutoWorldRegister.world_types["A Link to the Past"]
plando_options = PlandoOptions.from_option_string("bosses")
item_links = [ItemLinks.from_any(item_link_groups[0]), ItemLinks.from_any(item_link_groups[1])]
for link in item_links:
link.verify(world, "tester", plando_options)
self.assertIn("Hammer", link.value[0]["item_pool"])
self.assertIn("Sword", link.value[0]["item_pool"])
self.assertIn("Bow", link.value[0]["item_pool"])
# TODO test that the group created using these options has the items
@@ -82,19 +81,6 @@ class TestOptions(unittest.TestCase):
restricted_dumps(option.from_any(option.default))
if issubclass(option, Choice) and option.default in option.name_lookup:
restricted_dumps(option.from_text(option.name_lookup[option.default]))
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.world_types.items():
if game_name not in ("Archipelago", "Sudoku", "Super Metroid"):
for option_key, option in world_type.options_dataclass.type_hints.items():
if issubclass(option, OptionSet):
with self.subTest(game=game_name, option=option_key):
self.assertFalse(any(random_key in option.valid_keys for random_key in ("random",
"random-high",
"random-low")))
for key in option.valid_keys:
self.assertFalse("random-range" in key)
def test_pickle_dumps_plando(self):
"""Test that plando options using containers of a custom type can be pickled"""

View File

@@ -37,23 +37,3 @@ class TestPlayerOptions(unittest.TestCase):
self.assertEqual(new_weights["dict_2"]["option_g"], 50)
self.assertEqual(len(new_weights["set_1"]), 2)
self.assertIn("option_d", new_weights["set_1"])
def test_update_dict_supports_negatives_and_zeroes(self):
original_options = {
"dict_1": {"a": 1, "b": -1},
"dict_2": {"a": 1, "b": -1},
}
new_weights = Generate.update_weights(
original_options,
{
"+dict_1": {"a": -2, "b": 2},
"-dict_2": {"a": 1, "b": 2},
},
"Tested",
"",
)
self.assertEqual(new_weights["dict_1"]["a"], -1)
self.assertEqual(new_weights["dict_1"]["b"], 1)
self.assertEqual(new_weights["dict_2"]["a"], 0)
self.assertEqual(new_weights["dict_2"]["b"], -3)
self.assertIn("a", new_weights["dict_2"])

File diff suppressed because it is too large Load Diff

View File

@@ -70,13 +70,13 @@ if __name__ == "__main__":
empty_file = str(Path(tempdir) / "empty")
open(empty_file, "w").close()
sys.argv += ["--config_override", empty_file] # tests #5541
multis = [["APQuest"], ["Temp World"], ["APQuest", "Temp World"]]
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
p1_games: list[str] = []
data_paths: list[Path | None] = []
rooms: list[str] = []
multidata: Path | None
copy_world("APQuest", "Temp World")
copy_world("VVVVVV", "Temp World")
try:
for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)} offline")

View File

@@ -20,7 +20,7 @@ def copy(src: str, dst: str) -> None:
src_cls = AutoWorldRegister.world_types[src]
src_folder = Path(src_cls.__file__).parent
worlds_folder = src_folder.parent
if (not src_cls.__file__.endswith(("__init__.py", "world.py")) or not src_folder.is_dir()
if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir()
or not (worlds_folder / "generic").is_dir()):
raise ValueError(f"Unsupported layout for copy_world from {src}")
dst_folder = worlds_folder / dst_folder_name
@@ -28,14 +28,11 @@ def copy(src: str, dst: str) -> None:
raise ValueError(f"Destination {dst_folder} already exists")
shutil.copytree(src_folder, dst_folder)
_new_worlds[dst] = str(dst_folder)
for potential_world_class_file in ("__init__.py", "world.py"):
with open(dst_folder / potential_world_class_file, "r", encoding="utf-8-sig") as f:
contents = f.read()
r_src = re.escape(src)
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + r_src + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents)
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
contents = f.read()
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents)
def delete(name: str) -> None:

View File

@@ -61,7 +61,6 @@ class TestAllGamesMultiworld(MultiworldTestBase):
with self.subTest("filling multiworld", seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
call_all(self.multiworld, "finalize_multiworld")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
@@ -79,5 +78,4 @@ class TestTwoPlayerMulti(MultiworldTestBase):
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
call_all(self.multiworld, "finalize_multiworld")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")

View File

@@ -25,41 +25,31 @@ class TestGenerateYamlTemplates(unittest.TestCase):
if "World: with colon" in worlds.AutoWorld.AutoWorldRegister.world_types:
del worlds.AutoWorld.AutoWorldRegister.world_types["World: with colon"]
def test_name_with_colon(self) -> None:
from Options import generate_yaml_templates
from worlds.AutoWorld import AutoWorldRegister
from worlds.AutoWorld import World, WebWorld
class WebWorldWithColon(WebWorld):
options_presets = {
"Generic": {
"progression_balancing": "disabled",
"accessibility": "minimal",
}
}
from worlds.AutoWorld import World
class WorldWithColon(World):
game = "World: with colon"
item_name_to_id = {}
location_name_to_id = {}
web = WebWorldWithColon()
AutoWorldRegister.world_types = {WorldWithColon.game: WorldWithColon}
with TemporaryDirectory(f"archipelago_{__name__}") as temp_dir:
generate_yaml_templates(temp_dir)
path: Path
for path in Path(temp_dir).rglob("*"):
if path.is_file():
self.assertTrue(path.suffix == ".yaml")
with path.open(encoding="utf-8") as f:
try:
data = parse_yaml(f)
except:
f.seek(0)
print(f"Error in {path.name}:\n{f.read()}")
raise
self.assertIn("game", data)
self.assertIn(":", data["game"])
self.assertIn(data["game"], data)
self.assertIsInstance(data[data["game"]], dict)
for path in Path(temp_dir).iterdir():
self.assertTrue(path.is_file())
self.assertTrue(path.suffix == ".yaml")
with path.open(encoding="utf-8") as f:
try:
data = parse_yaml(f)
except:
f.seek(0)
print(f"Error in {path.name}:\n{f.read()}")
raise
self.assertIn("game", data)
self.assertIn(":", data["game"])
self.assertIn(data["game"], data)
self.assertIsInstance(data[data["game"]], dict)

View File

@@ -2,8 +2,8 @@ description: Almost blank test yaml
name: Player{NUMBER}
game:
APQuest: 1 # what else
Timespinner: 1 # what else
requires:
version: 0.2.6
APQuest: {}
Timespinner: {}

View File

@@ -1,22 +1,11 @@
import logging
import os
from uuid import UUID, uuid4, uuid5
from flask import url_for
from WebHostLib.customserver import set_up_logging, tear_down_logging
from . import TestBase
def _cleanup_logger(room_id: UUID) -> None:
from Utils import user_path
tear_down_logging(room_id)
try:
os.unlink(user_path("logs", f"{room_id}.txt"))
except OSError:
pass
class TestHostFakeRoom(TestBase):
room_id: UUID
log_filename: str
@@ -50,7 +39,7 @@ class TestHostFakeRoom(TestBase):
try:
os.unlink(self.log_filename)
except OSError:
except FileNotFoundError:
pass
def test_display_log_missing_full(self) -> None:
@@ -202,27 +191,3 @@ class TestHostFakeRoom(TestBase):
with db_session:
commands = select(command for command in Command if command.room.id == self.room_id) # type: ignore
self.assertNotIn("/help", (command.commandtext for command in commands))
def test_logger_teardown(self) -> None:
"""Verify that room loggers are removed from the global logging manager."""
from WebHostLib.customserver import tear_down_logging
room_id = uuid4()
self.addCleanup(_cleanup_logger, room_id)
set_up_logging(room_id)
self.assertIn(f"RoomLogger {room_id}", logging.Logger.manager.loggerDict)
tear_down_logging(room_id)
self.assertNotIn(f"RoomLogger {room_id}", logging.Logger.manager.loggerDict)
def test_handler_teardown(self) -> None:
"""Verify that handlers for room loggers are closed by tear_down_logging."""
from WebHostLib.customserver import tear_down_logging
room_id = uuid4()
self.addCleanup(_cleanup_logger, room_id)
logger = set_up_logging(room_id)
handlers = logger.handlers[:]
self.assertGreater(len(handlers), 0)
tear_down_logging(room_id)
for handler in handlers:
if isinstance(handler, logging.FileHandler):
self.assertTrue(handler.stream is None or handler.stream.closed)

View File

@@ -2,7 +2,7 @@ import unittest
from BaseClasses import PlandoOptions
from worlds import AutoWorldRegister
from Options import OptionCounter, NamedRange, NumericOption, OptionList, OptionSet, Visibility
from Options import OptionCounter, NamedRange, NumericOption, OptionList, OptionSet
class TestOptionPresets(unittest.TestCase):
@@ -19,9 +19,6 @@ class TestOptionPresets(unittest.TestCase):
# pass in all plando options in case a preset wants to require certain plando options
# for some reason
option.verify(world_type, "Test Player", PlandoOptions(sum(PlandoOptions)))
if not (Visibility.complex_ui in option.visibility or Visibility.simple_ui in option.visibility):
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' is not "
f"visible in any supported UI.")
supported_types = [NumericOption, OptionSet, OptionList, OptionCounter]
if not any([issubclass(option.__class__, t) for t in supported_types]):
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "

View File

@@ -1,82 +0,0 @@
from _typeshed import Incomplete
from kivy._clock import (
ClockEvent as ClockEvent,
ClockNotRunningError as ClockNotRunningError,
CyClockBase as CyClockBase,
CyClockBaseFree as CyClockBaseFree,
FreeClockEvent as FreeClockEvent,
)
__all__ = [
"Clock",
"ClockNotRunningError",
"ClockEvent",
"FreeClockEvent",
"CyClockBase",
"CyClockBaseFree",
"triggered",
"ClockBaseBehavior",
"ClockBaseInterruptBehavior",
"ClockBaseInterruptFreeBehavior",
"ClockBase",
"ClockBaseInterrupt",
"ClockBaseFreeInterruptAll",
"ClockBaseFreeInterruptOnly",
"mainthread",
]
class ClockBaseBehavior:
MIN_SLEEP: float
SLEEP_UNDERSHOOT: Incomplete
def __init__(self, async_lib: str = "asyncio", **kwargs) -> None: ...
def init_async_lib(self, lib) -> None: ...
@property
def frametime(self): ...
@property
def frames(self): ...
@property
def frames_displayed(self): ...
def usleep(self, microseconds) -> None: ...
def idle(self): ...
async def async_idle(self): ...
def tick(self) -> None: ...
async def async_tick(self) -> None: ...
def pre_idle(self) -> None: ...
def post_idle(self, ts, current): ...
def tick_draw(self) -> None: ...
def get_fps(self): ...
def get_rfps(self): ...
def get_time(self): ...
def get_boottime(self): ...
time: Incomplete
def handle_exception(self, e) -> None: ...
class ClockBaseInterruptBehavior(ClockBaseBehavior):
interupt_next_only: bool
def __init__(self, interupt_next_only: bool = False, **kwargs) -> None: ...
def init_async_lib(self, lib) -> None: ...
def usleep(self, microseconds) -> None: ...
async def async_usleep(self, microseconds) -> None: ...
def on_schedule(self, event) -> None: ...
def idle(self): ...
async def async_idle(self): ...
class ClockBaseInterruptFreeBehavior(ClockBaseInterruptBehavior):
def __init__(self, **kwargs) -> None: ...
def on_schedule(self, event): ...
class ClockBase(ClockBaseBehavior, CyClockBase):
def __init__(self, **kwargs) -> None: ...
def usleep(self, microseconds) -> None: ...
class ClockBaseInterrupt(ClockBaseInterruptBehavior, CyClockBase): ...
class ClockBaseFreeInterruptAll(ClockBaseInterruptFreeBehavior, CyClockBaseFree): ...
class ClockBaseFreeInterruptOnly(ClockBaseInterruptFreeBehavior, CyClockBaseFree):
def idle(self): ...
async def async_idle(self): ...
def mainthread(func): ...
def triggered(timeout: int = 0, interval: bool = False): ...
Clock: ClockBase

View File

@@ -1,26 +1,14 @@
from __future__ import annotations
import abc
from bisect import bisect_right
from dataclasses import dataclass
import enum
import logging
from typing import (TYPE_CHECKING, Any, ClassVar, Dict, Generic, Iterable,
Optional, Sequence, Tuple, TypeGuard, TypeVar, Union)
from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, Tuple, Any, Optional, Union, TypeGuard
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components
if TYPE_CHECKING:
from SNIClient import SNIContext
SNES_READ_CHUNK_SIZE = 2048
"""
note: SNI v0.0.101 currently has a bug where reads from
RetroArch >2048 bytes will only return the last ~2048 bytes read.
https://github.com/alttpo/sni/issues/51
"""
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"),
description="A client for connecting to SNES consoles via Super Nintendo Interface.")
components.append(component)
@@ -103,119 +91,3 @@ class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister):
def on_package(self, ctx: SNIContext, cmd: str, args: Dict[str, Any]) -> None:
""" override this with code to handle packages from the server """
pass
@dataclass(frozen=True, slots=True, order=True)
class Read:
""" snes memory read - address and size in bytes """
address: int
size: int
@dataclass(frozen=True, slots=True)
class _MemRead:
location: Read
data: bytes
_T_Enum = TypeVar("_T_Enum", bound=enum.Enum)
class SnesData(Generic[_T_Enum]):
_ranges: Sequence[_MemRead]
""" sorted by address """
def __init__(self, ranges: Sequence[tuple[Read, bytes]]) -> None:
self._ranges = [_MemRead(r, d) for r, d in ranges]
def get(self, read: _T_Enum) -> bytes:
assert isinstance(read.value, Read), read.value
address = read.value.address
index = bisect_right(self._ranges, address, key=lambda r: r.location.address) - 1
assert index >= 0, (self._ranges, read.value)
mem_read = self._ranges[index]
sub_index = address - mem_read.location.address
return mem_read.data[sub_index:sub_index + read.value.size]
class SnesReader(Generic[_T_Enum]):
"""
how to use:
```
from enum import Enum
from worlds.AutoSNIClient import Read, SNIClient, SnesReader
class MyGameMemory(Enum):
game_mode = Read(WRAM_START + 0x0998, 1)
send_queue = Read(SEND_QUEUE_START, 8 * 127)
...
snes_reader = SnesReader(MyGameMemory)
snes_data = await snes_reader.read(ctx)
if snes_data is None:
snes_logger.info("error reading from snes")
return
game_mode = snes_data.get(MyGameMemory.game_mode)
```
"""
_ranges: Sequence[Read]
""" sorted by address """
def __init__(self, reads: type[_T_Enum]) -> None:
self._ranges = self._make_ranges(reads)
@staticmethod
def _make_ranges(reads: type[enum.Enum]) -> Sequence[Read]:
unprocessed_reads: list[Read] = []
for e in reads:
assert isinstance(e.value, Read), (reads.__name__, e, e.value)
unprocessed_reads.append(e.value)
unprocessed_reads.sort()
ranges: list[Read] = []
for read in unprocessed_reads:
# v end of the previous range
if len(ranges) == 0 or read.address - (ranges[-1].address + ranges[-1].size) > 255:
ranges.append(read)
else: # combine with previous range
chunk_address = ranges[-1].address
assert read.address >= chunk_address, "sort() didn't work? or something"
original_chunk_size = ranges[-1].size
new_size = max((read.address + read.size) - chunk_address,
original_chunk_size)
ranges[-1] = Read(chunk_address, new_size)
logging.debug(f"{len(ranges)=} {max(r.size for r in ranges)=}")
return ranges
async def read(self, ctx: "SNIContext") -> SnesData[_T_Enum] | None:
"""
returns `None` if reading fails,
otherwise returns the data for the registered `Enum`
"""
from SNIClient import snes_read
reads: list[tuple[Read, bytes]] = []
for r in self._ranges:
if r.size < SNES_READ_CHUNK_SIZE: # most common
response = await snes_read(ctx, r.address, r.size)
if response is None:
return None
reads.append((r, response))
else: # big read
# Problems were reported with big reads,
# so we chunk it into smaller pieces.
read_so_far = 0
collection: list[bytes] = []
while read_so_far < r.size:
remaining_size = r.size - read_so_far
chunk_size = min(SNES_READ_CHUNK_SIZE, remaining_size)
response = await snes_read(ctx, r.address + read_so_far, chunk_size)
if response is None:
return None
collection.append(response)
read_so_far += chunk_size
reads.append((r, b"".join(collection)))
return SnesData(reads)

View File

@@ -5,18 +5,17 @@ import logging
import pathlib
import sys
import time
from collections.abc import Callable, Iterable, Mapping
from random import Random
from typing import (Any, ClassVar, Dict, FrozenSet, List, Optional, Self, Set, TextIO, Tuple,
from dataclasses import make_dataclass
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Mapping, Optional, Set, TextIO, Tuple,
TYPE_CHECKING, Type, Union)
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
from BaseClasses import CollectionState, Entrance
from rule_builder.rules import CustomRuleRegister, Rule
from BaseClasses import CollectionState
from Utils import Version
if TYPE_CHECKING:
from BaseClasses import CollectionRule, Item, Location, MultiWorld, Region, Tutorial
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
from NetUtils import GamesPackage, MultiData
from settings import Group
@@ -48,31 +47,27 @@ class AutoWorldRegister(type):
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoWorldRegister:
if "web" in dct:
assert isinstance(dct["web"], WebWorld), "WebWorld has to be instantiated."
# filter out any events
dct["item_name_to_id"] = {name: id for name, id in dct["item_name_to_id"].items() if id}
dct["location_name_to_id"] = {name: id for name, id in dct["location_name_to_id"].items() if id}
# build reverse lookups
dct["item_id_to_name"] = {code: name for name, code in dct["item_name_to_id"].items()}
dct["location_id_to_name"] = {code: name for name, code in dct["location_name_to_id"].items()}
# build rest
dct["item_names"] = frozenset(dct["item_name_to_id"])
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("item_name_groups", {}).items()}
dct["item_name_groups"]["Everything"] = dct["item_names"]
dct["location_names"] = frozenset(dct["location_name_to_id"])
dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("location_name_groups", {}).items()}
dct["location_name_groups"]["Everywhere"] = dct["location_names"]
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
# move away from get_required_client_version function
if "game" in dct:
assert "item_name_to_id" in dct, f"{name}: item_name_to_id is required"
assert "location_name_to_id" in dct, f"{name}: location_name_to_id is required"
# filter out any events
dct["item_name_to_id"] = {name: id for name, id in dct["item_name_to_id"].items() if id}
dct["location_name_to_id"] = {name: id for name, id in dct["location_name_to_id"].items() if id}
# build reverse lookups
dct["item_id_to_name"] = {code: name for name, code in dct["item_name_to_id"].items()}
dct["location_id_to_name"] = {code: name for name, code in dct["location_name_to_id"].items()}
# build rest
dct["item_names"] = frozenset(dct["item_name_to_id"])
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("item_name_groups", {}).items()}
dct["item_name_groups"]["Everything"] = dct["item_names"]
dct["location_names"] = frozenset(dct["location_name_to_id"])
dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("location_name_groups", {}).items()}
dct["location_name_groups"]["Everywhere"] = dct["location_names"]
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
# move away from get_required_client_version function
assert "get_required_client_version" not in dct, f"{name}: required_client_version is an attribute now"
# set minimum required_client_version from bases
if "required_client_version" in dct and bases:
@@ -178,8 +173,7 @@ def _timed_call(method: Callable[..., Any], *args: Any,
def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args: Any) -> Any:
world = multiworld.worlds[player]
method = getattr(world, method_name)
method = getattr(multiworld.worlds[player], method_name)
try:
ret = _timed_call(method, *args, multiworld=multiworld, player=player)
except Exception as e:
@@ -190,10 +184,6 @@ def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args:
logging.error(message)
raise e
else:
# Convenience for CachedRuleBuilderWorld users: Ensure that caching setup function is called
# Can be removed once dependency system is improved
if method_name == "set_rules" and hasattr(world, "register_rule_builder_dependencies"):
call_single(multiworld, "register_rule_builder_dependencies", player)
return ret
@@ -234,7 +224,7 @@ class WebWorld(metaclass=WebWorldRegister):
tutorials: List["Tutorial"]
"""docs folder will also be scanned for tutorial guides. Each Tutorial class is to be used for one guide."""
theme: str = "grass"
theme = "grass"
"""Choose a theme for you /game/* pages.
Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone"""
@@ -430,23 +420,6 @@ class World(metaclass=AutoWorldRegister):
This happens before progression balancing, so the items may not be in their final locations yet.
"""
def finalize_multiworld(self) -> None:
"""
Optional Method that is called after fill and progression balancing.
This is the last stage of generation where worlds may change logically relevant data,
such as item placements and connections. To not break assumptions,
only ever increase accessibility, never decrease it.
"""
pass
def pre_output(self):
"""
Optional method that is called before output generation.
Items and connections are not meant to be moved anymore,
anything that would affect logical spheres is forbidden at this point.
"""
pass
def generate_output(self, output_directory: str) -> None:
"""
This method gets called from a threadpool, do not use multiworld.random here.
@@ -511,14 +484,7 @@ class World(metaclass=AutoWorldRegister):
raise NotImplementedError
def get_filler_item_name(self) -> str:
"""
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
does not need to be ItemClassification.filler.
The item name returned can be for a trap, useful, and/or progression item as long as it's repeatable.
"""
"""Called when the item pool needs to be filled with additional items to match location count."""
logging.warning(f"World {self} is generating a filler item without custom filler pool.")
return self.random.choice(tuple(self.item_name_to_id.keys()))
@@ -572,10 +538,6 @@ class World(metaclass=AutoWorldRegister):
return True
return False
def reached_region(self, state: "CollectionState", region: "Region") -> None:
"""Called when a region is newly reachable by the state."""
pass
# following methods should not need to be overridden.
def create_filler(self) -> "Item":
return self.create_item(self.get_filler_item_name())
@@ -624,64 +586,6 @@ class World(metaclass=AutoWorldRegister):
res["checksum"] = data_package_checksum(res)
return res
@classmethod
def get_rule_cls(cls, name: str) -> type[Rule[Self]]:
"""Returns the world-registered or default rule with the given name"""
return CustomRuleRegister.get_rule_cls(cls.game, name)
@classmethod
def rule_from_dict(cls, data: Mapping[str, Any]) -> Rule[Self]:
"""Create a rule instance from a serialized dict representation"""
name = data.get("rule", "")
rule_class = cls.get_rule_cls(name)
return rule_class.from_dict(data, cls)
def set_rule(self, spot: Location | Entrance, rule: CollectionRule | Rule[Any]) -> None:
"""Sets an access rule for a location or entrance"""
if isinstance(rule, Rule):
rule = rule.resolve(self)
self.register_rule_dependencies(rule)
if isinstance(spot, Entrance):
self._register_rule_indirects(rule, spot)
spot.access_rule = rule
def set_completion_rule(self, rule: CollectionRule | Rule[Any]) -> None:
"""Set the completion rule for this world"""
if isinstance(rule, Rule):
rule = rule.resolve(self)
self.register_rule_dependencies(rule)
self.multiworld.completion_condition[self.player] = rule
def create_entrance(
self,
from_region: Region,
to_region: Region,
rule: CollectionRule | Rule[Any] | None = None,
name: str | None = None,
force_creation: bool = False,
) -> Entrance | None:
"""Try to create an entrance between regions with the given rule,
skipping it if the rule resolves to False (unless force_creation is True)"""
if rule is not None and isinstance(rule, Rule):
rule = rule.resolve(self)
if rule.always_false and not force_creation:
return None
self.register_rule_dependencies(rule)
entrance = from_region.connect(to_region, name, rule=rule)
if rule and isinstance(rule, Rule.Resolved):
self._register_rule_indirects(rule, entrance)
return entrance
def register_rule_dependencies(self, resolved_rule: Rule.Resolved) -> None:
"""Hook for registering dependencies when a rule is assigned for this world"""
pass
def _register_rule_indirects(self, resolved_rule: Rule.Resolved, entrance: Entrance) -> None:
if self.explicit_indirect_conditions:
for indirect_region in resolved_rule.region_dependencies().keys():
self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance)
# any methods attached to this can be used as part of CollectionState,
# please use a prefix as all of them get clobbered together

View File

@@ -21,10 +21,6 @@ if TYPE_CHECKING:
from Utils import Version
class ImproperlyConfiguredAutoPatchError(Exception):
pass
class AutoPatchRegister(abc.ABCMeta):
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
file_endings: ClassVar[Dict[str, AutoPatchRegister]] = {}
@@ -34,28 +30,8 @@ class AutoPatchRegister(abc.ABCMeta):
new_class = super().__new__(mcs, name, bases, dct)
if "game" in dct:
AutoPatchRegister.patch_types[dct["game"]] = new_class
if not callable(getattr(new_class, "patch", None)):
raise ImproperlyConfiguredAutoPatchError(
f"Container {new_class} uses metaclass AutoPatchRegister, but does not have a patch method defined."
)
patch_file_ending = dct.get("patch_file_ending")
if patch_file_ending == ".zip":
raise ImproperlyConfiguredAutoPatchError(
f'Auto patch container {new_class} uses file ending ".zip", which is not allowed.'
)
if patch_file_ending is None:
raise ImproperlyConfiguredAutoPatchError(
f"Need an expected file ending for auto patch container {new_class}"
)
existing_handler = AutoPatchRegister.file_endings.get(patch_file_ending)
if existing_handler:
raise ImproperlyConfiguredAutoPatchError(
f"Two auto patch containers are using the same file extension: {new_class}, {existing_handler}"
)
if not dct["patch_file_ending"]:
raise Exception(f"Need an expected file ending for {name}")
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
return new_class

View File

@@ -5,7 +5,7 @@ import weakref
from enum import Enum, auto
from typing import Optional, Callable, List, Iterable, Tuple
from Utils import local_path, open_filename, is_frozen, is_kivy_running, open_file, user_path, read_apignore
from Utils import local_path, open_filename, is_frozen, is_kivy_running
class Type(Enum):
@@ -204,18 +204,6 @@ def install_apworld(apworld_path: str = "") -> None:
Utils.messagebox("Install complete.", f"Installed APWorld from {source}.")
def export_datapackage() -> None:
import json
from worlds import network_data_package
path = user_path("datapackage_export.json")
with open(path, "w") as f:
json.dump(network_data_package, f, indent=4)
open_file(path)
components: List[Component] = [
# Launcher
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
@@ -225,8 +213,6 @@ components: List[Component] = [
description="Host a generated multiworld on your computer."),
Component('Generate', 'Generate', cli=True,
description="Generate a multiworld with the YAMLs in the players folder."),
Component("Options Creator", "OptionsCreator", "ArchipelagoOptionsCreator", component_type=Type.TOOL,
description="Visual creator for Archipelago option files."),
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld"),
description="Install an APWorld to play games not included with Archipelago by default."),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
@@ -244,11 +230,8 @@ components: List[Component] = [
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
# MegaMan Battle Network 3
Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3')),
Component("Export Datapackage", func=export_datapackage, component_type=Type.TOOL,
description="Write item/location data for installed worlds to a file and open it."),
#MegaMan Battle Network 3
Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3'))
]
@@ -279,10 +262,6 @@ if not is_frozen():
games = [(worldname, worldtype) for worldname, worldtype in AutoWorldRegister.world_types.items()
if not worldtype.zip_path]
global_apignores = read_apignore(local_path("data", "GLOBAL.apignore"))
if not global_apignores:
raise RuntimeError("Could not read global apignore file for build component")
apworlds_folder = os.path.join("build", "apworlds")
os.makedirs(apworlds_folder, exist_ok=True)
for worldname, worldtype in games:
@@ -296,11 +275,11 @@ if not is_frozen():
manifest = json.load(manifest_file)
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it "
f"World directory {world_directory} has an archipelago.json manifest file, but it"
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the "
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
else:
@@ -310,17 +289,18 @@ if not is_frozen():
apworld = APWorldContainer(str(zip_path))
apworld.game = worldtype.game
manifest.update(apworld.get_manifest())
apworld.manifest_path = os.path.join(file_name, "archipelago.json")
local_ignores = read_apignore(pathlib.Path(world_directory, ".apignore"))
apignores = global_apignores + local_ignores if local_ignores else global_apignores
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for file in apignores.match_tree_files(world_directory, negate=True):
zf.write(pathlib.Path(world_directory, file), pathlib.Path(file_name, file))
apworld.manifest_path = f"{file_name}/archipelago.json"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
for path in pathlib.Path(world_directory).rglob("*"):
relative_path = os.path.join(*path.parts[path.parts.index("worlds") + 1:])
if "__MACOSX" in relative_path or ".DS_STORE" in relative_path or "__pycache__" in relative_path:
continue
if not relative_path.endswith("archipelago.json"):
zf.write(path, relative_path)
zf.writestr(apworld.manifest_path, json.dumps(manifest))
open_folder(apworlds_folder)
components.append(Component("Build APWorlds", func=_build_apworlds, cli=True,
components.append(Component('Build APWorlds', func=_build_apworlds, cli=True,
description="Build APWorlds from loose-file world folders."))

View File

@@ -1,20 +1,17 @@
import importlib
import importlib.abc
import importlib.machinery
import importlib.util
import logging
import os
import sys
import warnings
import zipimport
import time
import dataclasses
import json
from pathlib import Path
from types import ModuleType
from typing import List, Sequence
from zipfile import BadZipFile
from typing import List
from NetUtils import DataPackage
from Utils import local_path, user_path, Version, version_tuple, tuplize_version, messagebox
from Utils import local_path, user_path, Version, version_tuple, tuplize_version
local_folder = os.path.dirname(__file__)
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
@@ -23,14 +20,14 @@ try:
except OSError: # can't access/write?
user_folder = None
__all__ = [
__all__ = {
"network_data_package",
"AutoWorldRegister",
"world_sources",
"local_folder",
"user_folder",
"failed_world_loads",
]
}
failed_world_loads: List[str] = []
@@ -56,7 +53,21 @@ class WorldSource:
def load(self) -> bool:
try:
start = time.perf_counter()
importlib.import_module(f".{Path(self.path).stem}", "worlds")
if self.is_zip:
importer = zipimport.zipimporter(self.resolved_path)
spec = importer.find_spec(os.path.basename(self.path).rsplit(".", 1)[0])
assert spec, f"{self.path} is not a loadable module"
mod = importlib.util.module_from_spec(spec)
mod.__package__ = f"worlds.{mod.__package__}"
mod.__name__ = f"worlds.{mod.__name__}"
sys.modules[mod.__name__] = mod
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="__package__ != __spec__.parent")
importer.exec_module(mod)
else:
importlib.import_module(f".{self.path}", "worlds")
self.time_taken = time.perf_counter()-start
return True
@@ -101,6 +112,7 @@ for world_source in world_sources:
else:
world_source.load()
from .AutoWorld import AutoWorldRegister
for world_source in world_sources:
@@ -145,15 +157,6 @@ if apworlds:
logging.error(e)
else:
raise e
except BadZipFile as e:
err_message = (f"The world source {apworld_source.resolved_path} is not a valid zip. "
"It is likely either corrupted, or was packaged incorrectly.")
if sys.stdout:
raise RuntimeError(err_message) from e
else:
messagebox("Couldn't load worlds", err_message, error=True)
sys.exit(1)
if apworld.minimum_ap_version and apworld.minimum_ap_version > version_tuple:
fail_world(apworld.game,
@@ -171,16 +174,6 @@ if apworlds:
core_compatible.sort(
key=lambda element: element[1].world_version if element[1].world_version else Version(0, 0, 0),
reverse=True)
apworld_module_specs = {}
class APWorldModuleFinder(importlib.abc.MetaPathFinder):
def find_spec(
self, fullname: str, _path: Sequence[str] | None, _target: ModuleType = None
) -> importlib.machinery.ModuleSpec | None:
return apworld_module_specs.get(fullname)
sys.meta_path.insert(0, APWorldModuleFinder())
for apworld_source, apworld in core_compatible:
if apworld.game and apworld.game in AutoWorldRegister.world_types:
fail_world(apworld.game,
@@ -188,12 +181,6 @@ if apworlds:
f"as its game {apworld.game} is already loaded.",
add_as_failed_to_load=False)
else:
importer = zipimport.zipimporter(apworld_source.resolved_path)
world_name = Path(apworld.path).stem
spec = importer.find_spec(f"worlds.{world_name}")
apworld_module_specs[f"worlds.{world_name}"] = spec
apworld_source.load()
if apworld.game in AutoWorldRegister.world_types:
# world could fail to load at this point

View File

@@ -9,7 +9,7 @@ from collections import Counter
from typing import TYPE_CHECKING, Any
from typing import Dict, Generator, Iterable, List, Set, Tuple, Union, final
from .proto import sc2api_pb2 as sc_pb
from s2clientprotocol import sc2api_pb2 as sc_pb
from .constants import (
IS_PLACEHOLDER,

View File

@@ -4,11 +4,11 @@ from typing import Dict, Iterable, List, Optional, Set, Tuple, Union
from worlds._sc2common.bot import logger
from .proto import debug_pb2 as debug_pb
from .proto import query_pb2 as query_pb
from .proto import raw_pb2 as raw_pb
from .proto import sc2api_pb2 as sc_pb
from .proto import spatial_pb2 as spatial_pb
from s2clientprotocol import debug_pb2 as debug_pb
from s2clientprotocol import query_pb2 as query_pb
from s2clientprotocol import raw_pb2 as raw_pb
from s2clientprotocol import sc2api_pb2 as sc_pb
from s2clientprotocol import spatial_pb2 as spatial_pb
from .data import ActionResult, ChatChannel, Race, Result, Status
from .game_data import AbilityData, GameData

View File

@@ -2,7 +2,7 @@ import platform
from pathlib import Path
from worlds._sc2common.bot import logger
from .proto import sc2api_pb2 as sc_pb
from s2clientprotocol import sc2api_pb2 as sc_pb
from .player import Computer
from .protocol import Protocol

View File

@@ -7,11 +7,11 @@ https://github.com/Blizzard/s2client-api/blob/d9ba0a33d6ce9d233c2a4ee988360c188f
"""
import enum
from .proto import common_pb2 as common_pb
from .proto import data_pb2 as data_pb
from .proto import error_pb2 as error_pb
from .proto import raw_pb2 as raw_pb
from .proto import sc2api_pb2 as sc_pb
from s2clientprotocol import common_pb2 as common_pb
from s2clientprotocol import data_pb2 as data_pb
from s2clientprotocol import error_pb2 as error_pb
from s2clientprotocol import raw_pb2 as raw_pb
from s2clientprotocol import sc2api_pb2 as sc_pb
CreateGameError = enum.Enum("CreateGameError", sc_pb.ResponseCreateGame.Error.items())

View File

@@ -15,7 +15,7 @@ import mpyq
import portpicker
from aiohttp import ClientSession, ClientWebSocketResponse
from worlds._sc2common.bot import logger
from .proto import sc2api_pb2 as sc_pb
from s2clientprotocol import sc2api_pb2 as sc_pb
from .bot_ai import BotAI
from .client import Client

View File

@@ -5,7 +5,7 @@ import math
import random
from typing import TYPE_CHECKING, Iterable, List, Set, Tuple, Union
from .proto import common_pb2 as common_pb
from s2clientprotocol import common_pb2 as common_pb
if TYPE_CHECKING:
from .unit import Unit

View File

@@ -1,50 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/common.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/common.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1ds2clientprotocol/common.proto\x12\x0eSC2APIProtocol\">\n\x10\x41vailableAbility\x12\x12\n\nability_id\x18\x01 \x01(\x05\x12\x16\n\x0erequires_point\x18\x02 \x01(\x08\"X\n\tImageData\x12\x16\n\x0e\x62its_per_pixel\x18\x01 \x01(\x05\x12%\n\x04size\x18\x02 \x01(\x0b\x32\x17.SC2APIProtocol.Size2DI\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"\x1e\n\x06PointI\x12\t\n\x01x\x18\x01 \x01(\x05\x12\t\n\x01y\x18\x02 \x01(\x05\"T\n\nRectangleI\x12\"\n\x02p0\x18\x01 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\x12\"\n\x02p1\x18\x02 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\"\x1f\n\x07Point2D\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\"(\n\x05Point\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\x12\t\n\x01z\x18\x03 \x01(\x02\"\x1f\n\x07Size2DI\x12\t\n\x01x\x18\x01 \x01(\x05\x12\t\n\x01y\x18\x02 \x01(\x05*A\n\x04Race\x12\n\n\x06NoRace\x10\x00\x12\n\n\x06Terran\x10\x01\x12\x08\n\x04Zerg\x10\x02\x12\x0b\n\x07Protoss\x10\x03\x12\n\n\x06Random\x10\x04')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.common_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_RACE']._serialized_start=429
_globals['_RACE']._serialized_end=494
_globals['_AVAILABLEABILITY']._serialized_start=49
_globals['_AVAILABLEABILITY']._serialized_end=111
_globals['_IMAGEDATA']._serialized_start=113
_globals['_IMAGEDATA']._serialized_end=201
_globals['_POINTI']._serialized_start=203
_globals['_POINTI']._serialized_end=233
_globals['_RECTANGLEI']._serialized_start=235
_globals['_RECTANGLEI']._serialized_end=319
_globals['_POINT2D']._serialized_start=321
_globals['_POINT2D']._serialized_end=352
_globals['_POINT']._serialized_start=354
_globals['_POINT']._serialized_end=394
_globals['_SIZE2DI']._serialized_start=396
_globals['_SIZE2DI']._serialized_end=427
# @@protoc_insertion_point(module_scope)

View File

@@ -1,55 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/data.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/data.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from . import common_pb2 as s2clientprotocol_dot_common__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bs2clientprotocol/data.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\"\xc4\x03\n\x0b\x41\x62ilityData\x12\x12\n\nability_id\x18\x01 \x01(\r\x12\x11\n\tlink_name\x18\x02 \x01(\t\x12\x12\n\nlink_index\x18\x03 \x01(\r\x12\x13\n\x0b\x62utton_name\x18\x04 \x01(\t\x12\x15\n\rfriendly_name\x18\x05 \x01(\t\x12\x0e\n\x06hotkey\x18\x06 \x01(\t\x12\x1c\n\x14remaps_to_ability_id\x18\x07 \x01(\r\x12\x11\n\tavailable\x18\x08 \x01(\x08\x12\x32\n\x06target\x18\t \x01(\x0e\x32\".SC2APIProtocol.AbilityData.Target\x12\x15\n\rallow_minimap\x18\n \x01(\x08\x12\x16\n\x0e\x61llow_autocast\x18\x0b \x01(\x08\x12\x13\n\x0bis_building\x18\x0c \x01(\x08\x12\x18\n\x10\x66ootprint_radius\x18\r \x01(\x02\x12\x1c\n\x14is_instant_placement\x18\x0e \x01(\x08\x12\x12\n\ncast_range\x18\x0f \x01(\x02\"I\n\x06Target\x12\x08\n\x04None\x10\x01\x12\t\n\x05Point\x10\x02\x12\x08\n\x04Unit\x10\x03\x12\x0f\n\x0bPointOrUnit\x10\x04\x12\x0f\n\x0bPointOrNone\x10\x05\"J\n\x0b\x44\x61mageBonus\x12,\n\tattribute\x18\x01 \x01(\x0e\x32\x19.SC2APIProtocol.Attribute\x12\r\n\x05\x62onus\x18\x02 \x01(\x02\"\xd7\x01\n\x06Weapon\x12/\n\x04type\x18\x01 \x01(\x0e\x32!.SC2APIProtocol.Weapon.TargetType\x12\x0e\n\x06\x64\x61mage\x18\x02 \x01(\x02\x12\x31\n\x0c\x64\x61mage_bonus\x18\x03 \x03(\x0b\x32\x1b.SC2APIProtocol.DamageBonus\x12\x0f\n\x07\x61ttacks\x18\x04 \x01(\r\x12\r\n\x05range\x18\x05 \x01(\x02\x12\r\n\x05speed\x18\x06 \x01(\x02\"*\n\nTargetType\x12\n\n\x06Ground\x10\x01\x12\x07\n\x03\x41ir\x10\x02\x12\x07\n\x03\x41ny\x10\x03\"\x95\x04\n\x0cUnitTypeData\x12\x0f\n\x07unit_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x11\n\tavailable\x18\x03 \x01(\x08\x12\x12\n\ncargo_size\x18\x04 \x01(\r\x12\x14\n\x0cmineral_cost\x18\x0c \x01(\r\x12\x14\n\x0cvespene_cost\x18\r \x01(\r\x12\x15\n\rfood_required\x18\x0e \x01(\x02\x12\x15\n\rfood_provided\x18\x12 \x01(\x02\x12\x12\n\nability_id\x18\x0f \x01(\r\x12\"\n\x04race\x18\x10 \x01(\x0e\x32\x14.SC2APIProtocol.Race\x12\x12\n\nbuild_time\x18\x11 \x01(\x02\x12\x13\n\x0bhas_vespene\x18\x13 \x01(\x08\x12\x14\n\x0chas_minerals\x18\x14 \x01(\x08\x12\x13\n\x0bsight_range\x18\x19 \x01(\x02\x12\x12\n\ntech_alias\x18\x15 \x03(\r\x12\x12\n\nunit_alias\x18\x16 \x01(\r\x12\x18\n\x10tech_requirement\x18\x17 \x01(\r\x12\x18\n\x10require_attached\x18\x18 \x01(\x08\x12-\n\nattributes\x18\x08 \x03(\x0e\x32\x19.SC2APIProtocol.Attribute\x12\x16\n\x0emovement_speed\x18\t \x01(\x02\x12\r\n\x05\x61rmor\x18\n \x01(\x02\x12\'\n\x07weapons\x18\x0b \x03(\x0b\x32\x16.SC2APIProtocol.Weapon\"\x86\x01\n\x0bUpgradeData\x12\x12\n\nupgrade_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x14\n\x0cmineral_cost\x18\x03 \x01(\r\x12\x14\n\x0cvespene_cost\x18\x04 \x01(\r\x12\x15\n\rresearch_time\x18\x05 \x01(\x02\x12\x12\n\nability_id\x18\x06 \x01(\r\")\n\x08\x42uffData\x12\x0f\n\x07\x62uff_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\"T\n\nEffectData\x12\x11\n\teffect_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x15\n\rfriendly_name\x18\x03 \x01(\t\x12\x0e\n\x06radius\x18\x04 \x01(\x02*\x9e\x01\n\tAttribute\x12\t\n\x05Light\x10\x01\x12\x0b\n\x07\x41rmored\x10\x02\x12\x0e\n\nBiological\x10\x03\x12\x0e\n\nMechanical\x10\x04\x12\x0b\n\x07Robotic\x10\x05\x12\x0b\n\x07Psionic\x10\x06\x12\x0b\n\x07Massive\x10\x07\x12\r\n\tStructure\x10\x08\x12\t\n\x05Hover\x10\t\x12\n\n\x06Heroic\x10\n\x12\x0c\n\x08Summoned\x10\x0b')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.data_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_ATTRIBUTE']._serialized_start=1630
_globals['_ATTRIBUTE']._serialized_end=1788
_globals['_ABILITYDATA']._serialized_start=79
_globals['_ABILITYDATA']._serialized_end=531
_globals['_ABILITYDATA_TARGET']._serialized_start=458
_globals['_ABILITYDATA_TARGET']._serialized_end=531
_globals['_DAMAGEBONUS']._serialized_start=533
_globals['_DAMAGEBONUS']._serialized_end=607
_globals['_WEAPON']._serialized_start=610
_globals['_WEAPON']._serialized_end=825
_globals['_WEAPON_TARGETTYPE']._serialized_start=783
_globals['_WEAPON_TARGETTYPE']._serialized_end=825
_globals['_UNITTYPEDATA']._serialized_start=828
_globals['_UNITTYPEDATA']._serialized_end=1361
_globals['_UPGRADEDATA']._serialized_start=1364
_globals['_UPGRADEDATA']._serialized_end=1498
_globals['_BUFFDATA']._serialized_start=1500
_globals['_BUFFDATA']._serialized_end=1541
_globals['_EFFECTDATA']._serialized_start=1543
_globals['_EFFECTDATA']._serialized_end=1627
# @@protoc_insertion_point(module_scope)

View File

@@ -1,71 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/debug.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/debug.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from . import common_pb2 as s2clientprotocol_dot_common__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cs2clientprotocol/debug.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\"\xbb\x03\n\x0c\x44\x65\x62ugCommand\x12)\n\x04\x64raw\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.DebugDrawH\x00\x12\x34\n\ngame_state\x18\x02 \x01(\x0e\x32\x1e.SC2APIProtocol.DebugGameStateH\x00\x12\x36\n\x0b\x63reate_unit\x18\x03 \x01(\x0b\x32\x1f.SC2APIProtocol.DebugCreateUnitH\x00\x12\x32\n\tkill_unit\x18\x04 \x01(\x0b\x32\x1d.SC2APIProtocol.DebugKillUnitH\x00\x12\x38\n\x0ctest_process\x18\x05 \x01(\x0b\x32 .SC2APIProtocol.DebugTestProcessH\x00\x12.\n\x05score\x18\x06 \x01(\x0b\x32\x1d.SC2APIProtocol.DebugSetScoreH\x00\x12\x30\n\x08\x65nd_game\x18\x07 \x01(\x0b\x32\x1c.SC2APIProtocol.DebugEndGameH\x00\x12\x37\n\nunit_value\x18\x08 \x01(\x0b\x32!.SC2APIProtocol.DebugSetUnitValueH\x00\x42\t\n\x07\x63ommand\"\xb5\x01\n\tDebugDraw\x12\'\n\x04text\x18\x01 \x03(\x0b\x32\x19.SC2APIProtocol.DebugText\x12(\n\x05lines\x18\x02 \x03(\x0b\x32\x19.SC2APIProtocol.DebugLine\x12\'\n\x05\x62oxes\x18\x03 \x03(\x0b\x32\x18.SC2APIProtocol.DebugBox\x12,\n\x07spheres\x18\x04 \x03(\x0b\x32\x1b.SC2APIProtocol.DebugSphere\"L\n\x04Line\x12!\n\x02p0\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12!\n\x02p1\x18\x02 \x01(\x0b\x32\x15.SC2APIProtocol.Point\"(\n\x05\x43olor\x12\t\n\x01r\x18\x01 \x01(\r\x12\t\n\x01g\x18\x02 \x01(\r\x12\t\n\x01\x62\x18\x03 \x01(\r\"\xa3\x01\n\tDebugText\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12\x0c\n\x04text\x18\x02 \x01(\t\x12*\n\x0bvirtual_pos\x18\x03 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12(\n\tworld_pos\x18\x04 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12\x0c\n\x04size\x18\x05 \x01(\r\"U\n\tDebugLine\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12\"\n\x04line\x18\x02 \x01(\x0b\x32\x14.SC2APIProtocol.Line\"x\n\x08\x44\x65\x62ugBox\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12\"\n\x03min\x18\x02 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12\"\n\x03max\x18\x03 \x01(\x0b\x32\x15.SC2APIProtocol.Point\"`\n\x0b\x44\x65\x62ugSphere\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12 \n\x01p\x18\x02 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12\t\n\x01r\x18\x03 \x01(\x02\"k\n\x0f\x44\x65\x62ugCreateUnit\x12\x11\n\tunit_type\x18\x01 \x01(\r\x12\r\n\x05owner\x18\x02 \x01(\x05\x12$\n\x03pos\x18\x03 \x01(\x0b\x32\x17.SC2APIProtocol.Point2D\x12\x10\n\x08quantity\x18\x04 \x01(\r\"\x1c\n\rDebugKillUnit\x12\x0b\n\x03tag\x18\x01 \x03(\x04\"\x80\x01\n\x10\x44\x65\x62ugTestProcess\x12\x33\n\x04test\x18\x01 \x01(\x0e\x32%.SC2APIProtocol.DebugTestProcess.Test\x12\x10\n\x08\x64\x65lay_ms\x18\x02 \x01(\x05\"%\n\x04Test\x12\x08\n\x04hang\x10\x01\x12\t\n\x05\x63rash\x10\x02\x12\x08\n\x04\x65xit\x10\x03\"\x1e\n\rDebugSetScore\x12\r\n\x05score\x18\x01 \x01(\x02\"z\n\x0c\x44\x65\x62ugEndGame\x12:\n\nend_result\x18\x01 \x01(\x0e\x32&.SC2APIProtocol.DebugEndGame.EndResult\".\n\tEndResult\x12\r\n\tSurrender\x10\x01\x12\x12\n\x0e\x44\x65\x63lareVictory\x10\x02\"\xa5\x01\n\x11\x44\x65\x62ugSetUnitValue\x12?\n\nunit_value\x18\x01 \x01(\x0e\x32+.SC2APIProtocol.DebugSetUnitValue.UnitValue\x12\r\n\x05value\x18\x02 \x01(\x02\x12\x10\n\x08unit_tag\x18\x03 \x01(\x04\".\n\tUnitValue\x12\n\n\x06\x45nergy\x10\x01\x12\x08\n\x04Life\x10\x02\x12\x0b\n\x07Shields\x10\x03*\xb2\x01\n\x0e\x44\x65\x62ugGameState\x12\x0c\n\x08show_map\x10\x01\x12\x11\n\rcontrol_enemy\x10\x02\x12\x08\n\x04\x66ood\x10\x03\x12\x08\n\x04\x66ree\x10\x04\x12\x11\n\rall_resources\x10\x05\x12\x07\n\x03god\x10\x06\x12\x0c\n\x08minerals\x10\x07\x12\x07\n\x03gas\x10\x08\x12\x0c\n\x08\x63ooldown\x10\t\x12\r\n\ttech_tree\x10\n\x12\x0b\n\x07upgrade\x10\x0b\x12\x0e\n\nfast_build\x10\x0c')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.debug_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_DEBUGGAMESTATE']._serialized_start=1897
_globals['_DEBUGGAMESTATE']._serialized_end=2075
_globals['_DEBUGCOMMAND']._serialized_start=80
_globals['_DEBUGCOMMAND']._serialized_end=523
_globals['_DEBUGDRAW']._serialized_start=526
_globals['_DEBUGDRAW']._serialized_end=707
_globals['_LINE']._serialized_start=709
_globals['_LINE']._serialized_end=785
_globals['_COLOR']._serialized_start=787
_globals['_COLOR']._serialized_end=827
_globals['_DEBUGTEXT']._serialized_start=830
_globals['_DEBUGTEXT']._serialized_end=993
_globals['_DEBUGLINE']._serialized_start=995
_globals['_DEBUGLINE']._serialized_end=1080
_globals['_DEBUGBOX']._serialized_start=1082
_globals['_DEBUGBOX']._serialized_end=1202
_globals['_DEBUGSPHERE']._serialized_start=1204
_globals['_DEBUGSPHERE']._serialized_end=1300
_globals['_DEBUGCREATEUNIT']._serialized_start=1302
_globals['_DEBUGCREATEUNIT']._serialized_end=1409
_globals['_DEBUGKILLUNIT']._serialized_start=1411
_globals['_DEBUGKILLUNIT']._serialized_end=1439
_globals['_DEBUGTESTPROCESS']._serialized_start=1442
_globals['_DEBUGTESTPROCESS']._serialized_end=1570
_globals['_DEBUGTESTPROCESS_TEST']._serialized_start=1533
_globals['_DEBUGTESTPROCESS_TEST']._serialized_end=1570
_globals['_DEBUGSETSCORE']._serialized_start=1572
_globals['_DEBUGSETSCORE']._serialized_end=1602
_globals['_DEBUGENDGAME']._serialized_start=1604
_globals['_DEBUGENDGAME']._serialized_end=1726
_globals['_DEBUGENDGAME_ENDRESULT']._serialized_start=1680
_globals['_DEBUGENDGAME_ENDRESULT']._serialized_end=1726
_globals['_DEBUGSETUNITVALUE']._serialized_start=1729
_globals['_DEBUGSETUNITVALUE']._serialized_end=1894
_globals['_DEBUGSETUNITVALUE_UNITVALUE']._serialized_start=1848
_globals['_DEBUGSETUNITVALUE_UNITVALUE']._serialized_end=1894
# @@protoc_insertion_point(module_scope)

File diff suppressed because one or more lines are too long

View File

@@ -1,52 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/query.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/query.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from . import common_pb2 as s2clientprotocol_dot_common__pb2
from . import error_pb2 as s2clientprotocol_dot_error__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cs2clientprotocol/query.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\x1a\x1cs2clientprotocol/error.proto\"\xf0\x01\n\x0cRequestQuery\x12\x34\n\x07pathing\x18\x01 \x03(\x0b\x32#.SC2APIProtocol.RequestQueryPathing\x12\x41\n\tabilities\x18\x02 \x03(\x0b\x32..SC2APIProtocol.RequestQueryAvailableAbilities\x12\x41\n\nplacements\x18\x03 \x03(\x0b\x32-.SC2APIProtocol.RequestQueryBuildingPlacement\x12$\n\x1cignore_resource_requirements\x18\x04 \x01(\x08\"\xce\x01\n\rResponseQuery\x12\x35\n\x07pathing\x18\x01 \x03(\x0b\x32$.SC2APIProtocol.ResponseQueryPathing\x12\x42\n\tabilities\x18\x02 \x03(\x0b\x32/.SC2APIProtocol.ResponseQueryAvailableAbilities\x12\x42\n\nplacements\x18\x03 \x03(\x0b\x32..SC2APIProtocol.ResponseQueryBuildingPlacement\"\x8a\x01\n\x13RequestQueryPathing\x12,\n\tstart_pos\x18\x01 \x01(\x0b\x32\x17.SC2APIProtocol.Point2DH\x00\x12\x12\n\x08unit_tag\x18\x02 \x01(\x04H\x00\x12(\n\x07\x65nd_pos\x18\x03 \x01(\x0b\x32\x17.SC2APIProtocol.Point2DB\x07\n\x05start\"(\n\x14ResponseQueryPathing\x12\x10\n\x08\x64istance\x18\x01 \x01(\x02\"2\n\x1eRequestQueryAvailableAbilities\x12\x10\n\x08unit_tag\x18\x01 \x01(\x04\"~\n\x1fResponseQueryAvailableAbilities\x12\x33\n\tabilities\x18\x01 \x03(\x0b\x32 .SC2APIProtocol.AvailableAbility\x12\x10\n\x08unit_tag\x18\x02 \x01(\x04\x12\x14\n\x0cunit_type_id\x18\x03 \x01(\r\"z\n\x1dRequestQueryBuildingPlacement\x12\x12\n\nability_id\x18\x01 \x01(\x05\x12+\n\ntarget_pos\x18\x02 \x01(\x0b\x32\x17.SC2APIProtocol.Point2D\x12\x18\n\x10placing_unit_tag\x18\x03 \x01(\x04\"N\n\x1eResponseQueryBuildingPlacement\x12,\n\x06result\x18\x01 \x01(\x0e\x32\x1c.SC2APIProtocol.ActionResult')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.query_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_REQUESTQUERY']._serialized_start=110
_globals['_REQUESTQUERY']._serialized_end=350
_globals['_RESPONSEQUERY']._serialized_start=353
_globals['_RESPONSEQUERY']._serialized_end=559
_globals['_REQUESTQUERYPATHING']._serialized_start=562
_globals['_REQUESTQUERYPATHING']._serialized_end=700
_globals['_RESPONSEQUERYPATHING']._serialized_start=702
_globals['_RESPONSEQUERYPATHING']._serialized_end=742
_globals['_REQUESTQUERYAVAILABLEABILITIES']._serialized_start=744
_globals['_REQUESTQUERYAVAILABLEABILITIES']._serialized_end=794
_globals['_RESPONSEQUERYAVAILABLEABILITIES']._serialized_start=796
_globals['_RESPONSEQUERYAVAILABLEABILITIES']._serialized_end=922
_globals['_REQUESTQUERYBUILDINGPLACEMENT']._serialized_start=924
_globals['_REQUESTQUERYBUILDINGPLACEMENT']._serialized_end=1046
_globals['_RESPONSEQUERYBUILDINGPLACEMENT']._serialized_start=1048
_globals['_RESPONSEQUERYBUILDINGPLACEMENT']._serialized_end=1126
# @@protoc_insertion_point(module_scope)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,44 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/score.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/score.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cs2clientprotocol/score.proto\x12\x0eSC2APIProtocol\"\xa8\x01\n\x05Score\x12\x33\n\nscore_type\x18\x06 \x01(\x0e\x32\x1f.SC2APIProtocol.Score.ScoreType\x12\r\n\x05score\x18\x07 \x01(\x05\x12\x33\n\rscore_details\x18\x08 \x01(\x0b\x32\x1c.SC2APIProtocol.ScoreDetails\"&\n\tScoreType\x12\x0e\n\nCurriculum\x10\x01\x12\t\n\x05Melee\x10\x02\"h\n\x14\x43\x61tegoryScoreDetails\x12\x0c\n\x04none\x18\x01 \x01(\x02\x12\x0c\n\x04\x61rmy\x18\x02 \x01(\x02\x12\x0f\n\x07\x65\x63onomy\x18\x03 \x01(\x02\x12\x12\n\ntechnology\x18\x04 \x01(\x02\x12\x0f\n\x07upgrade\x18\x05 \x01(\x02\"B\n\x11VitalScoreDetails\x12\x0c\n\x04life\x18\x01 \x01(\x02\x12\x0f\n\x07shields\x18\x02 \x01(\x02\x12\x0e\n\x06\x65nergy\x18\x03 \x01(\x02\"\x8a\n\n\x0cScoreDetails\x12\x1c\n\x14idle_production_time\x18\x01 \x01(\x02\x12\x18\n\x10idle_worker_time\x18\x02 \x01(\x02\x12\x19\n\x11total_value_units\x18\x03 \x01(\x02\x12\x1e\n\x16total_value_structures\x18\x04 \x01(\x02\x12\x1a\n\x12killed_value_units\x18\x05 \x01(\x02\x12\x1f\n\x17killed_value_structures\x18\x06 \x01(\x02\x12\x1a\n\x12\x63ollected_minerals\x18\x07 \x01(\x02\x12\x19\n\x11\x63ollected_vespene\x18\x08 \x01(\x02\x12 \n\x18\x63ollection_rate_minerals\x18\t \x01(\x02\x12\x1f\n\x17\x63ollection_rate_vespene\x18\n \x01(\x02\x12\x16\n\x0espent_minerals\x18\x0b \x01(\x02\x12\x15\n\rspent_vespene\x18\x0c \x01(\x02\x12\x37\n\tfood_used\x18\r \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12=\n\x0fkilled_minerals\x18\x0e \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12<\n\x0ekilled_vespene\x18\x0f \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12;\n\rlost_minerals\x18\x10 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12:\n\x0clost_vespene\x18\x11 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12\x44\n\x16\x66riendly_fire_minerals\x18\x12 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12\x43\n\x15\x66riendly_fire_vespene\x18\x13 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12;\n\rused_minerals\x18\x14 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12:\n\x0cused_vespene\x18\x15 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12\x41\n\x13total_used_minerals\x18\x16 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12@\n\x12total_used_vespene\x18\x17 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12=\n\x12total_damage_dealt\x18\x18 \x01(\x0b\x32!.SC2APIProtocol.VitalScoreDetails\x12=\n\x12total_damage_taken\x18\x19 \x01(\x0b\x32!.SC2APIProtocol.VitalScoreDetails\x12\x37\n\x0ctotal_healed\x18\x1a \x01(\x0b\x32!.SC2APIProtocol.VitalScoreDetails\x12\x13\n\x0b\x63urrent_apm\x18\x1b \x01(\x02\x12\x1d\n\x15\x63urrent_effective_apm\x18\x1c \x01(\x02')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.score_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_SCORE']._serialized_start=49
_globals['_SCORE']._serialized_end=217
_globals['_SCORE_SCORETYPE']._serialized_start=179
_globals['_SCORE_SCORETYPE']._serialized_end=217
_globals['_CATEGORYSCOREDETAILS']._serialized_start=219
_globals['_CATEGORYSCOREDETAILS']._serialized_end=323
_globals['_VITALSCOREDETAILS']._serialized_start=325
_globals['_VITALSCOREDETAILS']._serialized_end=391
_globals['_SCOREDETAILS']._serialized_start=394
_globals['_SCOREDETAILS']._serialized_end=1684
# @@protoc_insertion_point(module_scope)

View File

@@ -1,55 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/spatial.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/spatial.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from . import common_pb2 as s2clientprotocol_dot_common__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1es2clientprotocol/spatial.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\"\x88\x01\n\x17ObservationFeatureLayer\x12.\n\x07renders\x18\x01 \x01(\x0b\x32\x1d.SC2APIProtocol.FeatureLayers\x12=\n\x0fminimap_renders\x18\x02 \x01(\x0b\x32$.SC2APIProtocol.FeatureLayersMinimap\"\x9c\n\n\rFeatureLayers\x12-\n\nheight_map\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0evisibility_map\x18\x02 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05\x63reep\x18\x03 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05power\x18\x04 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tplayer_id\x18\x05 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tunit_type\x18\x06 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08selected\x18\x07 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0funit_hit_points\x18\x08 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x38\n\x15unit_hit_points_ratio\x18\x11 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12.\n\x0bunit_energy\x18\t \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x34\n\x11unit_energy_ratio\x18\x12 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12/\n\x0cunit_shields\x18\n \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x35\n\x12unit_shields_ratio\x18\x13 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0fplayer_relative\x18\x0b \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0funit_density_aa\x18\x0e \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12/\n\x0cunit_density\x18\x0f \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12*\n\x07\x65\x66\x66\x65\x63ts\x18\x14 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0ehallucinations\x18\x15 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12*\n\x07\x63loaked\x18\x16 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\'\n\x04\x62lip\x18\x17 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05\x62uffs\x18\x18 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x30\n\rbuff_duration\x18\x1a \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12)\n\x06\x61\x63tive\x18\x19 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0e\x62uild_progress\x18\x1b \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tbuildable\x18\x1c \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08pathable\x18\x1d \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12.\n\x0bplaceholder\x18\x1e \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\"\x90\x04\n\x14\x46\x65\x61tureLayersMinimap\x12-\n\nheight_map\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0evisibility_map\x18\x02 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05\x63reep\x18\x03 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12)\n\x06\x63\x61mera\x18\x04 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tplayer_id\x18\x05 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0fplayer_relative\x18\x06 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08selected\x18\x07 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12)\n\x06\x61lerts\x18\t \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tbuildable\x18\n \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08pathable\x18\x0b \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tunit_type\x18\x08 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\"g\n\x11ObservationRender\x12&\n\x03map\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12*\n\x07minimap\x18\x02 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\"\xbb\x02\n\rActionSpatial\x12@\n\x0cunit_command\x18\x01 \x01(\x0b\x32(.SC2APIProtocol.ActionSpatialUnitCommandH\x00\x12>\n\x0b\x63\x61mera_move\x18\x02 \x01(\x0b\x32\'.SC2APIProtocol.ActionSpatialCameraMoveH\x00\x12O\n\x14unit_selection_point\x18\x03 \x01(\x0b\x32/.SC2APIProtocol.ActionSpatialUnitSelectionPointH\x00\x12M\n\x13unit_selection_rect\x18\x04 \x01(\x0b\x32..SC2APIProtocol.ActionSpatialUnitSelectionRectH\x00\x42\x08\n\x06\x61\x63tion\"\xbe\x01\n\x18\x41\x63tionSpatialUnitCommand\x12\x12\n\nability_id\x18\x01 \x01(\x05\x12\x35\n\x13target_screen_coord\x18\x02 \x01(\x0b\x32\x16.SC2APIProtocol.PointIH\x00\x12\x36\n\x14target_minimap_coord\x18\x03 \x01(\x0b\x32\x16.SC2APIProtocol.PointIH\x00\x12\x15\n\rqueue_command\x18\x04 \x01(\x08\x42\x08\n\x06target\"I\n\x17\x41\x63tionSpatialCameraMove\x12.\n\x0e\x63\x65nter_minimap\x18\x01 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\"\xda\x01\n\x1f\x41\x63tionSpatialUnitSelectionPoint\x12\x36\n\x16selection_screen_coord\x18\x01 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\x12\x42\n\x04type\x18\x02 \x01(\x0e\x32\x34.SC2APIProtocol.ActionSpatialUnitSelectionPoint.Type\";\n\x04Type\x12\n\n\x06Select\x10\x01\x12\n\n\x06Toggle\x10\x02\x12\x0b\n\x07\x41llType\x10\x03\x12\x0e\n\nAddAllType\x10\x04\"s\n\x1e\x41\x63tionSpatialUnitSelectionRect\x12:\n\x16selection_screen_coord\x18\x01 \x03(\x0b\x32\x1a.SC2APIProtocol.RectangleI\x12\x15\n\rselection_add\x18\x02 \x01(\x08')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.spatial_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_OBSERVATIONFEATURELAYER']._serialized_start=82
_globals['_OBSERVATIONFEATURELAYER']._serialized_end=218
_globals['_FEATURELAYERS']._serialized_start=221
_globals['_FEATURELAYERS']._serialized_end=1529
_globals['_FEATURELAYERSMINIMAP']._serialized_start=1532
_globals['_FEATURELAYERSMINIMAP']._serialized_end=2060
_globals['_OBSERVATIONRENDER']._serialized_start=2062
_globals['_OBSERVATIONRENDER']._serialized_end=2165
_globals['_ACTIONSPATIAL']._serialized_start=2168
_globals['_ACTIONSPATIAL']._serialized_end=2483
_globals['_ACTIONSPATIALUNITCOMMAND']._serialized_start=2486
_globals['_ACTIONSPATIALUNITCOMMAND']._serialized_end=2676
_globals['_ACTIONSPATIALCAMERAMOVE']._serialized_start=2678
_globals['_ACTIONSPATIALCAMERAMOVE']._serialized_end=2751
_globals['_ACTIONSPATIALUNITSELECTIONPOINT']._serialized_start=2754
_globals['_ACTIONSPATIALUNITSELECTIONPOINT']._serialized_end=2972
_globals['_ACTIONSPATIALUNITSELECTIONPOINT_TYPE']._serialized_start=2913
_globals['_ACTIONSPATIALUNITSELECTIONPOINT_TYPE']._serialized_end=2972
_globals['_ACTIONSPATIALUNITSELECTIONRECT']._serialized_start=2974
_globals['_ACTIONSPATIALUNITSELECTIONRECT']._serialized_end=3089
# @@protoc_insertion_point(module_scope)

View File

@@ -1,76 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/ui.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/ui.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19s2clientprotocol/ui.proto\x12\x0eSC2APIProtocol\"\x86\x02\n\rObservationUI\x12,\n\x06groups\x18\x01 \x03(\x0b\x32\x1c.SC2APIProtocol.ControlGroup\x12-\n\x06single\x18\x02 \x01(\x0b\x32\x1b.SC2APIProtocol.SinglePanelH\x00\x12+\n\x05multi\x18\x03 \x01(\x0b\x32\x1a.SC2APIProtocol.MultiPanelH\x00\x12+\n\x05\x63\x61rgo\x18\x04 \x01(\x0b\x32\x1a.SC2APIProtocol.CargoPanelH\x00\x12\x35\n\nproduction\x18\x05 \x01(\x0b\x32\x1f.SC2APIProtocol.ProductionPanelH\x00\x42\x07\n\x05panel\"T\n\x0c\x43ontrolGroup\x12\x1b\n\x13\x63ontrol_group_index\x18\x01 \x01(\r\x12\x18\n\x10leader_unit_type\x18\x02 \x01(\r\x12\r\n\x05\x63ount\x18\x03 \x01(\r\"\x85\x02\n\x08UnitInfo\x12\x11\n\tunit_type\x18\x01 \x01(\r\x12\x17\n\x0fplayer_relative\x18\x02 \x01(\r\x12\x0e\n\x06health\x18\x03 \x01(\x05\x12\x0f\n\x07shields\x18\x04 \x01(\x05\x12\x0e\n\x06\x65nergy\x18\x05 \x01(\x05\x12\x1d\n\x15transport_slots_taken\x18\x06 \x01(\x05\x12\x16\n\x0e\x62uild_progress\x18\x07 \x01(\x02\x12(\n\x06\x61\x64\x64_on\x18\x08 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x12\n\nmax_health\x18\t \x01(\x05\x12\x13\n\x0bmax_shields\x18\n \x01(\x05\x12\x12\n\nmax_energy\x18\x0b \x01(\x05\"\x9d\x01\n\x0bSinglePanel\x12&\n\x04unit\x18\x01 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x1c\n\x14\x61ttack_upgrade_level\x18\x02 \x01(\x05\x12\x1b\n\x13\x61rmor_upgrade_level\x18\x03 \x01(\x05\x12\x1c\n\x14shield_upgrade_level\x18\x04 \x01(\x05\x12\r\n\x05\x62uffs\x18\x05 \x03(\x05\"5\n\nMultiPanel\x12\'\n\x05units\x18\x01 \x03(\x0b\x32\x18.SC2APIProtocol.UnitInfo\"{\n\nCargoPanel\x12&\n\x04unit\x18\x01 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12,\n\npassengers\x18\x02 \x03(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x17\n\x0fslots_available\x18\x03 \x01(\x05\"7\n\tBuildItem\x12\x12\n\nability_id\x18\x01 \x01(\r\x12\x16\n\x0e\x62uild_progress\x18\x02 \x01(\x02\"\x9d\x01\n\x0fProductionPanel\x12&\n\x04unit\x18\x01 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12-\n\x0b\x62uild_queue\x18\x02 \x03(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x33\n\x10production_queue\x18\x03 \x03(\x0b\x32\x19.SC2APIProtocol.BuildItem\"\xda\x04\n\x08\x41\x63tionUI\x12;\n\rcontrol_group\x18\x01 \x01(\x0b\x32\".SC2APIProtocol.ActionControlGroupH\x00\x12\x37\n\x0bselect_army\x18\x02 \x01(\x0b\x32 .SC2APIProtocol.ActionSelectArmyH\x00\x12\x42\n\x11select_warp_gates\x18\x03 \x01(\x0b\x32%.SC2APIProtocol.ActionSelectWarpGatesH\x00\x12\x39\n\x0cselect_larva\x18\x04 \x01(\x0b\x32!.SC2APIProtocol.ActionSelectLarvaH\x00\x12\x44\n\x12select_idle_worker\x18\x05 \x01(\x0b\x32&.SC2APIProtocol.ActionSelectIdleWorkerH\x00\x12\x37\n\x0bmulti_panel\x18\x06 \x01(\x0b\x32 .SC2APIProtocol.ActionMultiPanelH\x00\x12=\n\x0b\x63\x61rgo_panel\x18\x07 \x01(\x0b\x32&.SC2APIProtocol.ActionCargoPanelUnloadH\x00\x12P\n\x10production_panel\x18\x08 \x01(\x0b\x32\x34.SC2APIProtocol.ActionProductionPanelRemoveFromQueueH\x00\x12?\n\x0ftoggle_autocast\x18\t \x01(\x0b\x32$.SC2APIProtocol.ActionToggleAutocastH\x00\x42\x08\n\x06\x61\x63tion\"\xd4\x01\n\x12\x41\x63tionControlGroup\x12\x45\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x35.SC2APIProtocol.ActionControlGroup.ControlGroupAction\x12\x1b\n\x13\x63ontrol_group_index\x18\x02 \x01(\r\"Z\n\x12\x43ontrolGroupAction\x12\n\n\x06Recall\x10\x01\x12\x07\n\x03Set\x10\x02\x12\n\n\x06\x41ppend\x10\x03\x12\x0f\n\x0bSetAndSteal\x10\x04\x12\x12\n\x0e\x41ppendAndSteal\x10\x05\")\n\x10\x41\x63tionSelectArmy\x12\x15\n\rselection_add\x18\x01 \x01(\x08\".\n\x15\x41\x63tionSelectWarpGates\x12\x15\n\rselection_add\x18\x01 \x01(\x08\"\x13\n\x11\x41\x63tionSelectLarva\"\x82\x01\n\x16\x41\x63tionSelectIdleWorker\x12\x39\n\x04type\x18\x01 \x01(\x0e\x32+.SC2APIProtocol.ActionSelectIdleWorker.Type\"-\n\x04Type\x12\x07\n\x03Set\x10\x01\x12\x07\n\x03\x41\x64\x64\x10\x02\x12\x07\n\x03\x41ll\x10\x03\x12\n\n\x06\x41\x64\x64\x41ll\x10\x04\"\xb3\x01\n\x10\x41\x63tionMultiPanel\x12\x33\n\x04type\x18\x01 \x01(\x0e\x32%.SC2APIProtocol.ActionMultiPanel.Type\x12\x12\n\nunit_index\x18\x02 \x01(\x05\"V\n\x04Type\x12\x10\n\x0cSingleSelect\x10\x01\x12\x10\n\x0c\x44\x65selectUnit\x10\x02\x12\x13\n\x0fSelectAllOfType\x10\x03\x12\x15\n\x11\x44\x65selectAllOfType\x10\x04\",\n\x16\x41\x63tionCargoPanelUnload\x12\x12\n\nunit_index\x18\x01 \x01(\x05\":\n$ActionProductionPanelRemoveFromQueue\x12\x12\n\nunit_index\x18\x01 \x01(\x05\"*\n\x14\x41\x63tionToggleAutocast\x12\x12\n\nability_id\x18\x01 \x01(\x05')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.ui_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_OBSERVATIONUI']._serialized_start=46
_globals['_OBSERVATIONUI']._serialized_end=308
_globals['_CONTROLGROUP']._serialized_start=310
_globals['_CONTROLGROUP']._serialized_end=394
_globals['_UNITINFO']._serialized_start=397
_globals['_UNITINFO']._serialized_end=658
_globals['_SINGLEPANEL']._serialized_start=661
_globals['_SINGLEPANEL']._serialized_end=818
_globals['_MULTIPANEL']._serialized_start=820
_globals['_MULTIPANEL']._serialized_end=873
_globals['_CARGOPANEL']._serialized_start=875
_globals['_CARGOPANEL']._serialized_end=998
_globals['_BUILDITEM']._serialized_start=1000
_globals['_BUILDITEM']._serialized_end=1055
_globals['_PRODUCTIONPANEL']._serialized_start=1058
_globals['_PRODUCTIONPANEL']._serialized_end=1215
_globals['_ACTIONUI']._serialized_start=1218
_globals['_ACTIONUI']._serialized_end=1820
_globals['_ACTIONCONTROLGROUP']._serialized_start=1823
_globals['_ACTIONCONTROLGROUP']._serialized_end=2035
_globals['_ACTIONCONTROLGROUP_CONTROLGROUPACTION']._serialized_start=1945
_globals['_ACTIONCONTROLGROUP_CONTROLGROUPACTION']._serialized_end=2035
_globals['_ACTIONSELECTARMY']._serialized_start=2037
_globals['_ACTIONSELECTARMY']._serialized_end=2078
_globals['_ACTIONSELECTWARPGATES']._serialized_start=2080
_globals['_ACTIONSELECTWARPGATES']._serialized_end=2126
_globals['_ACTIONSELECTLARVA']._serialized_start=2128
_globals['_ACTIONSELECTLARVA']._serialized_end=2147
_globals['_ACTIONSELECTIDLEWORKER']._serialized_start=2150
_globals['_ACTIONSELECTIDLEWORKER']._serialized_end=2280
_globals['_ACTIONSELECTIDLEWORKER_TYPE']._serialized_start=2235
_globals['_ACTIONSELECTIDLEWORKER_TYPE']._serialized_end=2280
_globals['_ACTIONMULTIPANEL']._serialized_start=2283
_globals['_ACTIONMULTIPANEL']._serialized_end=2462
_globals['_ACTIONMULTIPANEL_TYPE']._serialized_start=2376
_globals['_ACTIONMULTIPANEL_TYPE']._serialized_end=2462
_globals['_ACTIONCARGOPANELUNLOAD']._serialized_start=2464
_globals['_ACTIONCARGOPANELUNLOAD']._serialized_end=2508
_globals['_ACTIONPRODUCTIONPANELREMOVEFROMQUEUE']._serialized_start=2510
_globals['_ACTIONPRODUCTIONPANELREMOVEFROMQUEUE']._serialized_end=2568
_globals['_ACTIONTOGGLEAUTOCAST']._serialized_start=2570
_globals['_ACTIONTOGGLEAUTOCAST']._serialized_end=2612
# @@protoc_insertion_point(module_scope)

Some files were not shown because too many files have changed in this diff Show More