Compare commits

..

4 Commits

Author SHA1 Message Date
NewSoupVi
453d89460f Update Options.py 2025-05-10 04:11:28 +02:00
NewSoupVi
28889e58aa Update Options.py
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-09 13:28:36 +02:00
NewSoupVi
f3c76399e0 Update Options.py 2025-05-08 15:00:21 +02:00
NewSoupVi
8384a23fe2 Institute limit on StartInventory 2025-05-08 14:54:11 +02:00
549 changed files with 12953 additions and 99574 deletions

View File

@@ -1,210 +0,0 @@
.git
.github
.run
docs
test
typings
*Client.py
.idea
.vscode
*_Spoiler.txt
*.bmbp
*.apbp
*.apl2ac
*.apm3
*.apmc
*.apz5
*.aptloz
*.apemerald
*.pyc
*.pyd
*.sfc
*.z64
*.n64
*.nes
*.smc
*.sms
*.gb
*.gbc
*.gba
*.wixobj
*.lck
*.db3
*multidata
*multisave
*.archipelago
*.apsave
*.BIN
*.puml
setups
build
bundle/components.wxs
dist
/prof/
README.html
.vs/
EnemizerCLI/
/Players/
/SNI/
/sni-*/
/appimagetool*
/host.yaml
/options.yaml
/config.yaml
/logs/
_persistent_storage.yaml
mystery_result_*.yaml
*-errors.txt
success.txt
output/
Output Logs/
/factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated
/freeze_requirements.txt
/Archipelago.zip
/setup.ini
/installdelete.iss
/data/user.kv
/datapackage
/custom_worlds
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
*.dll
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
installer.log
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# vim editor
*.swp
# SageMath parsed files
*.sage.py
# Environments
.env
.venv*
env/
venv/
/venv*/
ENV/
env.bak/
venv.bak/
*.code-workspace
shell.nix
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Cython intermediates
_speedups.c
_speedups.cpp
_speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv
.python-version
#undertale stuff
/Undertale/
# OS General Files
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
[Dd]esktop.ini

1
.github/labeler.yml vendored
View File

@@ -21,6 +21,7 @@
- '!data/**' - '!data/**'
- '!.run/**' - '!.run/**'
- '!.github/**' - '!.github/**'
- '!worlds_disabled/**'
- '!worlds/**' - '!worlds/**'
- '!WebHost.py' - '!WebHost.py'
- '!WebHostLib/**' - '!WebHostLib/**'

View File

@@ -19,12 +19,7 @@ on:
env: env:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, APPIMAGETOOL_VERSION: 13
# we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
permissions: # permissions required for attestation permissions: # permissions required for attestation
id-token: 'write' id-token: 'write'
@@ -103,7 +98,7 @@ jobs:
shell: bash shell: bash
run: | run: |
cd build/exe* cd build/exe*
cp Players/Templates/VVVVVV.yaml Players/ cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate timeout 30 ./ArchipelagoGenerate
- name: Store 7z - name: Store 7z
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -139,13 +134,10 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/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/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 chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
@@ -197,7 +189,7 @@ jobs:
shell: bash shell: bash
run: | run: |
cd build/exe* cd build/exe*
cp Players/Templates/VVVVVV.yaml Players/ cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate timeout 30 ./ArchipelagoGenerate
- name: Store AppImage - name: Store AppImage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -6,8 +6,6 @@ on:
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
env:
GH_REPO: ${{ github.repository }}
jobs: jobs:
labeler: labeler:

View File

@@ -9,12 +9,7 @@ on:
env: env:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, APPIMAGETOOL_VERSION: 13
# we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
permissions: # permissions required for attestation permissions: # permissions required for attestation
id-token: 'write' id-token: 'write'
@@ -127,13 +122,10 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/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/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 chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |

View File

@@ -8,24 +8,18 @@ on:
paths: paths:
- '**' - '**'
- '!docs/**' - '!docs/**'
- '!deploy/**'
- '!setup.py' - '!setup.py'
- '!Dockerfile'
- '!*.iss' - '!*.iss'
- '!.gitignore' - '!.gitignore'
- '!.dockerignore'
- '!.github/workflows/**' - '!.github/workflows/**'
- '.github/workflows/unittests.yml' - '.github/workflows/unittests.yml'
pull_request: pull_request:
paths: paths:
- '**' - '**'
- '!docs/**' - '!docs/**'
- '!deploy/**'
- '!setup.py' - '!setup.py'
- '!Dockerfile'
- '!*.iss' - '!*.iss'
- '!.gitignore' - '!.gitignore'
- '!.dockerignore'
- '!.github/workflows/**' - '!.github/workflows/**'
- '.github/workflows/unittests.yml' - '.github/workflows/unittests.yml'

7
.gitignore vendored
View File

@@ -56,6 +56,7 @@ success.txt
output/ output/
Output Logs/ Output Logs/
/factorio/ /factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated /WebHostLib/static/generated
/freeze_requirements.txt /freeze_requirements.txt
/Archipelago.zip /Archipelago.zip
@@ -183,6 +184,12 @@ _speedups.c
_speedups.cpp _speedups.cpp
_speedups.html _speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv # pyenv
.python-version .python-version

View File

@@ -1,4 +1,3 @@
import sys
from worlds.ahit.Client import launch from worlds.ahit.Client import launch
import Utils import Utils
import ModuleUpdate import ModuleUpdate
@@ -6,4 +5,4 @@ ModuleUpdate.update()
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("AHITClient", exception_logger="Client") Utils.init_logging("AHITClient", exception_logger="Client")
launch(*sys.argv[1:]) launch()

View File

@@ -11,7 +11,6 @@ from typing import List
import Utils import Utils
from settings import get_settings
from NetUtils import ClientStatus from NetUtils import ClientStatus
from Utils import async_start from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
@@ -81,8 +80,8 @@ class AdventureContext(CommonContext):
self.local_item_locations = {} self.local_item_locations = {}
self.dragon_speed_info = {} self.dragon_speed_info = {}
options = get_settings().adventure_options options = Utils.get_settings()
self.display_msgs = options.display_msgs self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -103,7 +102,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == 'Connected': if cmd == 'Connected':
self.locations_array = None self.locations_array = None
if get_settings().adventure_options.as_dict().get("death_link", False): if Utils.get_settings()["adventure_options"].get("death_link", False):
self.set_deathlink = True self.set_deathlink = True
async_start(self.get_freeincarnates_used()) async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo": elif cmd == "RoomInfo":
@@ -407,7 +406,6 @@ async def atari_sync_task(ctx: AdventureContext):
except ConnectionRefusedError: except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again") logger.debug("Connection Refused, Trying Again")
ctx.atari_status = CONNECTION_REFUSED_STATUS ctx.atari_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue continue
except CancelledError: except CancelledError:
pass pass
@@ -417,9 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile): async def run_game(romfile):
options = get_settings().adventure_options auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
auto_start = options.rom_start rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
rom_args = options.rom_args
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)

View File

@@ -5,14 +5,12 @@ import functools
import logging import logging
import random import random
import secrets import secrets
import warnings
from argparse import Namespace from argparse import Namespace
from collections import Counter, deque, defaultdict from collections import Counter, deque
from collections.abc import Collection, MutableSequence from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload) Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
import dataclasses
from typing_extensions import NotRequired, TypedDict from typing_extensions import NotRequired, TypedDict
@@ -56,21 +54,12 @@ class HasNameAndPlayer(Protocol):
player: int player: int
@dataclasses.dataclass
class PlandoItemBlock:
player: int
from_pool: bool
force: bool | Literal["silent"]
worlds: set[int] = dataclasses.field(default_factory=set)
items: list[str] = dataclasses.field(default_factory=list)
locations: list[str] = dataclasses.field(default_factory=list)
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
count: dict[str, int] = dataclasses.field(default_factory=dict)
class MultiWorld(): class MultiWorld():
debug_types = False debug_types = False
player_name: Dict[int, str] player_name: Dict[int, str]
plando_texts: List[Dict[str, str]]
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
worlds: Dict[int, "AutoWorld.World"] worlds: Dict[int, "AutoWorld.World"]
groups: Dict[int, Group] groups: Dict[int, Group]
regions: RegionManager regions: RegionManager
@@ -94,8 +83,6 @@ class MultiWorld():
start_location_hints: Dict[int, Options.StartLocationHints] start_location_hints: Dict[int, Options.StartLocationHints]
item_links: Dict[int, Options.ItemLinks] item_links: Dict[int, Options.ItemLinks]
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
game: Dict[int, str] game: Dict[int, str]
random: random.Random random: random.Random
@@ -154,11 +141,17 @@ class MultiWorld():
self.algorithm = 'balanced' self.algorithm = 'balanced'
self.groups = {} self.groups = {}
self.regions = self.RegionManager(players) self.regions = self.RegionManager(players)
self.shops = []
self.itempool = [] self.itempool = []
self.seed = None self.seed = None
self.seed_name: str = "Unavailable" self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids} self.precollected_items = {player: [] for player in self.player_ids}
self.required_locations = [] self.required_locations = []
self.light_world_light_cone = False
self.dark_world_light_cone = False
self.rupoor_cost = 10
self.aga_randomness = True
self.save_and_quit_from_boss = True
self.custom = False self.custom = False
self.customitemarray = [] self.customitemarray = []
self.shuffle_ganon = True self.shuffle_ganon = True
@@ -167,17 +160,18 @@ class MultiWorld():
self.local_early_items = {player: {} for player in self.player_ids} self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {} self.indirect_connections = {}
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {} self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
self.plando_item_blocks = {}
for player in range(1, players + 1): for player in range(1, players + 1):
def set_player_attr(attr: str, val) -> None: def set_player_attr(attr: str, val) -> None:
self.__dict__.setdefault(attr, {})[player] = val self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('plando_item_blocks', []) set_player_attr('plando_items', [])
set_player_attr('plando_texts', {})
set_player_attr('plando_connections', [])
set_player_attr('game', "Archipelago") set_player_attr('game', "Archipelago")
set_player_attr('completion_condition', lambda state: True) set_player_attr('completion_condition', lambda state: True)
self.worlds = {} self.worlds = {}
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the " self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
"world's random object instead (usually self.random)", True) "world's random object instead (usually self.random)")
self.plando_options = PlandoOptions.none self.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]: def get_all_ids(self) -> Tuple[int, ...]:
@@ -222,8 +216,17 @@ class MultiWorld():
self.seed_name = name if name else str(self.seed) self.seed_name = name if name else str(self.seed)
def set_options(self, args: Namespace) -> None: def set_options(self, args: Namespace) -> None:
# TODO - remove this section once all worlds use options dataclasses
from worlds import AutoWorld from worlds import AutoWorld
all_keys: Set[str] = {key for player in self.player_ids for key in
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
for option_key in all_keys:
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
f"Please use `self.options.{option_key}` instead.", True)
option.update(getattr(args, option_key, {}))
setattr(self, option_key, option)
for player in self.player_ids: for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player) self.worlds[player] = world_type(self, player)
@@ -424,39 +427,23 @@ class MultiWorld():
def get_location(self, location_name: str, player: int) -> Location: def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name] return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool | None = None, allow_partial_entrances: bool = False, def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState: cached = getattr(self, "_all_state", None)
""" if use_cache and cached:
Creates a new CollectionState, and collects all precollected items, all items in the multiworld itempool, those return cached.copy()
specified in each worlds' `get_pre_fill_items()`, and then sweeps the multiworld collecting any other items
it is able to reach, building as complete of a completed game state as possible.
:param use_cache: Deprecated and unused.
:param allow_partial_entrances: Whether the CollectionState should allow for disconnected entrances while
sweeping, such as before entrance randomization is complete.
:param collect_pre_fill_items: Whether the items in each worlds' `get_pre_fill_items()` should be added to this
state.
:param perform_sweep: Whether this state should perform a sweep for reachable locations, collecting any placed
items it can.
:return: The completed CollectionState.
"""
if __debug__ and use_cache is not None:
# TODO swap to Utils.deprecate when we want this to crash on source and warn on frozen
warnings.warn("multiworld.get_all_state no longer caches all_state and this argument will be removed.",
DeprecationWarning)
ret = CollectionState(self, allow_partial_entrances) ret = CollectionState(self, allow_partial_entrances)
for item in self.itempool: for item in self.itempool:
self.worlds[item.player].collect(ret, item) self.worlds[item.player].collect(ret, item)
if collect_pre_fill_items: for player in self.player_ids:
for player in self.player_ids: subworld = self.worlds[player]
subworld = self.worlds[player] for item in subworld.get_pre_fill_items():
for item in subworld.get_pre_fill_items(): subworld.collect(ret, item)
subworld.collect(ret, item) ret.sweep_for_advancements()
if perform_sweep:
ret.sweep_for_advancements()
if use_cache:
self._all_state = ret
return ret return ret
def get_items(self) -> List[Item]: def get_items(self) -> List[Item]:
@@ -558,9 +545,7 @@ class MultiWorld():
else: else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1))) return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
def can_beat_game(self, def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
starting_state: Optional[CollectionState] = None,
locations: Optional[Iterable[Location]] = None) -> bool:
if starting_state: if starting_state:
if self.has_beaten_game(starting_state): if self.has_beaten_game(starting_state):
return True return True
@@ -569,10 +554,25 @@ class MultiWorld():
state = CollectionState(self) state = CollectionState(self)
if self.has_beaten_game(state): if self.has_beaten_game(state):
return True return True
prog_locations = {location for location in self.get_locations() if location.item
and location.item.advancement and location not in state.locations_checked}
while prog_locations:
sphere: Set[Location] = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations:
if location.can_reach(state):
sphere.add(location)
if not sphere:
# ran out of places and did not finish yet, quit
return False
for location in sphere:
state.collect(location.item, True, location)
prog_locations -= sphere
for _ in state.sweep_for_advancements(locations,
yield_each_sweep=True,
checked_locations=state.locations_checked):
if self.has_beaten_game(state): if self.has_beaten_game(state):
return True return True
@@ -688,12 +688,6 @@ class MultiWorld():
sphere.append(locations.pop(n)) sphere.append(locations.pop(n))
if not sphere: if not sphere:
if __debug__:
from Fill import FillError
raise FillError(
f"Could not access required locations for accessibility check. Missing: {locations}",
multiworld=self,
)
# ran out of places and did not finish yet, quit # ran out of places and did not finish yet, quit
logging.warning(f"Could not access required locations for accessibility check." logging.warning(f"Could not access required locations for accessibility check."
f" Missing: {locations}") f" Missing: {locations}")
@@ -729,7 +723,6 @@ class CollectionState():
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
assert parent.worlds, "CollectionState created without worlds initialized in parent"
self.prog_items = {player: Counter() for player in parent.get_all_ids()} self.prog_items = {player: Counter() for player in parent.get_all_ids()}
self.multiworld = parent self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.reachable_regions = {player: set() for player in parent.get_all_ids()}
@@ -857,133 +850,20 @@ class CollectionState():
"Please switch over to sweep_for_advancements.") "Please switch over to sweep_for_advancements.")
return self.sweep_for_advancements(locations) return self.sweep_for_advancements(locations)
def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]], def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
yield_each_sweep: bool) -> Iterator[None]:
"""
The implementation for sweep_for_advancements is separated here because it returns a generator due to the use
of a yield statement.
"""
all_players = {player for player, _ in advancements_per_player}
players_to_check = all_players
# As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds
# are allowed to logically depend on other worlds, so once there are no more players that should be checked
# under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the
# sweep is finished.
checking_if_finished = False
while players_to_check:
next_advancements_per_player: List[Tuple[int, List[Location]]] = []
next_players_to_check = set()
for player, locations in advancements_per_player:
if player not in players_to_check:
next_advancements_per_player.append((player, locations))
continue
# Accessibility of each location is checked first because a player's region accessibility cache becomes
# stale whenever one of their own items is collected into the state.
reachable_locations: List[Location] = []
unreachable_locations: List[Location] = []
for location in locations:
if location.can_reach(self):
# Locations containing items that do not belong to `player` could be collected immediately
# because they won't stale `player`'s region accessibility cache, but, for simplicity, all the
# items at reachable locations are collected in a single loop.
reachable_locations.append(location)
else:
unreachable_locations.append(location)
if unreachable_locations:
next_advancements_per_player.append((player, unreachable_locations))
# A previous player's locations processed in the current `while players_to_check` iteration could have
# collected items belonging to `player`, but now that all of `player`'s reachable locations have been
# found, it can be assumed that `player` will not gain any more reachable locations until another one of
# their items is collected.
# It would be clearer to not add players to `next_players_to_check` in the first place if they have yet
# to be processed in the current `while players_to_check` iteration, but checking if a player should be
# added to `next_players_to_check` would need to be run once for every item that is collected, so it is
# more performant to instead discard `player` from `next_players_to_check` once their locations have
# been processed.
next_players_to_check.discard(player)
# Collect the items from the reachable locations.
for advancement in reachable_locations:
self.advancements.add(advancement)
item = advancement.item
assert isinstance(item, Item), "tried to collect advancement Location with no Item"
if self.collect(item, True, advancement):
# The player the item belongs to may be able to reach additional locations in the next sweep
# iteration.
next_players_to_check.add(item.player)
if not next_players_to_check:
if not checking_if_finished:
# It is assumed that each player's world only logically depends on itself, which may not be the
# case, so confirm that the sweep is finished by doing an extra iteration that checks every player.
checking_if_finished = True
next_players_to_check = all_players
else:
checking_if_finished = False
players_to_check = next_players_to_check
advancements_per_player = next_advancements_per_player
if yield_each_sweep:
yield
@overload
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *,
yield_each_sweep: Literal[True],
checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ...
@overload
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None,
yield_each_sweep: Literal[False] = False,
checked_locations: Optional[Set[Location]] = None) -> None: ...
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False,
checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]:
"""
Sweep through the locations that contain uncollected advancement items, collecting the items into the state
until there are no more reachable locations that contain uncollected advancement items.
:param locations: The locations to sweep through, defaulting to all locations in the multiworld.
:param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration.
:param checked_locations: Optional override of locations to filter out from the locations argument, defaults to
self.advancements when None.
"""
if checked_locations is None:
checked_locations = self.advancements
# Since the sweep loop usually performs many iterations, the locations are filtered in advance.
# A list of tuples is used, instead of a dictionary, because it is faster to iterate.
advancements_per_player: List[Tuple[int, List[Location]]]
if locations is None: if locations is None:
# `location.advancement` can only be True for filled locations, so unfilled locations are filtered out. locations = self.multiworld.get_filled_locations()
advancements_per_player = [] reachable_advancements = True
for player, locations_dict in self.multiworld.regions.location_cache.items(): # since the loop has a good chance to run more than once, only filter the advancements once
filtered_locations = [location for location in locations_dict.values() locations = {location for location in locations if location.advancement and location not in self.advancements}
if location.advancement and location not in checked_locations]
if filtered_locations:
advancements_per_player.append((player, filtered_locations))
else:
# Filter and separate the locations into a list for each player.
advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list)
for location in locations:
if location.advancement and location not in checked_locations:
advancements_per_player_dict[location.player].append(location)
# Convert to a list of tuples.
advancements_per_player = list(advancements_per_player_dict.items())
del advancements_per_player_dict
if yield_each_sweep: while reachable_advancements:
# Return a generator that will yield at the end of each sweep iteration. reachable_advancements = {location for location in locations if location.can_reach(self)}
return self._sweep_for_advancements_impl(advancements_per_player, True) locations -= reachable_advancements
else: for advancement in reachable_advancements:
# Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations self.advancements.add(advancement)
# once started, then start and exhaust the generator by attempting to iterate it. assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
for _ in self._sweep_for_advancements_impl(advancements_per_player, False): self.collect(advancement.item, True, advancement)
assert False, "Generator yielded when it should have run to completion without yielding"
return None
# item name related # item name related
def has(self, item: str, player: int, count: int = 1) -> bool: def has(self, item: str, player: int, count: int = 1) -> bool:
@@ -1119,17 +999,6 @@ class CollectionState():
return changed return changed
def add_item(self, item: str, player: int, count: int = 1) -> None:
"""
Adds the item to state.
:param item: The item to be added.
:param player: The player the item is for.
:param count: How many of the item to add.
"""
assert count > 0
self.prog_items[player][item] += count
def remove(self, item: Item): def remove(self, item: Item):
changed = self.multiworld.worlds[item.player].remove(self, item) changed = self.multiworld.worlds[item.player].remove(self, item)
if changed: if changed:
@@ -1138,33 +1007,6 @@ class CollectionState():
self.blocked_connections[item.player] = set() self.blocked_connections[item.player] = set()
self.stale[item.player] = True self.stale[item.player] = True
def remove_item(self, item: str, player: int, count: int = 1) -> None:
"""
Removes the item from state.
:param item: The item to be removed.
:param player: The player the item is for.
:param count: How many of the item to remove.
"""
assert count > 0
self.prog_items[player][item] -= count
if self.prog_items[player][item] < 1:
del (self.prog_items[player][item])
def set_item(self, item: str, player: int, count: int) -> None:
"""
Sets the item in state equal to the provided count.
:param item: The item to modify.
:param player: The player the item is for.
:param count: How many of the item to now have.
"""
assert count >= 0
if count == 0:
del (self.prog_items[player][item])
else:
self.prog_items[player][item] = count
class EntranceType(IntEnum): class EntranceType(IntEnum):
ONE_WAY = 1 ONE_WAY = 1
@@ -1251,13 +1093,13 @@ class Region:
self.region_manager = region_manager self.region_manager = region_manager
def __getitem__(self, index: int) -> Location: def __getitem__(self, index: int) -> Location:
return self._list[index] return self._list.__getitem__(index)
def __setitem__(self, index: int, value: Location) -> None: def __setitem__(self, index: int, value: Location) -> None:
raise NotImplementedError() raise NotImplementedError()
def __len__(self) -> int: def __len__(self) -> int:
return len(self._list) return self._list.__len__()
def __iter__(self): def __iter__(self):
return iter(self._list) return iter(self._list)
@@ -1271,8 +1113,8 @@ class Region:
class LocationRegister(Register): class LocationRegister(Register):
def __delitem__(self, index: int) -> None: def __delitem__(self, index: int) -> None:
location: Location = self._list[index] location: Location = self._list.__getitem__(index)
del self._list[index] self._list.__delitem__(index)
del(self.region_manager.location_cache[location.player][location.name]) del(self.region_manager.location_cache[location.player][location.name])
def insert(self, index: int, value: Location) -> None: def insert(self, index: int, value: Location) -> None:
@@ -1283,8 +1125,8 @@ class Region:
class EntranceRegister(Register): class EntranceRegister(Register):
def __delitem__(self, index: int) -> None: def __delitem__(self, index: int) -> None:
entrance: Entrance = self._list[index] entrance: Entrance = self._list.__getitem__(index)
del self._list[index] self._list.__delitem__(index)
del(self.region_manager.entrance_cache[entrance.player][entrance.name]) del(self.region_manager.entrance_cache[entrance.player][entrance.name])
def insert(self, index: int, value: Entrance) -> None: def insert(self, index: int, value: Entrance) -> None:
@@ -1438,8 +1280,8 @@ class Region:
Connects current region to regions in exit dictionary. Passed region names must exist first. Connects current region to regions in exit dictionary. Passed region names must exist first.
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided, :param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
created entrances will be named "self.name -> connecting_region" created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region": rule} :param rules: rules for the exits from this region. format is {"connecting_region", rule}
""" """
if not isinstance(exits, Dict): if not isinstance(exits, Dict):
exits = dict.fromkeys(exits) exits = dict.fromkeys(exits)
@@ -1531,47 +1373,31 @@ class Location:
class ItemClassification(IntFlag): class ItemClassification(IntFlag):
filler = 0b00000 filler = 0b0000
""" aka trash, as in filler items like ammo, currency etc """ """ aka trash, as in filler items like ammo, currency etc """
progression = 0b00001 progression = 0b0001
""" Item that is logically relevant. """ Item that is logically relevant.
Protects this item from being placed on excluded or unreachable locations. """ Protects this item from being placed on excluded or unreachable locations. """
useful = 0b00010 useful = 0b0010
""" Item that is especially useful. """ Item that is especially useful.
Protects this item from being placed on excluded or unreachable locations. Protects this item from being placed on excluded or unreachable locations.
When combined with another flag like "progression", it means "an especially useful progression item". """ When combined with another flag like "progression", it means "an especially useful progression item". """
trap = 0b00100 trap = 0b0100
""" Item that is detrimental in some way. """ """ Item that is detrimental in some way. """
skip_balancing = 0b01000 skip_balancing = 0b1000
""" should technically never occur on its own """ should technically never occur on its own
Item that is logically relevant, but progression balancing should not touch. Item that is logically relevant, but progression balancing should not touch.
Typically currency or other counted items. """
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) """
deprioritized = 0b10000 progression_skip_balancing = 0b1001 # only progression gets balanced
""" 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.
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",
these items often want both flags. """
progression_skip_balancing = 0b01001 # only progression gets balanced
progression_deprioritized = 0b10001 # only progression can be placed during priority fill
def as_flag(self) -> int: def as_flag(self) -> int:
"""As Network API flag int.""" """As Network API flag int."""
return int(self & 0b00111) return int(self & 0b0111)
class Item: class Item:
@@ -1615,10 +1441,6 @@ class Item:
def trap(self) -> bool: def trap(self) -> bool:
return ItemClassification.trap in self.classification return ItemClassification.trap in self.classification
@property
def deprioritized(self) -> bool:
return ItemClassification.deprioritized in self.classification
@property @property
def filler(self) -> bool: def filler(self) -> bool:
return not (self.advancement or self.useful or self.trap) return not (self.advancement or self.useful or self.trap)
@@ -1728,19 +1550,21 @@ class Spoiler:
# in the second phase, we cull each sphere such that the game is still beatable, # in the second phase, we cull each sphere such that the game is still beatable,
# reducing each range of influence to the bare minimum required inside it # reducing each range of influence to the bare minimum required inside it
required_locations = {location for sphere in collection_spheres for location in sphere} restore_later: Dict[Location, Item] = {}
for num, sphere in reversed(tuple(enumerate(collection_spheres))): for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete: Set[Location] = set() to_delete: Set[Location] = set()
for location in sphere: for location in sphere:
# we remove the location from required_locations to sweep from, and check if the game is still beatable # we remove the item at location and check if game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name, logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
location.item.player) location.item.player)
required_locations.remove(location) old_item = location.item
if multiworld.can_beat_game(state_cache[num], required_locations): location.item = None
if multiworld.can_beat_game(state_cache[num]):
to_delete.add(location) to_delete.add(location)
restore_later[location] = old_item
else: else:
# still required, got to keep it around # still required, got to keep it around
required_locations.add(location) location.item = old_item
# cull entries in spheres for spoiler walkthrough at end # cull entries in spheres for spoiler walkthrough at end
sphere -= to_delete sphere -= to_delete
@@ -1757,7 +1581,7 @@ class Spoiler:
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player) logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
precollected_items.remove(item) precollected_items.remove(item)
multiworld.state.remove(item) multiworld.state.remove(item)
if not multiworld.can_beat_game(multiworld.state, required_locations): if not multiworld.can_beat_game():
# Add the item back into `precollected_items` and collect it into `multiworld.state`. # Add the item back into `precollected_items` and collect it into `multiworld.state`.
multiworld.push_precollected(item) multiworld.push_precollected(item)
else: else:
@@ -1799,6 +1623,9 @@ class Spoiler:
self.create_paths(state, collection_spheres) self.create_paths(state, collection_spheres)
# repair the multiworld again # repair the multiworld again
for location, item in restore_later.items():
location.item = item
for item in removed_precollected: for item in removed_precollected:
multiworld.push_precollected(item) multiworld.push_precollected(item)
@@ -1926,7 +1753,7 @@ class Tutorial(NamedTuple):
description: str description: str
language: str language: str
file_name: str file_name: str
link: str # unused link: str
authors: List[str] authors: List[str]

View File

@@ -21,7 +21,7 @@ import Utils
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client") Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor, mark_raw from MultiServer import CommandProcessor
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType) RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
from Utils import Version, stream_input, async_start from Utils import Version, stream_input, async_start
@@ -99,17 +99,6 @@ class ClientCommandProcessor(CommandProcessor):
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"}) self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
return True return True
def get_current_datapackage(self) -> dict[str, typing.Any]:
"""
Return datapackage for current game if known.
:return: The datapackage for the currently registered game. If not found, an empty dictionary will be returned.
"""
if not self.ctx.game:
return {}
checksum = self.ctx.checksums[self.ctx.game]
return Utils.load_data_package_for_checksum(self.ctx.game, checksum)
def _cmd_missing(self, filter_text = "") -> bool: def _cmd_missing(self, filter_text = "") -> bool:
"""List all missing location checks, from your local game state. """List all missing location checks, from your local game state.
Can be given text, which will be used as filter.""" Can be given text, which will be used as filter."""
@@ -118,9 +107,7 @@ class ClientCommandProcessor(CommandProcessor):
return False return False
count = 0 count = 0
checked_count = 0 checked_count = 0
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
lookup = self.get_current_datapackage().get("location_name_to_id", {})
for location, location_id in lookup.items():
if filter_text and filter_text not in location: if filter_text and filter_text not in location:
continue continue
if location_id < 0: if location_id < 0:
@@ -141,91 +128,43 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.") self.output("No missing location checks found.")
return True return True
def output_datapackage_part(self, key: str, name: str) -> bool: def _cmd_items(self):
"""
Helper to digest a specific section of this game's datapackage.
:param key: The dictionary key in the datapackage.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
"""
if not self.ctx.game:
self.output(f"No game set, cannot determine {name}.")
return False
lookup = self.get_current_datapackage().get(key)
if lookup is None:
self.output("datapackage not yet loaded, try again")
return False
self.output(f"{name} for {self.ctx.game}")
for key in lookup:
self.output(key)
return True
def _cmd_items(self) -> bool:
"""List all item names for the currently running game.""" """List all item names for the currently running game."""
return self.output_datapackage_part("item_name_to_id", "Item Names")
def _cmd_locations(self) -> bool:
"""List all location names for the currently running game."""
return self.output_datapackage_part("location_name_to_id", "Location Names")
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
filter_key: str,
name: str) -> bool:
"""
Logs an item or location group from the player's game's datapackage.
:param group_key: Either Item or Location group to be processed.
:param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
"""
if not self.ctx.game: if not self.ctx.game:
self.output(f"No game set, cannot determine existing {name} Groups.") self.output("No game set, cannot determine existing items.")
return False return False
lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\ self.output(f"Item Names for {self.ctx.game}")
.get(self.ctx.game, {}).get(group_key, {}) for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
if lookup is None: self.output(item_name)
self.output("datapackage not yet loaded, try again")
def _cmd_item_groups(self):
"""List all item group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing item groups.")
return False return False
self.output(f"Item Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
self.output(group_name)
if filter_key: def _cmd_locations(self):
if filter_key not in lookup: """List all location names for the currently running game."""
self.output(f"Unknown {name} Group {filter_key}") if not self.ctx.game:
return False self.output("No game set, cannot determine existing locations.")
return False
self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
self.output(f"{name}s for {name} Group \"{filter_key}\"") def _cmd_location_groups(self):
for entry in lookup[filter_key]: """List all location group names for the currently running game."""
self.output(entry) if not self.ctx.game:
else: self.output("No game set, cannot determine existing location groups.")
self.output(f"{name} Groups for {self.ctx.game}") return False
for group in lookup: self.output(f"Location Group Names for {self.ctx.game}")
self.output(group) for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
return True self.output(group_name)
@mark_raw def _cmd_ready(self):
def _cmd_item_groups(self, key: str = "") -> bool:
"""
List all item group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("item_name_groups", key, "Item")
@mark_raw
def _cmd_location_groups(self, key: str = "") -> bool:
"""
List all location group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("location_name_groups", key, "Location")
def _cmd_ready(self) -> bool:
"""Send ready status to server.""" """Send ready status to server."""
self.ctx.ready = not self.ctx.ready self.ctx.ready = not self.ctx.ready
if self.ctx.ready: if self.ctx.ready:
@@ -235,7 +174,6 @@ class ClientCommandProcessor(CommandProcessor):
state = ClientStatus.CLIENT_CONNECTED state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.") self.output("Unreadied.")
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
return True
def default(self, raw: str): def default(self, raw: str):
"""The default message parser to be used when parsing any messages that do not match a command""" """The default message parser to be used when parsing any messages that do not match a command"""
@@ -263,7 +201,6 @@ class CommonContext:
# noinspection PyTypeChecker # noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]: def __getitem__(self, key: str) -> typing.Mapping[int, str]:
assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead"
return self._game_store[key] return self._game_store[key]
def __len__(self) -> int: def __len__(self) -> int:
@@ -273,7 +210,7 @@ class CommonContext:
return iter(self._game_store) return iter(self._game_store)
def __repr__(self) -> str: def __repr__(self) -> str:
return repr(self._game_store) return self._game_store.__repr__()
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str: def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is """Returns the name for an item/location id in the context of a specific game or own game if `game` is
@@ -329,71 +266,38 @@ class CommonContext:
last_death_link: float = time.time() # last send/received death link on AP layer last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info # remaining type info
slot_info: dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
"""Slot Info from the server for the current connection""" server_address: typing.Optional[str]
server_address: str | None password: typing.Optional[str]
"""Autoconnect address provided by the ctx constructor""" hint_cost: typing.Optional[int]
password: str | None hint_points: typing.Optional[int]
"""Password used for Connecting, expected by server_auth""" player_names: typing.Dict[int, str]
hint_cost: int | None
"""Current Hint Cost per Hint from the server"""
hint_points: int | None
"""Current avaliable Hint Points from the server"""
player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)"""
finished_game: bool finished_game: bool
"""
Bool to signal that status should be updated to Goal after reconnecting
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
"""
ready: bool ready: bool
"""Bool to keep track of state for the /ready command""" team: typing.Optional[int]
team: int | None slot: typing.Optional[int]
"""Team number of currently connected slot""" auth: typing.Optional[str]
slot: int | None seed_name: typing.Optional[str]
"""Slot number of currently connected slot"""
auth: str | None
"""Name used in Connect packet"""
seed_name: str | None
"""Seed name that will be validated on opening a socket if present"""
# locations # locations
locations_checked: set[int] locations_checked: typing.Set[int] # local state
""" locations_scouted: typing.Set[int]
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting items_received: typing.List[NetworkItem]
to be used to ensure that a LocationChecks packet does not get lost when disconnected missing_locations: typing.Set[int] # server state
""" checked_locations: typing.Set[int] # server state
locations_scouted: set[int] server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
""" locations_info: typing.Dict[int, NetworkItem]
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
to be used to ensure that a LocationScouts packet does not get lost when disconnected
"""
items_received: list[NetworkItem]
"""List of NetworkItems recieved from the server"""
missing_locations: set[int]
"""Container of Locations that are unchecked per server state"""
checked_locations: set[int]
"""Container of Locations that are checked per server state"""
server_locations: set[int]
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
locations_info: dict[int, NetworkItem]
"""Dict of location id: NetworkItem info from LocationScouts request"""
# data storage # data storage
stored_data: dict[str, typing.Any] stored_data: typing.Dict[str, typing.Any]
""" stored_data_notification_keys: typing.Set[str]
Data Storage values by key that were retrieved from the server
any keys subscribed to with SetNotify will be kept up to date
"""
stored_data_notification_keys: set[str]
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
# internals # internals
# current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None _messagebox: typing.Optional["kvui.MessageBox"] = None
"""Current message box through kvui""" # message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None _messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
"""Message box reporting a loss of connection"""
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None: def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
# server state # server state
@@ -441,8 +345,6 @@ class CommonContext:
self.jsontotextparser = JSONtoTextParser(self) self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self) self.rawjsontotextparser = RawJSONtoTextParser(self)
if self.game:
self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
self.update_data_package(network_data_package) self.update_data_package(network_data_package)
# execution # execution
@@ -702,24 +604,6 @@ class CommonContext:
for game, game_data in data_package["games"].items(): for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data) Utils.store_data_package_for_checksum(game, game_data)
def consume_network_item_groups(self):
data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
def consume_network_location_groups(self):
data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
# data storage # data storage
def set_notify(self, *keys: str) -> None: def set_notify(self, *keys: str) -> None:
@@ -1020,12 +904,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.hint_points = args.get("hint_points", 0) ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"]) ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}") ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
if ctx.game:
game = ctx.game
else:
game = ctx.slot_info[ctx.slot][1]
ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
msgs = [] msgs = []
if ctx.locations_checked: if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks", msgs.append({"cmd": "LocationChecks",
@@ -1106,19 +984,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.stored_data.update(args["keys"]) ctx.stored_data.update(args["keys"])
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]: if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
ctx.ui.update_hints() ctx.ui.update_hints()
if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_item_groups()
if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_location_groups()
elif cmd == "SetReply": elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"] ctx.stored_data[args["key"]] = args["value"]
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]: if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
ctx.ui.update_hints() ctx.ui.update_hints()
elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_item_groups()
elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_location_groups()
elif args["key"].startswith("EnergyLink"): elif args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"] ctx.current_energy_link_value = args["value"]
if ctx.ui: if ctx.ui:

View File

@@ -1,100 +0,0 @@
# hadolint global ignore=SC1090,SC1091
# Source
FROM scratch AS release
WORKDIR /release
ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
# Enemizer
FROM alpine:3.21 AS enemizer
ARG TARGETARCH
WORKDIR /release
COPY --from=release /release/Enemizer.zip .
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
apk add unzip=6.0-r15 --no-cache && \
unzip -u Enemizer.zip -d EnemizerCLI && \
chmod -R 777 EnemizerCLI; \
else touch EnemizerCLI; fi
# Cython builder stage
FROM python:3.12 AS cython-builder
WORKDIR /build
# Copy and install requirements first (better caching)
COPY requirements.txt WebHostLib/requirements.txt
RUN pip install --no-cache-dir -r \
WebHostLib/requirements.txt \
setuptools
COPY _speedups.pyx .
COPY intset.h .
RUN cythonize -b -i _speedups.pyx
# Archipelago
FROM python:3.12-slim AS archipelago
ARG TARGETARCH
ENV VIRTUAL_ENV=/opt/venv
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install requirements
# hadolint ignore=DL3008
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git \
gcc=4:12.2.0-3 \
libc6-dev \
libtk8.6=8.6.13-2 \
g++=4:12.2.0-3 \
curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Create and activate venv
RUN python -m venv $VIRTUAL_ENV; \
. $VIRTUAL_ENV/bin/activate
# Copy and install requirements first (better caching)
COPY WebHostLib/requirements.txt WebHostLib/requirements.txt
RUN pip install --no-cache-dir -r \
WebHostLib/requirements.txt \
gunicorn==23.0.0
COPY . .
COPY --from=cython-builder /build/*.so ./
# Run ModuleUpdate
RUN python ModuleUpdate.py -y
# Purge unneeded packages
RUN apt-get purge -y \
git \
gcc \
libc6-dev \
g++ && \
apt-get autoremove -y
# Copy necessary components
COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
cp -r /tmp/EnemizerCLI EnemizerCLI; \
fi; \
rm -rf /tmp/EnemizerCLI
# Define health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:${PORT:-80} || exit 1
# Ensure no runtime ModuleUpdate.
ENV SKIP_REQUIREMENTS_UPDATE=true
ENTRYPOINT [ "python", "WebHost.py" ]

267
FF1Client.py Normal file
View File

@@ -0,0 +1,267 @@
import asyncio
import copy
import json
import time
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
class FF1CommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_nes(self):
"""Check NES Connection State"""
if isinstance(self.ctx, FF1Context):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in EmuHawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
class FF1Context(CommonContext):
command_processor = FF1CommandProcessor
game = 'Final Fantasy'
items_handling = 0b111 # full remote
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.nes_streams: (StreamReader, StreamWriter) = None
self.nes_sync_task = None
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(FF1Context, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to NES to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[time.time(), msg_id] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
async_start(parse_locations(self.locations_array, self, True))
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_print_json(self, args: dict):
if self.ui:
self.ui.print_json(copy.deepcopy(args["data"]))
else:
text = self.jsontotextparser(copy.deepcopy(args["data"]))
logger.info(text)
relevant = args.get("type", None) in {"Hint", "ItemSend"}
if relevant:
item = args["item"]
# goes to this world
if self.slot_concerns_self(args["receiving"]):
relevant = True
# found in this world
elif self.slot_concerns_self(item.player):
relevant = True
# not related
else:
relevant = False
if relevant:
item = args["item"]
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
self._set_message(msg, item.item)
def run_gui(self):
from kvui import GameManager
class FF1Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Final Fantasy 1 Client"
self.ui = FF1Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: FF1Context):
current_time = time.time()
return json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10}
}
)
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
if locations_array == ctx.locations_array and not force:
return
else:
# print("New values")
ctx.locations_array = locations_array
locations_checked = []
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
for location in ctx.missing_locations:
# index will be - 0x100 or 0x200
index = location
if location < 0x200:
# Location is a chest
index -= 0x100
flag = 0x04
else:
# Location is an NPC
index -= 0x200
flag = 0x02
# print(f"Location: {ctx.location_names[location]}")
# print(f"Index: {str(hex(index))}")
# print(f"value: {locations_array[index] & flag != 0}")
if locations_array[index] & flag != 0:
locations_checked.append(location)
if locations_checked:
# print([ctx.location_names[location] for location in locations_checked])
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}
])
async def nes_sync_task(ctx: FF1Context):
logger.info("Starting nes connector. Use /nes for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.nes_streams:
(reader, writer) = ctx.nes_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
# print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to NES")
ctx.nes_status = CONNECTION_CONNECTED_STATUS
else:
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.nes_status = error_status
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
else:
try:
logger.debug("Attempting to connect to NES")
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.nes_status = CONNECTION_REFUSED_STATUS
continue
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("FF1Client")
options = Utils.get_options()
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
async def main(args):
ctx = FF1Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.nes_sync_task:
await ctx.nes_sync_task
import colorama
parser = get_base_parser()
args = parser.parse_args()
colorama.just_fix_windows_console()
asyncio.run(main(args))
colorama.deinit()

478
Fill.py
View File

@@ -4,7 +4,7 @@ import logging
import typing import typing
from collections import Counter, deque from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Options import Accessibility from Options import Accessibility
from worlds.AutoWorld import call_all from worlds.AutoWorld import call_all
@@ -100,7 +100,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# if minimal accessibility, only check whether location is reachable if game not beatable # if minimal accessibility, only check whether location is reachable if game not beatable
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state, perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
item_to_place.player) \ item_to_place.player) \
if single_player_placement else not has_beaten_game if single_player_placement else not has_beaten_game
else: else:
perform_access_check = True perform_access_check = True
@@ -116,13 +116,6 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
else: else:
# we filled all reachable spots. # we filled all reachable spots.
if swap: if swap:
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
# swap state, instead of sweeping from `base_state` each time.
previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
max_swap_base_state_cache_length = 3
# try swapping this item with previously placed items in a safe way then in an unsafe way # try swapping this item with previously placed items in a safe way then in an unsafe way
swap_attempts = ((i, location, unsafe) swap_attempts = ((i, location, unsafe)
for unsafe in (False, True) for unsafe in (False, True)
@@ -137,50 +130,40 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
location.item = None location.item = None
placed_item.location = None placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
for previous_safe_swap_state in previous_safe_swap_state_cache: multiworld.get_filled_locations(item.player)
# If a state has already checked the location of the swap, then it cannot be used. if single_player_placement else None)
if location not in previous_safe_swap_state.advancements:
# Previous swap states will have collected all items in `item_pool`, so the new
# `swap_state` can skip having to collect them again.
# Previous swap states will also have already checked many locations, making the sweep
# faster.
swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (),
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
break
else:
# No previous swap_state was usable as a base state to sweep from, so create a new one.
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
# Unsafe states should not be added to the cache because they have collected `placed_item`.
if not unsafe:
if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
# Remove the oldest cached state.
previous_safe_swap_state_cache.pop()
# Add the new state to the start of the cache.
previous_safe_swap_state_cache.appendleft(swap_state)
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place # unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic # by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails. # to clean that up later, so there is a chance generation fails.
if (not single_player_placement or location.player == item_to_place.player) \ if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check): and location.can_fill(swap_state, item_to_place, perform_access_check):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1 # Verify placing this item won't reduce available locations, which would be a useless swap.
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count prev_state = swap_state.copy()
prev_loc_count = len(
multiworld.get_reachable_locations(prev_state))
reachable_items[placed_item.player].appendleft( swap_state.collect(item_to_place, True)
placed_item) new_loc_count = len(
item_pool.append(placed_item) multiworld.get_reachable_locations(swap_state))
# cleanup at the end to hopefully get better errors if new_loc_count >= prev_loc_count:
cleanup_required = True # Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
break swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
# cleanup at the end to hopefully get better errors
cleanup_required = True
break
# Item can't be placed here, restore original item # Item can't be placed here, restore original item
location.item = placed_item location.item = placed_item
@@ -259,7 +242,7 @@ def remaining_fill(multiworld: MultiWorld,
unplaced_items: typing.List[Item] = [] unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = [] placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations)) total = min(len(itempool), len(locations))
placed = 0 placed = 0
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule # Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
@@ -358,17 +341,10 @@ def fast_fill(multiworld: MultiWorld,
return item_pool[placing:], fill_locations[placing:] return item_pool[placing:], fill_locations[placing:]
def accessibility_corrections(multiworld: MultiWorld, def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
state: CollectionState,
locations: list[Location],
pool: list[Item] | None = None) -> None:
if pool is None:
pool = []
maximum_exploration_state = sweep_from_pool(state, pool) maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in multiworld.player_ids if minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
multiworld.worlds[player].options.accessibility == "minimal"} unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
unreachable_locations = [location for location in multiworld.get_locations() if
location.player in minimal_players and
not location.can_reach(maximum_exploration_state)] not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations: for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not if (location.item is not None and location.item.advancement and location.address is not None and not
@@ -389,7 +365,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)] unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations: if unreachable_locations:
def forbid_important_item_rule(item: Item): def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal") return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
for location in unreachable_locations: for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule) add_item_rule(location, forbid_important_item_rule)
@@ -483,12 +459,6 @@ def distribute_early_items(multiworld: MultiWorld,
def distribute_items_restrictive(multiworld: MultiWorld, def distribute_items_restrictive(multiworld: MultiWorld,
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None: panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
assert all(item.location is None for item in multiworld.itempool), (
"At the start of distribute_items_restrictive, "
"there are items in the multiworld itempool that are already placed on locations:\n"
f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}"
)
fill_locations = sorted(multiworld.get_unfilled_locations()) fill_locations = sorted(multiworld.get_unfilled_locations())
multiworld.random.shuffle(fill_locations) multiworld.random.shuffle(fill_locations)
# get items to distribute # get items to distribute
@@ -531,48 +501,18 @@ def distribute_items_restrictive(multiworld: MultiWorld,
single_player = multiworld.players == 1 and not multiworld.groups single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations: if prioritylocations:
regular_progression = []
deprioritized_progression = []
for item in progitempool:
if item.deprioritized:
deprioritized_progression.append(item)
else:
regular_progression.append(item)
# "priority fill" # "priority fill"
# try without deprioritized items in the mix at all. This means they need to be collected into state first. maximum_exploration_state = sweep_from_pool(multiworld.state)
priority_fill_state = sweep_from_pool(multiworld.state, deprioritized_progression) fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
fill_restrictive(multiworld, priority_fill_state, prioritylocations, regular_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking, single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority", one_item_per_player=True, allow_partial=True) name="Priority", one_item_per_player=True, allow_partial=True)
if prioritylocations and regular_progression: if prioritylocations:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization # retry with one_item_per_player off because some priority fills can fail to fill with that optimization
# deprioritized items are still not in the mix, so they need to be collected into state first. maximum_exploration_state = sweep_from_pool(multiworld.state)
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression) fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression, single_player_placement=single_player, swap=False, on_place=mark_for_locking,
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority Retry", one_item_per_player=False)
name="Priority Retry", one_item_per_player=False, allow_partial=True)
if prioritylocations and deprioritized_progression:
# There are no more regular progression items that can be placed on any priority locations.
# We'd still prefer to place deprioritized progression items on priority locations over filler items.
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
priority_retry_2_state = sweep_from_pool(multiworld.state, regular_progression)
fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry 2", one_item_per_player=True, allow_partial=True)
if prioritylocations and deprioritized_progression:
# retry with deprioritized items AND without one_item_per_player optimisation
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
priority_retry_3_state = sweep_from_pool(multiworld.state, regular_progression)
fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry 3", one_item_per_player=False)
# restore original order of progitempool
progitempool[:] = [item for item in progitempool if not item.location]
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
@@ -737,9 +677,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if multiworld.worlds[player].options.progression_balancing > 0 if multiworld.worlds[player].options.progression_balancing > 0
} }
if not balanceable_players: if not balanceable_players:
logging.info("Skipping multiworld progression balancing.") logging.info('Skipping multiworld progression balancing.')
else: else:
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.") logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players) logging.debug(balanceable_players)
state: CollectionState = CollectionState(multiworld) state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set() checked_locations: typing.Set[Location] = set()
@@ -837,7 +777,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if player in threshold_percentages): if player in threshold_percentages):
break break
elif not balancing_sphere: elif not balancing_sphere:
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.") raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
# Gather a set of locations which we can swap items into # Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set) unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
for l in unchecked_locations: for l in unchecked_locations:
@@ -853,8 +793,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
testing = items_to_test.pop() testing = items_to_test.pop()
reducing_state = state.copy() reducing_state = state.copy()
for location in itertools.chain(( for location in itertools.chain((
l for l in items_to_replace l for l in items_to_replace
if l.item.player == player if l.item.player == player
), items_to_test): ), items_to_test):
reducing_state.collect(location.item, True, location) reducing_state.collect(location.item, True, location)
@@ -927,30 +867,52 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_2.item.location = location_2 location_2.item.location = location_2
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]: def distribute_planned(multiworld: MultiWorld) -> None:
def warn(warning: str, force: bool | str) -> None: def warn(warning: str, force: typing.Union[bool, str]) -> None:
if isinstance(force, bool): if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f"{warning}") logging.warning(f'{warning}')
else: else:
logging.debug(f"{warning}") logging.debug(f'{warning}')
def failed(warning: str, force: bool | str) -> None: def failed(warning: str, force: typing.Union[bool, str]) -> None:
if force is True: if force in [True, 'fail', 'failure']:
raise Exception(warning) raise Exception(warning)
else: else:
warn(warning, force) warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
world_name_lookup = multiworld.world_name_lookup world_name_lookup = multiworld.world_name_lookup
plando_blocks: dict[int, list[PlandoItemBlock]] = dict() block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
player_ids: set[int] = set(multiworld.player_ids) plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
player_ids = set(multiworld.player_ids)
for player in player_ids: for player in player_ids:
plando_blocks[player] = [] for block in multiworld.plando_items[player]:
for block in multiworld.worlds[player].options.plando_items: block['player'] = player
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force) if 'force' not in block:
target_world = block.world block['force'] = 'silent'
if 'from_pool' not in block:
block['from_pool'] = True
elif not isinstance(block['from_pool'], bool):
from_pool_type = type(block['from_pool'])
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
if 'world' not in block:
target_world = False
else:
target_world = block['world']
if target_world is False or multiworld.players == 1: # target own world if target_world is False or multiworld.players == 1: # target own world
worlds: set[int] = {player} worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own elif target_world is True: # target any worlds besides own
worlds = set(multiworld.player_ids) - {player} worlds = set(multiworld.player_ids) - {player}
elif target_world is None: # target all worlds elif target_world is None: # target all worlds
@@ -959,201 +921,173 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
worlds = set() worlds = set()
for listed_world in target_world: for listed_world in target_world:
if listed_world not in world_name_lookup: if listed_world not in world_name_lookup:
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.", failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block.force) block['force'])
continue continue
worlds.add(world_name_lookup[listed_world]) worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number elif type(target_world) == int: # target world by slot number
if target_world not in range(1, multiworld.players + 1): if target_world not in range(1, multiworld.players + 1):
failed( failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})", f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
block.force) block['force'])
continue continue
worlds = {target_world} worlds = {target_world}
else: # target world by slot name else: # target world by slot name
if target_world not in world_name_lookup: if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.", failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block.force) block['force'])
continue continue
worlds = {world_name_lookup[target_world]} worlds = {world_name_lookup[target_world]}
new_block.worlds = worlds block['world'] = worlds
items: list[str] | dict[str, typing.Any] = block.items items: block_value = []
if "items" in block:
items = block["items"]
if 'count' not in block:
block['count'] = False
elif "item" in block:
items = block["item"]
if 'count' not in block:
block['count'] = 1
else:
failed("You must specify at least one item to place items with plando.", block['force'])
continue
if isinstance(items, dict): if isinstance(items, dict):
item_list: list[str] = [] item_list: typing.List[str] = []
for key, value in items.items(): for key, value in items.items():
if value is True: if value is True:
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key)) value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
item_list += [key] * value item_list += [key] * value
items = item_list items = item_list
new_block.items = items if isinstance(items, str):
items = [items]
block['items'] = items
locations: list[str] = block.locations locations: block_value = []
if 'location' in block:
locations = block['location'] # just allow 'location' to keep old yamls compatible
elif 'locations' in block:
locations = block['locations']
if isinstance(locations, str): if isinstance(locations, str):
locations = [locations] locations = [locations]
resolved_locations: list[Location] = [] if isinstance(locations, dict):
for target_player in worlds: location_list = []
locations_from_groups: list[str] = [] for key, value in locations.items():
world_locations = multiworld.get_unfilled_locations(target_player) location_list += [key] * value
for group in multiworld.worlds[target_player].location_name_groups: locations = location_list
if group in locations:
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
resolved_locations.extend(location for location in world_locations
if location.name in [*locations, *locations_from_groups])
new_block.locations = sorted(dict.fromkeys(locations))
new_block.resolved_locations = sorted(set(resolved_locations))
count = block.count
if not count:
count = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
if isinstance(count, int):
count = {"min": count, "max": count}
if "min" not in count:
count["min"] = 0
if "max" not in count:
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
new_block.count = count
plando_blocks[player].append(new_block)
return plando_blocks
def resolve_early_locations_for_planned(multiworld: MultiWorld):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc)
for player in multiworld.plando_item_blocks:
removed = []
for block in multiworld.plando_item_blocks[player]:
locations = block.locations
resolved_locations = block.resolved_locations
worlds = block.worlds
if "early_locations" in locations: if "early_locations" in locations:
locations.remove("early_locations")
for target_player in worlds: for target_player in worlds:
resolved_locations += early_locations[target_player] locations += early_locations[target_player]
if "non_early_locations" in locations: if "non_early_locations" in locations:
locations.remove("non_early_locations")
for target_player in worlds: for target_player in worlds:
resolved_locations += non_early_locations[target_player] locations += non_early_locations[target_player]
if block.count["max"] > len(block.items): block['locations'] = list(dict.fromkeys(locations))
count = block.count["max"]
failed(f"Plando count {count} greater than items specified", block.force)
block.count["max"] = len(block.items)
if block.count["min"] > len(block.items):
block.count["min"] = len(block.items)
if block.count["max"] > len(block.resolved_locations) > 0:
count = block.count["max"]
failed(f"Plando count {count} greater than locations specified", block.force)
block.count["max"] = len(block.resolved_locations)
if block.count["min"] > len(block.resolved_locations):
block.count["min"] = len(block.resolved_locations)
block.count["target"] = multiworld.random.randint(block.count["min"],
block.count["max"])
if not block.count["target"]: if not block['count']:
removed.append(block) block['count'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if isinstance(block['count'], int):
block['count'] = {'min': block['count'], 'max': block['count']}
if 'min' not in block['count']:
block['count']['min'] = 0
if 'max' not in block['count']:
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if block['count']['max'] > len(block['items']):
count = block['count']
failed(f"Plando count {count} greater than items specified", block['force'])
block['count'] = len(block['items'])
if block['count']['max'] > len(block['locations']) > 0:
count = block['count']
failed(f"Plando count {count} greater than locations specified", block['force'])
block['count'] = len(block['locations'])
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
for block in removed: if block['count']['target'] > 0:
multiworld.plando_item_blocks[player].remove(block) plando_blocks.append(block)
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
# shuffle, but then sort blocks by number of locations minus number of items, # shuffle, but then sort blocks by number of locations minus number of items,
# so less-flexible blocks get priority # so less-flexible blocks get priority
multiworld.random.shuffle(plando_blocks) multiworld.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"] plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block.resolved_locations) > 0 if len(block['locations']) > 0
else len(multiworld.get_unfilled_locations(block.player)) - else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
block.count["target"]))
for placement in plando_blocks: for placement in plando_blocks:
player = placement.player player = placement['player']
try: try:
worlds = placement.worlds worlds = placement['world']
locations = placement.resolved_locations locations = placement['locations']
items = placement.items items = placement['items']
maxcount = placement.count["target"] maxcount = placement['count']['target']
from_pool = placement.from_pool from_pool = placement['from_pool']
item_candidates = [] candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
if from_pool:
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
for item in multiworld.random.sample(items, maxcount):
candidate = next((i for i in instances if i.name == item), None)
if candidate is None:
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
f"it's already missing from it", placement.force)
candidate = multiworld.worlds[player].create_item(item)
else:
multiworld.itempool.remove(candidate)
instances.remove(candidate)
item_candidates.append(candidate)
else:
item_candidates = [multiworld.worlds[player].create_item(item)
for item in multiworld.random.sample(items, maxcount)]
if any(item.code is None for item in item_candidates) \
and not all(item.code is None for item in item_candidates):
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
f"event items and non-event items. "
f"Event items: {[item for item in item_candidates if item.code is None]}, "
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
placement.force)
continue
else:
is_real = item_candidates[0].code is not None
candidates = [candidate for candidate in locations if candidate.item is None
and bool(candidate.address) == is_real]
multiworld.random.shuffle(candidates) multiworld.random.shuffle(candidates)
allstate = multiworld.get_all_state(False) multiworld.random.shuffle(items)
mincount = placement.count["min"] count = 0
allowed_margin = len(item_candidates) - mincount err: typing.List[str] = []
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True, successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
allow_partial=True, name="Plando Main Fill") claimed_indices: typing.Set[typing.Optional[int]] = set()
for item_name in items:
index_to_delete: typing.Optional[int] = None
if from_pool:
try:
# If from_pool, try to find an existing item with this name & player in the itempool and use it
index_to_delete, item = next(
(i, item) for i, item in enumerate(multiworld.itempool)
if item.player == player and item.name == item_name and i not in claimed_indices
)
except StopIteration:
warn(
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
item = multiworld.worlds[player].create_item(item_name)
else:
item = multiworld.worlds[player].create_item(item_name)
for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item:
if location.item_rule(item):
if location.can_fill(multiworld.state, item, False):
successful_pairs.append((index_to_delete, item, location))
claimed_indices.add(index_to_delete)
candidates.remove(location)
count = count + 1
break
else:
err.append(f"Can't place item at {location} due to fill condition not met.")
else:
err.append(f"{item_name} not allowed at {location}.")
else:
err.append(f"Cannot place {item_name} into already filled location {location}.")
else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
if count == maxcount:
break
if count < placement['count']['min']:
m = placement['count']['min']
failed(
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
# Sort indices in reverse so we can remove them one by one
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
for (index, item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False)
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if index is not None: # If this item is from_pool and was found in the pool, remove it.
multiworld.itempool.pop(index)
if len(item_candidates) > allowed_margin:
failed(f"Could not place {len(item_candidates)} "
f"of {mincount + allowed_margin} item(s) "
f"for {multiworld.player_name[player]}, "
f"remaining items: {item_candidates}",
placement.force)
if from_pool:
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
except Exception as e: except Exception as e:
raise Exception( raise Exception(
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e f"Error running plando for player {player} ({multiworld.player_name[player]})") from e

View File

@@ -10,8 +10,8 @@ import sys
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from collections import Counter from collections import Counter
from typing import Any, Dict, Tuple, Union
from itertools import chain from itertools import chain
from typing import Any
import ModuleUpdate import ModuleUpdate
@@ -77,7 +77,7 @@ def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits) return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None) -> tuple[argparse.Namespace, int]: def main(args=None) -> Tuple[argparse.Namespace, int]:
# __name__ == "__main__" check so unittests that already imported worlds don't trip this. # __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules: if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.") raise Exception("Worlds system should not be loaded before logging init.")
@@ -95,7 +95,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
logging.info("Race mode enabled. Using non-deterministic random source.") logging.info("Race mode enabled. Using non-deterministic random source.")
random.seed() # reset to time-based random source random.seed() # reset to time-based random source
weights_cache: dict[str, tuple[Any, ...]] = {} weights_cache: Dict[str, Tuple[Any, ...]] = {}
if args.weights_file_path and os.path.exists(args.weights_file_path): if args.weights_file_path and os.path.exists(args.weights_file_path):
try: try:
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path) weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
@@ -180,7 +180,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
erargs.name = {} erargs.name = {}
erargs.csv_output = args.csv_output erargs.csv_output = args.csv_output
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \ settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None) {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
for fname, yamls in weights_cache.items()} for fname, yamls in weights_cache.items()}
@@ -212,7 +212,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
path = player_path_cache[player] path = player_path_cache[player]
if path: if path:
try: try:
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \ settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path]) tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings: for settingsObject in settings:
for k, v in vars(settingsObject).items(): for k, v in vars(settingsObject).items():
@@ -224,14 +224,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
except Exception as e: except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e raise Exception(f"Error setting {k} to {v} for player {player}") from e
# name was not specified if path == args.weights_file_path: # if name came from the weights file, just use base player name
if player not in erargs.name: erargs.name[player] = f"Player{player}"
if path == args.weights_file_path: elif player not in erargs.name: # if name was not specified, generate it from filename
# weights file, so we need to make the name unique erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = f"Player{player}"
else:
# use the filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter) erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
player += 1 player += 1
@@ -246,7 +242,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
return erargs, seed return erargs, seed
def read_weights_yamls(path) -> tuple[Any, ...]: def read_weights_yamls(path) -> Tuple[Any, ...]:
try: try:
if urllib.parse.urlparse(path).scheme in ('https', 'file'): if urllib.parse.urlparse(path).scheme in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig") yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
@@ -338,6 +334,12 @@ def handle_name(name: str, player: int, name_counter: Counter):
return new_name return new_name
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict: def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}') logging.debug(f'Applying {new_weights}')
cleaned_weights = {} cleaned_weights = {}
@@ -382,7 +384,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
return weights return weights
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any: def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
from worlds import AutoWorldRegister from worlds import AutoWorldRegister
if not game: if not game:
@@ -403,7 +405,7 @@ def roll_linked_options(weights: dict) -> dict:
if "name" not in option_set: if "name" not in option_set:
raise ValueError("One of your linked options does not have a name.") raise ValueError("One of your linked options does not have a name.")
try: try:
if Options.roll_percentage(option_set["percentage"]): if roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.") logging.debug(f"Linked option {option_set['name']} triggered.")
new_options = option_set["options"] new_options = option_set["options"]
for category_name, category_options in new_options.items(): for category_name, category_options in new_options.items():
@@ -436,7 +438,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
trigger_result = get_choice("option_result", option_set) trigger_result = get_choice("option_result", option_set)
result = get_choice(key, currently_targeted_weights) result = get_choice(key, currently_targeted_weights)
currently_targeted_weights[key] = result currently_targeted_weights[key] = result
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)): if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
for category_name, category_options in option_set["options"].items(): for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights currently_targeted_weights = weights
if category_name: if category_name:
@@ -540,6 +542,10 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
handle_option(ret, game_weights, option_key, option, plando_options) handle_option(ret, game_weights, option_key, option, plando_options)
valid_keys.add(option_key) valid_keys.add(option_key)
# TODO remove plando_items after moving it to the options system
valid_keys.add("plando_items")
if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
if ret.game == "A Link to the Past": if ret.game == "A Link to the Past":
# TODO there are still more LTTP options not on the options system # TODO there are still more LTTP options not on the options system
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"} valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}

View File

@@ -11,16 +11,14 @@ Additional components can be added to worlds.LauncherComponents.components.
import argparse import argparse
import logging import logging
import multiprocessing import multiprocessing
import os
import shlex import shlex
import subprocess import subprocess
import sys import sys
import urllib.parse import urllib.parse
import webbrowser import webbrowser
from collections.abc import Callable, Sequence
from os.path import isfile from os.path import isfile
from shutil import which from shutil import which
from typing import Any from typing import Callable, Optional, Sequence, Tuple, Union, Any
if __name__ == "__main__": if __name__ == "__main__":
import ModuleUpdate import ModuleUpdate
@@ -42,17 +40,13 @@ def open_host_yaml():
if is_linux: if is_linux:
exe = which('sensible-editor') or which('gedit') or \ exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open') which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
elif is_macos: elif is_macos:
exe = which("open") exe = which("open")
subprocess.Popen([exe, file])
else: else:
webbrowser.open(file) webbrowser.open(file)
return
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, file], env=env)
def open_patch(): def open_patch():
suffixes = [] suffixes = []
@@ -97,11 +91,7 @@ def open_folder(folder_path):
return return
if exe: if exe:
env = os.environ subprocess.Popen([exe, folder_path])
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, folder_path], env=env)
else: else:
logging.warning(f"No file browser available to open {folder_path}") logging.warning(f"No file browser available to open {folder_path}")
@@ -113,51 +103,66 @@ def update_settings():
components.extend([ components.extend([
# Functions # Functions
Component("Open host.yaml", func=open_host_yaml, Component("Open host.yaml", func=open_host_yaml),
description="Open the host.yaml file to change settings for generation, games, and more."), Component("Open Patch", func=open_patch),
Component("Open Patch", func=open_patch, Component("Generate Template Options", func=generate_yamls),
description="Open a patch file, downloaded from the room page or provided by the host."), Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
Component("Generate Template Options", func=generate_yamls, Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
description="Generate template YAMLs for currently installed games."),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
description="Open archipelago.gg in your browser."),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
Component("Unrated/18+ Discord Server", icon="discord", Component("Unrated/18+ Discord Server", icon="discord",
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"), func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
description="Find unrated and 18+ games in the After Dark Discord server."), Component("Browse Files", func=browse_files),
Component("Browse Files", func=browse_files,
description="Open the Archipelago installation folder in your file browser."),
]) ])
def handle_uri(path: str) -> tuple[list[Component], Component]: def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
url = urllib.parse.urlparse(path) url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query) queries = urllib.parse.parse_qs(url.query)
client_components = [] launch_args = (path, *launch_args)
client_component = []
text_client_component = None text_client_component = None
game = queries["game"][0] if "game" in queries:
game = queries["game"][0]
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
game = "Archipelago"
for component in components: for component in components:
if component.supports_uri and component.game_name == game: if component.supports_uri and component.game_name == game:
client_components.append(component) client_component.append(component)
elif component.display_name == "Text Client": elif component.display_name == "Text Client":
text_client_component = component text_client_component = component
return client_components, text_client_component
from kvui import MDButton, MDButtonText
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText
from kivymd.uix.divider import MDDivider
if not client_component:
run_component(text_client_component, *launch_args)
return
else:
popup_text = MDDialogSupportingText(text="Select client to open and connect with.")
component_buttons = [MDDivider()]
for component in [text_client_component, *client_component]:
component_buttons.append(MDButton(
MDButtonText(text=component.display_name),
on_release=lambda *args, comp=component: run_component(comp, *launch_args),
style="text"
))
component_buttons.append(MDDivider())
MDDialog(
# Headline
MDDialogHeadlineText(text="Connect to Multiworld"),
# Text
popup_text,
# Content
MDDialogContentContainer(
*component_buttons,
orientation="vertical"
),
).open()
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None: def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
from kvui import ButtonsPrompt
component_options = {
component.display_name: component for component in component_list
}
popup = ButtonsPrompt("Connect to Multiworld",
"Select client to open and connect with.",
lambda component_name: run_component(component_options[component_name], *launch_args),
*component_options.keys())
popup.open()
def identify(path: None | str) -> tuple[None | str, None | Component]:
if path is None: if path is None:
return None, None return None, None
for component in components: for component in components:
@@ -168,7 +173,7 @@ def identify(path: None | str) -> tuple[None | str, None | Component]:
return None, None return None, None
def get_exe(component: str | Component) -> Sequence[str] | None: def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
if isinstance(component, str): if isinstance(component, str):
name = component name = component
component = None component = None
@@ -196,8 +201,7 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
def launch(exe, in_terminal=False): def launch(exe, in_terminal=False):
if in_terminal: if in_terminal:
if is_windows: if is_windows:
# intentionally using a window title with a space so it gets quoted and treated as a title subprocess.Popen(['start', *exe], shell=True)
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
return return
elif is_linux: elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm') terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
@@ -222,10 +226,10 @@ def create_shortcut(button: Any, component: Component) -> None:
button.menu.dismiss() button.menu.dismiss()
refresh_components: Callable[[], None] | None = None refresh_components: Optional[Callable[[], None]] = None
def run_gui(launch_components: list[Component], args: Any) -> None: def run_gui(path: str, args: Any) -> None:
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox) from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
from kivy.properties import ObjectProperty from kivy.properties import ObjectProperty
from kivy.core.window import Window from kivy.core.window import Window
@@ -258,12 +262,12 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
cards: list[LauncherCard] cards: list[LauncherCard]
current_filter: Sequence[str | Type] | None current_filter: Sequence[str | Type] | None
def __init__(self, ctx=None, components=None, args=None): def __init__(self, ctx=None, path=None, args=None):
self.title = self.base_title + " " + Utils.__version__ self.title = self.base_title + " " + Utils.__version__
self.ctx = ctx self.ctx = ctx
self.icon = r"data/icon.png" self.icon = r"data/icon.png"
self.favorites = [] self.favorites = []
self.launch_components = components self.launch_uri = path
self.launch_args = args self.launch_args = args
self.cards = [] self.cards = []
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC) self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
@@ -385,9 +389,9 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
return self.top_screen return self.top_screen
def on_start(self): def on_start(self):
if self.launch_components: if self.launch_uri:
build_uri_popup(self.launch_components, self.launch_args) handle_uri(self.launch_uri, self.launch_args)
self.launch_components = None self.launch_uri = None
self.launch_args = None self.launch_args = None
@staticmethod @staticmethod
@@ -405,7 +409,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
if file and component: if file and component:
run_component(component, file) run_component(component, file)
else: else:
logging.warning(f"unable to identify component for {filename}") logging.warning(f"unable to identify component for {file}")
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]): def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
# Activate search as soon as we start typing, no matter if we are focused on the search box or not. # Activate search as soon as we start typing, no matter if we are focused on the search box or not.
@@ -428,7 +432,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
for filter in self.current_filter)) for filter in self.current_filter))
super().on_stop() super().on_stop()
Launcher(components=launch_components, args=args).run() Launcher(path=path, args=args).run()
# avoiding Launcher reference leak # avoiding Launcher reference leak
# and don't try to do something with widgets after window closed # and don't try to do something with widgets after window closed
@@ -447,7 +451,7 @@ def run_component(component: Component, *args):
logging.warning(f"Component {component} does not appear to be executable.") logging.warning(f"Component {component} does not appear to be executable.")
def main(args: argparse.Namespace | dict | None = None): def main(args: Optional[Union[argparse.Namespace, dict]] = None):
if isinstance(args, argparse.Namespace): if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()} args = {k: v for k, v in args._get_kwargs()}
elif not args: elif not args:
@@ -455,15 +459,7 @@ def main(args: argparse.Namespace | dict | None = None):
path = args.get("Patch|Game|Component|url", None) path = args.get("Patch|Game|Component|url", None)
if path is not None: if path is not None:
if path.startswith("archipelago://"): if not path.startswith("archipelago://"):
args["args"] = (path, *args.get("args", ()))
# add the url arg to the passthrough args
components, text_client_component = handle_uri(path)
if not components:
args["component"] = text_client_component
else:
args['launch_components'] = [text_client_component, *components]
else:
file, component = identify(path) file, component = identify(path)
if file: if file:
args['file'] = file args['file'] = file
@@ -479,7 +475,7 @@ def main(args: argparse.Namespace | dict | None = None):
elif "component" in args: elif "component" in args:
run_component(args["component"], *args["args"]) run_component(args["component"], *args["args"])
elif not args["update_settings"]: elif not args["update_settings"]:
run_gui(args.get("launch_components", None), args.get("args", ())) run_gui(path, args.get("args", ()))
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -32,7 +32,6 @@ GAME_ALTTP = "A Link to the Past"
WINDOW_MIN_HEIGHT = 525 WINDOW_MIN_HEIGHT = 525
WINDOW_MIN_WIDTH = 425 WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object): class AdjusterWorld(object):
class AdjusterSubWorld(object): class AdjusterSubWorld(object):
def __init__(self, random): def __init__(self, random):
@@ -41,6 +40,7 @@ class AdjusterWorld(object):
def __init__(self, sprite_pool): def __init__(self, sprite_pool):
import random import random
self.sprite_pool = {1: sprite_pool} self.sprite_pool = {1: sprite_pool}
self.per_slot_randoms = {1: random}
self.worlds = {1: self.AdjusterSubWorld(random)} self.worlds = {1: self.AdjusterSubWorld(random)}
@@ -49,7 +49,6 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
def _get_help_string(self, action): def _get_help_string(self, action):
return textwrap.dedent(action.help) return textwrap.dedent(action.help)
# See argparse.BooleanOptionalAction # See argparse.BooleanOptionalAction
class BooleanOptionalActionWithDisable(argparse.Action): class BooleanOptionalActionWithDisable(argparse.Action):
def __init__(self, def __init__(self,
@@ -365,10 +364,10 @@ def run_sprite_update():
logging.info("Done updating sprites") logging.info("Done updating sprites")
def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"): def update_sprites(task, on_finish=None):
resultmessage = "" resultmessage = ""
successful = True successful = True
sprite_dir = user_path("data", "sprites", "alttp", "remote") sprite_dir = user_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True) os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
@@ -378,11 +377,11 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
on_finish(successful, resultmessage) on_finish(successful, resultmessage)
try: try:
task.update_status("Downloading remote sprites list") task.update_status("Downloading alttpr sprites list")
with urlopen(repository_url, context=ctx) as response: with urlopen('https://alttpr.com/sprites', context=ctx) as response:
sprites_arr = json.loads(response.read().decode("utf-8")) sprites_arr = json.loads(response.read().decode("utf-8"))
except Exception as e: except Exception as e:
resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e) resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False successful = False
task.queue_event(finished) task.queue_event(finished)
return return
@@ -390,13 +389,13 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
try: try:
task.update_status("Determining needed sprites") task.update_status("Determining needed sprites")
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')] current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path)) alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
for sprite in sprites_arr if sprite["author"] != "Nintendo"] for sprite in sprites_arr if sprite["author"] != "Nintendo"]
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
filename not in current_sprites] filename not in current_sprites]
remote_filenames = [filename for (_, filename) in remote_sprites] alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames] obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
except Exception as e: except Exception as e:
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % ( resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
type(e).__name__, e) type(e).__name__, e)
@@ -448,7 +447,7 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
successful = False successful = False
if successful: if successful:
resultmessage = "Remote sprites updated successfully" resultmessage = "alttpr sprites updated successfully"
task.queue_event(finished) task.queue_event(finished)
@@ -869,7 +868,7 @@ class SpriteSelector():
def open_custom_sprite_dir(_evt): def open_custom_sprite_dir(_evt):
open_file(self.custom_sprite_dir) open_file(self.custom_sprite_dir)
remote_frametitle = Label(self.window, text='Remote Sprites') alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
custom_frametitle = Frame(self.window) custom_frametitle = Frame(self.window)
title_text = Label(custom_frametitle, text="Custom Sprites") title_text = Label(custom_frametitle, text="Custom Sprites")
@@ -878,8 +877,8 @@ class SpriteSelector():
title_link.pack(side=LEFT) title_link.pack(side=LEFT)
title_link.bind("<Button-1>", open_custom_sprite_dir) title_link.bind("<Button-1>", open_custom_sprite_dir)
self.icon_section(remote_frametitle, self.remote_sprite_dir, self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
'Remote sprites not found. Click "Update remote sprites" to download them.') 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir, self.icon_section(custom_frametitle, self.custom_sprite_dir,
'Put sprites in the custom sprites folder (see open link above) to have them appear here.') 'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
if not randomOnEvent: if not randomOnEvent:
@@ -892,18 +891,11 @@ class SpriteSelector():
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite) button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
button.pack(side=RIGHT, padx=(5, 0)) button.pack(side=RIGHT, padx=(5, 0))
button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites) button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
button.pack(side=RIGHT, padx=(5, 0)) button.pack(side=RIGHT, padx=(5, 0))
repository_label = Label(frame, text='Sprite Repository:')
self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
repository_entry = Entry(frame, textvariable=self.repository_url)
repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite) button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
button.pack(side=LEFT, padx=(0, 5)) button.pack(side=LEFT,padx=(0,5))
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite) button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
button.pack(side=LEFT, padx=(0, 5)) button.pack(side=LEFT, padx=(0, 5))
@@ -1063,7 +1055,7 @@ class SpriteSelector():
for i, button in enumerate(frame.buttons): for i, button in enumerate(frame.buttons):
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow) button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
def update_remote_sprites(self): def update_alttpr_sprites(self):
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us. # need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
self.window.destroy() self.window.destroy()
self.parent.update() self.parent.update()
@@ -1076,8 +1068,7 @@ class SpriteSelector():
messagebox.showerror("Sprite Updater", resultmessage) messagebox.showerror("Sprite Updater", resultmessage)
SpriteSelector(self.parent, self.callback, self.adjuster) SpriteSelector(self.parent, self.callback, self.adjuster)
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
on_finish, self.repository_url.get())
def browse_for_sprite(self): def browse_for_sprite(self):
sprite = filedialog.askopenfilename( sprite = filedialog.askopenfilename(
@@ -1167,13 +1158,12 @@ class SpriteSelector():
os.makedirs(self.custom_sprite_dir) os.makedirs(self.custom_sprite_dir)
@property @property
def remote_sprite_dir(self): def alttpr_sprite_dir(self):
return user_path("data", "sprites", "alttp", "remote") return user_path("data", "sprites", "alttpr")
@property @property
def custom_sprite_dir(self): def custom_sprite_dir(self):
return user_path("data", "sprites", "alttp", "custom") return user_path("data", "sprites", "custom")
def get_image_for_sprite(sprite, gif_only: bool = False): def get_image_for_sprite(sprite, gif_only: bool = False):
if not sprite.valid: if not sprite.valid:

View File

@@ -286,14 +286,16 @@ async def gba_sync_task(ctx: MMBN3Context):
except ConnectionRefusedError: except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again") logger.debug("Connection Refused, Trying Again")
ctx.gba_status = CONNECTION_REFUSED_STATUS ctx.gba_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue continue
async def run_game(romfile): async def run_game(romfile):
from worlds.mmbn3 import MMBN3World options = Utils.get_options().get("mmbn3_options", None)
auto_start = MMBN3World.settings.rom_start if options is None:
if auto_start is True: auto_start = True
else:
auto_start = options.get("rom_start", True)
if auto_start:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
elif os.path.isfile(auto_start): elif os.path.isfile(auto_start):

86
Main.py
View File

@@ -1,21 +1,20 @@
import collections import collections
from collections.abc import Mapping
import concurrent.futures import concurrent.futures
import logging import logging
import os import os
import pickle
import tempfile import tempfile
import time import time
from typing import Any
import zipfile import zipfile
import zlib import zlib
from typing import Dict, List, Optional, Set, Tuple, Union
import worlds import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \ from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned flood_items
from NetUtils import convert_to_base_types
from Options import StartInventoryPool from Options import StartInventoryPool
from Utils import __version__, output_path, restricted_dumps, version_tuple from Utils import __version__, output_path, version_tuple, get_settings
from settings import get_settings from settings import get_settings
from worlds import AutoWorld from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -23,7 +22,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
__all__ = ["main"] __all__ = ["main"]
def main(args, seed=None, baked_server_options: dict[str, object] | None = None): def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
if not baked_server_options: if not baked_server_options:
baked_server_options = get_settings().server_options.as_dict() baked_server_options = get_settings().server_options.as_dict()
assert isinstance(baked_server_options, dict) assert isinstance(baked_server_options, dict)
@@ -38,6 +37,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger = logging.getLogger() logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando_options multiworld.plando_options = args.plando_options
multiworld.plando_items = args.plando_items.copy()
multiworld.plando_texts = args.plando_texts.copy()
multiworld.plando_connections = args.plando_connections.copy()
multiworld.game = args.game.copy() multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy() multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy() multiworld.sprite = args.sprite.copy()
@@ -94,15 +96,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
del local_early del local_early
del early del early
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
# Clear non-applicable local and non-local items.
if multiworld.players == 1:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
logger.info('Creating MultiWorld.') logger.info('Creating MultiWorld.')
AutoWorld.call_all(multiworld, "create_regions") AutoWorld.call_all(multiworld, "create_regions")
@@ -110,6 +103,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
AutoWorld.call_all(multiworld, "create_items") AutoWorld.call_all(multiworld, "create_items")
logger.info('Calculating Access Rules.') logger.info('Calculating Access Rules.')
for player in multiworld.player_ids:
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
AutoWorld.call_all(multiworld, "set_rules") AutoWorld.call_all(multiworld, "set_rules")
for player in multiworld.player_ids: for player in multiworld.player_ids:
@@ -130,11 +129,11 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
# Set local and non-local item rules. # Set local and non-local item rules.
# This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
if multiworld.players > 1: if multiworld.players > 1:
locality_rules(multiworld) locality_rules(multiworld)
else:
multiworld.plando_item_blocks = parse_planned_blocks(multiworld) multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
AutoWorld.call_all(multiworld, "connect_entrances") AutoWorld.call_all(multiworld, "connect_entrances")
AutoWorld.call_all(multiworld, "generate_basic") AutoWorld.call_all(multiworld, "generate_basic")
@@ -142,7 +141,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
# remove starting inventory from pool items. # remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible. # Because some worlds don't actually create items during create_items this has to be as late as possible.
fallback_inventory = StartInventoryPool({}) fallback_inventory = StartInventoryPool({})
depletion_pool: dict[int, dict[str, int]] = { depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy() player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
for player in multiworld.player_ids for player in multiworld.player_ids
} }
@@ -151,7 +150,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
} }
if target_per_player: if target_per_player:
new_itempool: list[Item] = [] new_itempool: List[Item] = []
# Make new itempool with start_inventory_from_pool items removed # Make new itempool with start_inventory_from_pool items removed
for item in multiworld.itempool: for item in multiworld.itempool:
@@ -176,13 +175,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.link_items() multiworld.link_items()
if any(world.options.item_links for world in multiworld.worlds.values()): if any(multiworld.item_links.values()):
multiworld._all_state = None multiworld._all_state = None
logger.info("Running Item Plando.") logger.info("Running Item Plando.")
resolve_early_locations_for_planned(multiworld)
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks distribute_planned(multiworld)
for x in multiworld.plando_item_blocks[player]])
logger.info('Running Pre Main Fill.') logger.info('Running Pre Main Fill.')
@@ -235,19 +233,17 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir)) pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
# collect ER hint info # collect ER hint info
er_hint_data: dict[int, dict[int, str]] = {} er_hint_data: Dict[int, Dict[int, str]] = {}
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data) AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
def write_multidata(): def write_multidata():
import NetUtils import NetUtils
from NetUtils import HintStatus from NetUtils import HintStatus
slot_data: dict[int, Mapping[str, Any]] = {} slot_data = {}
client_versions: dict[int, tuple[int, int, int]] = {} client_versions = {}
games: dict[int, str] = {} games = {}
minimum_versions: NetUtils.MinimumVersions = { minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
"server": AutoWorld.World.required_server_version, "clients": client_versions slot_info = {}
}
slot_info: dict[int, NetUtils.NetworkSlot] = {}
names = [[name for player, name in sorted(multiworld.player_name.items())]] names = [[name for player, name in sorted(multiworld.player_name.items())]]
for slot in multiworld.player_ids: for slot in multiworld.player_ids:
player_world: AutoWorld.World = multiworld.worlds[slot] player_world: AutoWorld.World = multiworld.worlds[slot]
@@ -262,9 +258,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
group_members=sorted(group["players"])) group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int] precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
for player, world_precollected in multiworld.precollected_items.items()} for player, world_precollected in multiworld.precollected_items.items()}
precollected_hints: dict[int, set[NetUtils.Hint]] = { precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))
}
for slot in multiworld.player_ids: for slot in multiworld.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data() slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
@@ -280,7 +274,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
for player in multiworld.groups[location.item.player]["players"]: for player in multiworld.groups[location.item.player]["players"]:
precollected_hints[player].add(hint) precollected_hints[player].add(hint)
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids} locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
for location in multiworld.get_filled_locations(): for location in multiworld.get_filled_locations():
if type(location.address) == int: if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \ assert location.item.code is not None, "item code None should be event, " \
@@ -309,19 +303,19 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
} }
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"] data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
checks_in_area: dict[int, dict[str, int | list[int]]] = {} checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
# get spheres -> filter address==None -> skip empty # get spheres -> filter address==None -> skip empty
spheres: list[dict[int, set[int]]] = [] spheres: List[Dict[int, Set[int]]] = []
for sphere in multiworld.get_sendable_spheres(): for sphere in multiworld.get_sendable_spheres():
current_sphere: dict[int, set[int]] = collections.defaultdict(set) current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
for sphere_location in sphere: for sphere_location in sphere:
current_sphere[sphere_location.player].add(sphere_location.address) current_sphere[sphere_location.player].add(sphere_location.address)
if current_sphere: if current_sphere:
spheres.append(dict(current_sphere)) spheres.append(dict(current_sphere))
multidata: NetUtils.MultiData | bytes = { multidata = {
"slot_data": slot_data, "slot_data": slot_data,
"slot_info": slot_info, "slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()}, "connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
@@ -331,7 +325,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
"er_hint_data": er_hint_data, "er_hint_data": er_hint_data,
"precollected_items": precollected_items, "precollected_items": precollected_items,
"precollected_hints": precollected_hints, "precollected_hints": precollected_hints,
"version": (version_tuple.major, version_tuple.minor, version_tuple.build), "version": tuple(version_tuple),
"tags": ["AP"], "tags": ["AP"],
"minimum_versions": minimum_versions, "minimum_versions": minimum_versions,
"seed_name": multiworld.seed_name, "seed_name": multiworld.seed_name,
@@ -339,13 +333,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
"datapackage": data_package, "datapackage": data_package,
"race_mode": int(multiworld.is_race), "race_mode": int(multiworld.is_race),
} }
# TODO: change to `"version": version_tuple` after getting better serialization
AutoWorld.call_all(multiworld, "modify_multidata", multidata) AutoWorld.call_all(multiworld, "modify_multidata", multidata)
for key in ("slot_data", "er_hint_data"): multidata = zlib.compress(pickle.dumps(multidata), 9)
multidata[key] = convert_to_base_types(multidata[key])
multidata = zlib.compress(restricted_dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f: with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([3])) # version of format f.write(bytes([3])) # version of format

344
MinecraftClient.py Normal file
View File

@@ -0,0 +1,344 @@
import argparse
import json
import os
import sys
import re
import atexit
import shutil
from subprocess import Popen
from shutil import copyfile
from time import strftime
import logging
import requests
import Utils
from Utils import is_windows
atexit.register(input, "Press enter to exit.")
# 1 or more digits followed by m or g, then optional b
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
def prompt_yes_no(prompt):
yes_inputs = {'yes', 'ye', 'y'}
no_inputs = {'no', 'n'}
while True:
choice = input(prompt + " [y/n] ").lower()
if choice in yes_inputs:
return True
elif choice in no_inputs:
return False
else:
print('Please respond with "y" or "n".')
def find_ap_randomizer_jar(forge_dir):
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
mods_dir = os.path.join(forge_dir, 'mods')
if os.path.isdir(mods_dir):
for entry in os.scandir(mods_dir):
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
logging.info(f"Found AP randomizer mod: {entry.name}")
return entry.name
return None
else:
os.mkdir(mods_dir)
logging.info(f"Created mods folder in {forge_dir}")
return None
def replace_apmc_files(forge_dir, apmc_file):
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
if apmc_file is None:
return
apdata_dir = os.path.join(forge_dir, 'APData')
copy_apmc = True
if not os.path.isdir(apdata_dir):
os.mkdir(apdata_dir)
logging.info(f"Created APData folder in {forge_dir}")
for entry in os.scandir(apdata_dir):
if entry.name.endswith(".apmc") and entry.is_file():
if not os.path.samefile(apmc_file, entry.path):
os.remove(entry.path)
logging.info(f"Removed {entry.name} in {apdata_dir}")
else: # apmc already in apdata
copy_apmc = False
if copy_apmc:
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
def read_apmc_file(apmc_file):
from base64 import b64decode
with open(apmc_file, 'r') as f:
return json.loads(b64decode(f.read()))
def update_mod(forge_dir, url: str):
"""Check mod version, download new mod from GitHub releases page if needed. """
ap_randomizer = find_ap_randomizer_jar(forge_dir)
os.path.basename(url)
if ap_randomizer is not None:
logging.info(f"Your current mod is {ap_randomizer}.")
else:
logging.info(f"You do not have the AP randomizer mod installed.")
if ap_randomizer != os.path.basename(url):
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{os.path.basename(url)}")
if prompt_yes_no("Would you like to update?"):
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(url)
if apmod_resp.status_code == 200:
with open(new_ap_mod, 'wb') as f:
f.write(apmod_resp.content)
logging.info(f"Wrote new mod file to {new_ap_mod}")
if old_ap_mod is not None:
os.remove(old_ap_mod)
logging.info(f"Removed old mod file from {old_ap_mod}")
else:
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
logging.error(f"Please report this issue on the Archipelago Discord server.")
sys.exit(1)
def check_eula(forge_dir):
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
eula_path = os.path.join(forge_dir, "eula.txt")
if not os.path.isfile(eula_path):
# Create eula.txt
with open(eula_path, 'w') as f:
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
f.write("eula=false\n")
with open(eula_path, 'r+') as f:
text = f.read()
if 'false' in text:
# Prompt user to agree to the EULA
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
if prompt_yes_no("Do you agree to the EULA?"):
f.seek(0)
f.write(text.replace('false', 'true'))
f.truncate()
logging.info(f"Set {eula_path} to true")
else:
sys.exit(0)
def find_jdk_dir(version: str) -> str:
"""get the specified versions jdk directory"""
for entry in os.listdir():
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
return os.path.abspath(entry)
def find_jdk(version: str) -> str:
"""get the java exe location"""
if is_windows:
jdk = find_jdk_dir(version)
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
return jdk_exe
else:
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
if not jdk_exe:
raise Exception("Could not find Java. Is Java installed on the system?")
return jdk_exe
def download_java(java: str):
"""Download Corretto (Amazon JDK)"""
jdk = find_jdk_dir(java)
if jdk is not None:
print(f"Removing old JDK...")
from shutil import rmtree
rmtree(jdk)
print(f"Downloading Java...")
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
resp = requests.get(jdk_url)
if resp.status_code == 200: # OK
print(f"Extracting...")
import zipfile
from io import BytesIO
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
zf.extractall()
else:
print(f"Error downloading Java (status code {resp.status_code}).")
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
if not prompt_yes_no("Continue anyways?"):
sys.exit(0)
def install_forge(directory: str, forge_version: str, java_version: str):
"""download and install forge"""
java_exe = find_jdk(java_version)
if java_exe is not None:
print(f"Downloading Forge {forge_version}...")
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
resp = requests.get(forge_url)
if resp.status_code == 200: # OK
forge_install_jar = os.path.join(directory, "forge_install.jar")
if not os.path.exists(directory):
os.mkdir(directory)
with open(forge_install_jar, 'wb') as f:
f.write(resp.content)
print(f"Installing Forge...")
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
install_process.wait()
os.remove(forge_install_jar)
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
"""Run the Forge server."""
java_exe = find_jdk(java_version)
if not os.path.isfile(java_exe):
java_exe = "java" # try to fall back on java in the PATH
heap_arg = max_heap_re.match(heap_arg).group()
if heap_arg[-1] in ['b', 'B']:
heap_arg = heap_arg[:-1]
heap_arg = "-Xmx" + heap_arg
os_args = "win_args.txt" if is_windows else "unix_args.txt"
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
forge_args = []
with open(args_file) as argfile:
for line in argfile:
forge_args.extend(line.strip().split(" "))
args = [java_exe, heap_arg, *forge_args, "-nogui"]
logging.info(f"Running Forge server: {args}")
os.chdir(forge_dir)
return Popen(args)
def get_minecraft_versions(version, release_channel="release"):
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
resp = requests.get(version_file_endpoint)
local = False
if resp.status_code == 200: # OK
try:
data = resp.json()
except requests.exceptions.JSONDecodeError:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
else:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
if local:
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
data = json.load(f)
else:
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
json.dump(data, f)
try:
if version:
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
else:
return resp.json()[release_channel][0]
except (StopIteration, KeyError):
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
if release_channel != "release":
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
else:
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
sys.exit(0)
def is_correct_forge(forge_dir) -> bool:
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
return True
return False
if __name__ == '__main__':
Utils.init_logging("MinecraftClient")
parser = argparse.ArgumentParser()
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
help="Specify release channel to use.")
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
help="specify java version.")
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
help="specify forge version. (Minecraft Version-Forge Version)")
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
help="specify Mod data version to download.")
args = parser.parse_args()
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
# Change to executable's working directory
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
options = Utils.get_options()
channel = args.channel or options["minecraft_options"]["release_channel"]
apmc_data = None
data_version = args.data_version or None
if apmc_file is None and not args.install:
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
if apmc_file is not None and data_version is None:
apmc_data = read_apmc_file(apmc_file)
data_version = apmc_data.get('client_version', '')
versions = get_minecraft_versions(data_version, channel)
forge_dir = options["minecraft_options"]["forge_directory"]
max_heap = options["minecraft_options"]["max_heap_size"]
forge_version = args.forge or versions["forge"]
java_version = args.java or versions["java"]
mod_url = versions["url"]
java_dir = find_jdk_dir(java_version)
if args.install:
if is_windows:
print("Installing Java")
download_java(java_version)
if not is_correct_forge(forge_dir):
print("Installing Minecraft Forge")
install_forge(forge_dir, forge_version, java_version)
else:
print("Correct Forge version already found, skipping install.")
sys.exit(0)
if apmc_data is None:
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
if is_windows:
if java_dir is None or not os.path.isdir(java_dir):
if prompt_yes_no("Did not find java directory. Download and install java now?"):
download_java(java_version)
java_dir = find_jdk_dir(java_version)
if java_dir is None or not os.path.isdir(java_dir):
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
if not is_correct_forge(forge_dir):
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
install_forge(forge_dir, forge_version, java_version)
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if not max_heap_re.match(max_heap):
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
update_mod(forge_dir, mod_url)
replace_apmc_files(forge_dir, apmc_file)
check_eula(forge_dir)
server_process = run_forge_server(forge_dir, java_version, max_heap)
server_process.wait()

View File

@@ -16,11 +16,7 @@ elif sys.version_info < (3, 10, 1):
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.") raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess) # don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool( _skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
getattr(sys, "frozen", False) or
multiprocessing.parent_process() or
os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes")
)
update_ran = _skip_update update_ran = _skip_update

View File

@@ -43,7 +43,7 @@ import NetUtils
import Utils import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore, MultiData, Hint, HintStatus SlotType, LocationStore, Hint, HintStatus
from BaseClasses import ItemClassification from BaseClasses import ItemClassification
@@ -445,7 +445,7 @@ class Context:
raise Utils.VersionException("Incompatible multidata.") raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:])) return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any], def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
use_embedded_server_options: bool): use_embedded_server_options: bool):
self.read_data = {} self.read_data = {}
@@ -458,12 +458,8 @@ class Context:
self.generator_version = Version(*decoded_obj["version"]) self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {}) clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {} self.minimum_client_versions = {}
if self.generator_version < Version(0, 6, 2):
min_version = Version(0, 1, 6)
else:
min_version = min_client_version
for player, version in clients_ver.items(): for player, version in clients_ver.items():
self.minimum_client_versions[player] = max(Version(*version), min_version) self.minimum_client_versions[player] = max(Version(*version), min_client_version)
self.slot_info = decoded_obj["slot_info"] self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()} self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
@@ -546,7 +542,6 @@ class Context:
def _save(self, exit_save: bool = False) -> bool: def _save(self, exit_save: bool = False) -> bool:
try: try:
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
encoded_save = pickle.dumps(self.get_save()) encoded_save = pickle.dumps(self.get_save())
with open(self.save_filename, "wb") as f: with open(self.save_filename, "wb") as f:
f.write(zlib.compress(encoded_save)) f.write(zlib.compress(encoded_save))
@@ -753,7 +748,7 @@ class Context:
return self.player_names[team, slot] return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False, def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None): recipients: typing.Sequence[int] = None):
"""Send and remember hints.""" """Send and remember hints."""
if only_new: if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]] hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
@@ -768,9 +763,8 @@ class Context:
if not hint.local and data not in concerns[hint.finding_player]: if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data) concerns[hint.finding_player].append(data)
# For !hint use cases, only hints that were not already found at the time of creation should be remembered # only remember hints that were not already found at the time of creation
# For LocationScouts use-cases, all hints should be remembered if not hint.found:
if not hint.found or persist_even_if_found:
# since hints are bidirectional, finding player and receiving player, # since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists # we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]: if hint not in self.hints[team, hint.finding_player]:
@@ -1832,7 +1826,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.clients[team][slot].append(client) ctx.clients[team][slot].append(client)
client.version = args['version'] client.version = args['version']
client.tags = args['tags'] client.tags = args['tags']
client.no_locations = bool(client.tags & _non_game_messages.keys()) client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
# set NoText for old PopTracker clients that predate the tag to save traffic # set NoText for old PopTracker clients that predate the tag to save traffic
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1)) client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
connected_packet = { connected_packet = {
@@ -1906,7 +1900,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
old_tags = client.tags old_tags = client.tags
client.tags = args["tags"] client.tags = args["tags"]
if set(old_tags) != set(client.tags): if set(old_tags) != set(client.tags):
client.no_locations = bool(client.tags & _non_game_messages.keys()) client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
client.no_text = "NoText" in client.tags or ( client.no_text = "NoText" in client.tags or (
"PopTracker" in client.tags and client.version < (0, 5, 1) "PopTracker" in client.tags and client.version < (0, 5, 1)
) )
@@ -1948,52 +1942,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location, hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
HintStatus.HINT_UNSPECIFIED)) HintStatus.HINT_UNSPECIFIED))
locs.append(NetworkItem(target_item, location, target_player, flags)) locs.append(NetworkItem(target_item, location, target_player, flags))
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True) ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
if locs and create_as_hint: if locs and create_as_hint:
ctx.save() ctx.save()
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}]) await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'CreateHints':
location_player = args.get("player", client.slot)
locations = args["locations"]
status = args.get("status", HintStatus.HINT_UNSPECIFIED)
if not locations:
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
hints = []
for location in locations:
if location_player != client.slot and location not in ctx.locations[location_player]:
error_text = (
"CreateHints: One or more of the locations do not exist for the specified off-world player. "
"Please refrain from hinting other slot's locations that you don't know contain your items."
)
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
target_item, item_player, flags = ctx.locations[location_player][location]
if client.slot not in ctx.slot_set(item_player):
if status != HintStatus.HINT_UNSPECIFIED:
error_text = 'CreateHints: Must use "unspecified"/None status for items from other players.'
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
if client.slot != location_player:
error_text = "CreateHints: Can only create hints for own items or own locations."
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
hints += collect_hint_location_id(ctx, client.team, location_player, location, status)
# As of writing this code, only_new=True does not update status for existing hints
ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True)
ctx.save()
elif cmd == 'UpdateHint': elif cmd == 'UpdateHint':
location = args["location"] location = args["location"]
@@ -2038,14 +1990,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.save() ctx.save()
for slot in concerning_slots: for slot in concerning_slots:
ctx.on_changed_hints(client.team, slot) ctx.on_changed_hints(client.team, slot)
elif cmd == 'StatusUpdate': elif cmd == 'StatusUpdate':
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL: update_client_status(ctx, client, args["status"])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": "Trackers can't register Goal Complete",
"original_cmd": cmd}])
else:
update_client_status(ctx, client, args["status"])
elif cmd == 'Say': elif cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable(): if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence
import typing import typing
import enum import enum
import warnings import warnings
@@ -84,7 +83,7 @@ class NetworkSlot(typing.NamedTuple):
name: str name: str
game: str game: str
type: SlotType type: SlotType
group_members: Sequence[int] = () # only populated if type == group group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
class NetworkItem(typing.NamedTuple): class NetworkItem(typing.NamedTuple):
@@ -107,27 +106,6 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
return obj return obj
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
def convert_to_base_types(obj: typing.Any) -> _base_types:
if isinstance(obj, (tuple, list, set, frozenset)):
return tuple(convert_to_base_types(o) for o in obj)
elif isinstance(obj, dict):
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
elif obj is None or type(obj) in (str, int, float, bool):
return obj
# unwrap simple types to their base, such as StrEnum
elif isinstance(obj, str):
return str(obj)
elif isinstance(obj, int):
return int(obj)
elif isinstance(obj, float):
return float(obj)
else:
raise Exception(f"Cannot handle {type(obj)}")
_encode = JSONEncoder( _encode = JSONEncoder(
ensure_ascii=False, ensure_ascii=False,
check_circular=False, check_circular=False,
@@ -472,42 +450,6 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
location_id not in checked]) location_id not in checked])
class MinimumVersions(typing.TypedDict):
server: tuple[int, int, int]
clients: dict[int, tuple[int, int, int]]
class GamesPackage(typing.TypedDict, total=False):
item_name_groups: dict[str, list[str]]
item_name_to_id: dict[str, int]
location_name_groups: dict[str, list[str]]
location_name_to_id: dict[str, int]
checksum: str
class DataPackage(typing.TypedDict):
games: dict[str, GamesPackage]
class MultiData(typing.TypedDict):
slot_data: dict[int, Mapping[str, typing.Any]]
slot_info: dict[int, NetworkSlot]
connect_names: dict[str, tuple[int, int]]
locations: dict[int, dict[int, tuple[int, int, int]]]
checks_in_area: dict[int, dict[str, int | list[int]]]
server_options: dict[str, object]
er_hint_data: dict[int, dict[int, str]]
precollected_items: dict[int, list[int]]
precollected_hints: dict[int, set[Hint]]
version: tuple[int, int, int]
tags: list[str]
minimum_versions: MinimumVersions
seed_name: str
spheres: list[dict[int, set[int]]]
datapackage: dict[str, GamesPackage]
race_mode: int
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
LocationStore = _LocationStore LocationStore = _LocationStore
else: else:

View File

@@ -12,7 +12,6 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
import Utils import Utils
from Utils import async_start from Utils import async_start
from worlds import network_data_package from worlds import network_data_package
from worlds.oot import OOTWorld
from worlds.oot.Rom import Rom, compress_rom_file from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file from worlds.oot.N64Patch import apply_patch_file
from worlds.oot.Utils import data_path from worlds.oot.Utils import data_path
@@ -277,12 +276,11 @@ async def n64_sync_task(ctx: OoTContext):
except ConnectionRefusedError: except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again") logger.debug("Connection Refused, Trying Again")
ctx.n64_status = CONNECTION_REFUSED_STATUS ctx.n64_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue continue
async def run_game(romfile): async def run_game(romfile):
auto_start = OOTWorld.settings.rom_start auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
@@ -297,7 +295,7 @@ async def patch_and_run_game(apz5_file):
decomp_path = base_name + '-decomp.z64' decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64' comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM # Load vanilla ROM, patch file, compress ROM
rom_file_name = OOTWorld.settings.rom_file rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
rom = Rom(rom_file_name) rom = Rom(rom_file_name)
sub_file = None sub_file = None

View File

@@ -24,12 +24,6 @@ if typing.TYPE_CHECKING:
import pathlib import pathlib
def roll_percentage(percentage: int | float) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
class OptionError(ValueError): class OptionError(ValueError):
pass pass
@@ -494,30 +488,6 @@ class Choice(NumericOption):
else: else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
def __lt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} < {other}"
other = self.options[other]
return super(Choice, self).__lt__(other)
def __gt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} > {other}"
other = self.options[other]
return super(Choice, self).__gt__(other)
def __le__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} <= {other}"
other = self.options[other]
return super(Choice, self).__le__(other)
def __ge__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} >= {other}"
other = self.options[other]
return super(Choice, self).__ge__(other)
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
@@ -889,13 +859,13 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
return ", ".join(f"{key}: {v}" for key, v in value.items()) return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any: def __getitem__(self, item: str) -> typing.Any:
return self.value[item] return self.value.__getitem__(item)
def __iter__(self) -> typing.Iterator[str]: def __iter__(self) -> typing.Iterator[str]:
return iter(self.value) return self.value.__iter__()
def __len__(self) -> int: def __len__(self) -> int:
return len(self.value) return self.value.__len__()
# __getitem__ fallback fails for Counters, so we define this explicitly # __getitem__ fallback fails for Counters, so we define this explicitly
def __contains__(self, item) -> bool: def __contains__(self, item) -> bool:
@@ -1049,7 +1019,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
if isinstance(data, typing.Iterable): if isinstance(data, typing.Iterable):
for text in data: for text in data:
if isinstance(text, typing.Mapping): if isinstance(text, typing.Mapping):
if roll_percentage(text.get("percentage", 100)): if random.random() < float(text.get("percentage", 100)/100):
at = text.get("at", None) at = text.get("at", None)
if at is not None: if at is not None:
if isinstance(at, dict): if isinstance(at, dict):
@@ -1075,7 +1045,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
else: else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!") raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText): elif isinstance(text, PlandoText):
if roll_percentage(text.percentage): if random.random() < float(text.percentage/100):
texts.append(text) texts.append(text)
else: else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}") raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
@@ -1091,10 +1061,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
yield from self.value yield from self.value
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText: def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
return self.value[index] return self.value.__getitem__(index)
def __len__(self) -> int: def __len__(self) -> int:
return len(self.value) return self.value.__len__()
class ConnectionsMeta(AssembleOptions): class ConnectionsMeta(AssembleOptions):
@@ -1118,7 +1088,7 @@ class PlandoConnection(typing.NamedTuple):
entrance: str entrance: str
exit: str exit: str
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
percentage: int = 100 percentage: int = 100
@@ -1199,7 +1169,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
for connection in data: for connection in data:
if isinstance(connection, typing.Mapping): if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100) percentage = connection.get("percentage", 100)
if roll_percentage(percentage): if random.random() < float(percentage / 100):
entrance = connection.get("entrance", None) entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance): if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance)) entrance = random.choice(sorted(entrance))
@@ -1217,7 +1187,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
percentage percentage
)) ))
elif isinstance(connection, PlandoConnection): elif isinstance(connection, PlandoConnection):
if roll_percentage(connection.percentage): if random.random() < float(connection.percentage / 100):
value.append(connection) value.append(connection)
else: else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.") raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
@@ -1241,7 +1211,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
connection.exit) for connection in value]) connection.exit) for connection in value])
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection: def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
return self.value[index] return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoConnection]: def __iter__(self) -> typing.Iterator[PlandoConnection]:
yield from self.value yield from self.value
@@ -1339,7 +1309,6 @@ class CommonOptions(metaclass=OptionsMetaProperty):
will be returned as a sorted list. will be returned as a sorted list.
""" """
assert option_names, "options.as_dict() was used without any option names." assert option_names, "options.as_dict() was used without any option names."
assert len(option_names) < len(self.__class__.type_hints), "Specify only options you need."
option_results = {} option_results = {}
for option_name in option_names: for option_name in option_names:
if option_name not in type(self).type_hints: if option_name not in type(self).type_hints:
@@ -1500,133 +1469,6 @@ class ItemLinks(OptionList):
link["item_pool"] = list(pool) link["item_pool"] = list(pool)
@dataclass(frozen=True)
class PlandoItem:
items: list[str] | dict[str, typing.Any]
locations: list[str]
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
from_pool: bool = True
force: bool | typing.Literal["silent"] = "silent"
count: int | bool | dict[str, int] = False
percentage: int = 100
class PlandoItems(Option[typing.List[PlandoItem]]):
"""Generic items plando."""
default = ()
supports_weighting = False
display_name = "Plando Items"
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))
super().__init__()
@classmethod
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
if not isinstance(data, typing.Iterable):
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
value: typing.List[PlandoItem] = []
for item in data:
if isinstance(item, typing.Mapping):
percentage = item.get("percentage", 100)
if not isinstance(percentage, int):
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
if not (0 <= percentage <= 100):
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
if roll_percentage(percentage):
count = item.get("count", False)
items = item.get("items", [])
if not items:
items = item.get("item", None) # explicitly throw an error here if not present
if not items:
raise OptionError("You must specify at least one item to place items with plando.")
count = 1
if isinstance(items, str):
items = [items]
elif not isinstance(items, (dict, list)):
raise OptionError(f"Plando 'items' has to be string, list, or "
f"dictionary, not {type(items)}")
locations = item.get("locations", [])
if not locations:
locations = item.get("location", [])
if locations:
count = 1
else:
locations = ["Everywhere"]
if isinstance(locations, str):
locations = [locations]
if not isinstance(locations, list):
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
world = item.get("world", False)
from_pool = item.get("from_pool", True)
force = item.get("force", "silent")
if not isinstance(from_pool, bool):
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
if not (isinstance(force, bool) or force == "silent"):
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
elif isinstance(item, PlandoItem):
if roll_percentage(item.percentage):
value.append(item)
else:
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
return cls(value)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if not self.value:
return
from BaseClasses import PlandoOptions
if not (PlandoOptions.items & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando items module is turned off, "
f"so items for {player_name} will be ignored.")
else:
# filter down item groups
for plando in self.value:
# confirm a valid count
if isinstance(plando.count, dict):
if "min" in plando.count and "max" in plando.count:
if plando.count["min"] > plando.count["max"]:
raise OptionError("Plando cannot have count `min` greater than `max`.")
items_copy = plando.items.copy()
if isinstance(plando.items, dict):
for item in items_copy:
if item in world.item_name_groups:
value = plando.items.pop(item)
group = world.item_name_groups[item]
filtered_items = sorted(group.difference(list(plando.items.keys())))
if not filtered_items:
raise OptionError(f"Plando `items` contains the group \"{item}\" "
f"and every item in it. This is not allowed.")
if value is True:
for key in filtered_items:
plando.items[key] = True
else:
for key in random.choices(filtered_items, k=value):
plando.items[key] = plando.items.get(key, 0) + 1
else:
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
for item in items_copy:
if item in world.item_name_groups:
plando.items.remove(item)
plando.items.extend(sorted(world.item_name_groups[item]))
@classmethod
def get_option_name(cls, value: list[PlandoItem]) -> str:
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoItem]:
yield from self.value
def __len__(self) -> int:
return len(self.value)
class Removed(FreeText): class Removed(FreeText):
"""This Option has been Removed.""" """This Option has been Removed."""
rich_text_doc = True rich_text_doc = True
@@ -1649,7 +1491,6 @@ class PerGameCommonOptions(CommonOptions):
exclude_locations: ExcludeLocations exclude_locations: ExcludeLocations
priority_locations: PriorityLocations priority_locations: PriorityLocations
item_links: ItemLinks item_links: ItemLinks
plando_items: PlandoItems
@dataclass @dataclass
@@ -1668,7 +1509,7 @@ class OptionGroup(typing.NamedTuple):
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints, item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems] StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
""" """
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group. Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
@@ -1703,7 +1544,6 @@ def get_option_groups(world: typing.Type[World], visibility_level: Visibility =
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None: def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
import os import os
from inspect import cleandoc
import yaml import yaml
from jinja2 import Template from jinja2 import Template
@@ -1742,21 +1582,19 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
# yaml dump may add end of document marker and newlines. # yaml dump may add end of document marker and newlines.
return yaml.dump(scalar).replace("...\n", "").strip() return yaml.dump(scalar).replace("...\n", "").strip()
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
template = Template(file_data)
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden: if not world.hidden or generate_hidden:
option_groups = get_option_groups(world) option_groups = get_option_groups(world)
with open(local_path("data", "options.yaml")) as f:
res = template.render( file_data = f.read()
res = Template(file_data).render(
option_groups=option_groups, option_groups=option_groups,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar, __version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range, dictify_range=dictify_range,
cleandoc=cleandoc,
) )
del file_data
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f: with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res) f.write(res)

View File

@@ -7,6 +7,7 @@ Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past * The Legend of Zelda: A Link to the Past
* Factorio * Factorio
* Minecraft
* Subnautica * Subnautica
* Risk of Rain 2 * Risk of Rain 2
* The Legend of Zelda: Ocarina of Time * The Legend of Zelda: Ocarina of Time
@@ -14,6 +15,7 @@ Currently, the following games are supported:
* Super Metroid * Super Metroid
* Secret of Evermore * Secret of Evermore
* Final Fantasy * Final Fantasy
* Rogue Legacy
* VVVVVV * VVVVVV
* Raft * Raft
* Super Mario 64 * Super Mario 64
@@ -40,6 +42,7 @@ Currently, the following games are supported:
* The Messenger * The Messenger
* Kingdom Hearts 2 * Kingdom Hearts 2
* The Legend of Zelda: Link's Awakening DX * The Legend of Zelda: Link's Awakening DX
* Clique
* Adventure * Adventure
* DLC Quest * DLC Quest
* Noita * Noita
@@ -77,10 +80,6 @@ Currently, the following games are supported:
* Inscryption * Inscryption
* Civilization VI * Civilization VI
* The Legend of Zelda: The Wind Waker * The Legend of Zelda: The Wind Waker
* Jak and Daxter: The Precursor Legacy
* Super Mario Land 2: 6 Golden Coins
* shapez
* Paint
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -18,7 +18,6 @@ from json import loads, dumps
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
import Utils import Utils
from settings import Settings
from Utils import async_start from Utils import async_start
from MultiServer import mark_raw from MultiServer import mark_raw
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
@@ -286,7 +285,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None: def launch_sni() -> None:
sni_path = Settings.sni_options.sni_path sni_path = Utils.get_settings()["sni_options"]["sni_path"]
if not os.path.isdir(sni_path): if not os.path.isdir(sni_path):
sni_path = Utils.local_path(sni_path) sni_path = Utils.local_path(sni_path)
@@ -669,7 +668,8 @@ async def game_watcher(ctx: SNIContext) -> None:
async def run_game(romfile: str) -> None: async def run_game(romfile: str) -> None:
auto_start = Settings.sni_options.snes_rom_start auto_start = typing.cast(typing.Union[bool, str],
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)

View File

@@ -47,7 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.6.3" __version__ = "0.6.2"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -139,11 +139,8 @@ def local_path(*path: str) -> str:
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0])) local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else: else:
import __main__ import __main__
if globals().get("__file__") and os.path.isfile(__file__): if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment # we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment, but AP was imported weirdly
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__)) local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else: else:
# pray # pray
@@ -166,10 +163,6 @@ def home_path(*path: str) -> str:
os.symlink(home_path.cached_path, legacy_home_path) os.symlink(home_path.cached_path, legacy_home_path)
else: else:
os.makedirs(home_path.cached_path, 0o700, exist_ok=True) os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
elif sys.platform == 'darwin':
import platformdirs
home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else: else:
# not implemented # not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously home_path.cached_path = local_path() # this will generate the same exceptions we got previously
@@ -181,7 +174,7 @@ def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions.""" """Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, "cached_path"): if hasattr(user_path, "cached_path"):
pass pass
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()): elif os.access(local_path(), os.W_OK):
user_path.cached_path = local_path() user_path.cached_path = local_path()
else: else:
user_path.cached_path = home_path() user_path.cached_path = home_path()
@@ -230,12 +223,7 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
from shutil import which from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open")) open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
assert open_command, "Didn't find program for open_file! Please report this together with system details." assert open_command, "Didn't find program for open_file! Please report this together with system details."
subprocess.call([open_command, filename])
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.call([open_command, filename], env=env)
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes # from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
@@ -413,23 +401,13 @@ def get_adjuster_settings(game_name: str) -> Namespace:
@cache_argsless @cache_argsless
def get_unique_identifier(): def get_unique_identifier():
common_path = cache_path("common.json") uuid = persistent_load().get("client", {}).get("uuid", None)
if os.path.exists(common_path):
with open(common_path) as f:
common_file = json.load(f)
uuid = common_file.get("uuid", None)
else:
common_file = {}
uuid = None
if uuid: if uuid:
return uuid return uuid
from uuid import uuid4 import uuid
uuid = str(uuid4()) uuid = uuid.getnode()
common_file["uuid"] = uuid persistent_store("client", "uuid", uuid)
with open(common_path, "w") as f:
json.dump(common_file, f, separators=(",", ":"))
return uuid return uuid
@@ -452,7 +430,6 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "builtins" and name in safe_builtins: if module == "builtins" and name in safe_builtins:
return getattr(builtins, name) return getattr(builtins, name)
# used by OptionCounter # used by OptionCounter
# necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
if module == "collections" and name == "Counter": if module == "collections" and name == "Counter":
return collections.Counter return collections.Counter
# used by MultiServer -> savegame/multidata # used by MultiServer -> savegame/multidata
@@ -483,18 +460,6 @@ def restricted_loads(s: bytes) -> Any:
return RestrictedUnpickler(io.BytesIO(s)).load() return RestrictedUnpickler(io.BytesIO(s)).load()
def restricted_dumps(obj: Any) -> bytes:
"""Helper function analogous to pickle.dumps()."""
s = pickle.dumps(obj)
# Assert that the string can be successfully loaded by restricted_loads
try:
restricted_loads(s)
except pickle.UnpicklingError as e:
raise pickle.PicklingError(e) from e
return s
class ByValue: class ByValue:
""" """
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent. Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
@@ -572,8 +537,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
if add_timestamp: if add_timestamp:
stream_handler.setFormatter(formatter) stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler) root_logger.addHandler(stream_handler)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# Relay unhandled exceptions to logger. # Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -740,30 +703,25 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(open_filename(*args)) res.put(open_filename(*args))
def _run_for_stdout(*args: str):
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]: -> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.") logging.info(f"Opening file input dialog for {title}.")
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux: if is_linux:
# prefer native dialog # prefer native dialog
from shutil import which from shutil import which
kdialog = which("kdialog") kdialog = which("kdialog")
if kdialog: if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
zenity = which("zenity") zenity = which("zenity")
if zenity: if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else () selection = (f"--filename={suggest}",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk # fall back to tk
try: try:
@@ -797,18 +755,21 @@ def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux: if is_linux:
# prefer native dialog # prefer native dialog
from shutil import which from shutil import which
kdialog = which("kdialog") kdialog = which("kdialog")
if kdialog: if kdialog:
return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory", return run(kdialog, f"--title={title}", "--getexistingdirectory",
os.path.abspath(suggest) if suggest else ".") os.path.abspath(suggest) if suggest else ".")
zenity = which("zenity") zenity = which("zenity")
if zenity: if zenity:
z_filters = ("--directory",) z_filters = ("--directory",)
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else () selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk # fall back to tk
try: try:
@@ -835,6 +796,9 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def messagebox(title: str, text: str, error: bool = False) -> None: def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_kivy_running(): if is_kivy_running():
from kvui import MessageBox from kvui import MessageBox
MessageBox(title, text, error).open() MessageBox(title, text, error).open()
@@ -845,10 +809,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
from shutil import which from shutil import which
kdialog = which("kdialog") kdialog = which("kdialog")
if kdialog: if kdialog:
return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text) return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
zenity = which("zenity") zenity = which("zenity")
if zenity: if zenity:
return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
elif is_windows: elif is_windows:
import ctypes import ctypes
@@ -953,7 +917,8 @@ def _extend_freeze_support() -> None:
# Handle the first process that MP will create # Handle the first process that MP will create
if ( if (
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith(( len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
'from multiprocessing.resource_tracker import main', 'from multiprocessing.semaphore_tracker import main', # Py<3.8
'from multiprocessing.resource_tracker import main', # Py>=3.8
'from multiprocessing.forkserver import main' 'from multiprocessing.forkserver import main'
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags()) )) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
): ):

View File

@@ -2,15 +2,14 @@ from __future__ import annotations
import atexit import atexit
import os import os
import pkgutil
import sys import sys
import asyncio import asyncio
import random import random
import typing import shutil
from typing import Tuple, List, Iterable, Dict from typing import Tuple, List, Iterable, Dict
from . import WargrooveWorld from worlds.wargroove import WargrooveWorld
from .Items import item_table, faction_table, CommanderData, ItemData from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -22,7 +21,7 @@ import logging
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("WargrooveClient", exception_logger="Client") Utils.init_logging("WargrooveClient", exception_logger="Client")
from NetUtils import ClientStatus from NetUtils import NetworkItem, ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \ from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop CommonContext, server_loop
@@ -30,34 +29,6 @@ wg_logger = logging.getLogger("WG")
class WargrooveClientCommandProcessor(ClientCommandProcessor): class WargrooveClientCommandProcessor(ClientCommandProcessor):
def _cmd_sacrifice_summon(self):
"""Toggles sacrifices and summons On/Off"""
if isinstance(self.ctx, WargrooveContext):
self.ctx.has_sacrifice_summon = not self.ctx.has_sacrifice_summon
if self.ctx.has_sacrifice_summon:
self.output(f"Sacrifices and summons are enabled.")
else:
unit_summon_response_file = os.path.join(self.ctx.game_communication_path, "unitSummonResponse")
if os.path.exists(unit_summon_response_file):
os.remove(unit_summon_response_file)
self.output(f"Sacrifices and summons are disabled.")
def _cmd_deathlink(self):
"""Toggles deathlink On/Off"""
if isinstance(self.ctx, WargrooveContext):
self.ctx.has_death_link = not self.ctx.has_death_link
Utils.async_start(self.ctx.update_death_link(self.ctx.has_death_link), name="Update Deathlink")
if self.ctx.has_death_link:
death_link_send_file = os.path.join(self.ctx.game_communication_path, "deathLinkSend")
if os.path.exists(death_link_send_file):
os.remove(death_link_send_file)
self.output(f"Deathlink enabled.")
else:
death_link_receive_file = os.path.join(self.ctx.game_communication_path, "deathLinkReceive")
if os.path.exists(death_link_receive_file):
os.remove(death_link_receive_file)
self.output(f"Deathlink disabled.")
def _cmd_resync(self): def _cmd_resync(self):
"""Manually trigger a resync.""" """Manually trigger a resync."""
self.output(f"Syncing items.") self.output(f"Syncing items.")
@@ -87,11 +58,6 @@ class WargrooveContext(CommonContext):
commander_defense_boost_multiplier: int = 0 commander_defense_boost_multiplier: int = 0
income_boost_multiplier: int = 0 income_boost_multiplier: int = 0
starting_groove_multiplier: float starting_groove_multiplier: float
has_death_link: bool = False
has_sacrifice_summon: bool = True
player_stored_units_key: str = ""
ai_stored_units_key: str = ""
max_stored_units: int = 1000
faction_item_ids = { faction_item_ids = {
'Starter': 0, 'Starter': 0,
'Cherrystone': 52025, 'Cherrystone': 52025,
@@ -105,31 +71,6 @@ class WargrooveContext(CommonContext):
'Income Boost': 52023, 'Income Boost': 52023,
'Commander Defense Boost': 52024, 'Commander Defense Boost': 52024,
} }
unit_classes = {
"archer",
"ballista",
"balloon",
"dog",
"dragon",
"giant",
"harpoonship",
"harpy",
"knight",
"mage",
"merman",
"rifleman",
"soldier",
"spearman",
"thief",
"thief_with_gold",
"travelboat",
"trebuchet",
"turtle",
"villager",
"wagon",
"warship",
"witch",
}
def __init__(self, server_address, password): def __init__(self, server_address, password):
super(WargrooveContext, self).__init__(server_address, password) super(WargrooveContext, self).__init__(server_address, password)
@@ -137,80 +78,31 @@ class WargrooveContext(CommonContext):
self.syncing = False self.syncing = False
self.awaiting_bridge = False self.awaiting_bridge = False
# self.game_communication_path: files go in this path to pass data between us and the actual game # self.game_communication_path: files go in this path to pass data between us and the actual game
game_options = WargrooveWorld.settings
# Validate the AppData directory with Wargroove save data.
# By default, Windows sets an environment variable we can leverage.
# However, other OSes don't usually have this value set, so we need to rely on a settings value instead.
appdata_wargroove = None
if "appdata" in os.environ: if "appdata" in os.environ:
appdata_wargroove = os.environ['appdata'] options = Utils.get_options()
else: root_directory = os.path.join(options["wargroove_options"]["root_directory"])
try: data_directory = os.path.join("lib", "worlds", "wargroove", "data")
appdata_wargroove = game_options.save_directory dev_data_directory = os.path.join("worlds", "wargroove", "data")
except FileNotFoundError: appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
print_error_and_close("WargrooveClient couldn't detect a path to the AppData folder.\n" if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
"Unable to infer required game_communication_path.\n" print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
"Try setting the \"save_directory\" value in your local options file " "Unable to infer required game_communication_path")
"to the AppData folder containing your Wargroove saves.") self.game_communication_path = os.path.join(root_directory, "AP")
appdata_wargroove = os.path.expandvars(os.path.join(appdata_wargroove, "Chucklefish", "Wargroove")) if not os.path.exists(self.game_communication_path):
if not os.path.isdir(appdata_wargroove): os.makedirs(self.game_communication_path)
print_error_and_close(f"WargrooveClient couldn't find Wargroove data in your AppData folder.\n" self.remove_communication_files()
f"Looked in \"{appdata_wargroove}\".\n" atexit.register(self.remove_communication_files)
f"If you haven't yet booted the game at least once, boot Wargroove " if not os.path.isdir(appdata_wargroove):
f"and then close it to attempt to fix this error.\n" print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
f"If the AppData folder above seems wrong, try setting the " "Boot Wargroove and then close it to attempt to fix this error")
f"\"save_directory\" value in your local options file " if not os.path.isdir(data_directory):
f"to the AppData folder containing your Wargroove saves.") data_directory = dev_data_directory
if not os.path.isdir(data_directory):
# Check for the Wargroove game executable path.
# This should always be set regardless of the OS.
root_directory = game_options["root_directory"]
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
print_error_and_close(f"WargrooveClient couldn't find wargroove64.exe in "
f"\"{root_directory}/win64_bin/\".\n"
f"Unable to infer required game_communication_path.\n"
f"Please verify the \"root_directory\" value in your local "
f"options file is set correctly.")
self.game_communication_path = os.path.join(root_directory, "AP")
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
self.remove_communication_files()
atexit.register(self.remove_communication_files)
if not os.path.isdir(appdata_wargroove):
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
"Boot Wargroove and then close it to attempt to fix this error")
mods_directory = os.path.join(appdata_wargroove, "mods", "ArchipelagoMod")
save_directory = os.path.join(appdata_wargroove, "save")
# Wargroove doesn't always create the mods directory, so we have to do it
if not os.path.isdir(mods_directory):
os.makedirs(mods_directory)
resources = ["data/mods/ArchipelagoMod/maps.dat",
"data/mods/ArchipelagoMod/mod.dat",
"data/mods/ArchipelagoMod/modAssets.dat",
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp",
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak"]
file_paths = [os.path.join(mods_directory, "maps.dat"),
os.path.join(mods_directory, "mod.dat"),
os.path.join(mods_directory, "modAssets.dat"),
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp"),
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak")]
for resource, destination in zip(resources, file_paths):
file_data = pkgutil.get_data("worlds.wargroove", resource)
if file_data is None:
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!") print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
with open(destination, 'wb') as f: shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
f.write(file_data) else:
print_error_and_close("WargrooveClient couldn't detect system type. "
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: "Unable to infer required game_communication_path")
with open(os.path.join(self.game_communication_path, "deathLinkReceive"), 'w+') as f:
text = data.get("cause", "")
if text:
f.write(f"DeathLink: {text}")
else:
f.write(f"DeathLink: Received from {data['source']}")
super(WargrooveContext, self).on_deathlink(data)
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -246,25 +138,20 @@ class WargrooveContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}: if cmd in {"Connected"}:
slot_data = args["slot_data"]
self.has_death_link = slot_data.get("death_link", False)
filename = f"AP_settings.json" filename = f"AP_settings.json"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
json.dump(slot_data, f) slot_data = args["slot_data"]
json.dump(args["slot_data"], f)
self.can_choose_commander = slot_data["can_choose_commander"] self.can_choose_commander = slot_data["can_choose_commander"]
print('can choose commander:', self.can_choose_commander) print('can choose commander:', self.can_choose_commander)
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"] self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
self.income_boost_multiplier = slot_data["income_boost"] self.income_boost_multiplier = slot_data["income_boost"]
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"] self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
f.close()
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
pass f.close()
self.player_stored_units_key = f"wargroove_player_units_{self.team}"
self.ai_stored_units_key = f"wargroove_ai_units_{self.team}"
self.set_notify(self.player_stored_units_key, self.ai_stored_units_key)
self.update_commander_data() self.update_commander_data()
self.ui.update_tracker() self.ui.update_tracker()
@@ -274,6 +161,7 @@ class WargrooveContext(CommonContext):
filename = f"seed{i}" filename = f"seed{i}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.write(str(random.randint(0, 4294967295))) f.write(str(random.randint(0, 4294967295)))
f.close()
if cmd in {"RoomInfo"}: if cmd in {"RoomInfo"}:
self.seed_name = args["seed_name"] self.seed_name = args["seed_name"]
@@ -301,6 +189,7 @@ class WargrooveContext(CommonContext):
f.write(f"{item_count * self.commander_defense_boost_multiplier}") f.write(f"{item_count * self.commander_defense_boost_multiplier}")
else: else:
f.write(f"{item_count}") f.write(f"{item_count}")
f.close()
print_filename = f"AP_{str(network_item.item)}.item.print" print_filename = f"AP_{str(network_item.item)}.item.print"
print_path = os.path.join(self.game_communication_path, print_filename) print_path = os.path.join(self.game_communication_path, print_filename)
@@ -311,6 +200,7 @@ class WargrooveContext(CommonContext):
self.item_names.lookup_in_game(network_item.item) + self.item_names.lookup_in_game(network_item.item) +
" from " + " from " +
self.player_names[network_item.player]) self.player_names[network_item.player])
f.close()
self.update_commander_data() self.update_commander_data()
self.ui.update_tracker() self.ui.update_tracker()
@@ -319,7 +209,7 @@ class WargrooveContext(CommonContext):
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
pass f.close()
def run_gui(self): def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task.""" """Import kivy UI system and start running it as self.ui_task."""
@@ -495,75 +385,32 @@ class WargrooveContext(CommonContext):
async def game_watcher(ctx: WargrooveContext): async def game_watcher(ctx: WargrooveContext):
from worlds.wargroove.Locations import location_table
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
try: if ctx.syncing == True:
if ctx.syncing == True: sync_msg = [{'cmd': 'Sync'}]
sync_msg = [{'cmd': 'Sync'}] if ctx.locations_checked:
if ctx.locations_checked: sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) await ctx.send_msgs(sync_msg)
await ctx.send_msgs(sync_msg) ctx.syncing = False
ctx.syncing = False sending = []
sending = [] victory = False
victory = False for root, dirs, files in os.walk(ctx.game_communication_path):
for root, dirs, files in os.walk(ctx.game_communication_path): for file in files:
for file in files: if file.find("send") > -1:
if file == "deathLinkSend" and ctx.has_death_link: st = file.split("send", -1)[1]
with open(os.path.join(ctx.game_communication_path, file), 'r') as f: sending = sending+[(int(st))]
failed_mission = f.read() os.remove(os.path.join(ctx.game_communication_path, file))
if ctx.slot is not None: if file.find("victory") > -1:
await ctx.send_death(f"{ctx.player_names[ctx.slot]} failed {failed_mission}") victory = True
os.remove(os.path.join(ctx.game_communication_path, file)) os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("send") > -1: ctx.locations_checked = sending
st = file.split("send", -1)[1] message = [{"cmd": 'LocationChecks', "locations": sending}]
sending = sending+[(int(st))] await ctx.send_msgs(message)
os.remove(os.path.join(ctx.game_communication_path, file)) if not ctx.finished_game and victory:
if file.find("victory") > -1: await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
victory = True ctx.finished_game = True
os.remove(os.path.join(ctx.game_communication_path, file)) await asyncio.sleep(0.1)
if file == "unitSacrifice" or file == "unitSacrificeAI":
if ctx.has_sacrifice_summon:
stored_units_key = ctx.player_stored_units_key
if file == "unitSacrificeAI":
stored_units_key = ctx.ai_stored_units_key
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
unit_class = f.read()
message = [{"cmd": 'Set', "key": stored_units_key,
"default": [],
"want_reply": True,
"operations": [{"operation": "add", "value": [unit_class[:64]]}]}]
await ctx.send_msgs(message)
os.remove(os.path.join(ctx.game_communication_path, file))
if file == "unitSummonRequestAI" or file == "unitSummonRequest":
if ctx.has_sacrifice_summon:
stored_units_key = ctx.player_stored_units_key
if file == "unitSummonRequestAI":
stored_units_key = ctx.ai_stored_units_key
with open(os.path.join(ctx.game_communication_path, "unitSummonResponse"), 'w') as f:
if stored_units_key in ctx.stored_data:
stored_units = ctx.stored_data[stored_units_key]
if stored_units is None:
stored_units = []
wg1_stored_units = [unit for unit in stored_units if unit in ctx.unit_classes]
if len(wg1_stored_units) != 0:
summoned_unit = random.choice(wg1_stored_units)
message = [{"cmd": 'Set', "key": stored_units_key,
"default": [],
"want_reply": True,
"operations": [{"operation": "remove", "value": summoned_unit[:64]}]}]
await ctx.send_msgs(message)
f.write(summoned_unit)
os.remove(os.path.join(ctx.game_communication_path, file))
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
except Exception as err:
logger.warn("Exception in communication thread, a check may not have been sent: " + str(err))
def print_error_and_close(msg): def print_error_and_close(msg):
@@ -571,9 +418,8 @@ def print_error_and_close(msg):
Utils.messagebox("Error", msg, error=True) Utils.messagebox("Error", msg, error=True)
sys.exit(1) sys.exit(1)
def launch(*launch_args: str): if __name__ == '__main__':
async def main(): async def main(args):
args = parser.parse_args(launch_args)
ctx = WargrooveContext(args.connect, args.password) ctx = WargrooveContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled: if gui_enabled:
@@ -593,6 +439,7 @@ def launch(*launch_args: str):
parser = get_base_parser(description="Wargroove Client, for text interfacing.") parser = get_base_parser(description="Wargroove Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.just_fix_windows_console() colorama.just_fix_windows_console()
asyncio.run(main()) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

View File

@@ -54,15 +54,16 @@ def get_app() -> "Flask":
return app return app
def copy_tutorials_files_to_static() -> None: def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json
import shutil import shutil
import zipfile import zipfile
from werkzeug.utils import secure_filename
zfile: zipfile.ZipInfo zfile: zipfile.ZipInfo
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
worlds = {} worlds = {}
data = []
for game, world in AutoWorldRegister.world_types.items(): for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'): if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world worlds[game] = world
@@ -71,7 +72,7 @@ def copy_tutorials_files_to_static() -> None:
shutil.rmtree(base_target_path, ignore_errors=True) shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items(): for game, world in worlds.items():
# copy files from world's docs folder to the generated folder # copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, secure_filename(game)) target_path = os.path.join(base_target_path, get_file_safe_name(game))
os.makedirs(target_path, exist_ok=True) os.makedirs(target_path, exist_ok=True)
if world.zip_path: if world.zip_path:
@@ -84,14 +85,45 @@ def copy_tutorials_files_to_static() -> None:
for zfile in zf.infolist(): for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename: if not zfile.is_dir() and "/docs/" in zfile.filename:
zfile.filename = os.path.basename(zfile.filename) zfile.filename = os.path.basename(zfile.filename)
with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f: zf.extract(zfile, target_path)
f.write(zf.read(zfile))
else: else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs") source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
files = os.listdir(source_path) files = os.listdir(source_path)
for file in files: for file in files:
shutil.copyfile(Utils.local_path(source_path, file), shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
Utils.local_path(target_path, secure_filename(file)))
# build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials:
# build dict for the json file
current_tutorial = {
'name': tutorial.tutorial_name,
'description': tutorial.description,
'files': [{
'language': tutorial.language,
'filename': game + '/' + tutorial.file_name,
'link': f'{game}/{tutorial.link}',
'authors': tutorial.authors
}]
}
# check if the name of the current guide exists already
for guide in game_data['tutorials']:
if guide and tutorial.tutorial_name == guide['name']:
guide['files'].append(current_tutorial['files'][0])
break
else:
game_data['tutorials'].append(current_tutorial)
data.append(game_data)
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
generic_data = {}
for games in data:
if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games))
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data
if __name__ == "__main__": if __name__ == "__main__":
@@ -110,7 +142,7 @@ if __name__ == "__main__":
logging.warning("Could not update LttP sprites.") logging.warning("Could not update LttP sprites.")
app = get_app() app = get_app()
create_options_files() create_options_files()
copy_tutorials_files_to_static() create_ordered_tutorials_file()
if app.config["SELFLAUNCH"]: if app.config["SELFLAUNCH"]:
autohost(app.config) autohost(app.config)
if app.config["SELFGEN"]: if app.config["SELFGEN"]:

View File

@@ -61,43 +61,32 @@ cache = Cache()
Compress(app) Compress(app)
def to_python(value):
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(value):
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
class B64UUIDConverter(BaseConverter): class B64UUIDConverter(BaseConverter):
def to_python(self, value): def to_python(self, value):
return to_python(value) return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(self, value): def to_url(self, value):
return to_url(value) return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
# short UUID # short UUID
app.url_map.converters["suuid"] = B64UUIDConverter app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters["suuid"] = to_url app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
app.jinja_env.filters["title_sorted"] = title_sorted app.jinja_env.filters["title_sorted"] = title_sorted
def register(): def register():
"""Import submodules, triggering their registering on flask routing. """Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem.""" Note: initializes worlds subsystem."""
import importlib
from werkzeug.utils import find_modules
# has automatic patch integration # has automatic patch integration
import worlds.AutoWorld
import worlds.Files import worlds.Files
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
for module in find_modules("WebHostLib", include_packages=True):
importlib.import_module(module)
from . import api
app.register_blueprint(api.api_endpoints) app.register_blueprint(api.api_endpoints)

View File

@@ -1,11 +1,11 @@
import json import json
import pickle
from uuid import UUID from uuid import UUID
from flask import request, session, url_for from flask import request, session, url_for
from markupsafe import Markup from markupsafe import Markup
from pony.orm import commit from pony.orm import commit
from Utils import restricted_dumps
from WebHostLib import app from WebHostLib import app
from WebHostLib.check import get_yaml_data, roll_options from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta from WebHostLib.generate import get_meta
@@ -56,7 +56,7 @@ def generate_api():
"detail": results}, 400 "detail": results}, 400
else: else:
gen = Generation( gen = Generation(
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}), options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible # convert to json compatible
meta=json.dumps(meta), state=STATE_QUEUED, meta=json.dumps(meta), state=STATE_QUEUED,
owner=session["_id"]) owner=session["_id"])

View File

@@ -3,7 +3,6 @@ from uuid import UUID
from flask import abort, url_for from flask import abort, url_for
from WebHostLib import to_url
import worlds.Files import worlds.Files
from . import api_endpoints, get_players from . import api_endpoints, get_players
from ..models import Room from ..models import Room
@@ -34,7 +33,7 @@ def room_info(room_id: UUID) -> Dict[str, Any]:
downloads.append(slot_download) downloads.append(slot_download)
return { return {
"tracker": to_url(room.tracker), "tracker": room.tracker,
"players": get_players(room.seed), "players": get_players(room.seed),
"last_port": room.last_port, "last_port": room.last_port,
"last_activity": room.last_activity, "last_activity": room.last_activity,

View File

@@ -1,7 +1,6 @@
from flask import session, jsonify from flask import session, jsonify
from pony.orm import select from pony.orm import select
from WebHostLib import to_url
from WebHostLib.models import Room, Seed from WebHostLib.models import Room, Seed
from . import api_endpoints, get_players from . import api_endpoints, get_players
@@ -11,13 +10,13 @@ def get_rooms():
response = [] response = []
for room in select(room for room in Room if room.owner == session["_id"]): for room in select(room for room in Room if room.owner == session["_id"]):
response.append({ response.append({
"room_id": to_url(room.id), "room_id": room.id,
"seed_id": to_url(room.seed.id), "seed_id": room.seed.id,
"creation_time": room.creation_time, "creation_time": room.creation_time,
"last_activity": room.last_activity, "last_activity": room.last_activity,
"last_port": room.last_port, "last_port": room.last_port,
"timeout": room.timeout, "timeout": room.timeout,
"tracker": to_url(room.tracker), "tracker": room.tracker,
}) })
return jsonify(response) return jsonify(response)
@@ -27,7 +26,7 @@ def get_seeds():
response = [] response = []
for seed in select(seed for seed in Seed if seed.owner == session["_id"]): for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
response.append({ response.append({
"seed_id": to_url(seed.id), "seed_id": seed.id,
"creation_time": seed.creation_time, "creation_time": seed.creation_time,
"players": get_players(seed), "players": get_players(seed),
}) })

View File

@@ -164,6 +164,9 @@ def autogen(config: dict):
Thread(target=keep_running, name="AP_Autogen").start() Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
class MultiworldInstance(): class MultiworldInstance():
def __init__(self, config: dict, id: int): def __init__(self, config: dict, id: int):
self.room_ids = set() self.room_ids = set()

View File

@@ -1,7 +1,7 @@
import os import os
import zipfile import zipfile
import base64 import base64
from collections.abc import Set from typing import Union, Dict, Set, Tuple
from flask import request, flash, redirect, url_for, render_template from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup from markupsafe import Markup
@@ -43,7 +43,7 @@ def mysterycheck():
return redirect(url_for("check"), 301) return redirect(url_for("check"), 301)
def get_yaml_data(files) -> dict[str, str] | str | Markup: def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
options = {} options = {}
for uploaded_file in files: for uploaded_file in files:
if banned_file(uploaded_file.filename): if banned_file(uploaded_file.filename):
@@ -84,12 +84,12 @@ def get_yaml_data(files) -> dict[str, str] | str | Markup:
return options return options
def roll_options(options: dict[str, dict | str], def roll_options(options: Dict[str, Union[dict, str]],
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \ plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
tuple[dict[str, str | bool], dict[str, dict]]: Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
plando_options = PlandoOptions.from_set(set(plando_options)) plando_options = PlandoOptions.from_set(set(plando_options))
results: dict[str, str | bool] = {} results = {}
rolled_results: dict[str, dict] = {} rolled_results = {}
for filename, text in options.items(): for filename, text in options.items():
try: try:
if type(text) is dict: if type(text) is dict:

View File

@@ -129,7 +129,7 @@ class WebHostContext(Context):
else: else:
row = GameDataPackage.get(checksum=game_data["checksum"]) row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = restricted_loads(row.data) game_data_packages[game] = Utils.restricted_loads(row.data)
continue continue
else: else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}") self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
@@ -159,7 +159,6 @@ class WebHostContext(Context):
@db_session @db_session
def _save(self, exit_save: bool = False) -> bool: def _save(self, exit_save: bool = False) -> bool:
room = Room.get(id=self.room_id) room = Room.get(id=self.room_id)
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
room.multisave = pickle.dumps(self.get_save()) room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity # saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again

View File

@@ -61,7 +61,12 @@ def download_slot_file(room_id, player_id: int):
else: else:
import io import io
if slot_data.game == "Factorio": if slot_data.game == "Minecraft":
from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist(): for name in zf.namelist():
if name.endswith("info.json"): if name.endswith("info.json"):

View File

@@ -1,12 +1,12 @@
import concurrent.futures import concurrent.futures
import json import json
import os import os
import pickle
import random import random
import tempfile import tempfile
import zipfile import zipfile
from collections import Counter from collections import Counter
from pickle import PicklingError from typing import Any, Dict, List, Optional, Union, Set
from typing import Any
from flask import flash, redirect, render_template, request, session, url_for from flask import flash, redirect, render_template, request, session, url_for
from pony.orm import commit, db_session from pony.orm import commit, db_session
@@ -14,7 +14,7 @@ from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name from Generate import PlandoOptions, handle_name
from Main import main as ERmain from Main import main as ERmain
from Utils import __version__, restricted_dumps from Utils import __version__
from WebHostLib import app from WebHostLib import app
from settings import ServerOptions, GeneratorOptions from settings import ServerOptions, GeneratorOptions
from worlds.alttp.EntranceRandomizer import parse_arguments from worlds.alttp.EntranceRandomizer import parse_arguments
@@ -23,8 +23,8 @@ from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db from .upload import upload_zip_to_db
def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]: def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
plando_options: set[str] = set() plando_options: Set[str] = set()
for substr in ("bosses", "items", "connections", "texts"): for substr in ("bosses", "items", "connections", "texts"):
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options): if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
plando_options.add(substr) plando_options.add(substr)
@@ -73,7 +73,7 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__) return render_template("generate.html", race=race, version=__version__)
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"])) results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()): if any(type(result) == str for result in results.values()):
@@ -83,18 +83,12 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
f"If you have a larger group, please generate it yourself and upload it.") f"If you have a larger group, please generate it yourself and upload it.")
return redirect(url_for(request.endpoint, **(request.view_args or {}))) return redirect(url_for(request.endpoint, **(request.view_args or {})))
elif len(gen_options) >= app.config["JOB_THRESHOLD"]: elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
try: gen = Generation(
gen = Generation( options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}), # convert to json compatible
# convert to json compatible meta=json.dumps(meta),
meta=json.dumps(meta), state=STATE_QUEUED,
state=STATE_QUEUED, owner=session["_id"])
owner=session["_id"])
except PicklingError as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=("PicklingError: " + str(e)))
commit() commit()
return redirect(url_for("wait_seed", seed=gen.id)) return redirect(url_for("wait_seed", seed=gen.id))
@@ -110,9 +104,9 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
return redirect(url_for("view_seed", seed=seed_id)) return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None): def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if meta is None: if not meta:
meta = {} meta: Dict[str, Any] = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10) meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("generator_options", {}).setdefault("race", False) race = meta.setdefault("generator_options", {}).setdefault("race", False)

View File

@@ -14,7 +14,7 @@ def update_sprites_lttp():
from LttPAdjuster import update_sprites from LttPAdjuster import update_sprites
# Target directories # Target directories
input_dir = user_path("data", "sprites", "alttp", "remote") input_dir = user_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True) os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)

View File

@@ -7,69 +7,17 @@ from flask import request, redirect, url_for, render_template, Response, session
from pony.orm import count, commit, db_session from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister, World from worlds.AutoWorld import AutoWorldRegister
from . import app, cache from . import app, cache
from .models import Seed, Room, Command, UUID, uuid4 from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted
def get_world_theme(game_name: str) -> str: def get_world_theme(game_name: str):
if game_name in AutoWorldRegister.world_types: if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme return AutoWorldRegister.world_types[game_name].web.theme
return 'grass' return 'grass'
def get_visible_worlds() -> dict[str, type(World)]:
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return worlds
def render_markdown(path: str) -> str:
import mistune
from collections import Counter
markdown = mistune.create_markdown(
escape=False,
plugins=[
"strikethrough",
"footnotes",
"table",
"speedup",
],
)
heading_id_count: Counter[str] = Counter()
def heading_id(text: str) -> str:
nonlocal heading_id_count
import re # there is no good way to do this without regex
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
n = heading_id_count[s]
heading_id_count[s] += 1
if n > 0:
s += f"-{n}"
return s
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
for tok in state.tokens:
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
text = tok["text"]
assert isinstance(text, str)
unique_id = heading_id(text)
tok["attrs"]["id"] = unique_id
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
markdown.before_render_hooks.append(id_hook)
with open(path, encoding="utf-8-sig") as f:
document = f.read()
return markdown(document)
@app.errorhandler(404) @app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound) @app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err): def page_not_found(err):
@@ -83,94 +31,83 @@ def start_playing():
return render_template(f"startPlaying.html") return render_template(f"startPlaying.html")
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>') @app.route('/games/<string:game>/info/<string:lang>')
@cache.cached() @cache.cached()
def game_info(game, lang): def game_info(game, lang):
"""Game Info Pages"""
try: try:
theme = get_world_theme(game) world = AutoWorldRegister.world_types[game]
secure_game_name = secure_filename(game) if lang not in world.web.game_info_languages:
lang = secure_filename(lang) raise KeyError("Sorry, this game's info page is not available in that language yet.")
document = render_markdown(os.path.join( except KeyError:
app.static_folder, "generated", "docs",
secure_game_name, f"{lang}_{secure_game_name}.md"
))
return render_template(
"markdown_document.html",
title=f"{game} Guide",
html_from_markdown=document,
theme=theme,
)
except FileNotFoundError:
return abort(404) return abort(404)
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@app.route('/games') @app.route('/games')
@cache.cached() @cache.cached()
def games(): def games():
"""List of supported games""" worlds = {}
return render_template("supportedGames.html", worlds=get_visible_worlds()) for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("supportedGames.html", worlds=worlds)
@app.route('/tutorial/<string:game>/<string:file>') @app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
@cache.cached() @cache.cached()
def tutorial(game: str, file: str): def tutorial(game, file, lang):
try: try:
theme = get_world_theme(game) world = AutoWorldRegister.world_types[game]
secure_game_name = secure_filename(game) if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]:
file = secure_filename(file) raise KeyError("Sorry, the tutorial is not available in that language yet.")
document = render_markdown(os.path.join( except KeyError:
app.static_folder, "generated", "docs",
secure_game_name, file+".md"
))
return render_template(
"markdown_document.html",
title=f"{game} Guide",
html_from_markdown=document,
theme=theme,
)
except FileNotFoundError:
return abort(404) return abort(404)
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/') @app.route('/tutorial/')
@cache.cached() @cache.cached()
def tutorial_landing(): def tutorial_landing():
tutorials = {} return render_template("tutorialLanding.html")
worlds = AutoWorldRegister.world_types
for world_name, world_type in worlds.items():
current_world = tutorials[world_name] = {}
for tutorial in world_type.web.tutorials:
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
"description": tutorial.description, "files": {}})
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
"authors": tutorial.authors,
"language": tutorial.language
}
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)
@app.route('/faq/<string:lang>/') @app.route('/faq/<string:lang>/')
@cache.cached() @cache.cached()
def faq(lang: str): def faq(lang: str):
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) import markdown
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template( return render_template(
"markdown_document.html", "markdown_document.html",
title="Frequently Asked Questions", title="Frequently Asked Questions",
html_from_markdown=document, html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
) )
@app.route('/glossary/<string:lang>/') @app.route('/glossary/<string:lang>/')
@cache.cached() @cache.cached()
def glossary(lang: str): def glossary(lang: str):
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) import markdown
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template( return render_template(
"markdown_document.html", "markdown_document.html",
title="Glossary", title="Glossary",
html_from_markdown=document, html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
) )

View File

@@ -1,4 +1,4 @@
flask>=3.1.1 flask>=3.1.0
werkzeug>=3.1.3 werkzeug>=3.1.3
pony>=0.7.19 pony>=0.7.19
waitress>=3.0.2 waitress>=3.0.2
@@ -7,5 +7,6 @@ Flask-Compress>=1.17
Flask-Limiter>=3.12 Flask-Limiter>=3.12
bokeh>=3.6.3 bokeh>=3.6.3
markupsafe>=3.0.2 markupsafe>=3.0.2
Markdown>=3.7
mdx-breakless-lists>=1.0.1
setproctitle>=1.3.5 setproctitle>=1.3.5
mistune>=3.1.3

View File

@@ -0,0 +1,45 @@
window.addEventListener('load', () => {
const gameInfo = document.getElementById('game-info');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, this game's info page is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the info page.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
});
});

View File

@@ -0,0 +1,49 @@
window.addEventListener('load', () => {
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
};
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
categories[i].addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});

View File

@@ -0,0 +1,52 @@
window.addEventListener('load', () => {
const tutorialWrapper = document.getElementById('tutorial-wrapper');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, the tutorial is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the tutorial.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
showdown.setOption('disableForced4SpacesIndentedSublists', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
const title = document.querySelector('h1')
if (title) {
document.title = title.textContent;
}
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
});
});

View File

@@ -0,0 +1,81 @@
const showError = () => {
const tutorial = document.getElementById('tutorial-landing');
document.getElementById('page-title').innerText = 'This page is out of logic!';
tutorial.removeChild(document.getElementById('loading'));
const userMessage = document.createElement('h3');
const homepageLink = document.createElement('a');
homepageLink.innerText = 'Click here';
homepageLink.setAttribute('href', '/');
userMessage.append(homepageLink);
userMessage.append(' to go back to safety!');
tutorial.append(userMessage);
};
window.addEventListener('load', () => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
const tutorialDiv = document.getElementById('tutorial-landing');
if (ajax.status !== 200) { return showError(); }
try {
const games = JSON.parse(ajax.responseText);
games.forEach((game) => {
const gameTitle = document.createElement('h2');
gameTitle.innerText = game.gameTitle;
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
tutorialDiv.appendChild(gameTitle);
game.tutorials.forEach((tutorial) => {
const tutorialName = document.createElement('h3');
tutorialName.innerText = tutorial.name;
tutorialDiv.appendChild(tutorialName);
const tutorialDescription = document.createElement('p');
tutorialDescription.innerText = tutorial.description;
tutorialDiv.appendChild(tutorialDescription);
const intro = document.createElement('p');
intro.innerText = 'This guide is available in the following languages:';
tutorialDiv.appendChild(intro);
const fileList = document.createElement('ul');
tutorial.files.forEach((file) => {
const listItem = document.createElement('li');
const anchor = document.createElement('a');
anchor.innerText = file.language;
anchor.setAttribute('href', `${window.location.origin}/tutorial/${file.link}`);
listItem.appendChild(anchor);
listItem.append(' by ');
for (let author of file.authors) {
listItem.append(author);
if (file.authors.indexOf(author) !== (file.authors.length -1)) {
listItem.append(', ');
}
}
fileList.appendChild(listItem);
});
tutorialDiv.appendChild(fileList);
});
});
tutorialDiv.removeChild(document.getElementById('loading'));
} catch (error) {
showError();
console.error(error);
}
// Check if we are on an anchor when coming in, and scroll to it.
const hash = window.location.hash;
if (hash) {
const offset = 128; // To account for navbar banner at top of page.
window.scrollTo(0, 0);
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
window.scrollTo(rect.left, rect.top - offset);
}
};
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
ajax.send();
});

View File

@@ -0,0 +1,102 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
border-top: 2px solid #000000;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 384px;
background-color: #42b149;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
}
#inventory-table img.acquired{
filter: none;
}
#inventory-table div.counted-item {
position: relative;
}
#inventory-table div.item-count {
position: absolute;
color: white;
font-family: "Minecraftia", monospace;
font-weight: bold;
bottom: 0;
right: 0;
}
#location-table{
width: 384px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #42b149;
padding: 0 3px 3px;
font-family: "Minecraftia", monospace;
font-size: 14px;
cursor: default;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 12px;
}
#location-table td.location-name {
padding-left: 16px;
}
.hide {
display: none;
}

View File

@@ -1,3 +1,4 @@
import typing
from collections import Counter, defaultdict from collections import Counter, defaultdict
from colorsys import hsv_to_rgb from colorsys import hsv_to_rgb
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
@@ -17,23 +18,21 @@ from .models import Room
PLOT_WIDTH = 600 PLOT_WIDTH = 600
def get_db_data(known_games: set[str]) -> tuple[Counter[str], defaultdict[date, dict[str, int]]]: def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
games_played: defaultdict[date, dict[str, int]] = defaultdict(Counter) typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
total_games: Counter[str] = Counter() games_played = defaultdict(Counter)
total_games = Counter()
cutoff = date.today() - timedelta(days=30) cutoff = date.today() - timedelta(days=30)
room: Room room: Room
for room in select(room for room in Room if room.creation_time >= cutoff): for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots: for slot in room.seed.slots:
if slot.game in known_games: if slot.game in known_games:
current_game = slot.game total_games[slot.game] += 1
else: games_played[room.creation_time.date()][slot.game] += 1
current_game = "Other"
total_games[current_game] += 1
games_played[room.creation_time.date()][current_game] += 1
return total_games, games_played return total_games, games_played
def get_color_palette(colors_needed: int) -> list[RGB]: def get_color_palette(colors_needed: int) -> typing.List[RGB]:
colors = [] colors = []
# colors_needed +1 to prevent first and last color being too close to each other # colors_needed +1 to prevent first and last color being too close to each other
colors_needed += 1 colors_needed += 1
@@ -48,7 +47,8 @@ def get_color_palette(colors_needed: int) -> list[RGB]:
return colors return colors
def create_game_played_figure(all_games_data: dict[date, dict[str, int]], game: str, color: RGB) -> figure: def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
game: str, color: RGB) -> figure:
occurences = [] occurences = []
days = [day for day, game_data in all_games_data.items() if game_data[game]] days = [day for day, game_data in all_games_data.items() if game_data[game]]
for day in days: for day in days:
@@ -84,7 +84,7 @@ def stats():
days = sorted(games_played) days = sorted(games_played)
color_palette = get_color_palette(len(total_games)) color_palette = get_color_palette(len(total_games))
game_to_color: dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)} game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
for game in sorted(total_games): for game in sorted(total_games):
occurences = [] occurences = []

View File

@@ -0,0 +1,17 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>{{ game }} Info</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/gameInfo.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/'+theme+'Header.html' %}
<div id="game-info" class="markdown" data-lang="{{ lang }}" data-game="{{ game | get_file_safe_name }}">
<!-- Populated my JS / MD -->
</div>
{% endblock %}

View File

@@ -26,18 +26,30 @@
<td>{{ patch.game }}</td> <td>{{ patch.game }}</td>
<td> <td>
{% if patch.data %} {% if patch.data %}
{% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %} {% if patch.game == "Minecraft" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMC File...</a>
{% elif patch.game == "Factorio" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game == "Kingdom Hearts 2" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Kingdom Hearts 2 Mod...</a>
{% elif patch.game == "Ocarina of Time" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APZ5 File...</a>
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download> <a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APV6 File...</a> Download APV6 File...</a>
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %} {% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download> <a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APSM64EX File...</a> Download APSM64EX File...</a>
{% elif patch.game == "Factorio" %} {% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download> <a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a> Download Patch File...</a>
{% elif patch.game == "Final Fantasy Mystic Quest" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMQ File...</a>
{% else %} {% else %}
No file to download for this game. No file to download for this game.
{% endif %} {% endif %}

View File

@@ -1,8 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{% set theme_name = theme|default("grass", true) %} {% include 'header/grassHeader.html' %}
{% include "header/"+theme_name+"Header.html" %}
<title>{{ title }}</title> <title>{{ title }}</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
title="Progressive Resource Crafting" /></td>
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Ender Pearl'] }}" class="{{ 'acquired' if '3 Ender Pearls' in acquired_items }}" title="Ender Pearls" />
<div class="item-count">{{ pearls_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Bucket'] }}" class="{{ 'acquired' if 'Bucket' in acquired_items }}" title="Bucket" /></td>
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Archery' in acquired_items }}" title="Archery" /></td>
<td><img src="{{ icons['Shield'] }}" class="{{ 'acquired' if 'Shield' in acquired_items }}" title="Shield" /></td>
<td><img src="{{ icons['Red Bed'] }}" class="{{ 'acquired' if 'Bed' in acquired_items }}" title="Bed" /></td>
<td><img src="{{ icons['Water Bottle'] }}" class="{{ 'acquired' if 'Bottles' in acquired_items }}" title="Bottles" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Netherite Scrap'] }}" class="{{ 'acquired' if '8 Netherite Scrap' in acquired_items }}" title="Netherite Scrap" />
<div class="item-count">{{ scrap_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Flint and Steel'] }}" class="{{ 'acquired' if 'Flint and Steel' in acquired_items }}" title="Flint and Steel" /></td>
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
<div class="item-count">{{ shard_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
</tr>
</table>
<table id="location-table">
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,17 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{% include 'header/'+theme+'Header.html' %}
<title>Archipelago</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorial.js") }}"></script>
{% endblock %}
{% block body %}
<div id="tutorial-wrapper" class="markdown" data-game="{{ game | get_file_safe_name }}" data-file="{{ file | get_file_safe_name }}" data-lang="{{ lang }}">
<!-- Content generated by JavaScript -->
</div>
{% endblock %}

View File

@@ -3,32 +3,14 @@
{% block head %} {% block head %}
{% include 'header/grassHeader.html' %} {% include 'header/grassHeader.html' %}
<title>Archipelago Guides</title> <title>Archipelago Guides</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorialLanding.js") }}"></script>
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div id="tutorial-landing" class="markdown"> <div id="tutorial-landing" class="markdown" data-game="{{ game }}" data-file="{{ file }}" data-lang="{{ lang }}">
<h1>Archipelago Guides</h1> <h1 id="page-title">Archipelago Guides</h1>
{% for world_name, world_type in worlds.items() %} <p id="loading">Loading...</p>
<h2 id="{{ world_type.game | urlencode }}">{{ world_type.game }}</h2>
{% for tutorial_name, tutorial_data in tutorials[world_name].items() %}
<h3>{{ tutorial_name }}</h3>
<p>{{ tutorial_data.description }}</p>
<p>This guide is available in the following languages:</p>
<ul>
{% for file_name, file_data in tutorial_data.files.items() %}
<li>
<a href="{{ url_for("tutorial", game=world_name, file=file_name) }}">{{ file_data.language }}</a>
by
{% for author in file_data.authors %}
{{ author }}
{% if not loop.last %}, {% endif %}
{% endfor %}
</li>
{% endfor %}
</ul>
{% endfor %}
{% endfor %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -706,6 +706,127 @@ if "A Link to the Past" in network_data_package["games"]:
_multiworld_trackers["A Link to the Past"] = render_ALinkToThePast_multiworld_tracker _multiworld_trackers["A Link to the Past"] = render_ALinkToThePast_multiworld_tracker
_player_trackers["A Link to the Past"] = render_ALinkToThePast_tracker _player_trackers["A Link to the Past"] = render_ALinkToThePast_tracker
if "Minecraft" in network_data_package["games"]:
def render_Minecraft_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
icons = {
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
"Stone Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c4/Stone_Pickaxe_JE2_BE2.png",
"Iron Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d1/Iron_Pickaxe_JE3_BE2.png",
"Diamond Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e7/Diamond_Pickaxe_JE3_BE3.png",
"Wooden Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d5/Wooden_Sword_JE2_BE2.png",
"Stone Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b1/Stone_Sword_JE2_BE2.png",
"Iron Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/8/8e/Iron_Sword_JE2_BE2.png",
"Diamond Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/4/44/Diamond_Sword_JE3_BE3.png",
"Leather Tunic": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b7/Leather_Tunic_JE4_BE2.png",
"Iron Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Iron_Chestplate_JE2_BE2.png",
"Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png",
"Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png",
"Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png",
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png",
"Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png",
"Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png",
"Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png",
"Shield": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c6/Shield_JE2_BE1.png",
"Red Bed": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/6/6a/Red_Bed_%28N%29.png",
"Netherite Scrap": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/33/Netherite_Scrap_JE2_BE1.png",
"Flint and Steel": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/94/Flint_and_Steel_JE4_BE2.png",
"Enchanting Table": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Enchanting_Table.gif",
"Fishing Rod": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7f/Fishing_Rod_JE2_BE2.png",
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
"Saddle": "https://i.imgur.com/2QtDyR0.png",
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
}
minecraft_location_ids = {
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014],
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020,
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105,
42099, 42103, 42110, 42100],
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111,
42112,
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
}
display_data = {}
# Determine display for progressive items
progressive_items = {
"Progressive Tools": 45013,
"Progressive Weapons": 45012,
"Progressive Armor": 45014,
"Progressive Resource Crafting": 45001
}
progressive_names = {
"Progressive Tools": ["Wooden Pickaxe", "Stone Pickaxe", "Iron Pickaxe", "Diamond Pickaxe"],
"Progressive Weapons": ["Wooden Sword", "Stone Sword", "Iron Sword", "Diamond Sword"],
"Progressive Armor": ["Leather Tunic", "Iron Chestplate", "Diamond Chestplate"],
"Progressive Resource Crafting": ["Iron Ingot", "Iron Ingot", "Block of Iron"]
}
inventory = tracker_data.get_player_inventory_counts(team, player)
for item_name, item_id in progressive_items.items():
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
display_name = progressive_names[item_name][level]
base_name = item_name.split(maxsplit=1)[1].lower().replace(" ", "_")
display_data[base_name + "_url"] = icons[display_name]
# Multi-items
multi_items = {
"3 Ender Pearls": 45029,
"8 Netherite Scrap": 45015,
"Dragon Egg Shard": 45043
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
if count >= 0:
display_data[base_name + "_count"] = count
# Victory condition
game_state = tracker_data.get_player_client_status(team, player)
display_data["game_finished"] = game_state == 30
# Turn location IDs into advancement tab counts
checked_locations = tracker_data.get_player_checked_locations(team, player)
lookup_name = lambda id: tracker_data.location_id_to_name["Minecraft"][id]
location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations}
for tab_name, tab_locations in minecraft_location_ids.items()}
checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations])
for tab_name, tab_locations in minecraft_location_ids.items()}
checks_done["Total"] = len(checked_locations)
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in minecraft_location_ids.items()}
checks_in_area["Total"] = sum(checks_in_area.values())
lookup_any_item_id_to_name = tracker_data.item_id_to_name["Minecraft"]
return render_template(
"tracker__Minecraft.html",
inventory=inventory,
icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0},
player=player,
team=team,
room=tracker_data.room,
player_name=tracker_data.get_player_name(team, player),
saving_second=tracker_data.get_room_saving_second(),
checks_done=checks_done,
checks_in_area=checks_in_area,
location_info=location_info,
**display_data,
)
_player_trackers["Minecraft"] = render_Minecraft_tracker
if "Ocarina of Time" in network_data_package["games"]: if "Ocarina of Time" in network_data_package["games"]:
def render_OcarinaOfTime_tracker(tracker_data: TrackerData, team: int, player: int) -> str: def render_OcarinaOfTime_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
icons = { icons = {

View File

@@ -1,3 +1,4 @@
import base64
import json import json
import pickle import pickle
import typing import typing
@@ -13,8 +14,9 @@ from pony.orm.core import TransactionIntegrityError
import schema import schema
import MultiServer import MultiServer
from NetUtils import GamesPackage, SlotType from NetUtils import SlotType
from Utils import VersionException, __version__ from Utils import VersionException, __version__
from worlds import GamesPackage
from worlds.Files import AutoPatchRegister from worlds.Files import AutoPatchRegister
from worlds.AutoWorld import data_package_checksum from worlds.AutoWorld import data_package_checksum
from . import app from . import app
@@ -117,9 +119,9 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# AP Container # AP Container
elif handler: elif handler:
data = zfile.open(file, "r").read() data = zfile.open(file, "r").read()
with zipfile.ZipFile(BytesIO(data)) as container: patch = handler(BytesIO(data))
player = json.loads(container.open("archipelago.json").read())["player"] patch.read()
files[player] = data files[patch.player] = data
# Spoiler # Spoiler
elif file.filename.endswith(".txt"): elif file.filename.endswith(".txt"):
@@ -133,6 +135,11 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
flash("Could not load multidata. File may be corrupted or incompatible.") flash("Could not load multidata. File may be corrupted or incompatible.")
multidata = None multidata = None
# Minecraft
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
files[metadata["player_id"]] = data
# Factorio # Factorio
elif file.filename.endswith(".zip"): elif file.filename.endswith(".zip"):

View File

@@ -333,7 +333,6 @@ async def nes_sync_task(ctx: ZeldaContext):
except ConnectionRefusedError: except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again") logger.debug("Connection Refused, Trying Again")
ctx.nes_status = CONNECTION_REFUSED_STATUS ctx.nes_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue continue

View File

@@ -24,20 +24,9 @@
<BaseButton>: <BaseButton>:
ripple_color: app.theme_cls.primaryColor ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2 ripple_duration_in_fast: 0.2
<MDNavigationItemBase>: <MDTabsItemBase>:
on_release: app.screens.switch_screens(self) ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2
MDNavigationItemLabel:
text: root.text
theme_text_color: "Custom"
text_color_active: self.theme_cls.primaryColor
text_color_normal: 1, 1, 1, 1
# indicator is on icon only for some reason
canvas.before:
Color:
rgba: self.theme_cls.secondaryContainerColor if root.active else self.theme_cls.transparentColor
Rectangle:
size: root.size
<TooltipLabel>: <TooltipLabel>:
adaptive_height: True adaptive_height: True
theme_font_size: "Custom" theme_font_size: "Custom"
@@ -233,8 +222,3 @@
spacing: 10 spacing: 10
size_hint_y: None size_hint_y: None
height: self.minimum_height height: self.minimum_height
<MessageBoxLabel>:
valign: "middle"
halign: "center"
text_size: self.width, None
height: self.texture_size[1]

View File

@@ -365,14 +365,18 @@ request_handlers = {
["PREFERRED_CORES"] = function (req) ["PREFERRED_CORES"] = function (req)
local res = {} local res = {}
local preferred_cores = client.getconfig().PreferredCores local preferred_cores = client.getconfig().PreferredCores
local systems_enumerator = preferred_cores.Keys:GetEnumerator()
res["type"] = "PREFERRED_CORES_RESPONSE" res["type"] = "PREFERRED_CORES_RESPONSE"
res["value"] = {} res["value"] = {}
res["value"]["NES"] = preferred_cores.NES
while systems_enumerator:MoveNext() do res["value"]["SNES"] = preferred_cores.SNES
res["value"][systems_enumerator.Current] = preferred_cores[systems_enumerator.Current] res["value"]["GB"] = preferred_cores.GB
end res["value"]["GBC"] = preferred_cores.GBC
res["value"]["DGB"] = preferred_cores.DGB
res["value"]["SGB"] = preferred_cores.SGB
res["value"]["PCE"] = preferred_cores.PCE
res["value"]["PCECD"] = preferred_cores.PCECD
res["value"]["SGX"] = preferred_cores.SGX
return res return res
end, end,

462
data/lua/connector_ff1.lua Normal file
View File

@@ -0,0 +1,462 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require("common")
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local ITEM_INDEX = 0x03
local WEAPON_INDEX = 0x07
local ARMOR_INDEX = 0x0B
local goldLookup = {
[0x16C] = 10,
[0x16D] = 20,
[0x16E] = 25,
[0x16F] = 30,
[0x170] = 55,
[0x171] = 70,
[0x172] = 85,
[0x173] = 110,
[0x174] = 135,
[0x175] = 155,
[0x176] = 160,
[0x177] = 180,
[0x178] = 240,
[0x179] = 255,
[0x17A] = 260,
[0x17B] = 295,
[0x17C] = 300,
[0x17D] = 315,
[0x17E] = 330,
[0x17F] = 350,
[0x180] = 385,
[0x181] = 400,
[0x182] = 450,
[0x183] = 500,
[0x184] = 530,
[0x185] = 575,
[0x186] = 620,
[0x187] = 680,
[0x188] = 750,
[0x189] = 795,
[0x18A] = 880,
[0x18B] = 1020,
[0x18C] = 1250,
[0x18D] = 1455,
[0x18E] = 1520,
[0x18F] = 1760,
[0x190] = 1975,
[0x191] = 2000,
[0x192] = 2750,
[0x193] = 3400,
[0x194] = 4150,
[0x195] = 5000,
[0x196] = 5450,
[0x197] = 6400,
[0x198] = 6720,
[0x199] = 7340,
[0x19A] = 7690,
[0x19B] = 7900,
[0x19C] = 8135,
[0x19D] = 9000,
[0x19E] = 9300,
[0x19F] = 9500,
[0x1A0] = 9900,
[0x1A1] = 10000,
[0x1A2] = 12350,
[0x1A3] = 13000,
[0x1A4] = 13450,
[0x1A5] = 14050,
[0x1A6] = 14720,
[0x1A7] = 15000,
[0x1A8] = 17490,
[0x1A9] = 18010,
[0x1AA] = 19990,
[0x1AB] = 20000,
[0x1AC] = 20010,
[0x1AD] = 26000,
[0x1AE] = 45000,
[0x1AF] = 65000
}
local extensionConsumableLookup = {
[432] = 0x3C,
[436] = 0x3C,
[440] = 0x3C,
[433] = 0x3D,
[437] = 0x3D,
[441] = 0x3D,
[434] = 0x3E,
[438] = 0x3E,
[442] = 0x3E,
[435] = 0x3F,
[439] = 0x3F,
[443] = 0x3F
}
local noOverworldItemsLookup = {
[499] = 0x2B,
[500] = 0x12,
}
local consumableStacks = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local ff1Socket = nil
local frame = 0
local isNesHawk = false
--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded
local function defineMemoryFunctions()
local memDomain = {}
local domains = memory.getmemorydomainlist()
if domains[1] == "System Bus" then
--NesHawk
isNesHawk = true
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
elseif domains[1] == "WRAM" then
--QuickNES
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
end
return memDomain
end
local memDomain = defineMemoryFunctions()
local function StateOKForMainLoop()
memDomain.saveram()
local A = u8(0x102) -- Party Made
local B = u8(0x0FC)
local C = u8(0x0A3)
return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2)
end
function generateLocationChecked()
memDomain.saveram()
data = uRange(0x01FF, 0x101)
data[0] = nil
return data
end
function setConsumableStacks()
memDomain.rom()
consumableStacks = {}
-- In order shards, tent, cabin, house, heal, pure, soft, ext1, ext2, ext3, ex4
consumableStacks[0x35] = 1
consumableStacks[0x36] = u8(0x47400) + 1
consumableStacks[0x37] = u8(0x47401) + 1
consumableStacks[0x38] = u8(0x47402) + 1
consumableStacks[0x39] = u8(0x47403) + 1
consumableStacks[0x3A] = u8(0x47404) + 1
consumableStacks[0x3B] = u8(0x47405) + 1
consumableStacks[0x3C] = u8(0x47406) + 1
consumableStacks[0x3D] = u8(0x47407) + 1
consumableStacks[0x3E] = u8(0x47408) + 1
consumableStacks[0x3F] = u8(0x47409) + 1
end
function getEmptyWeaponSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x118, 0x4)
slot2 = uRange(0x158, 0x4)
slot3 = uRange(0x198, 0x4)
slot4 = uRange(0x1D8, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x118 + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x158 + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x198 + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1D8 + i
count = count + 1
end
end
return ret
end
function getEmptyArmorSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x11C, 0x4)
slot2 = uRange(0x15C, 0x4)
slot3 = uRange(0x19C, 0x4)
slot4 = uRange(0x1DC, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x11C + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x15C + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x19C + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1DC + i
count = count + 1
end
end
return ret
end
local function slice (tbl, s, e)
local pos, new = 1, {}
for i = s + 1, e do
new[pos] = tbl[i]
pos = pos + 1
end
return new
end
function processBlock(block)
local msgBlock = block['messages']
if msgBlock ~= nil then
for i, v in pairs(msgBlock) do
if itemMessages[i] == nil then
local msg = {TTL=450, message=v, color=0xFFFF0000}
itemMessages[i] = msg
end
end
end
local itemsBlock = block["items"]
memDomain.saveram()
isInGame = u8(0x102)
if itemsBlock ~= nil and isInGame ~= 0x00 then
if consumableStacks == nil then
setConsumableStacks()
end
memDomain.saveram()
-- print('ITEMBLOCK: ')
-- print(itemsBlock)
itemIndex = u8(ITEM_INDEX)
-- print('ITEMINDEX: '..itemIndex)
for i, v in pairs(slice(itemsBlock, itemIndex, #itemsBlock)) do
-- Minus the offset and add to the correct domain
local memoryLocation = v
if v >= 0x100 and v <= 0x114 then
-- This is a key item
memoryLocation = memoryLocation - 0x0E0
wU8(memoryLocation, 0x01)
elseif v >= 0x1E0 and v <= 0x1F2 then
-- This is a movement item
-- Minus Offset (0x100) - movement offset (0xE0)
memoryLocation = memoryLocation - 0x1E0
-- Canal is a flipped bit
if memoryLocation == 0x0C then
wU8(memoryLocation, 0x00)
else
wU8(memoryLocation, 0x01)
end
elseif v >= 0x1F3 and v <= 0x1F4 then
-- NoOverworld special items
memoryLocation = noOverworldItemsLookup[v]
wU8(memoryLocation, 0x01)
elseif v >= 0x16C and v <= 0x1AF then
-- This is a gold item
amountToAdd = goldLookup[v]
biggest = u8(0x01E)
medium = u8(0x01D)
smallest = u8(0x01C)
currentValue = 0x10000 * biggest + 0x100 * medium + smallest
newValue = currentValue + amountToAdd
newBiggest = math.floor(newValue / 0x10000)
newMedium = math.floor(math.fmod(newValue, 0x10000) / 0x100)
newSmallest = math.floor(math.fmod(newValue, 0x100))
wU8(0x01E, newBiggest)
wU8(0x01D, newMedium)
wU8(0x01C, newSmallest)
elseif v >= 0x115 and v <= 0x11B then
-- This is a regular consumable OR a shard
-- Minus Offset (0x100) + item offset (0x20)
memoryLocation = memoryLocation - 0x0E0
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
wU8(memoryLocation, currentValue + amountToAdd)
end
elseif v >= 0x1B0 and v <= 0x1BB then
-- This is an extension consumable
memoryLocation = extensionConsumableLookup[v]
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
value = currentValue + amountToAdd
if value > 99 then
value = 99
end
wU8(memoryLocation, value)
end
end
end
if #itemsBlock > itemIndex then
wU8(ITEM_INDEX, #itemsBlock)
end
memDomain.saveram()
weaponIndex = u8(WEAPON_INDEX)
emptyWeaponSlots = getEmptyWeaponSlots()
lastUsedWeaponIndex = weaponIndex
-- print('WEAPON_INDEX: '.. weaponIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, weaponIndex, #itemsBlock)) do
if v >= 0x11C and v <= 0x143 then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x11B
if #emptyWeaponSlots > 0 then
slot = table.remove(emptyWeaponSlots, 1)
wU8(slot, itemValue)
lastUsedWeaponIndex = weaponIndex + i
else
break
end
end
end
if lastUsedWeaponIndex ~= weaponIndex then
wU8(WEAPON_INDEX, lastUsedWeaponIndex)
end
memDomain.saveram()
armorIndex = u8(ARMOR_INDEX)
emptyArmorSlots = getEmptyArmorSlots()
lastUsedArmorIndex = armorIndex
-- print('ARMOR_INDEX: '.. armorIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, armorIndex, #itemsBlock)) do
if v >= 0x144 and v <= 0x16B then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x143
if #emptyArmorSlots > 0 then
slot = table.remove(emptyArmorSlots, 1)
wU8(slot, itemValue)
lastUsedArmorIndex = armorIndex + i
else
break
end
end
end
if lastUsedArmorIndex ~= armorIndex then
wU8(ARMOR_INDEX, lastUsedArmorIndex)
end
end
end
function receive()
l, e = ff1Socket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
print("timeout")
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
processBlock(json.decode(l))
-- Determine Message to send back
memDomain.rom()
local playerName = uRange(0x7BCBF, 0x41)
playerName[0] = nil
local retTable = {}
retTable["playerName"] = playerName
if StateOKForMainLoop() then
retTable["locations"] = generateLocationChecked()
end
msg = json.encode(retTable).."\n"
local ret, error = ff1Socket:send(msg)
if ret == nil then
print(error)
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
curstate = STATE_TENTATIVELY_CONNECTED
elseif curstate == STATE_TENTATIVELY_CONNECTED then
print("Connected!")
itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"}
curstate = STATE_OK
end
end
function main()
if not checkBizHawkVersion() then
return
end
server, error = socket.bind('localhost', 52980)
while true do
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
frame = frame + 1
drawMessages()
if not (curstate == prevstate) then
-- console.log("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Blue")
receive()
else
gui.drawEllipse(248, 9, 6, 6, "Black", "Green")
end
elseif (curstate == STATE_UNINITIALIZED) then
gui.drawEllipse(248, 9, 6, 6, "Black", "White")
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
drawText(5, 8, "Waiting for client", 0xFFFF0000)
drawText(5, 32, "Please start FF1Client.exe", 0xFFFF0000)
-- Advance so the messages are drawn
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
-- print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
ff1Socket = client
ff1Socket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

View File

@@ -477,7 +477,7 @@ function main()
elseif (curstate == STATE_UNINITIALIZED) then elseif (curstate == STATE_UNINITIALIZED) then
-- If we're uninitialized, attempt to make the connection. -- If we're uninitialized, attempt to make the connection.
if (frame % 120 == 0) then if (frame % 120 == 0) then
server:settimeout(120) server:settimeout(2)
local client, timeout = server:accept() local client, timeout = server:accept()
if timeout == nil then if timeout == nil then
print('Initial Connection Made') print('Initial Connection Made')

BIN
data/mcicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -46,16 +46,15 @@ requires:
{{ yaml_dump(game) }}: {{ yaml_dump(game) }}:
{%- for group_name, group_options in option_groups.items() %} {%- for group_name, group_options in option_groups.items() %}
##{% for _ in group_name %}#{% endfor %}## # {{ group_name }}
# {{ group_name }} #
##{% for _ in group_name %}#{% endfor %}##
{%- for option_key, option in group_options.items() %} {%- for option_key, option in group_options.items() %}
{{ option_key }}: {{ option_key }}:
{%- if option.__doc__ %} {%- if option.__doc__ %}
# {{ cleandoc(option.__doc__) # {{ option.__doc__
| trim | trim
| replace('\n', '\n# ') | replace('\n\n', '\n \n')
| replace('\n ', '\n# ')
| indent(4, first=False) | indent(4, first=False)
}} }}
{%- endif -%} {%- endif -%}

View File

@@ -1,61 +0,0 @@
services:
multiworld:
# Build only once. Web service uses the same image build
build:
context: ..
# Name image for use in web service
image: archipelago-base
# Use locally-built image
pull_policy: never
# Launch main process without website hosting (config override)
entrypoint: python WebHost.py --config_override selflaunch.yaml
volumes:
# Mount application volume
- app_volume:/app
# Mount configs
- ./example_config.yaml:/app/config.yaml
- ./example_selflaunch.yaml:/app/selflaunch.yaml
# Expose on host network for access to dynamically mapped ports
network_mode: host
# No Healthcheck in place yet for multiworld
healthcheck:
test: ["NONE"]
web:
# Use image build by multiworld service
image: archipelago-base
# Use locally-built image
pull_policy: never
# Launch gunicorn targeting WebHost application
entrypoint: gunicorn -c gunicorn.conf.py
volumes:
# Mount application volume
- app_volume:/app
# Mount configs
- ./example_config.yaml:/app/config.yaml
- ./example_gunicorn.conf.py:/app/gunicorn.conf.py
environment:
# Bind gunicorn on 8000
- PORT=8000
nginx:
image: nginx:stable-alpine
volumes:
# Mount application volume
- app_volume:/app
# Mount config
- ./example_nginx.conf:/etc/nginx/nginx.conf
ports:
# Nginx listening internally on port 80 -- mapped to 8080 on host
- 8080:80
depends_on:
- web
volumes:
# Share application directory amongst multiworld and web services
# (for access to log files and the like), and nginx (for static files)
app_volume:

View File

@@ -1,10 +0,0 @@
# Refer to ../docs/webhost configuration sample.yaml
# We'll be hosting VIA gunicorn
SELFHOST: false
# We'll start a separate process for rooms and generators
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

View File

@@ -1,19 +0,0 @@
workers = 2
threads = 2
wsgi_app = "WebHost:get_app()"
accesslog = "-"
access_log_format = (
'%({x-forwarded-for}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
)
worker_class = "gthread" # "sync" | "gthread"
forwarded_allow_ips = "*"
loglevel = "info"
"""
You can programatically set values.
For example, set number of workers to half of the cpu count:
import multiprocessing
workers = multiprocessing.cpu_count() / 2
"""

View File

@@ -1,64 +0,0 @@
worker_processes 1;
user nobody nogroup;
# 'user nobody nobody;' for systems with 'nobody' as a group instead
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024; # increase if you have lots of clients
accept_mutex off; # set to 'on' if nginx worker_processes > 1
# 'use epoll;' to enable for Linux 2.6+
# 'use kqueue;' to enable for FreeBSD, OSX
use epoll;
}
http {
include mime.types;
# fallback in case we can't determine a type
default_type application/octet-stream;
access_log /var/log/nginx/access.log combined;
sendfile on;
upstream app_server {
# fail_timeout=0 means we always retry an upstream even if it failed
# to return a good HTTP response
# for UNIX domain socket setups
# server unix:/tmp/gunicorn.sock fail_timeout=0;
# for a TCP configuration
server web:8000 fail_timeout=0;
}
server {
# use 'listen 80 deferred;' for Linux
# use 'listen 80 accept_filter=httpready;' for FreeBSD
listen 80 deferred;
client_max_body_size 4G;
# set the correct host(s) for your site
# server_name example.com www.example.com;
keepalive_timeout 5;
# path for static files
root /app/WebHostLib;
location / {
# checks for static file, if not found proxy to app
try_files $uri @proxy_to_app;
}
location @proxy_to_app {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
# we don't want nginx trying to do something clever with
# redirects, we set the Host: header above already.
proxy_redirect off;
proxy_pass http://app_server;
}
}
}

View File

@@ -1,13 +0,0 @@
# Refer to ../docs/webhost configuration sample.yaml
# We'll be hosting VIA gunicorn
SELFHOST: false
# Start room and generator processes
SELFLAUNCH: true
JOB_THRESHOLD: 0
# Maximum concurrent world gens
GENERATORS: 3
# Rooms will be spread across multiple processes
HOSTERS: 4

View File

@@ -48,6 +48,9 @@
# Civilization VI # Civilization VI
/worlds/civ6/ @hesto2 /worlds/civ6/ @hesto2
# Clique
/worlds/clique/ @ThePhar
# Dark Souls III # Dark Souls III
/worlds/dark_souls_3/ @Marechal-L @nex3 /worlds/dark_souls_3/ @Marechal-L @nex3
@@ -84,9 +87,6 @@
# Inscryption # Inscryption
/worlds/inscryption/ @DrBibop @Glowbuzz /worlds/inscryption/ @DrBibop @Glowbuzz
# Jak and Daxter: The Precursor Legacy
/worlds/jakanddaxter/ @massimilianodelliubaldini
# Kirby's Dream Land 3 # Kirby's Dream Land 3
/worlds/kdl3/ @Silvris /worlds/kdl3/ @Silvris
@@ -118,6 +118,9 @@
# The Messenger # The Messenger
/worlds/messenger/ @alwaysintreble /worlds/messenger/ @alwaysintreble
# Minecraft
/worlds/minecraft/ @KonoTyran @espeon65536
# Mega Man 2 # Mega Man 2
/worlds/mm2/ @Silvris /worlds/mm2/ @Silvris
@@ -136,9 +139,6 @@
# Overcooked! 2 # Overcooked! 2
/worlds/overcooked2/ @toasterparty /worlds/overcooked2/ @toasterparty
# Paint
/worlds/paint/ @MarioManTAW
# Pokemon Emerald # Pokemon Emerald
/worlds/pokemon_emerald/ @Zunawe /worlds/pokemon_emerald/ @Zunawe
@@ -148,15 +148,15 @@
# Raft # Raft
/worlds/raft/ @SunnyBat /worlds/raft/ @SunnyBat
# Rogue Legacy
/worlds/rogue_legacy/ @ThePhar
# Risk of Rain 2 # Risk of Rain 2
/worlds/ror2/ @kindasneaki /worlds/ror2/ @kindasneaki
# Saving Princess # Saving Princess
/worlds/saving_princess/ @LeonarthCG /worlds/saving_princess/ @LeonarthCG
# shapez
/worlds/shapez/ @BlastSlimey
# Shivers # Shivers
/worlds/shivers/ @GodlFire @korydondzila /worlds/shivers/ @GodlFire @korydondzila
@@ -175,9 +175,6 @@
# Super Mario 64 # Super Mario 64
/worlds/sm64ex/ @N00byKing /worlds/sm64ex/ @N00byKing
# Super Mario Land 2: 6 Golden Coins
/worlds/marioland2/ @Alchav
# Super Mario World # Super Mario World
/worlds/smw/ @PoryGone /worlds/smw/ @PoryGone
@@ -200,7 +197,7 @@
/worlds/timespinner/ @Jarno458 /worlds/timespinner/ @Jarno458
# The Legend of Zelda (1) # The Legend of Zelda (1)
/worlds/tloz/ @Rosalie-A /worlds/tloz/ @Rosalie-A @t3hf1gm3nt
# TUNIC # TUNIC
/worlds/tunic/ @silent-destroyer @ScipioWright /worlds/tunic/ @silent-destroyer @ScipioWright
@@ -235,7 +232,7 @@
## Active Unmaintained Worlds ## Active Unmaintained Worlds
# The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks # The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for # compatibility, these worlds may be moved to `worlds_disabled`. If you are interested in stepping up as maintainer for
# any of these worlds, please review `/docs/world maintainer.md` documentation. # any of these worlds, please review `/docs/world maintainer.md` documentation.
# Final Fantasy (1) # Final Fantasy (1)
@@ -244,6 +241,15 @@
# Ocarina of Time # Ocarina of Time
# /worlds/oot/ # /worlds/oot/
## Disabled Unmaintained Worlds
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
# interested in stepping up as maintainer for any of these worlds, please review `/docs/world maintainer.md`
# documentation.
# Ori and the Blind Forest
# /worlds_disabled/oribf/
################### ###################
## Documentation ## ## Documentation ##
################### ###################

View File

@@ -122,21 +122,3 @@ Concrete examples of soft logic include:
- Buying expensive shop items might logically require access to a place where you can quickly farm money, or logically require access to enough parts of the game that checking other locations should naturally generate enough money without grinding. - Buying expensive shop items might logically require access to a place where you can quickly farm money, or logically require access to enough parts of the game that checking other locations should naturally generate enough money without grinding.
Remember that all items referenced by logic (however hard or soft) must be `progression`. Since you typically don't want to turn a ton of `filler` items into `progression` just for this, it's common to e.g. write money logic using only the rare "$100" item, so the dozens of "$1" and "$10" items in your world can remain `filler`. Remember that all items referenced by logic (however hard or soft) must be `progression`. Since you typically don't want to turn a ton of `filler` items into `progression` just for this, it's common to e.g. write money logic using only the rare "$100" item, so the dozens of "$1" and "$10" items in your world can remain `filler`.
---
### What if my game has "missable" or "one-time-only" locations or region connections?
Archipelago logic assumes that once a region or location becomes reachable, it stays reachable forever, no matter what
the player does in-game. Slightly more formally: Receiving an AP item must never cause a region connection or location
to "go out of logic" (become unreachable when it was previously reachable), and receiving AP items is the only kind of
state change that AP logic acknowledges. No other actions or events can change reachability.
So when the game itself does not follow this assumption, the options are:
- Modify the game to make that location/connection repeatable
- If there are both missable and repeatable ways to check the location/traverse the connection, then write logic for
only the repeatable ways
- Don't generate the missable location/connection at all
- For connections, any logical regions will still need to be reachable through other, *repeatable* connections
- For locations, this may require game changes to remove the vanilla item if it affects logic
- Decide that resetting the save file is part of the game's logic, and warn players about that

View File

@@ -1,92 +0,0 @@
# Deploy Using Containers
If you just want to play and there is a compiled version available on the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases), use that version.
To build the full Archipelago software stack, refer to [Running From Source](running%20from%20source.md).
Follow these steps to build and deploy a containerized instance of the web host software, optionally integrating [Gunicorn](https://gunicorn.org/) WSGI HTTP Server running behind the [nginx](https://nginx.org/) reverse proxy.
## Building the Container Image
What you'll need:
* A container runtime engine such as:
* [Docker](https://www.docker.com/) (Version 23.0 or later)
* [Podman](https://podman.io/) (version 4.0 or later)
* For running with rootless podman, you need to ensure all ports used are usable rootless, by default ports less than 1024 are root only. See [the official tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md) for details.
* The Docker Buildx plugin (for Docker), as the Dockerfile uses `$TARGETARCH` for architecture detection. Follow [Docker's guide](https://docs.docker.com/build/buildx/install/). Verify with `docker buildx version`.
Starting from the root repository directory, the standalone Archipelago image can be built and run with the command:
`docker build -t archipelago .`
Or:
`podman build -t archipelago .`
It is recommended to tag the image using `-t` to more easily identify the image and run it.
## Running the Container
Running the container can be performed using:
`docker run --network host archipelago`
Or:
`podman run --network host archipelago`
The Archipelago web host requires access to multiple ports in order to host game servers simultaneously. To simplify configuration for this purpose, specify `--network host`.
Given the default configuration, the website will be accessible at the hostname/IP address (localhost if run locally) of the machine being deployed to, at port 80. It can be configured by creating a YAML file and mapping a volume to the container when running initially:
`docker run archipelago --network host -v /path/to/config.yaml:/app/config.yaml`
See `docs/webhost configuration sample.yaml` for example.
## Using Docker Compose
An example [docker compose](../deploy/docker-compose.yml) file can be found in [deploy](../deploy), along with example configuration files used by the services it orchestrates. Using these files as-is will spin up two separate archipelago containers with special modifications to their runtime arguments, in addition to deploying an `nginx` reverse proxy container.
To deploy in this manner, from the ["deploy"](../deploy) directory, run:
`docker compose up -d`
### Services
The `docker-compose.yaml` file defines three services:
* multiworld:
* Executes the main `WebHost` process, using the [example config](../deploy/example_config.yaml), and overriding with a secondary [selflaunch example config](../deploy/example_selflaunch.yaml). This is because we do not want to launch the website through this service.
* web:
* Executes `gunicorn` using its [example config](../deploy/example_gunicorn.conf.py), which will bind it to the `WebHost` application, in effect launching it.
* We mount the main [config](../deploy/example_config.yaml) without an override to specify that we are launching the website through this service.
* No ports are exposed through to the host.
* nginx:
* Serves as a reverse proxy with `web` as its upstream.
* Directs all HTTP traffic from port 80 to the upstream service.
* Exposed to the host on port 8080. This is where we can reach the website.
### Configuration
As these are examples, they can be copied and modified. For instance setting the value of `HOST_ADDRESS` in [example config](../deploy/example_config.yaml) to host machines local IP address, will expose the service to its local area network.
The configuration files may be modified to handle for machine-specific optimizations, such as:
* Web pages responding too slowly
* Edit [the gunicorn config](../deploy/example_gunicorn.conf.py) to increase thread and/or worker count.
* Game generation stalls
* Increase the generator count in [selflaunch config](../deploy/example_selflaunch.yaml)
* Gameplay lags
* Increase the hoster count in [selflaunch config](../deploy/example_selflaunch.yaml)
Changes made to `docker-compose.yaml` can be applied by running `docker compose up -d`, while those made to other files are applied by running `docker compose restart`.
## Windows
It is possible to carry out these deployment steps on Windows under [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install).
## Optional: A Link to the Past Enemizer
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
error if it is required.
Enemizer can be enabled on `x86_64` platform architecture, and is included in the image build process. Enemizer requires a version 1.0 Japanese "Zelda no Densetsu" `.sfc` rom file to be placed in the application directory:
`docker run archipelago -v "/path/to/zelda.sfc:/app/Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"`.
Enemizer is not currently available for `aarch64`.
## Optional: Git
Building the image requires a local copy of the ArchipelagoMW source code.
Refer to [Running From Source](running%20from%20source.md#optional-git).

View File

@@ -117,6 +117,12 @@ flowchart LR
%% Java Based Games %% Java Based Games
subgraph Java subgraph Java
JM[Mod with Archipelago.MultiClient.Java] JM[Mod with Archipelago.MultiClient.Java]
subgraph Minecraft
MCS[Minecraft Forge Server]
JMC[Any Java Minecraft Clients]
MCS <-- TCP --> JMC
end
JM <-- Forge Mod Loader --> MCS
end end
AS <-- WebSockets --> JM AS <-- WebSockets --> JM
@@ -125,8 +131,10 @@ flowchart LR
NM[Mod with Archipelago.MultiClient.Net] NM[Mod with Archipelago.MultiClient.Net]
subgraph FNA/XNA subgraph FNA/XNA
TS[Timespinner] TS[Timespinner]
RL[Rogue Legacy]
end end
NM <-- TsRandomizer --> TS NM <-- TsRandomizer --> TS
NM <-- RogueLegacyRandomizer --> RL
subgraph Unity subgraph Unity
ROR[Risk of Rain 2] ROR[Risk of Rain 2]
SN[Subnautica] SN[Subnautica]
@@ -175,4 +183,4 @@ flowchart LR
FMOD <--> FMAPI FMOD <--> FMAPI
end end
CC <-- Integrated --> FC CC <-- Integrated --> FC
``` ```

View File

@@ -231,11 +231,11 @@ Sent to clients after a client requested this message be sent to them, more info
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for. Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
#### Arguments #### Arguments
| Name | Type | Notes | | Name | Type | Notes |
| ---- |-------------| ----- | | ---- | ---- | ----- |
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. | | type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
| original_cmd | str \| None | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. | | original_cmd | Optional[str] | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. |
| text | str | A descriptive message of the problem at hand. | | text | str | A descriptive message of the problem at hand. |
##### PacketProblemType ##### PacketProblemType
`PacketProblemType` indicates the type of problem that was detected in the faulty packet, the known problem types are below but others may be added in the future. `PacketProblemType` indicates the type of problem that was detected in the faulty packet, the known problem types are below but others may be added in the future.
@@ -276,7 +276,6 @@ These packets are sent purely from client to server. They are not accepted by cl
* [Sync](#Sync) * [Sync](#Sync)
* [LocationChecks](#LocationChecks) * [LocationChecks](#LocationChecks)
* [LocationScouts](#LocationScouts) * [LocationScouts](#LocationScouts)
* [CreateHints](#CreateHints)
* [UpdateHint](#UpdateHint) * [UpdateHint](#UpdateHint)
* [StatusUpdate](#StatusUpdate) * [StatusUpdate](#StatusUpdate)
* [Say](#Say) * [Say](#Say)
@@ -295,7 +294,7 @@ Sent by the client to initiate a connection to an Archipelago game session.
| password | str | If the game session requires a password, it should be passed here. | | password | str | If the game session requires a password, it should be passed here. |
| game | str | The name of the game the client is playing. Example: `A Link to the Past` | | game | str | The name of the game the client is playing. Example: `A Link to the Past` |
| name | str | The player name for this client. | | name | str | The player name for this client. |
| uuid | str | Unique identifier for player. Cached in the user cache \Archipelago\Cache\common.json | | uuid | str | Unique identifier for player client. |
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. | | version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. | | items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) | | tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
@@ -340,8 +339,7 @@ Sent to the server to retrieve the items that are on a specified list of locatio
Fully remote clients without a patch file may use this to "place" items onto their in-game locations, most commonly to display their names or item classifications before/upon pickup. Fully remote clients without a patch file may use this to "place" items onto their in-game locations, most commonly to display their names or item classifications before/upon pickup.
LocationScouts can also be used to inform the server of locations the client has seen, but not checked. This creates a hint as if the player had run `!hint_location` on a location, but without deducting hint points. LocationScouts can also be used to inform the server of locations the client has seen, but not checked. This creates a hint as if the player had run `!hint_location` on a location, but without deducting hint points.
This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value. This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value.
Note that LocationScouts with a non-zero `create_as_hint` value will _always_ create a **persistent** hint (listed in the Hints tab of concerning players' TextClients), even if the location was already found. If this is not desired behavior, you need to prevent sending LocationScouts with `create_as_hint` for already found locations in your client-side code.
#### Arguments #### Arguments
| Name | Type | Notes | | Name | Type | Notes |
@@ -349,21 +347,6 @@ Note that LocationScouts with a non-zero `create_as_hint` value will _always_ cr
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. | | locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. | | create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
### CreateHints
Sent to the server to create hints for a specified list of locations.
Hints that already exist will be silently skipped and their status will not be updated.
When creating hints for another slot's locations, the packet will fail if any of those locations don't contain items for the requesting slot.
When creating hints for your own slot's locations, non-existing locations will silently be skipped.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| locations | list\[int\] | The ids of the locations to create hints for. |
| player | int | The ID of the player whose locations are being hinted for. Defaults to the requesting slot. |
| status | [HintStatus](#HintStatus) | If included, sets the status of the hint to this status. Defaults to `HINT_UNSPECIFIED`. Cannot set `HINT_FOUND`. |
### UpdateHint ### UpdateHint
Sent to the server to update the status of a Hint. The client must be the 'receiving_player' of the Hint, or the update fails. Sent to the server to update the status of a Hint. The client must be the 'receiving_player' of the Hint, or the update fails.
@@ -568,14 +551,14 @@ In JSON this may look like:
Message nodes sent along with [PrintJSON](#PrintJSON) packet to be reconstructed into a legible message. The nodes are intended to be read in the order they are listed in the packet. Message nodes sent along with [PrintJSON](#PrintJSON) packet to be reconstructed into a legible message. The nodes are intended to be read in the order they are listed in the packet.
```python ```python
from typing import TypedDict from typing import TypedDict, Optional
class JSONMessagePart(TypedDict): class JSONMessagePart(TypedDict):
type: str | None type: Optional[str]
text: str | None text: Optional[str]
color: str | None # only available if type is a color color: Optional[str] # only available if type is a color
flags: int | None # only available if type is an item_id or item_name flags: Optional[int] # only available if type is an item_id or item_name
player: int | None # only available if type is either item or location player: Optional[int] # only available if type is either item or location
hint_status: HintStatus | None # only available if type is hint_status hint_status: Optional[HintStatus] # only available if type is hint_status
``` ```
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently. `type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.

View File

@@ -333,7 +333,7 @@ within the world.
### TextChoice ### TextChoice
Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any
user defined string as a valid option, so will either need to be validated by adding a validation step to the option user defined string as a valid option, so will either need to be validated by adding a validation step to the option
class or within world, if necessary. Value for this class is `str | int` so if you need the value at a specified class or within world, if necessary. Value for this class is `Union[str, int]` so if you need the value at a specified
point, `self.options.my_option.current_key` will always return a string. point, `self.options.my_option.current_key` will always return a string.
### PlandoBosses ### PlandoBosses
@@ -344,7 +344,7 @@ names, and `def can_place_boss`, which passes a boss and location, allowing you
your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is
also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False
by default, and will reject duplicate boss names from the user. For an example of using this class, refer to by default, and will reject duplicate boss names from the user. For an example of using this class, refer to
`worlds/alttp/Options.py` `worlds.alttp.options.py`
### OptionDict ### OptionDict
This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the

View File

@@ -102,16 +102,17 @@ In worlds, this should only be used for the top level to avoid issues when upgra
### Bool ### Bool
Since `bool` can not be subclassed, use the `settings.Bool` helper in a union to get a comment in host.yaml. Since `bool` can not be subclassed, use the `settings.Bool` helper in a `typing.Union` to get a comment in host.yaml.
```python ```python
import settings import settings
import typing
class MySettings(settings.Group): class MySettings(settings.Group):
class MyBool(settings.Bool): class MyBool(settings.Bool):
"""Doc string""" """Doc string"""
my_value: MyBool | bool = True my_value: typing.Union[MyBool, bool] = True
``` ```
### UserFilePath ### UserFilePath
@@ -133,15 +134,15 @@ Checks the file against [md5s](#md5s) by default.
Resolves to an executable (varying file extension based on platform) Resolves to an executable (varying file extension based on platform)
#### description: str | None #### description: Optional\[str\]
Human-readable name to use in file browser Human-readable name to use in file browser
#### copy_to: str | None #### copy_to: Optional\[str\]
Instead of storing the path, copy the file. Instead of storing the path, copy the file.
#### md5s: list[str | bytes] #### md5s: List[Union[str, bytes]]
Provide md5 hashes as hex digests or raw bytes for automatic validation. Provide md5 hashes as hex digests or raw bytes for automatic validation.
@@ -181,3 +182,10 @@ circular / partial imports. Instead, the code should fetch from settings on dema
"Global" settings are populated immediately, while worlds settings are lazy loaded, so if really necessary, "Global" settings are populated immediately, while worlds settings are lazy loaded, so if really necessary,
"global" settings could be used in global scope of worlds. "global" settings could be used in global scope of worlds.
### APWorld Backwards Compatibility
APWorlds that want to be compatible with both stable and dev versions, have two options:
1. use the old Utils.get_options() API until Archipelago 0.4.2 is out
2. add some sort of compatibility code to your world that mimics the new API

View File

@@ -29,10 +29,6 @@
* New classes, attributes, and methods in core code should have docstrings that follow * New classes, attributes, and methods in core code should have docstrings that follow
[reST style](https://peps.python.org/pep-0287/). [reST style](https://peps.python.org/pep-0287/).
* Worlds that do not follow PEP8 should still have a consistent style across its files to make reading easier. * Worlds that do not follow PEP8 should still have a consistent style across its files to make reading easier.
* [Match statements](https://docs.python.org/3/tutorial/controlflow.html#tut-match)
may be used instead of `if`-`elif` if they result in nicer code, or they actually use pattern matching.
Beware of the performance: they are not `goto`s, but `if`-`elif` under the hood, and you may have less control. When
in doubt, just don't use it.
## Markdown ## Markdown

View File

@@ -11,13 +11,8 @@ found in the [general test directory](/test/general).
## Defining World Tests ## Defining World Tests
In order to run tests from your world, you will need to create a `test` package within your world package. This can be In order to run tests from your world, you will need to create a `test` package within your world package. This can be
done by creating a `test` directory inside your world with an (empty) `__init__.py` inside it. By convention, a base done by creating a `test` directory with a file named `__init__.py` inside it inside your world. By convention, a base
for your world tests can be created in `bases.py` or any file that does not start with `test`, that you can then import for your world tests can be created in this file that you can then import into other modules.
into other modules. All tests should be defined in files named `test_*.py` (all lower case) and be member functions
(named `test_*`) of classes (named `Test*` or `*Test`) that inherit from `unittest.TestCase` or a test base.
Defining anything inside `test/__init__.py` is deprecated. Defining TestBase there was previously the norm; however,
it complicates test discovery because some worlds also put actual tests into `__init__.py`.
### WorldTestBase ### WorldTestBase
@@ -26,7 +21,7 @@ interactions in the world interact as expected, you will want to use the [WorldT
comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying
options combinations. options combinations.
Example `/worlds/<my_game>/test/bases.py`: Example `/worlds/<my_game>/test/__init__.py`:
```python ```python
from test.bases import WorldTestBase from test.bases import WorldTestBase
@@ -54,7 +49,7 @@ with `test_`.
Example `/worlds/<my_game>/test/test_chest_access.py`: Example `/worlds/<my_game>/test/test_chest_access.py`:
```python ```python
from .bases import MyGameTestBase from . import MyGameTestBase
class TestChestAccess(MyGameTestBase): class TestChestAccess(MyGameTestBase):
@@ -124,12 +119,8 @@ variable to keep all the benefits of the test framework while not running the ma
#### Using Pycharm #### Using Pycharm
In PyCharm, running all tests can be done by right-clicking the root test directory and selecting Run 'Archipelago Unittests'. In PyCharm, running all tests can be done by right-clicking the root test directory and selecting Run 'Archipelago Unittests'.
If you have never previously run ModuleUpdate.py, then you will need to do this once before the tests will run. Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this, edit the run configuration,
You can run ModuleUpdate.py by right-clicking ModuleUpdate.py and selecting `Run 'ModuleUpdate'`. and set the working directory to the Archipelago directory which contains all the project files.
After running ModuleUpdate.py you may still get a `ModuleNotFoundError: No module named 'flask'` for the webhost tests.
If this happens, run WebHost.py by right-clicking it and selecting `Run 'WebHost'`. Make sure to press enter when prompted.
Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this,
edit the run configuration, and set the working directory to the Archipelago directory which contains all the project files.
If you only want to run your world's defined tests, repeat the steps for the test directory within your world. If you only want to run your world's defined tests, repeat the steps for the test directory within your world.
Your working directory should be the directory of your world in the worlds directory and the script should be the Your working directory should be the directory of your world in the worlds directory and the script should be the

View File

@@ -1,347 +0,0 @@
# API Guide
Archipelago has a rudimentary API that can be queried by endpoints. The API is a work-in-progress and should be improved over time.
The following API requests are formatted as: `https://<Archipelago URL>/api/<endpoint>`
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
- [`/datapackage`](#datapackage)
- [`/datapackage/<string:checksum>`](#datapackagestringchecksum)
- [`/datapackage_checksum`](#datapackagechecksum)
- Generation API
- [`/generate`](#generate)
- [`/status/<suuid:seed>`](#status)
- Room API
- [`/room_status/<suuid:room_id>`](#roomstatus)
- User API
- [`/get_rooms`](#getrooms)
- [`/get_seeds`](#getseeds)
## 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.
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`
- A dict of item groups `item_name_groups`
- Item name to AP ID dict `item_name_to_id`
- A dict of location groups `location_name_groups`
- Location name to AP ID dict `location_name_to_id`
Example:
```
{
"games": {
...
"Clique": {
"checksum": "0271f7a80b44ba72187f92815c2bc8669cb464c7",
"item_name_groups": {
"Everything": [
"A Cool Filler Item (No Satisfaction Guaranteed)",
"Button Activation",
"Feeling of Satisfaction"
]
},
"item_name_to_id": {
"A Cool Filler Item (No Satisfaction Guaranteed)": 69696967,
"Button Activation": 69696968,
"Feeling of Satisfaction": 69696969
},
"location_name_groups": {
"Everywhere": [
"The Big Red Button",
"The Item on the Desk"
]
},
"location_name_to_id": {
"The Big Red Button": 69696969,
"The Item on the Desk": 69696968
}
},
...
}
}
```
### `/datapackage/<string:checksum>`
<a name="datapackagestringchecksum"></a>
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`
- Item name to AP ID dict `item_name_to_id`
- A dict of location groups `location_name_groups`
- Location name to AP ID dict `location_name_to_id`
Its format will be identical to the whole-datapackage endpoint (`/datapackage`), except you'll only be returned the single game's data in a dict.
### `/datapackage_checksum`
<a name="datapackagechecksum"></a>
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:
```
{
...
"Donkey Kong Country 3":"f90acedcd958213f483a6a4c238e2a3faf92165e",
"Factorio":"a699194a9589db3ebc0d821915864b422c782f44",
...
}
```
## Generation Endpoint
These endpoints are used internally for the WebHost to generate games and validate their generation. They are also used by external applications to generate games automatically.
### `/generate`
<a name="generate"></a>
Submits a game to the WebHost for generation.
**This endpoint only accepts a POST HTTP request.**
There are two ways to submit data for generation: With a file and with JSON.
#### With a file:
Have your ZIP of yaml(s) or a single yaml, and submit a POST request to the `/generate` endpoint.
If the options are valid, you'll be returned a successful generation response. (see [Generation Response](#generation-response))
Example using the python requests library:
```
file = {'file': open('Games.zip', 'rb')}
req = requests.post("https://archipelago.gg/api/generate", files=file)
```
#### With JSON:
Compile your weights/yaml data into a dict. Then insert that into a dict with the key `"weights"`.
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:
```
data = {"Test":{"game": "Factorio","name": "Test","Factorio": {}},}
weights={"weights": data}
req = requests.post("https://archipelago.gg/api/generate", json=weights)
```
#### Generation Response:
##### Successful Generation:
Upon successful generation, you'll be sent a JSON dict response detailing the generation:
- The UUID of the generation `detail`
- The SUUID of the generation `encoded`
- The response text `text`
- The page that will resolve to the seed/room generation page once generation has completed `url`
- The API status page of the generation `wait_api_url` (see [Status Endpoint](#status))
Example:
```
{
"detail": "19878f16-5a58-4b76-aab7-d6bf38be9463",
"encoded": "GYePFlpYS3aqt9a_OL6UYw",
"text": "Generation of seed 19878f16-5a58-4b76-aab7-d6bf38be9463 started successfully.",
"url": "http://archipelago.gg/wait/GYePFlpYS3aqt9a_OL6UYw",
"wait_api_url": "http://archipelago.gg/api/status/GYePFlpYS3aqt9a_OL6UYw"
}
```
##### Failed Generation:
Upon failed generation, you'll be returned a single key-value pair. The key will always be `text`
The value will give you a hint as to what may have gone wrong.
- Options without tags, and a 400 status code
- Options in a string, and a 400 status code
- Invalid file/weight string, `No options found. Expected file attachment or json weights.` with a 400 status code
- Too many slots for the server to process, `Max size of multiworld exceeded` with a 409 status code
If the generation detects a issue in generation, you'll be sent a dict with two key-value pairs (`text` and `detail`) and a 400 status code. The values will be:
- Summary of issue in `text`
- Detailed issue in `detail`
In the event of an unhandled server exception, you'll be provided a dict with a single key `text`:
- Exception, `Uncought Exception: <error>` with a 500 status code
### `/status/<suuid:seed>`
<a name="status"></a>
Retrieves the status of the seed's generation.
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
- Generation of the seed failed: `Generation failed` with a 500 status code
- Generation is in progress still: `Generation running` with a 202 status code
## Room Endpoints
Endpoints to fetch information of the active WebHost room with the supplied room_ID.
### `/room_status/<suuid:room_id>`
<a name="roomstatus"></a>
Will provide a dict of room data with the following keys:
- Tracker SUUID (`tracker`)
- A list of players (`players`)
- Each item containing a list with the Slot name and Game
- Last known hosted port (`last_port`)
- 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 containings the download URL and slot (`slot`, `download`)
Example:
```
{
"downloads": [
{
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/1",
"slot": 1
},
{
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/2",
"slot": 2
},
{
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/3",
"slot": 3
},
{
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/4",
"slot": 4
},
{
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/5",
"slot": 5
}
],
"last_activity": "Fri, 18 Apr 2025 20:35:45 GMT",
"last_port": 52122,
"players": [
[
"Slot_Name_1",
"Ocarina of Time"
],
[
"Slot_Name_2",
"Ocarina of Time"
],
[
"Slot_Name_3",
"Ocarina of Time"
],
[
"Slot_Name_4",
"Ocarina of Time"
],
[
"Slot_Name_5",
"Ocarina of Time"
]
],
"timeout": 7200,
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171"
}
```
## User Endpoints
User endpoints can get room and seed details from the current session tokens (cookies)
### `/get_rooms`
<a name="getrooms"></a>
Retreives a list of all rooms currently owned by the session token.
Each list item will contain a dict with the room's details:
- Room SUUID (`room_id`)
- Seed SUUID (`seed_id`)
- Creation timestamp (`creation_time`)
- Last activity timestamp (`last_activity`)
- Last known AP port (`last_port`)
- Room timeout counter in seconds (`timeout`)
- Room tracker SUUID (`tracker`)
Example:
```
[
{
"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": "90ae5f9b-177c-4df8-ac53-9629fc3bff7a",
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6",
"timeout": 7200,
"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": "14465c05-d08e-4d28-96bd-916f994609d8",
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb",
"timeout": 7200,
"tracker": "4e624bd8-32b6-42e4-9178-aa407f72751c"
}
]
```
### `/get_seeds`
<a name="getseeds"></a>
Retreives a list of all seeds currently owned by the session token.
Each item in the list will contain a dict with the seed's details:
- Seed SUUID (`seed_id`)
- Creation timestamp (`creation_time`)
- A list of player slots (`players`)
- Each item in the list will contain a list of the slot name and game
Example:
```
[
{
"creation_time": "Fri, 18 Apr 2025 19:46:52 GMT",
"players": [
[
"Slot_Name_1",
"Ocarina of Time"
],
[
"Slot_Name_2",
"Ocarina of Time"
],
[
"Slot_Name_3",
"Ocarina of Time"
],
[
"Slot_Name_4",
"Ocarina of Time"
],
[
"Slot_Name_5",
"Ocarina of Time"
]
],
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6"
},
{
"creation_time": "Fri, 18 Apr 2025 20:36:39 GMT",
"players": [
[
"Slot_Name_1",
"Clique"
],
[
"Slot_Name_2",
"Clique"
],
[
"Slot_Name_3",
"Clique"
],
[
"Slot_Name_4",
"Archipelago"
]
],
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
}
]
```

View File

@@ -258,6 +258,31 @@ another flag like "progression", it means "an especially useful progression item
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that * `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres
### Events
An Event is a special combination of a Location and an Item, with both having an `id` of `None`. These can be used to
track certain logic interactions, with the Event Item being required for access in other locations or regions, but not
being "real". Since the item and location have no ID, they get dropped at the end of generation and so the server is
never made aware of them and these locations can never be checked, nor can the items be received during play.
They may also be used for making the spoiler log look nicer, i.e. by having a `"Victory"` Event Item, that
is required to finish the game. This makes it very clear when the player finishes, rather than only seeing their last
relevant Item. Events function just like any other Location, and can still have their own access rules, etc.
By convention, the Event "pair" of Location and Item typically have the same name, though this is not a requirement.
They must not exist in the `name_to_id` lookups, as they have no ID.
The most common way to create an Event pair is to create and place the Item on the Location as soon as it's created:
```python
from worlds.AutoWorld import World
from BaseClasses import ItemClassification
from .subclasses import MyGameLocation, MyGameItem
class MyGameWorld(World):
victory_loc = MyGameLocation(self.player, "Victory", None)
victory_loc.place_locked_item(MyGameItem("Victory", ItemClassification.progression, None, self.player))
```
### Regions ### Regions
Regions are logical containers that typically hold locations that share some common access rules. If location logic is Regions are logical containers that typically hold locations that share some common access rules. If location logic is
@@ -266,7 +291,7 @@ like entrance randomization in logic.
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions. Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L310-L311)), There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)),
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit"). from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
### Entrances ### Entrances
@@ -314,63 +339,6 @@ avoiding the need for indirect conditions at the expense of performance.
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
reject the placement of an item there. reject the placement of an item there.
### Events (or "generation-only items/locations")
An event item or location is one that only exists during multiworld generation; the server is never made aware of them.
Event locations can never be checked by the player, and event items cannot be received during play.
Events are used to represent in-game actions (that aren't regular Archipelago locations) when either:
* We want to show in the spoiler log when the player is expected to perform the in-game action.
* It's the cleanest way to represent how that in-game action impacts logic.
Typical examples include completing the goal, defeating a boss, or flipping a switch that affects multiple areas.
To be precise: the term "event" on its own refers to the special combination of an "event item" placed on an "event
location". Event items and locations are created the same way as normal items and locations, except that they have an
`id` of `None`, and an event item must be placed on an event location
(and vice versa). Finally, although events are often described as "fake" items and locations, it's important to
understand that they are perfectly real during generation.
The most common way to create an event is to create the event item and the event location, then immediately call
`Location.place_locked_item()`:
```python
victory_loc = MyGameLocation(self.player, "Defeat the Final Boss", None, final_boss_arena_region)
victory_loc.place_locked_item(MyGameItem("Victory", ItemClassification.progression, None, self.player))
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
set_rule(victory_loc, lambda state: state.has("Boss Defeating Sword", self.player))
```
Requiring an event to finish the game will make the spoiler log display an additional
`Defeat the Final Boss: Victory` line when the player is expected to finish, rather than only showing their last
relevant item. But events aren't just about the spoiler log; a more substantial example of using events to structure
your logic might be:
```python
water_loc = MyGameLocation(self.player, "Water Level Switch", None, pump_station_region)
water_loc.place_locked_item(MyGameItem("Lowered Water Level", ItemClassification.progression, None, self.player))
pump_station_region.locations.append(water_loc)
set_rule(water_loc, lambda state: state.has("Double Jump", self.player)) # the switch is really high up
...
basement_loc = MyGameLocation(self.player, "Flooded House - Basement Chest", None, flooded_house_region)
flooded_house_region.locations += [upstairs_loc, ground_floor_loc, basement_loc]
...
set_rule(basement_loc, lambda state: state.has("Lowered Water Level", self.player))
```
This creates a "Lowered Water Level" event and a regular location whose access rule depends on that
event being reachable. If you made several more locations the same way, this would ensure all of those locations can
only become reachable when the event location is reachable (i.e. when the water level can be lowered), without
copy-pasting the event location's access rule and then repeatedly re-evaluating it. Also, the spoiler log will show
`Water Level Switch: Lowered Water Level` when the player is expected to do this.
To be clear, this example could also be modeled with a second Region (perhaps "Un-Flooded House"). Or you could modify
the game so flipping that switch checks a regular AP location in addition to lowering the water level.
Events are never required, but it may be cleaner to use an event if e.g. flipping that switch affects the logic in
dozens of half-flooded areas that would all otherwise need additional Regions, and you don't want it to be a regular
location. It depends on the game.
## Implementation ## Implementation
### Your World ### Your World
@@ -515,14 +483,13 @@ In addition, the following methods can be implemented and are called in this ord
called per player before any items or locations are created. You can set properties on your called per player before any items or locations are created. You can set properties on your
world here. Already has access to player options and RNG. This is the earliest step where the world should start world here. Already has access to player options and RNG. This is the earliest step where the world should start
setting up for the current multiworld, as the multiworld itself is still setting up before this point. setting up for the current multiworld, as the multiworld itself is still setting up before this point.
You cannot modify `local_items`, or `non_local_items` after this step.
* `create_regions(self)` * `create_regions(self)`
called to place player's regions and their locations into the MultiWorld's regions list. called to place player's regions and their locations into the MultiWorld's regions list.
If it's hard to separate, this can be done during `generate_early` or `create_items` as well. If it's hard to separate, this can be done during `generate_early` or `create_items` as well.
* `create_items(self)` * `create_items(self)`
called to place player's items into the MultiWorld's itempool. By the end of this step all regions, locations and called to place player's items into the MultiWorld's itempool. By the end of this step all regions, locations and
items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions after items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions
this step. Locations cannot be moved to different regions after this step. This includes event items and locations. after this step. Locations cannot be moved to different regions after this step.
* `set_rules(self)` * `set_rules(self)`
called to set access and item rules on locations and entrances. called to set access and item rules on locations and entrances.
* `connect_entrances(self)` * `connect_entrances(self)`
@@ -534,12 +501,12 @@ In addition, the following methods can be implemented and are called in this ord
called to modify item placement before, during, and after the regular fill process; all finishing before called to modify item placement before, during, and after the regular fill process; all finishing before
`generate_output`. Any items that need to be placed during `pre_fill` should not exist in the itempool, and if there `generate_output`. Any items that need to be placed during `pre_fill` should not exist in the itempool, and if there
are any items that need to be filled this way, but need to be in state while you fill other items, they can be are any items that need to be filled this way, but need to be in state while you fill other items, they can be
returned from `get_pre_fill_items`. returned from `get_prefill_items`.
* `generate_output(self, output_directory: str)` * `generate_output(self, output_directory: str)`
creates the output files if there is output to be generated. When this is called, creates the output files if there is output to be generated. When this is called,
`self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the `self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the
item. `location.item.player` can be used to see if it's a local item. item. `location.item.player` can be used to see if it's a local item.
* `fill_slot_data(self)` and `modify_multidata(self, multidata: MultiData)` can be used to modify the data that * `fill_slot_data(self)` and `modify_multidata(self, multidata: Dict[str, Any])` can be used to modify the data that
will be used by the server to host the MultiWorld. will be used by the server to host the MultiWorld.
All instance methods can, optionally, have a class method defined which will be called after all instance methods are All instance methods can, optionally, have a class method defined which will be called after all instance methods are
@@ -612,10 +579,17 @@ def create_items(self) -> None:
# If there are two of the same item, the item has to be twice in the pool. # If there are two of the same item, the item has to be twice in the pool.
# Which items are added to the pool may depend on player options, e.g. custom win condition like triforce hunt. # Which items are added to the pool may depend on player options, e.g. custom win condition like triforce hunt.
# Having an item in the start inventory won't remove it from the pool. # Having an item in the start inventory won't remove it from the pool.
# If you want to do that, use start_inventory_from_pool # If an item can't have duplicates it has to be excluded manually.
# List of items to exclude, as a copy since it will be destroyed below
exclude = [item for item in self.multiworld.precollected_items[self.player]]
for item in map(self.create_item, mygame_items): for item in map(self.create_item, mygame_items):
self.multiworld.itempool.append(item) if item in exclude:
exclude.remove(item) # this is destructive. create unique list above
self.multiworld.itempool.append(self.create_item("nothing"))
else:
self.multiworld.itempool.append(item)
# itempool and number of locations should match up. # itempool and number of locations should match up.
# If this is not the case we want to fill the itempool with junk. # If this is not the case we want to fill the itempool with junk.

View File

@@ -65,5 +65,5 @@ date, voting members and final result in the commit message.
## Handling of Unmaintained Worlds ## Handling of Unmaintained Worlds
As long as worlds are known to work for the most part, they can stay included. Once the world becomes broken, it shall As long as worlds are known to work for the most part, they can stay included. Once a world becomes broken it shall be
be deleted. moved from `worlds/` to `worlds_disabled/`.

View File

@@ -52,15 +52,13 @@ class EntranceLookup:
_coupled: bool _coupled: bool
_usable_exits: set[Entrance] _usable_exits: set[Entrance]
def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance], targets: Iterable[Entrance]): def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance]):
self.dead_ends = EntranceLookup.GroupLookup() self.dead_ends = EntranceLookup.GroupLookup()
self.others = EntranceLookup.GroupLookup() self.others = EntranceLookup.GroupLookup()
self._random = rng self._random = rng
self._expands_graph_cache = {} self._expands_graph_cache = {}
self._coupled = coupled self._coupled = coupled
self._usable_exits = usable_exits self._usable_exits = usable_exits
for target in targets:
self.add(target)
def _can_expand_graph(self, entrance: Entrance) -> bool: def _can_expand_graph(self, entrance: Entrance) -> bool:
""" """
@@ -123,14 +121,7 @@ class EntranceLookup:
dead_end: bool, dead_end: bool,
preserve_group_order: bool preserve_group_order: bool
) -> Iterable[Entrance]: ) -> Iterable[Entrance]:
"""
Gets available targets for the requested groups
:param groups: The groups to find targets for
:param dead_end: Whether to find dead ends. If false, finds non-dead-ends
:param preserve_group_order: Whether to preserve the group order in the returned iterable. If true, a sequence
like AAABBB is guaranteed. If false, groups can be interleaved, e.g. BAABAB.
"""
lookup = self.dead_ends if dead_end else self.others lookup = self.dead_ends if dead_end else self.others
if preserve_group_order: if preserve_group_order:
for group in groups: for group in groups:
@@ -141,27 +132,6 @@ class EntranceLookup:
self._random.shuffle(ret) self._random.shuffle(ret)
return ret return ret
def find_target(self, name: str, group: int | None = None, dead_end: bool | None = None) -> Entrance | None:
"""
Finds a specific target in the lookup, if it is present.
:param name: The name of the target
:param group: The target's group. Providing this will make the lookup faster, but can be omitted if it is not
known ahead of time for some reason.
:param dead_end: Whether the target is a dead end. Providing this will make the lookup faster, but can be
omitted if this is not known ahead of time (much more likely)
"""
if dead_end is None:
return (found
if (found := self.find_target(name, group, True))
else self.find_target(name, group, False))
lookup = self.dead_ends if dead_end else self.others
targets_to_check = lookup if group is None else lookup[group]
for target in targets_to_check:
if target.name == name:
return target
return None
def __len__(self): def __len__(self):
return len(self.dead_ends) + len(self.others) return len(self.dead_ends) + len(self.others)
@@ -176,18 +146,15 @@ class ERPlacementState:
"""The world which is having its entrances randomized""" """The world which is having its entrances randomized"""
collection_state: CollectionState collection_state: CollectionState
"""The CollectionState backing the entrance randomization logic""" """The CollectionState backing the entrance randomization logic"""
entrance_lookup: EntranceLookup
"""A lookup table of all unconnected ER targets"""
coupled: bool coupled: bool
"""Whether entrance randomization is operating in coupled mode""" """Whether entrance randomization is operating in coupled mode"""
def __init__(self, world: World, entrance_lookup: EntranceLookup, coupled: bool): def __init__(self, world: World, coupled: bool):
self.placements = [] self.placements = []
self.pairings = [] self.pairings = []
self.world = world self.world = world
self.coupled = coupled self.coupled = coupled
self.collection_state = world.multiworld.get_all_state(False, True) self.collection_state = world.multiworld.get_all_state(False, True)
self.entrance_lookup = entrance_lookup
@property @property
def placed_regions(self) -> set[Region]: def placed_regions(self) -> set[Region]:
@@ -215,7 +182,6 @@ class ERPlacementState:
self.collection_state.stale[self.world.player] = True self.collection_state.stale[self.world.player] = True
self.placements.append(source_exit) self.placements.append(source_exit)
self.pairings.append((source_exit.name, target_entrance.name)) self.pairings.append((source_exit.name, target_entrance.name))
self.entrance_lookup.remove(target_entrance)
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance, def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
usable_exits: set[Entrance]) -> bool: usable_exits: set[Entrance]) -> bool:
@@ -345,7 +311,7 @@ def randomize_entrances(
preserve_group_order: bool = False, preserve_group_order: bool = False,
er_targets: list[Entrance] | None = None, er_targets: list[Entrance] | None = None,
exits: list[Entrance] | None = None, exits: list[Entrance] | None = None,
on_connect: Callable[[ERPlacementState, list[Entrance], list[Entrance]], bool | None] | None = None on_connect: Callable[[ERPlacementState, list[Entrance]], None] | None = None
) -> ERPlacementState: ) -> ERPlacementState:
""" """
Randomizes Entrances for a single world in the multiworld. Randomizes Entrances for a single world in the multiworld.
@@ -362,18 +328,14 @@ def randomize_entrances(
:param exits: The list of exits (Entrance objects with no target region) to use for randomization. :param exits: The list of exits (Entrance objects with no target region) to use for randomization.
Remember to be deterministic! If not provided, automatically discovers all valid exits in your world. Remember to be deterministic! If not provided, automatically discovers all valid exits in your world.
:param on_connect: A callback function which allows specifying side effects after a placement is completed :param on_connect: A callback function which allows specifying side effects after a placement is completed
successfully and the underlying collection state has been updated. The arguments are successfully and the underlying collection state has been updated.
1. The ER state
2. The exits placed in this placement pass
3. The entrances they were connected to.
If you use on_connect to make additional placements, you are expected to return True to inform
GER that an additional sweep is needed.
""" """
if not world.explicit_indirect_conditions: if not world.explicit_indirect_conditions:
raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order " raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order "
+ "to correctly analyze whether dead end regions can be required in logic.") + "to correctly analyze whether dead end regions can be required in logic.")
start_time = time.perf_counter() start_time = time.perf_counter()
er_state = ERPlacementState(world, coupled)
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility # similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
perform_validity_check = True perform_validity_check = True
@@ -389,25 +351,23 @@ def randomize_entrances(
# used when membership checks are needed on the exit list, e.g. speculative sweep # used when membership checks are needed on the exit list, e.g. speculative sweep
exits_set = set(exits) exits_set = set(exits)
entrance_lookup = EntranceLookup(world.random, coupled, exits_set)
for entrance in er_targets:
entrance_lookup.add(entrance)
er_state = ERPlacementState(
world,
EntranceLookup(world.random, coupled, exits_set, er_targets),
coupled
)
# place the menu region and connected start region(s) # place the menu region and connected start region(s)
er_state.collection_state.update_reachable_regions(world.player) er_state.collection_state.update_reachable_regions(world.player)
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None: def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
placed_exits, paired_entrances = er_state.connect(source_exit, target_entrance) placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
# remove the placed targets from consideration
for entrance in removed_entrances:
entrance_lookup.remove(entrance)
# propagate new connections # propagate new connections
er_state.collection_state.update_reachable_regions(world.player) er_state.collection_state.update_reachable_regions(world.player)
er_state.collection_state.sweep_for_advancements() er_state.collection_state.sweep_for_advancements()
if on_connect: if on_connect:
change = on_connect(er_state, placed_exits, paired_entrances) on_connect(er_state, placed_exits)
if change:
er_state.collection_state.update_reachable_regions(world.player)
er_state.collection_state.sweep_for_advancements()
def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool: def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool:
# speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph # speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph
@@ -428,12 +388,12 @@ def randomize_entrances(
# check to see if we are proposing the last placement # check to see if we are proposing the last placement
if not coupled: if not coupled:
# in uncoupled, this check is easy as there will only be one target. # in uncoupled, this check is easy as there will only be one target.
is_last_placement = len(er_state.entrance_lookup) == 1 is_last_placement = len(entrance_lookup) == 1
else: else:
# a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way. # a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way.
# if it is two way, we can safely assume that one of the targets is the logical pair of the exit. # if it is two way, we can safely assume that one of the targets is the logical pair of the exit.
desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1 desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1
is_last_placement = len(er_state.entrance_lookup) == desired_target_count is_last_placement = len(entrance_lookup) == desired_target_count
# if it's not the last placement, we need a sweep # if it's not the last placement, we need a sweep
return not is_last_placement return not is_last_placement
@@ -442,7 +402,7 @@ def randomize_entrances(
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits) placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
for source_exit in placeable_exits: for source_exit in placeable_exits:
target_groups = target_group_lookup[source_exit.randomization_group] target_groups = target_group_lookup[source_exit.randomization_group]
for target_entrance in er_state.entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
# when requiring new exits, ideally we would like to make it so that every placement increases # when requiring new exits, ideally we would like to make it so that every placement increases
# (or keeps the same number of) reachable exits. The goal is to continue to expand the search space # (or keeps the same number of) reachable exits. The goal is to continue to expand the search space
# so that we do not crash. In the interest of performance and bias reduction, generally, just checking # so that we do not crash. In the interest of performance and bias reduction, generally, just checking
@@ -460,7 +420,7 @@ def randomize_entrances(
else: else:
# no source exits had any valid target so this stage is deadlocked. retries may be implemented if early # no source exits had any valid target so this stage is deadlocked. retries may be implemented if early
# deadlocking is a frequent issue. # deadlocking is a frequent issue.
lookup = er_state.entrance_lookup.dead_ends if dead_end else er_state.entrance_lookup.others lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others
# if we're in a stage where we're trying to get to new regions, we could also enter this # if we're in a stage where we're trying to get to new regions, we could also enter this
# branch in a success state (when all regions of the preferred type have been placed, but there are still # branch in a success state (when all regions of the preferred type have been placed, but there are still
@@ -506,21 +466,21 @@ def randomize_entrances(
f"All unplaced exits: {unplaced_exits}") f"All unplaced exits: {unplaced_exits}")
# stage 1 - try to place all the non-dead-end entrances # stage 1 - try to place all the non-dead-end entrances
while er_state.entrance_lookup.others: while entrance_lookup.others:
if not find_pairing(dead_end=False, require_new_exits=True): if not find_pairing(dead_end=False, require_new_exits=True):
break break
# stage 2 - try to place all the dead-end entrances # stage 2 - try to place all the dead-end entrances
while er_state.entrance_lookup.dead_ends: while entrance_lookup.dead_ends:
if not find_pairing(dead_end=True, require_new_exits=True): if not find_pairing(dead_end=True, require_new_exits=True):
break break
# stage 3 - all the regions should be placed at this point. We now need to connect dangling edges # stage 3 - all the regions should be placed at this point. We now need to connect dangling edges
# stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions) # stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions)
# doing this before the non-dead-ends is important to ensure there are enough connections to # doing this before the non-dead-ends is important to ensure there are enough connections to
# go around # go around
while er_state.entrance_lookup.dead_ends: while entrance_lookup.dead_ends:
find_pairing(dead_end=True, require_new_exits=False) find_pairing(dead_end=True, require_new_exits=False)
# stage 3b - tie all the other loose ends connecting visited regions to each other # stage 3b - tie all the other loose ends connecting visited regions to each other
while er_state.entrance_lookup.others: while entrance_lookup.others:
find_pairing(dead_end=False, require_new_exits=False) find_pairing(dead_end=False, require_new_exits=False)
running_time = time.perf_counter() - start_time running_time = time.perf_counter() - start_time

View File

@@ -53,6 +53,10 @@ Name: "full"; Description: "Full installation"
Name: "minimal"; Description: "Minimal installation" Name: "minimal"; Description: "Minimal installation"
Name: "custom"; Description: "Custom installation"; Flags: iscustom Name: "custom"; Description: "Custom installation"; Flags: iscustom
[Components]
Name: "core"; Description: "Archipelago"; Types: full minimal custom; Flags: fixed
Name: "lttp_sprites"; Description: "Download ""A Link to the Past"" player sprites"; Types: full;
[Dirs] [Dirs]
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify; NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
@@ -72,6 +76,7 @@ Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLaunc
[Run] [Run]
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..." Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: lttp_sprites
Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden
Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent
@@ -81,7 +86,6 @@ Type: dirifempty; Name: "{app}"
[InstallDelete] [InstallDelete]
Type: files; Name: "{app}\*.exe" Type: files; Name: "{app}\*.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua" Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: files; Name: "{app}\data\lua\connector_ff1.lua"
Type: filesandordirs; Name: "{app}\SNI\lua*" Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*" Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss" #include "installdelete.iss"
@@ -133,6 +137,11 @@ Root: HKCR; Subkey: "{#MyAppName}kdl3patch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}kdl3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}kdl3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata\shell\open\command"; ValueData: """{app}\ArchipelagoMinecraftClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: "";

222
kvui.py
View File

@@ -6,6 +6,7 @@ import re
import io import io
import pkgutil import pkgutil
from collections import deque from collections import deque
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility" assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
if sys.platform == "win32": if sys.platform == "win32":
@@ -56,14 +57,10 @@ from kivy.animation import Animation
from kivy.uix.popup import Popup from kivy.uix.popup import Popup
from kivy.uix.image import AsyncImage from kivy.uix.image import AsyncImage
from kivymd.app import MDApp from kivymd.app import MDApp
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogSupportingText, MDDialogButtonContainer
from kivymd.uix.gridlayout import MDGridLayout from kivymd.uix.gridlayout import MDGridLayout
from kivymd.uix.floatlayout import MDFloatLayout from kivymd.uix.floatlayout import MDFloatLayout
from kivymd.uix.boxlayout import MDBoxLayout from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.navigationbar import MDNavigationBar, MDNavigationItem from kivymd.uix.tab.tab import MDTabsSecondary, MDTabsItem, MDTabsItemText, MDTabsCarousel
from kivymd.uix.screen import MDScreen
from kivymd.uix.screenmanager import MDScreenManager
from kivymd.uix.menu import MDDropdownMenu from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.menu.menu import MDDropdownTextItem from kivymd.uix.menu.menu import MDDropdownTextItem
from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText
@@ -713,94 +710,72 @@ class CommandPromptTextInput(ResizableTextField):
self.text = self._command_history[self._command_history_index] self.text = self._command_history[self._command_history_index]
class MessageBoxLabel(MDLabel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._label.refresh()
class MessageBox(Popup): class MessageBox(Popup):
class MessageBoxLabel(MDLabel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._label.refresh()
def __init__(self, title, text, error=False, **kwargs): def __init__(self, title, text, error=False, **kwargs):
label = MessageBoxLabel(text=text) label = MessageBox.MessageBoxLabel(text=text)
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.] separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40), super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
separator_color=separator_color, **kwargs) separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18) self.height += max(0, label.height - 18)
class MDNavigationItemBase(MDNavigationItem): class ClientTabs(MDTabsSecondary):
text = StringProperty(None) carousel: MDTabsCarousel
lock_swiping = True
def __init__(self, *args, **kwargs):
self.carousel = MDTabsCarousel(lock_swiping=True, anim_move_duration=0.2)
super().__init__(*args, MDDivider(size_hint_y=None, height=dp(1)), self.carousel, **kwargs)
self.size_hint_y = 1
class ButtonsPrompt(MDDialog): def _check_panel_height(self, *args):
def __init__(self, title: str, text: str, response: typing.Callable[[str], None], self.ids.tab_scroll.height = dp(38)
*prompts: str, **kwargs) -> None:
"""
Customizable popup box that lets you create any number of buttons. The text of the pressed button is returned to
the callback.
:param title: The title of the popup. def update_indicator(
:param text: The message prompt in the popup. self, x: float = 0.0, w: float = 0.0, instance: MDTabsItem = None
:param response: A callable that will get called when the user presses a button. The prompt will not close ) -> None:
itself so should be done here if you want to close it when certain buttons are pressed. def update_indicator(*args):
:param prompts: Any number of strings to be used for the buttons. indicator_pos = (0, 0)
""" indicator_size = (0, 0)
layout = MDBoxLayout(orientation="vertical")
label = MessageBoxLabel(text=text)
layout.add_widget(label)
def on_release(button: MDButton, *args) -> None: item_text_object = self._get_tab_item_text_icon_object()
response(button.text)
buttons = [MDDivider()] if item_text_object:
for prompt in prompts: indicator_pos = (
button = MDButton( instance.x + dp(12),
MDButtonText(text=prompt, pos_hint={"center_x": 0.5, "center_y": 0.5}), self.indicator.pos[1]
on_release=on_release, if not self._tabs_carousel
style="text", else self._tabs_carousel.height,
theme_width="Custom", )
size_hint_x=1, indicator_size = (
) instance.width - dp(24),
button.text = prompt self.indicator_height,
buttons.extend([button, MDDivider()]) )
super().__init__( Animation(
MDDialogHeadlineText(text=title), pos=indicator_pos,
MDDialogSupportingText(text=text), size=indicator_size,
MDDialogButtonContainer(*buttons, orientation="vertical"), d=0 if not self.indicator_anim else self.indicator_duration,
**kwargs, t=self.indicator_transition,
) ).start(self.indicator)
if not instance:
class MDScreenManagerBase(MDScreenManager): self.indicator.pos = (x, self.indicator.pos[1])
current_tab: MDNavigationItemBase self.indicator.size = (w, self.indicator_height)
local_screen_names: list[str]
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.local_screen_names = []
def add_widget(self, widget: Widget, *args, **kwargs) -> None:
super().add_widget(widget, *args, **kwargs)
if "index" in kwargs:
self.local_screen_names.insert(kwargs["index"], widget.name)
else: else:
self.local_screen_names.append(widget.name) Clock.schedule_once(update_indicator)
def switch_screens(self, new_tab: MDNavigationItemBase) -> None: def remove_tab(self, tab, content=None):
""" if content is None:
Called whenever the user clicks a tab to switch to a different screen. content = tab.content
self.ids.container.remove_widget(tab)
:param new_tab: The new screen to switch to's tab. self.carousel.remove_widget(content)
""" self.on_size(self, self.size)
name = new_tab.text
if self.local_screen_names.index(name) > self.local_screen_names.index(self.current_screen.name):
self.transition.direction = "left"
else:
self.transition.direction = "right"
self.current = name
self.current_tab = new_tab
class CommandButton(MDButton, MDTooltip): class CommandButton(MDButton, MDTooltip):
@@ -828,9 +803,6 @@ class GameManager(ThemedApp):
main_area_container: MDGridLayout main_area_container: MDGridLayout
""" subclasses can add more columns beside the tabs """ """ subclasses can add more columns beside the tabs """
tabs: MDNavigationBar
screens: MDScreenManagerBase
def __init__(self, ctx: context_type): def __init__(self, ctx: context_type):
self.title = self.base_title self.title = self.base_title
self.ctx = ctx self.ctx = ctx
@@ -860,7 +832,7 @@ class GameManager(ThemedApp):
@property @property
def tab_count(self): def tab_count(self):
if hasattr(self, "tabs"): if hasattr(self, "tabs"):
return max(1, len(self.tabs.children)) return max(1, len(self.tabs.tab_list))
return 1 return 1
def on_start(self): def on_start(self):
@@ -900,32 +872,30 @@ class GameManager(ThemedApp):
self.grid.add_widget(self.progressbar) self.grid.add_widget(self.progressbar)
# middle part # middle part
self.screens = MDScreenManagerBase(pos_hint={"center_x": 0.5}) self.tabs = ClientTabs(pos_hint={"center_x": 0.5, "center_y": 0.5})
self.tabs = MDNavigationBar(orientation="horizontal", size_hint_y=None, height=dp(40), set_bars_color=True) self.tabs.add_widget(MDTabsItem(MDTabsItemText(text="All" if len(self.logging_pairs) > 1 else "Archipelago")))
# bind the method to the bar for back compatibility self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name)
self.tabs.remove_tab = self.remove_client_tab for logger_name, name in
self.screens.current_tab = self.add_client_tab( self.logging_pairs))
"All" if len(self.logging_pairs) > 1 else "Archipelago", self.tabs.carousel.add_widget(self.tabs.default_tab_content)
UILog(*(logging.getLogger(logger_name) for logger_name, name in self.logging_pairs)),
)
self.log_panels["All"] = self.screens.current_tab.content
self.screens.current_tab.active = True
for logger_name, display_name in self.logging_pairs: for logger_name, display_name in self.logging_pairs:
bridge_logger = logging.getLogger(logger_name) bridge_logger = logging.getLogger(logger_name)
self.log_panels[display_name] = UILog(bridge_logger) self.log_panels[display_name] = UILog(bridge_logger)
if len(self.logging_pairs) > 1: if len(self.logging_pairs) > 1:
self.add_client_tab(display_name, self.log_panels[display_name]) panel = MDTabsItem(MDTabsItemText(text=display_name))
panel.content = self.log_panels[display_name]
# show Archipelago tab if other logging is present
self.tabs.carousel.add_widget(panel.content)
self.tabs.add_widget(panel)
hint_panel = self.add_client_tab("Hints", HintLayout())
self.hint_log = HintLog(self.json_to_kivy_parser) self.hint_log = HintLog(self.json_to_kivy_parser)
hint_panel = self.add_client_tab("Hints", HintLayout(self.hint_log))
self.log_panels["Hints"] = hint_panel.content self.log_panels["Hints"] = hint_panel.content
hint_panel.content.add_widget(self.hint_log)
self.main_area_container = MDGridLayout(size_hint_y=1, rows=1) self.main_area_container = MDGridLayout(size_hint_y=1, rows=1)
tab_container = MDGridLayout(size_hint_y=1, cols=1) self.main_area_container.add_widget(self.tabs)
tab_container.add_widget(self.tabs)
tab_container.add_widget(self.screens)
self.main_area_container.add_widget(tab_container)
self.grid.add_widget(self.main_area_container) self.grid.add_widget(self.main_area_container)
@@ -962,61 +932,25 @@ class GameManager(ThemedApp):
return self.container return self.container
def add_client_tab(self, title: str, content: Widget, index: int = -1) -> MDNavigationItemBase: def add_client_tab(self, title: str, content: Widget, index: int = -1) -> Widget:
""" """Adds a new tab to the client window with a given title, and provides a given Widget as its content.
Adds a new tab to the client window with a given title, and provides a given Widget as its content. Returns the new tab widget, with the provided content being placed on the tab as content."""
Returns the new tab widget, with the provided content being placed on the tab as content. new_tab = MDTabsItem(MDTabsItemText(text=title))
:param title: The title of the tab.
:param content: The Widget to be added as content for this tab's new MDScreen. Will also be added to the
returned tab as tab.content.
:param index: The index to insert the tab at. Defaults to -1, meaning the tab will be appended to the end.
:return: The new tab.
"""
if self.tabs.children:
self.tabs.add_widget(MDDivider(orientation="vertical"))
new_tab = MDNavigationItemBase(text=title)
new_tab.content = content new_tab.content = content
new_screen = MDScreen(name=title) if -1 < index <= len(self.tabs.carousel.slides):
new_screen.add_widget(content) new_tab.bind(on_release=self.tabs.set_active_item)
if -1 < index <= len(self.tabs.children): new_tab._tabs = self.tabs
remapped_index = len(self.tabs.children) - index self.tabs.ids.container.add_widget(new_tab, index=index)
self.tabs.add_widget(new_tab, index=remapped_index) self.tabs.carousel.add_widget(new_tab.content, index=len(self.tabs.carousel.slides) - index)
self.screens.add_widget(new_screen, index=index)
else: else:
self.tabs.add_widget(new_tab) self.tabs.add_widget(new_tab)
self.screens.add_widget(new_screen) self.tabs.carousel.add_widget(new_tab.content)
return new_tab return new_tab
def remove_client_tab(self, tab: MDNavigationItemBase) -> None:
"""
Called to remove a tab and its screen.
:param tab: The tab to remove.
"""
tab_index = self.tabs.children.index(tab)
# if the tab is currently active we need to swap before removing it
if tab == self.screens.current_tab:
if not tab_index:
# account for the divider
swap_index = tab_index + 2
else:
swap_index = tab_index - 2
self.tabs.children[swap_index].on_release()
# self.screens.switch_screens(self.tabs.children[swap_index])
# get the divider to the left if we can
if not tab_index:
divider_index = tab_index + 1
else:
divider_index = tab_index - 1
self.tabs.remove_widget(self.tabs.children[divider_index])
self.tabs.remove_widget(tab)
self.screens.remove_widget(self.screens.get_screen(tab.text))
def update_texts(self, dt): def update_texts(self, dt):
if hasattr(self.screens.current_tab.content, "fix_heights"): for slide in self.tabs.carousel.slides:
getattr(self.screens.current_tab.content, "fix_heights")() if hasattr(slide, "fix_heights"):
slide.fix_heights() # TODO: remove this when Kivy fixes this upstream
if self.ctx.server: if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \ self.title = self.base_title + " " + Utils.__version__ + \
f" | Connected to: {self.ctx.server_address} " \ f" | Connected to: {self.ctx.server_address} " \

View File

@@ -1,5 +1,5 @@
[pytest] [pytest]
python_files = test_*.py Test*.py **/test*/**/__init__.py # TODO: remove Test* once all worlds have been ported python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported
python_classes = Test python_classes = Test
python_functions = test python_functions = test
testpaths = testpaths =

View File

@@ -10,10 +10,9 @@ import sys
import types import types
import typing import typing
import warnings import warnings
from collections.abc import Iterator, Sequence
from enum import IntEnum from enum import IntEnum
from threading import Lock from threading import Lock
from typing import cast, Any, BinaryIO, ClassVar, TextIO, TypeVar, Union from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar
__all__ = [ __all__ = [
"get_settings", "fmt_doc", "no_gui", "get_settings", "fmt_doc", "no_gui",
@@ -24,7 +23,7 @@ __all__ = [
no_gui = False no_gui = False
skip_autosave = False skip_autosave = False
_world_settings_name_cache: dict[str, str] = {} # TODO: cache on disk and update when worlds change _world_settings_name_cache: Dict[str, str] = {} # TODO: cache on disk and update when worlds change
_world_settings_name_cache_updated = False _world_settings_name_cache_updated = False
_lock = Lock() _lock = Lock()
@@ -54,7 +53,7 @@ def fmt_doc(cls: type, level: int) -> str:
class Group: class Group:
_type_cache: ClassVar[dict[str, Any] | None] = None _type_cache: ClassVar[Optional[Dict[str, Any]]] = None
_dumping: bool = False _dumping: bool = False
_has_attr: bool = False _has_attr: bool = False
_changed: bool = False _changed: bool = False
@@ -107,7 +106,7 @@ class Group:
self.__dict__.values())) self.__dict__.values()))
@classmethod @classmethod
def get_type_hints(cls) -> dict[str, Any]: def get_type_hints(cls) -> Dict[str, Any]:
"""Returns resolved type hints for the class""" """Returns resolved type hints for the class"""
if cls._type_cache is None: if cls._type_cache is None:
if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str): if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
@@ -125,10 +124,10 @@ class Group:
return self[key] return self[key]
return default return default
def items(self) -> list[tuple[str, Any]]: def items(self) -> List[Tuple[str, Any]]:
return [(key, getattr(self, key)) for key in self] return [(key, getattr(self, key)) for key in self]
def update(self, dct: dict[str, Any]) -> None: def update(self, dct: Dict[str, Any]) -> None:
assert isinstance(dct, dict), f"{self.__class__.__name__}.update called with " \ assert isinstance(dct, dict), f"{self.__class__.__name__}.update called with " \
f"{dct.__class__.__name__} instead of dict." f"{dct.__class__.__name__} instead of dict."
@@ -197,7 +196,7 @@ class Group:
warnings.warn(f"{self.__class__.__name__}.{k} " warnings.warn(f"{self.__class__.__name__}.{k} "
f"assigned from incompatible type {type(v).__name__}") f"assigned from incompatible type {type(v).__name__}")
def as_dict(self, *args: str, downcast: bool = True) -> dict[str, Any]: def as_dict(self, *args: str, downcast: bool = True) -> Dict[str, Any]:
return { return {
name: _to_builtin(cast(object, getattr(self, name))) if downcast else getattr(self, name) name: _to_builtin(cast(object, getattr(self, name))) if downcast else getattr(self, name)
for name in self if not args or name in args for name in self if not args or name in args
@@ -212,7 +211,7 @@ class Group:
f.write(f"{indent}{yaml_line}") f.write(f"{indent}{yaml_line}")
@classmethod @classmethod
def _dump_item(cls, name: str | None, attr: object, f: TextIO, level: int) -> None: def _dump_item(cls, name: Optional[str], attr: object, f: TextIO, level: int) -> None:
"""Write a group, dict or sequence item to f, where attr can be a scalar or a collection""" """Write a group, dict or sequence item to f, where attr can be a scalar or a collection"""
# lazy construction of yaml Dumper to avoid loading Utils early # lazy construction of yaml Dumper to avoid loading Utils early
@@ -224,7 +223,7 @@ class Group:
def represent_mapping(self, tag: str, mapping: Any, flow_style: Any = None) -> MappingNode: def represent_mapping(self, tag: str, mapping: Any, flow_style: Any = None) -> MappingNode:
from yaml import ScalarNode from yaml import ScalarNode
res: MappingNode = super().represent_mapping(tag, mapping, flow_style) res: MappingNode = super().represent_mapping(tag, mapping, flow_style)
pairs = cast(list[tuple[ScalarNode, Any]], res.value) pairs = cast(List[Tuple[ScalarNode, Any]], res.value)
for k, v in pairs: for k, v in pairs:
k.style = None # remove quotes from keys k.style = None # remove quotes from keys
return res return res
@@ -330,9 +329,9 @@ class Path(str):
"""Marks the file as required and opens a file browser when missing""" """Marks the file as required and opens a file browser when missing"""
is_exe: bool = False is_exe: bool = False
"""Special cross-platform handling for executables""" """Special cross-platform handling for executables"""
description: str | None = None description: Optional[str] = None
"""Title to display when browsing for the file""" """Title to display when browsing for the file"""
copy_to: str | None = None copy_to: Optional[str] = None
"""If not None, copy to AP folder instead of linking it""" """If not None, copy to AP folder instead of linking it"""
@classmethod @classmethod
@@ -340,7 +339,7 @@ class Path(str):
"""Overload and raise to validate input files from browse""" """Overload and raise to validate input files from browse"""
pass pass
def browse(self: T, **kwargs: Any) -> T | None: def browse(self: T, **kwargs: Any) -> Optional[T]:
"""Opens a file browser to search for the file""" """Opens a file browser to search for the file"""
raise NotImplementedError(f"Please use a subclass of Path for {self.__class__.__name__}") raise NotImplementedError(f"Please use a subclass of Path for {self.__class__.__name__}")
@@ -370,12 +369,12 @@ class _LocalPath(str):
class FilePath(Path): class FilePath(Path):
# path to a file # path to a file
md5s: ClassVar[list[str | bytes]] = [] md5s: ClassVar[List[Union[str, bytes]]] = []
"""MD5 hashes for default validator.""" """MD5 hashes for default validator."""
def browse(self: T, def browse(self: T,
filetypes: Sequence[tuple[str, Sequence[str]]] | None = None, **kwargs: Any)\ filetypes: Optional[typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]] = None, **kwargs: Any)\
-> T | None: -> Optional[T]:
from Utils import open_filename, is_windows from Utils import open_filename, is_windows
if not filetypes: if not filetypes:
if self.is_exe: if self.is_exe:
@@ -440,7 +439,7 @@ class FilePath(Path):
class FolderPath(Path): class FolderPath(Path):
# path to a folder # path to a folder
def browse(self: T, **kwargs: Any) -> T | None: def browse(self: T, **kwargs: Any) -> Optional[T]:
from Utils import open_directory from Utils import open_directory
res = open_directory(f"Select {self.description or self.__class__.__name__}", self) res = open_directory(f"Select {self.description or self.__class__.__name__}", self)
if res: if res:
@@ -598,16 +597,16 @@ class ServerOptions(Group):
OFF = 0 OFF = 0
ON = 1 ON = 1
host: str | None = None host: Optional[str] = None
port: int = 38281 port: int = 38281
password: str | None = None password: Optional[str] = None
multidata: str | None = None multidata: Optional[str] = None
savefile: str | None = None savefile: Optional[str] = None
disable_save: bool = False disable_save: bool = False
loglevel: str = "info" loglevel: str = "info"
logtime: bool = False logtime: bool = False
server_password: ServerPassword | None = None server_password: Optional[ServerPassword] = None
disable_item_cheat: DisableItemCheat | bool = False disable_item_cheat: Union[DisableItemCheat, bool] = False
location_check_points: LocationCheckPoints = LocationCheckPoints(1) location_check_points: LocationCheckPoints = LocationCheckPoints(1)
hint_cost: HintCost = HintCost(10) hint_cost: HintCost = HintCost(10)
release_mode: ReleaseMode = ReleaseMode("auto") release_mode: ReleaseMode = ReleaseMode("auto")
@@ -703,7 +702,7 @@ does nothing if not found
""" """
sni_path: SNIPath = SNIPath("SNI") sni_path: SNIPath = SNIPath("SNI")
snes_rom_start: SnesRomStart | bool = True snes_rom_start: Union[SnesRomStart, bool] = True
class BizHawkClientOptions(Group): class BizHawkClientOptions(Group):
@@ -722,7 +721,7 @@ class BizHawkClientOptions(Group):
""" """
emuhawk_path: EmuHawkPath = EmuHawkPath(None) emuhawk_path: EmuHawkPath = EmuHawkPath(None)
rom_start: RomStart | bool = True rom_start: Union[RomStart, bool] = True
# Top-level group with lazy loading of worlds # Top-level group with lazy loading of worlds
@@ -734,7 +733,7 @@ class Settings(Group):
sni_options: SNIOptions = SNIOptions() sni_options: SNIOptions = SNIOptions()
bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions() bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions()
_filename: str | None = None _filename: Optional[str] = None
def __getattribute__(self, key: str) -> Any: def __getattribute__(self, key: str) -> Any:
if key.startswith("_") or key in self.__class__.__dict__: if key.startswith("_") or key in self.__class__.__dict__:
@@ -788,7 +787,7 @@ class Settings(Group):
return super().__getattribute__(key) return super().__getattribute__(key)
def __init__(self, location: str | None): # change to PathLike[str] once we drop 3.8? def __init__(self, location: Optional[str]): # change to PathLike[str] once we drop 3.8?
super().__init__() super().__init__()
if location: if location:
from Utils import parse_yaml from Utils import parse_yaml
@@ -822,7 +821,7 @@ class Settings(Group):
import atexit import atexit
atexit.register(autosave) atexit.register(autosave)
def save(self, location: str | None = None) -> None: # as above def save(self, location: Optional[str] = None) -> None: # as above
from Utils import parse_yaml from Utils import parse_yaml
location = location or self._filename location = location or self._filename
assert location, "No file specified" assert location, "No file specified"
@@ -855,7 +854,7 @@ class Settings(Group):
super().dump(f, level) super().dump(f, level)
@property @property
def filename(self) -> str | None: def filename(self) -> Optional[str]:
return self._filename return self._filename
@@ -868,7 +867,7 @@ def get_settings() -> Settings:
if not res: if not res:
from Utils import user_path, local_path from Utils import user_path, local_path
filenames = ("options.yaml", "host.yaml") filenames = ("options.yaml", "host.yaml")
locations: list[str] = [] locations: List[str] = []
if os.path.join(os.getcwd()) != local_path(): if os.path.join(os.getcwd()) != local_path():
locations += filenames # use files from cwd only if it's not the local_path locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames] locations += [user_path(filename) for filename in filenames]

View File

@@ -1,24 +1,21 @@
import base64 import base64
import datetime import datetime
import io
import json
import os import os
import platform import platform
import shutil import shutil
import subprocess
import sys import sys
import sysconfig import sysconfig
import threading
import urllib.error
import urllib.request
import warnings import warnings
import zipfile import zipfile
from collections.abc import Iterable, Sequence import urllib.request
import io
import json
import threading
import subprocess
from hashlib import sha3_512 from hashlib import sha3_512
from pathlib import Path from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union
SNI_VERSION = "v0.0.100" # change back to "latest" once tray icon issues are fixed
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it # This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
@@ -62,11 +59,14 @@ from Utils import version_tuple, is_windows, is_linux
from Cython.Build import cythonize from Cython.Build import cythonize
non_apworlds: set[str] = { # On Python < 3.10 LogicMixin is not currently supported.
non_apworlds: Set[str] = {
"A Link to the Past", "A Link to the Past",
"Adventure", "Adventure",
"ArchipIDLE", "ArchipIDLE",
"Archipelago", "Archipelago",
"Clique",
"Final Fantasy",
"Lufia II Ancient Cave", "Lufia II Ancient Cave",
"Meritous", "Meritous",
"Ocarina of Time", "Ocarina of Time",
@@ -78,6 +78,9 @@ non_apworlds: set[str] = {
"Wargroove", "Wargroove",
} }
# LogicMixin is broken before 3.10 import revamp
if sys.version_info < (3,10):
non_apworlds.add("Hollow Knight")
def download_SNI() -> None: def download_SNI() -> None:
print("Updating SNI") print("Updating SNI")
@@ -90,8 +93,7 @@ def download_SNI() -> None:
machine_name = platform.machine().lower() machine_name = platform.machine().lower()
# force amd64 on macos until we have universal2 sni, otherwise resolve to GOARCH # force amd64 on macos until we have universal2 sni, otherwise resolve to GOARCH
machine_name = "universal" if platform_name == "darwin" else machine_to_go.get(machine_name, machine_name) machine_name = "universal" if platform_name == "darwin" else machine_to_go.get(machine_name, machine_name)
sni_version_ref = "latest" if SNI_VERSION == "latest" else f"tags/{SNI_VERSION}" with urllib.request.urlopen("https://api.github.com/repos/alttpo/sni/releases/latest") as request:
with urllib.request.urlopen(f"https://api.github.com/repos/alttpo/SNI/releases/{sni_version_ref}") as request:
data = json.load(request) data = json.load(request)
files = data["assets"] files = data["assets"]
@@ -105,8 +107,8 @@ def download_SNI() -> None:
# prefer "many" builds # prefer "many" builds
if "many" in download_url: if "many" in download_url:
break break
# prefer non-windows7 builds to get up-to-date dependencies # prefer the correct windows or windows7 build
if platform_name == "windows" and "windows7" not in download_url: if platform_name == "windows" and ("windows7" in download_url) == (sys.version_info < (3, 9)):
break break
if source_url and source_url.endswith(".zip"): if source_url and source_url.endswith(".zip"):
@@ -145,16 +147,15 @@ def download_SNI() -> None:
print(f"No SNI found for system spec {platform_name} {machine_name}") print(f"No SNI found for system spec {platform_name} {machine_name}")
signtool: str | None = None signtool: Optional[str]
try: if os.path.exists("X:/pw.txt"):
with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response: print("Using signtool")
html = response.read() with open("X:/pw.txt", encoding="utf-8-sig") as f:
if b"status=OK\n" in html: pw = f.read()
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 ' signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \
r'/tr http://timestamp.digicert.com/ ') r'" /fd sha256 /td sha256 /tr http://timestamp.digicert.com/ '
print("Using signtool") else:
except (ConnectionError, TimeoutError, urllib.error.URLError) as e: signtool = None
pass
build_platform = sysconfig.get_platform() build_platform = sysconfig.get_platform()
@@ -199,13 +200,12 @@ extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else []
def remove_sprites_from_folder(folder: Path) -> None: def remove_sprites_from_folder(folder: Path) -> None:
if os.path.isdir(folder): for file in os.listdir(folder):
for file in os.listdir(folder): if file != ".gitignore":
if file != ".gitignore": os.remove(folder / file)
os.remove(folder / file)
def _threaded_hash(filepath: str | Path) -> str: def _threaded_hash(filepath: Union[str, Path]) -> str:
hasher = sha3_512() hasher = sha3_512()
hasher.update(open(filepath, "rb").read()) hasher.update(open(filepath, "rb").read())
return base64.b85encode(hasher.digest()).decode() return base64.b85encode(hasher.digest()).decode()
@@ -255,7 +255,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
self.libfolder = Path(self.buildfolder, "lib") self.libfolder = Path(self.buildfolder, "lib")
self.library = Path(self.libfolder, "library.zip") self.library = Path(self.libfolder, "library.zip")
def installfile(self, path: Path, subpath: str | Path | None = None, keep_content: bool = False) -> None: def installfile(self, path: Path, subpath: Optional[Union[str, Path]] = None, keep_content: bool = False) -> None:
folder = self.buildfolder folder = self.buildfolder
if subpath: if subpath:
folder /= subpath folder /= subpath
@@ -374,7 +374,11 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
assert not non_apworlds - set(AutoWorldRegister.world_types), \ assert not non_apworlds - set(AutoWorldRegister.world_types), \
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld" f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
folders_to_remove: list[str] = [] folders_to_remove: List[str] = []
disabled_worlds_folder = "worlds_disabled"
for entry in os.listdir(disabled_worlds_folder):
if os.path.isdir(os.path.join(disabled_worlds_folder, entry)):
folders_to_remove.append(entry)
generate_yaml_templates(self.buildfolder / "Players" / "Templates", False) generate_yaml_templates(self.buildfolder / "Players" / "Templates", False)
for worldname, worldtype in AutoWorldRegister.world_types.items(): for worldname, worldtype in AutoWorldRegister.world_types.items():
if worldname not in non_apworlds: if worldname not in non_apworlds:
@@ -411,14 +415,13 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
os.system(signtool + os.path.join(self.buildfolder, "lib", "worlds", "oot", "data", *exe_path)) os.system(signtool + os.path.join(self.buildfolder, "lib", "worlds", "oot", "data", *exe_path))
remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttpr") remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttpr")
remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttp" / "remote")
self.create_manifest() self.create_manifest()
if is_windows: if is_windows:
# Inno setup stuff # Inno setup stuff
with open("setup.ini", "w") as f: with open("setup.ini", "w") as f:
min_supported_windows = "6.2.9200" min_supported_windows = "6.2.9200" if sys.version_info > (3, 9) else "6.0.6000"
f.write(f"[Data]\nsource_path={self.buildfolder}\nmin_windows={min_supported_windows}\n") f.write(f"[Data]\nsource_path={self.buildfolder}\nmin_windows={min_supported_windows}\n")
with open("installdelete.iss", "w") as f: with open("installdelete.iss", "w") as f:
f.writelines("Type: filesandordirs; Name: \"{app}\\lib\\worlds\\"+world_directory+"\"\n" f.writelines("Type: filesandordirs; Name: \"{app}\\lib\\worlds\\"+world_directory+"\"\n"
@@ -443,12 +446,12 @@ class AppImageCommand(setuptools.Command):
("app-exec=", None, "The application to run inside the image."), ("app-exec=", None, "The application to run inside the image."),
("yes", "y", 'Answer "yes" to all questions.'), ("yes", "y", 'Answer "yes" to all questions.'),
] ]
build_folder: Path | None build_folder: Optional[Path]
dist_file: Path | None dist_file: Optional[Path]
app_dir: Path | None app_dir: Optional[Path]
app_name: str app_name: str
app_exec: Path | None app_exec: Optional[Path]
app_icon: Path | None # source file app_icon: Optional[Path] # source file
app_id: str # lower case name, used for icon and .desktop app_id: str # lower case name, used for icon and .desktop
yes: bool yes: bool
@@ -485,12 +488,12 @@ tmp="${{exe#*/}}"
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
exe="{default_exe.parent}/$exe" exe="{default_exe.parent}/$exe"
fi fi
export LD_LIBRARY_PATH="${{LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}}$APPDIR/{default_exe.parent}/lib" export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$APPDIR/{default_exe.parent}/lib"
$APPDIR/$exe "$@" $APPDIR/$exe "$@"
""") """)
launcher_filename.chmod(0o755) launcher_filename.chmod(0o755)
def install_icon(self, src: Path, name: str | None = None, symlink: Path | None = None) -> None: def install_icon(self, src: Path, name: Optional[str] = None, symlink: Optional[Path] = None) -> None:
assert self.app_dir, "Invalid app_dir" assert self.app_dir, "Invalid app_dir"
try: try:
from PIL import Image from PIL import Image
@@ -553,7 +556,7 @@ $APPDIR/$exe "$@"
subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True) subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True)
def find_libs(*args: str) -> Sequence[tuple[str, str]]: def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
"""Try to find system libraries to be included.""" """Try to find system libraries to be included."""
if not args: if not args:
return [] return []
@@ -561,7 +564,7 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
arch = build_arch.replace('_', '-') arch = build_arch.replace('_', '-')
libc = 'libc6' # we currently don't support musl libc = 'libc6' # we currently don't support musl
def parse(line: str) -> tuple[tuple[str, str, str], str]: def parse(line: str) -> Tuple[Tuple[str, str, str], str]:
lib, path = line.strip().split(' => ') lib, path = line.strip().split(' => ')
lib, typ = lib.split(' ', 1) lib, typ = lib.split(' ', 1)
for test_arch in ('x86-64', 'i386', 'aarch64'): for test_arch in ('x86-64', 'i386', 'aarch64'):
@@ -586,8 +589,8 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
k: v for k, v in (parse(line) for line in data if "=>" in line) k: v for k, v in (parse(line) for line in data if "=>" in line)
} }
def find_lib(lib: str, arch: str, libc: str) -> str | None: def find_lib(lib: str, arch: str, libc: str) -> Optional[str]:
cache: dict[tuple[str, str, str], str] = getattr(find_libs, "cache") cache: Dict[Tuple[str, str, str], str] = getattr(find_libs, "cache")
for k, v in cache.items(): for k, v in cache.items():
if k == (lib, arch, libc): if k == (lib, arch, libc):
return v return v
@@ -596,7 +599,7 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
return v return v
return None return None
res: list[tuple[str, str]] = [] res: List[Tuple[str, str]] = []
for arg in args: for arg in args:
# try exact match, empty libc, empty arch, empty arch and libc # try exact match, empty libc, empty arch, empty arch and libc
file = find_lib(arg, arch, libc) file = find_lib(arg, arch, libc)

View File

@@ -159,6 +159,7 @@ class WorldTestBase(unittest.TestCase):
self.multiworld.game[self.player] = self.game self.multiworld.game[self.player] = self.game
self.multiworld.player_name = {self.player: "Tester"} self.multiworld.player_name = {self.player: "Tester"}
self.multiworld.set_seed(seed) self.multiworld.set_seed(seed)
self.multiworld.state = CollectionState(self.multiworld)
random.seed(self.multiworld.seed) random.seed(self.multiworld.seed)
self.multiworld.seed_name = get_seed_name(random) # only called to get same RNG progression as Generate.py self.multiworld.seed_name = get_seed_name(random) # only called to get same RNG progression as Generate.py
args = Namespace() args = Namespace()
@@ -167,7 +168,6 @@ class WorldTestBase(unittest.TestCase):
1: option.from_any(self.options.get(name, option.default)) 1: option.from_any(self.options.get(name, option.default))
}) })
self.multiworld.set_options(args) self.multiworld.set_options(args)
self.multiworld.state = CollectionState(self.multiworld)
self.world = self.multiworld.worlds[self.player] self.world = self.multiworld.worlds[self.player]
for step in gen_steps: for step in gen_steps:
call_all(self.multiworld, step) call_all(self.multiworld, step)

View File

@@ -29,9 +29,14 @@ def run_locations_benchmark():
rule_iterations: int = 100_000 rule_iterations: int = 100_000
@staticmethod if sys.version_info >= (3, 9):
def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str: @staticmethod
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str:
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
else:
@staticmethod
def format_times_from_counter(counter: collections.Counter, top: int = 5) -> str:
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float: def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
with TimeIt(f"{test_location.game} {self.rule_iterations} " with TimeIt(f"{test_location.game} {self.rule_iterations} "
@@ -54,13 +59,13 @@ def run_locations_benchmark():
multiworld.game[1] = game multiworld.game[1] = game
multiworld.player_name = {1: "Tester"} multiworld.player_name = {1: "Tester"}
multiworld.set_seed(0) multiworld.set_seed(0)
multiworld.state = CollectionState(multiworld)
args = argparse.Namespace() args = argparse.Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types[game].options_dataclass.type_hints.items(): for name, option in AutoWorld.AutoWorldRegister.world_types[game].options_dataclass.type_hints.items():
setattr(args, name, { setattr(args, name, {
1: option.from_any(getattr(option, "default")) 1: option.from_any(getattr(option, "default"))
}) })
multiworld.set_options(args) multiworld.set_options(args)
multiworld.state = CollectionState(multiworld)
gc.collect() gc.collect()
for step in self.gen_steps: for step in self.gen_steps:

View File

@@ -1,66 +0,0 @@
"""Micro benchmark comparing match as "switch" with if-elif and dict access"""
from timeit import timeit
def make_match(count: int) -> str:
code = f"for val in range({count}):\n match val:\n"
for n in range(count):
m = n + 1
code += f" case {n}:\n"
code += f" res = {m}\n"
return code
def make_elif(count: int) -> str:
code = f"for val in range({count}):\n"
for n in range(count):
m = n + 1
code += f" {'' if n == 0 else 'el'}if val == {n}:\n"
code += f" res = {m}\n"
return code
def make_dict(count: int, mode: str) -> str:
if mode == "value":
code = "dct = {\n"
for n in range(count):
m = n + 1
code += f" {n}: {m},\n"
code += "}\n"
code += f"for val in range({count}):\n res = dct[val]"
return code
elif mode == "call":
code = ""
for n in range(count):
m = n + 1
code += f"def func{n}():\n val = {m}\n\n"
code += "dct = {\n"
for n in range(count):
code += f" {n}: func{n},\n"
code += "}\n"
code += f"for val in range({count}):\n dct[val]()"
return code
return ""
def timeit_best_of_5(stmt: str, setup: str = "pass") -> float:
"""
Benchmark some code, returning the best of 5 runs.
:param stmt: Code to benchmark
:param setup: Optional code to set up environment
:return: Time taken in microseconds
"""
return min(timeit(stmt, setup, number=10000, globals={}) for _ in range(5)) * 100
def main() -> None:
for count in (3, 5, 8, 10, 20, 30):
print(f"value of {count:-2} with match: {timeit_best_of_5(make_match(count)) / count:.3f} us")
print(f"value of {count:-2} with elif: {timeit_best_of_5(make_elif(count)) / count:.3f} us")
print(f"value of {count:-2} with dict: {timeit_best_of_5(make_dict(count, 'value')) / count:.3f} us")
print(f"call of {count:-2} with dict: {timeit_best_of_5(make_dict(count, 'call')) / count:.3f} us")
if __name__ == "__main__":
main()

View File

@@ -49,6 +49,7 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)} multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids} multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
multiworld.set_seed(seed) multiworld.set_seed(seed)
multiworld.state = CollectionState(multiworld)
args = Namespace() args = Namespace()
for player, world_type in enumerate(worlds, 1): for player, world_type in enumerate(worlds, 1):
for key, option in world_type.options_dataclass.type_hints.items(): for key, option in world_type.options_dataclass.type_hints.items():
@@ -56,7 +57,6 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
updated_options[player] = option.from_any(option.default) updated_options[player] = option.from_any(option.default)
setattr(args, key, updated_options) setattr(args, key, updated_options)
multiworld.set_options(args) multiworld.set_options(args)
multiworld.state = CollectionState(multiworld)
for step in steps: for step in steps:
call_all(multiworld, step) call_all(multiworld, step)
return multiworld return multiworld

View File

@@ -69,9 +69,11 @@ class TestEntranceLookup(unittest.TestCase):
exits_set = set([ex for region in multiworld.get_regions(1) exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region]) for ex in region.exits if not ex.connected_region])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1) er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region] for entrance in region.entrances if not entrance.parent_region]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets) for entrance in er_targets:
lookup.add(entrance)
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM], retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
False, False) False, False)
@@ -90,9 +92,11 @@ class TestEntranceLookup(unittest.TestCase):
exits_set = set([ex for region in multiworld.get_regions(1) exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region]) for ex in region.exits if not ex.connected_region])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1) er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region] for entrance in region.entrances if not entrance.parent_region]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets) for entrance in er_targets:
lookup.add(entrance)
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM], retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
False, True) False, True)
@@ -108,10 +112,12 @@ class TestEntranceLookup(unittest.TestCase):
for ex in region.exits if not ex.connected_region for ex in region.exits if not ex.connected_region
and ex.name != "region20_right" and ex.name != "region21_left"]) and ex.name != "region20_right" and ex.name != "region21_left"])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1) er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region and for entrance in region.entrances if not entrance.parent_region and
entrance.name != "region20_right" and entrance.name != "region21_left"] entrance.name != "region20_right" and entrance.name != "region21_left"]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets) for entrance in er_targets:
lookup.add(entrance)
# region 20 is the bottom left corner of the grid, and therefore only has a right entrance from region 21 # region 20 is the bottom left corner of the grid, and therefore only has a right entrance from region 21
# and a top entrance from region 15; since we've told lookup to ignore the right entrance from region 21, # and a top entrance from region 15; since we've told lookup to ignore the right entrance from region 21,
# the top entrance from region 15 should be considered a dead-end # the top entrance from region 15 should be considered a dead-end
@@ -123,56 +129,6 @@ class TestEntranceLookup(unittest.TestCase):
self.assertTrue(dead_end in lookup.dead_ends) self.assertTrue(dead_end in lookup.dead_ends)
self.assertEqual(len(lookup.dead_ends), 1) self.assertEqual(len(lookup.dead_ends), 1)
def test_find_target_by_name(self):
"""Tests that find_target can find the correct target by name only"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
target = lookup.find_target("region0_right")
self.assertEqual(target.name, "region0_right")
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
self.assertIsNone(lookup.find_target("nonexistant"))
def test_find_target_by_name_and_group(self):
"""Tests that find_target can find the correct target by name and group"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
target = lookup.find_target("region0_right", ERTestGroups.RIGHT)
self.assertEqual(target.name, "region0_right")
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
# wrong group
self.assertIsNone(lookup.find_target("region0_right", ERTestGroups.LEFT))
def test_find_target_by_name_and_group_and_category(self):
"""Tests that find_target can find the correct target by name, group, and dead-endedness"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
target = lookup.find_target("region0_right", ERTestGroups.RIGHT, False)
self.assertEqual(target.name, "region0_right")
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
# wrong deadendedness
self.assertIsNone(lookup.find_target("region0_right", ERTestGroups.RIGHT, True))
class TestBakeTargetGroupLookup(unittest.TestCase): class TestBakeTargetGroupLookup(unittest.TestCase):
def test_lookup_generation(self): def test_lookup_generation(self):
multiworld = generate_test_multiworld() multiworld = generate_test_multiworld()
@@ -309,12 +265,12 @@ class TestRandomizeEntrances(unittest.TestCase):
generate_disconnected_region_grid(multiworld, 5) generate_disconnected_region_grid(multiworld, 5)
seen_placement_count = 0 seen_placement_count = 0
def verify_coupled(_: ERPlacementState, placed_exits: list[Entrance], placed_targets: list[Entrance]): def verify_coupled(_: ERPlacementState, placed_entrances: list[Entrance]):
nonlocal seen_placement_count nonlocal seen_placement_count
seen_placement_count += len(placed_exits) seen_placement_count += len(placed_entrances)
self.assertEqual(2, len(placed_exits)) self.assertEqual(2, len(placed_entrances))
self.assertEqual(placed_exits[0].parent_region, placed_exits[1].connected_region) self.assertEqual(placed_entrances[0].parent_region, placed_entrances[1].connected_region)
self.assertEqual(placed_exits[1].parent_region, placed_exits[0].connected_region) self.assertEqual(placed_entrances[1].parent_region, placed_entrances[0].connected_region)
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup, result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup,
on_connect=verify_coupled) on_connect=verify_coupled)
@@ -357,10 +313,10 @@ class TestRandomizeEntrances(unittest.TestCase):
generate_disconnected_region_grid(multiworld, 5) generate_disconnected_region_grid(multiworld, 5)
seen_placement_count = 0 seen_placement_count = 0
def verify_uncoupled(state: ERPlacementState, placed_exits: list[Entrance], placed_targets: list[Entrance]): def verify_uncoupled(state: ERPlacementState, placed_entrances: list[Entrance]):
nonlocal seen_placement_count nonlocal seen_placement_count
seen_placement_count += len(placed_exits) seen_placement_count += len(placed_entrances)
self.assertEqual(1, len(placed_exits)) self.assertEqual(1, len(placed_entrances))
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup, result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup,
on_connect=verify_uncoupled) on_connect=verify_uncoupled)

View File

@@ -48,14 +48,13 @@ class TestBase(unittest.TestCase):
original_get_all_state = multiworld.get_all_state original_get_all_state = multiworld.get_all_state
def patched_get_all_state(use_cache: bool | None = None, allow_partial_entrances: bool = False, def patched_get_all_state(use_cache: bool, allow_partial_entrances: bool = False):
**kwargs):
self.assertTrue(allow_partial_entrances, ( self.assertTrue(allow_partial_entrances, (
"Before the connect_entrances step finishes, other worlds might still have partial entrances. " "Before the connect_entrances step finishes, other worlds might still have partial entrances. "
"As such, any call to get_all_state must use allow_partial_entrances = True." "As such, any call to get_all_state must use allow_partial_entrances = True."
)) ))
return original_get_all_state(use_cache, allow_partial_entrances, **kwargs) return original_get_all_state(use_cache, allow_partial_entrances)
multiworld.get_all_state = patched_get_all_state multiworld.get_all_state = patched_get_all_state

View File

@@ -603,28 +603,6 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
self.assertTrue(player3.locations[2].item.advancement) self.assertTrue(player3.locations[2].item.advancement)
self.assertTrue(player3.locations[3].item.advancement) self.assertTrue(player3.locations[3].item.advancement)
def test_deprioritized_does_not_land_on_priority(self):
multiworld = generate_test_multiworld(1)
player1 = generate_player_data(multiworld, 1, 2, prog_item_count=2)
player1.prog_items[0].classification |= ItemClassification.deprioritized
player1.locations[0].progress_type = LocationProgressType.PRIORITY
distribute_items_restrictive(multiworld)
self.assertFalse(player1.locations[0].item.deprioritized)
def test_deprioritized_still_goes_on_priority_ahead_of_filler(self):
multiworld = generate_test_multiworld(1)
player1 = generate_player_data(multiworld, 1, 2, prog_item_count=1, basic_item_count=1)
player1.prog_items[0].classification |= ItemClassification.deprioritized
player1.locations[0].progress_type = LocationProgressType.PRIORITY
distribute_items_restrictive(multiworld)
self.assertTrue(player1.locations[0].item.advancement)
def test_can_remove_locations_in_fill_hook(self): def test_can_remove_locations_in_fill_hook(self):
"""Test that distribute_items_restrictive calls the fill hook and allows for item and location removal""" """Test that distribute_items_restrictive calls the fill hook and allows for item and location removal"""
multiworld = generate_test_multiworld() multiworld = generate_test_multiworld()

View File

@@ -1,7 +1,7 @@
import unittest import unittest
from Fill import distribute_items_restrictive from Fill import distribute_items_restrictive
from NetUtils import convert_to_base_types from NetUtils import encode
from worlds.AutoWorld import AutoWorldRegister, call_all from worlds.AutoWorld import AutoWorldRegister, call_all
from worlds import failed_world_loads from worlds import failed_world_loads
from . import setup_solo_multiworld from . import setup_solo_multiworld
@@ -47,28 +47,12 @@ class TestImplemented(unittest.TestCase):
call_all(multiworld, "post_fill") call_all(multiworld, "post_fill")
for key, data in multiworld.worlds[1].fill_slot_data().items(): for key, data in multiworld.worlds[1].fill_slot_data().items():
self.assertIsInstance(key, str, "keys in slot data must be a string") 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 self.assertIsInstance(encode(data), str, f"object {type(data).__name__} not serializable.")
def test_no_failed_world_loads(self): def test_no_failed_world_loads(self):
if failed_world_loads: if failed_world_loads:
self.fail(f"The following worlds failed to load: {failed_world_loads}") self.fail(f"The following worlds failed to load: {failed_world_loads}")
def test_prefill_items(self):
"""Test that every world can reach every location from allstate before pre_fill."""
for gamename, world_type in AutoWorldRegister.world_types.items():
if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"):
with self.subTest(gamename):
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
"set_rules", "connect_entrances", "generate_basic"))
allstate = multiworld.get_all_state(False)
locations = multiworld.get_locations()
reachable = multiworld.get_reachable_locations(allstate)
unreachable = [location for location in locations if location not in reachable]
self.assertTrue(not unreachable,
f"Locations were not reachable with all state before prefill: "
f"{unreachable}. Seed: {multiworld.seed}")
def test_explicit_indirect_conditions_spheres(self): def test_explicit_indirect_conditions_spheres(self):
"""Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit """Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit
indirect conditions""" indirect conditions"""

View File

@@ -148,8 +148,8 @@ class TestBase(unittest.TestCase):
def test_locality_not_modified(self): def test_locality_not_modified(self):
"""Test that worlds don't modify the locality of items after duplicates are resolved""" """Test that worlds don't modify the locality of items after duplicates are resolved"""
gen_steps = ("generate_early",) gen_steps = ("generate_early", "create_regions", "create_items")
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill") additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()} worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
for game_name, world_type in worlds_to_test.items(): for game_name, world_type in worlds_to_test.items():
with self.subTest("Game", game=game_name): with self.subTest("Game", game=game_name):

View File

@@ -1,8 +1,7 @@
import unittest import unittest
from BaseClasses import PlandoOptions from BaseClasses import MultiWorld, PlandoOptions
from Options import ItemLinks, Choice from Options import ItemLinks
from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
@@ -74,10 +73,9 @@ class TestOptions(unittest.TestCase):
def test_pickle_dumps(self): def test_pickle_dumps(self):
"""Test options can be pickled into database for WebHost generation""" """Test options can be pickled into database for WebHost generation"""
import pickle
for gamename, world_type in AutoWorldRegister.world_types.items(): for gamename, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden: if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items(): for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key): with self.subTest(game=gamename, option=option_key):
restricted_dumps(option.from_any(option.default)) pickle.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]))

View File

@@ -8,12 +8,7 @@ class TestPackages(unittest.TestCase):
to indicate full package rather than namespace package.""" to indicate full package rather than namespace package."""
import Utils import Utils
# Ignore directories with these names.
ignore_dirs = {".github"}
worlds_path = Utils.local_path("worlds") worlds_path = Utils.local_path("worlds")
for dirpath, dirnames, filenames in os.walk(worlds_path): for dirpath, dirnames, filenames in os.walk(worlds_path):
# Drop ignored directories from dirnames, excluding them from walking.
dirnames[:] = [d for d in dirnames if d not in ignore_dirs]
with self.subTest(directory=dirpath): with self.subTest(directory=dirpath):
self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames)) self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames))

View File

@@ -26,4 +26,4 @@ class TestBase(unittest.TestCase):
for step in self.test_steps: for step in self.test_steps:
with self.subTest("Step", step=step): with self.subTest("Step", step=step):
call_all(multiworld, step) call_all(multiworld, step)
self.assertTrue(multiworld.get_all_state(False, allow_partial_entrances=True)) self.assertTrue(multiworld.get_all_state(False, True))

View File

@@ -63,12 +63,12 @@ if __name__ == "__main__":
spacer = '=' * 80 spacer = '=' * 80
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]] multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]]
p1_games = [] p1_games = []
data_paths = [] data_paths = []
rooms = [] rooms = []
copy_world("VVVVVV", "Temp World") copy_world("Clique", "Temp World")
try: try:
for n, games in enumerate(multis, 1): for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)}") print(f"Generating [{n}] {', '.join(games)}")
@@ -101,7 +101,7 @@ if __name__ == "__main__":
with Client(host.address, game, "Player1") as client: with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations) local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration if collected_items < 2: # Clique only has 2 Locations
client.collect_any() client.collect_any()
# TODO: Ctrl+C test here as well # TODO: Ctrl+C test here as well
@@ -125,7 +125,7 @@ if __name__ == "__main__":
with Client(host.address, game, "Player1") as client: with Client(host.address, game, "Player1") as client:
web_data_packages = client.games_packages web_data_packages = client.games_packages
web_collected_items = len(client.checked_locations) web_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration if collected_items < 2: # Clique only has 2 Locations
client.collect_any() client.collect_any()
if collected_items == 1: if collected_items == 1:
sleep(1) # wait for the server to collect the item sleep(1) # wait for the server to collect the item

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