Compare commits

..

4 Commits

Author SHA1 Message Date
Chris Wilson
f8fe779add Merge branch 'main' into fix-text-choice 2024-05-31 17:23:01 -04:00
Chris Wilson
95af4554d5 Update WebHostLib/options.py
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2024-05-28 21:00:32 -04:00
Chris Wilson
e95a850f11 Merge branch 'main' into fix-text-choice 2024-05-23 18:23:44 -04:00
Chris Wilson
1b8b256886 Fix TextChoice options with custom values improperly being included in YAML output 2024-05-23 18:19:14 -04:00
849 changed files with 37175 additions and 109724 deletions

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
worlds/blasphemous/region_data.py linguist-generated=true

View File

@@ -36,15 +36,10 @@ jobs:
run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
if ( $? -eq $false ) {
Write-Error "setup.py failed!"
exit 1
}
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME"
@@ -54,6 +49,12 @@ jobs:
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Store 7z
uses: actions/upload-artifact@v4
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
retention-days: 7 # keep for 7 days, should be enough
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
@@ -64,38 +65,11 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
- name: Check build loads expected worlds
shell: bash
run: |
cd build/exe*
mv Players/Templates/meta.yaml .
ls -1 Players/Templates | sort > setup-player-templates.txt
rm -R Players/Templates
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
ls -1 Players/Templates | sort > generated-player-templates.txt
cmp setup-player-templates.txt generated-player-templates.txt \
|| diff setup-player-templates.txt generated-player-templates.txt
mv meta.yaml Players/Templates/
- name: Test Generate
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store 7z
uses: actions/upload-artifact@v4
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
compression-level: 0 # .7z is incompressible by zip
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
- name: Store Setup
uses: actions/upload-artifact@v4
with:
name: ${{ env.SETUP_NAME }}
path: setups/${{ env.SETUP_NAME }}
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2004:
@@ -136,7 +110,7 @@ jobs:
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
@@ -144,36 +118,15 @@ jobs:
run: |
source venv/bin/activate
python setup.py build_exe --yes
- name: Check build loads expected worlds
shell: bash
run: |
cd build/exe*
mv Players/Templates/meta.yaml .
ls -1 Players/Templates | sort > setup-player-templates.txt
rm -R Players/Templates
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
ls -1 Players/Templates | sort > generated-player-templates.txt
cmp setup-player-templates.txt generated-player-templates.txt \
|| diff setup-player-templates.txt generated-player-templates.txt
mv meta.yaml Players/Templates/
- name: Test Generate
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store AppImage
uses: actions/upload-artifact@v4
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
if-no-files-found: error
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v4
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}
compression-level: 0 # .gz is incompressible by zip
if-no-files-found: error
retention-days: 7

View File

@@ -1,54 +0,0 @@
# Run CMake / CTest C++ unit tests
name: ctest
on:
push:
paths:
- '**.cc?'
- '**.cpp'
- '**.cxx'
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '.github/workflows/ctest.yml'
pull_request:
paths:
- '**.cc?'
- '**.cpp'
- '**.cxx'
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '.github/workflows/ctest.yml'
jobs:
ctest:
runs-on: ${{ matrix.os }}
name: Test C++ ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@v1
if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@v1
with:
build-type: 'Release'
- name: Build tests
run: |
cd test/cpp
mkdir build
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
cmake --build build/ --config Release
ls
- name: Run tests
run: |
cd test/cpp
ctest --test-dir build/ -C Release --output-on-failure

View File

@@ -69,7 +69,7 @@ jobs:
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -

View File

@@ -24,7 +24,7 @@ on:
- '.github/workflows/unittests.yml'
jobs:
unit:
build:
runs-on: ${{ matrix.os }}
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
@@ -37,13 +37,12 @@ jobs:
- {version: '3.9'}
- {version: '3.10'}
- {version: '3.11'}
- {version: '3.12'}
include:
- python: {version: '3.8'} # win7 compat
os: windows-latest
- python: {version: '3.12'} # current
- python: {version: '3.11'} # current
os: windows-latest
- python: {version: '3.12'} # current
- python: {version: '3.11'} # current
os: macos-latest
steps:
@@ -61,32 +60,3 @@ jobs:
- name: Unittests
run: |
pytest -n auto
hosting:
runs-on: ${{ matrix.os }}
name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }}
strategy:
matrix:
os:
- ubuntu-latest
python:
- {version: '3.12'} # current
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Test hosting
run: |
source venv/bin/activate
export PYTHONPATH=$(pwd)
python test/hosting/__main__.py

4
.gitignore vendored
View File

@@ -62,7 +62,6 @@ Output Logs/
/installdelete.iss
/data/user.kv
/datapackage
/custom_worlds
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -150,7 +149,7 @@ venv/
ENV/
env.bak/
venv.bak/
*.code-workspace
.code-workspace
shell.nix
# Spyder project settings
@@ -178,7 +177,6 @@ dmypy.json
cython_debug/
# Cython intermediates
_speedups.c
_speedups.cpp
_speedups.html

View File

@@ -80,7 +80,7 @@ class AdventureContext(CommonContext):
self.local_item_locations = {}
self.dragon_speed_info = {}
options = Utils.get_settings()
options = Utils.get_options()
self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False):
@@ -102,7 +102,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if Utils.get_settings()["adventure_options"].get("death_link", False):
if Utils.get_options()["adventure_options"].get("death_link", False):
self.set_deathlink = True
async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo":
@@ -112,7 +112,7 @@ class AdventureContext(CommonContext):
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names.lookup_in_game(item.item) for item in args['items']])}"
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved":
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
@@ -415,8 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile):
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
import collections
import copy
import itertools
import functools
import logging
@@ -11,10 +11,8 @@ from argparse import Namespace
from collections import Counter, deque
from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, Type)
from typing_extensions import NotRequired, TypedDict
from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
TypedDict, Union, Type, ClassVar
import NetUtils
import Options
@@ -24,16 +22,16 @@ if typing.TYPE_CHECKING:
from worlds import AutoWorld
class Group(TypedDict):
class Group(TypedDict, total=False):
name: str
game: str
world: "AutoWorld.World"
players: AbstractSet[int]
item_pool: NotRequired[Set[str]]
replacement_items: NotRequired[Dict[int, Optional[str]]]
local_items: NotRequired[Set[str]]
non_local_items: NotRequired[Set[str]]
link_replacement: NotRequired[bool]
players: Set[int]
item_pool: Set[str]
replacement_items: Dict[int, Optional[str]]
local_items: Set[str]
non_local_items: Set[str]
link_replacement: bool
class ThreadBarrierProxy:
@@ -50,11 +48,6 @@ class ThreadBarrierProxy:
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
class HasNameAndPlayer(Protocol):
name: str
player: int
class MultiWorld():
debug_types = False
player_name: Dict[int, str]
@@ -70,6 +63,7 @@ class MultiWorld():
state: CollectionState
plando_options: PlandoOptions
accessibility: Dict[int, Options.Accessibility]
early_items: Dict[int, Dict[str, int]]
local_early_items: Dict[int, Dict[str, int]]
local_items: Dict[int, Options.LocalItems]
@@ -163,7 +157,7 @@ class MultiWorld():
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
for player in range(1, players + 1):
def set_player_attr(attr: str, val) -> None:
def set_player_attr(attr, val):
self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('plando_items', [])
set_player_attr('plando_texts', {})
@@ -172,13 +166,13 @@ class MultiWorld():
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
"world's random object instead (usually self.random)")
"world's random object instead (usually self.random)")
self.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]:
return self.player_ids + tuple(self.groups)
def add_group(self, name: str, game: str, players: AbstractSet[int] = frozenset()) -> Tuple[int, Group]:
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
"""Create a group with name and return the assigned player ID and group.
If a group of this name already exists, the set of players is extended instead of creating a new one."""
from worlds import AutoWorld
@@ -194,9 +188,7 @@ class MultiWorld():
self.player_types[new_id] = NetUtils.SlotType.group
world_type = AutoWorld.AutoWorldRegister.world_types[game]
self.worlds[new_id] = world_type.create_group(self, new_id, players)
self.worlds[new_id].collect_item = AutoWorld.World.collect_item.__get__(self.worlds[new_id])
self.worlds[new_id].collect = AutoWorld.World.collect.__get__(self.worlds[new_id])
self.worlds[new_id].remove = AutoWorld.World.remove.__get__(self.worlds[new_id])
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
self.player_name[new_id] = name
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
@@ -204,7 +196,7 @@ class MultiWorld():
return new_id, new_group
def get_player_groups(self, player: int) -> Set[int]:
def get_player_groups(self, player) -> Set[int]:
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
@@ -267,7 +259,7 @@ class MultiWorld():
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
}
for _name, item_link in item_links.items():
for name, item_link in item_links.items():
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
pool = set()
local_items = set()
@@ -296,88 +288,6 @@ class MultiWorld():
group["non_local_items"] = item_link["non_local_items"]
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
def link_items(self) -> None:
"""Called to link together items in the itempool related to the registered item link groups."""
from worlds import AutoWorld
for group_id, group in self.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
]:
classifications: Dict[str, int] = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in self.itempool:
if item.player in counters and item.name in shared_pool:
counters[item.player][item.name] += 1
classifications[item.name] |= item.classification
for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player)
del (counters[player])
if not players:
return None, None
for item in shared_pool:
count = min(counters[player][item] for player in players)
if count:
for player in players:
counters[player][item] = count
else:
for player in players:
del (counters[player][item])
return counters, classifications
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count:
continue
new_itempool: List[Item] = []
for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count):
new_item = group["world"].create_item(item_name)
# mangle together all original classification bits
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)
region = Region("Menu", group_id, self, "ItemLink")
self.regions.append(region)
locations = region.locations
# ensure that progression items are linked first, then non-progression
self.itempool.sort(key=lambda item: item.advancement)
for item in self.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
loc = Location(group_id, f"Item Link: {item.name} -> {self.player_name[item.player]} {count}",
None, region)
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
state.has(item_name, group_id_, count_)
locations.append(loc)
loc.place_locked_item(item)
common_item_count[item.player][item.name] -= 1
else:
new_itempool.append(item)
itemcount = len(self.itempool)
self.itempool = new_itempool
while itemcount > len(self.itempool):
items_to_add = []
for player in group["players"]:
if group["link_replacement"]:
item_player = group_id
else:
item_player = player
if group["replacement_items"][player]:
items_to_add.append(AutoWorld.call_single(self, "create_item", item_player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player))
self.random.shuffle(items_to_add)
self.itempool.extend(items_to_add[:itemcount - len(self.itempool)])
def secure(self):
self.random = ThreadBarrierProxy(secrets.SystemRandom())
self.is_race = True
@@ -399,7 +309,7 @@ class MultiWorld():
return tuple(world for player, world in self.worlds.items() if
player not in self.groups and self.game[player] == game_name)
def get_name_string_for_object(self, obj: HasNameAndPlayer) -> str:
def get_name_string_for_object(self, obj) -> str:
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
def get_player_name(self, player: int) -> str:
@@ -441,7 +351,7 @@ class MultiWorld():
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
ret.sweep_for_advancements()
ret.sweep_for_events()
if use_cache:
self._all_state = ret
@@ -450,7 +360,7 @@ class MultiWorld():
def get_items(self) -> List[Item]:
return [loc.item for loc in self.get_filled_locations()] + self.itempool
def find_item_locations(self, item: str, player: int, resolve_group_locations: bool = False) -> List[Location]:
def find_item_locations(self, item, player: int, resolve_group_locations: bool = False) -> List[Location]:
if resolve_group_locations:
player_groups = self.get_player_groups(player)
return [location for location in self.get_locations() if
@@ -459,7 +369,7 @@ class MultiWorld():
return [location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player]
def find_item(self, item: str, player: int) -> Location:
def find_item(self, item, player: int) -> Location:
return next(location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player)
@@ -552,9 +462,9 @@ class MultiWorld():
return True
state = starting_state.copy()
else:
state = CollectionState(self)
if self.has_beaten_game(state):
if self.has_beaten_game(self.state):
return True
state = CollectionState(self)
prog_locations = {location for location in self.get_locations() if location.item
and location.item.advancement and location not in state.locations_checked}
@@ -613,21 +523,26 @@ class MultiWorld():
players: Dict[str, Set[int]] = {
"minimal": set(),
"items": set(),
"full": set()
"locations": set()
}
for player, world in self.worlds.items():
players[world.options.accessibility.current_key].add(player)
for player, access in self.accessibility.items():
players[access.current_key].add(player)
beatable_fulfilled = False
def location_condition(location: Location) -> bool:
def location_condition(location: Location):
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
return location.player in players["full"] or \
(location.item and location.item.player not in players["minimal"])
if location.player in players["locations"] or (location.item and location.item.player not in
players["minimal"]):
return True
return False
def location_relevant(location: Location) -> bool:
def location_relevant(location: Location):
"""Determine if this location is relevant to sweep."""
return location.player in players["full"] or location.advancement
if location.progress_type != LocationProgressType.EXCLUDED \
and (location.player in players["locations"] or location.advancement):
return True
return False
def all_done() -> bool:
"""Check if all access rules are fulfilled"""
@@ -672,7 +587,7 @@ class CollectionState():
multiworld: MultiWorld
reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]]
advancements: Set[Location]
events: Set[Location]
path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location]
stale: Dict[int, bool]
@@ -684,7 +599,7 @@ class CollectionState():
self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
self.advancements = set()
self.events = set()
self.path = {}
self.locations_checked = set()
self.stale = {player: True for player in parent.get_all_ids()}
@@ -696,25 +611,17 @@ class CollectionState():
def update_reachable_regions(self, player: int):
self.stale[player] = False
world: AutoWorld.World = self.multiworld.worlds[player]
reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
queue = deque(self.blocked_connections[player])
start: Region = world.get_region(world.origin_region_name)
start = self.multiworld.get_region("Menu", player)
# init on first call - this can't be done on construction since the regions don't exist yet
if start not in reachable_regions:
reachable_regions.add(start)
self.blocked_connections[player].update(start.exits)
blocked_connections.update(start.exits)
queue.extend(start.exits)
if world.explicit_indirect_conditions:
self._update_reachable_regions_explicit_indirect_conditions(player, queue)
else:
self._update_reachable_regions_auto_indirect_conditions(player, queue)
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
# run BFS on all connections, and keep track of those blocked by missing items
while queue:
connection = queue.popleft()
@@ -722,7 +629,7 @@ class CollectionState():
if new_region in reachable_regions:
blocked_connections.remove(connection)
elif connection.can_reach(self):
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
reachable_regions.add(new_region)
blocked_connections.remove(connection)
blocked_connections.update(new_region.exits)
@@ -734,39 +641,16 @@ class CollectionState():
if new_entrance in blocked_connections and new_entrance not in queue:
queue.append(new_entrance)
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
new_connection: bool = True
# run BFS on all connections, and keep track of those blocked by missing items
while new_connection:
new_connection = False
while queue:
connection = queue.popleft()
new_region = connection.connected_region
if new_region in reachable_regions:
blocked_connections.remove(connection)
elif connection.can_reach(self):
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
reachable_regions.add(new_region)
blocked_connections.remove(connection)
blocked_connections.update(new_region.exits)
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
new_connection = True
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
queue.extend(blocked_connections)
def copy(self) -> CollectionState:
ret = CollectionState(self.multiworld)
ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()}
ret.reachable_regions = {player: region_set.copy() for player, region_set in
self.reachable_regions.items()}
ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in
self.blocked_connections.items()}
ret.advancements = self.advancements.copy()
ret.path = self.path.copy()
ret.locations_checked = self.locations_checked.copy()
ret.prog_items = copy.deepcopy(self.prog_items)
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
self.reachable_regions}
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
self.blocked_connections}
ret.events = copy.copy(self.events)
ret.path = copy.copy(self.path)
ret.locations_checked = copy.copy(self.locations_checked)
for function in self.additional_copy_functions:
ret = function(self, ret)
return ret
@@ -796,25 +680,20 @@ class CollectionState():
def can_reach_region(self, spot: str, player: int) -> bool:
return self.multiworld.get_region(spot, player).can_reach(self)
def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None:
Utils.deprecate("sweep_for_events has been renamed to sweep_for_advancements. The functionality is the same. "
"Please switch over to sweep_for_advancements.")
return self.sweep_for_advancements(locations)
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None:
locations = self.multiworld.get_filled_locations()
reachable_advancements = True
# since the loop has a good chance to run more than once, only filter the advancements once
locations = {location for location in locations if location.advancement and location not in self.advancements}
while reachable_advancements:
reachable_advancements = {location for location in locations if location.can_reach(self)}
locations -= reachable_advancements
for advancement in reachable_advancements:
self.advancements.add(advancement)
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
self.collect(advancement.item, True, advancement)
reachable_events = True
# since the loop has a good chance to run more than once, only filter the events once
locations = {location for location in locations if location.advancement and location not in self.events and
not key_only or getattr(location.item, "locked_dungeon_item", False)}
while reachable_events:
reachable_events = {location for location in locations if location.can_reach(self)}
locations -= reachable_events
for event in reachable_events:
self.events.add(event)
assert isinstance(event.item, Item), "tried to collect Event with no Item"
self.collect(event.item, True, event)
# item name related
def has(self, item: str, player: int, count: int = 1) -> bool:
@@ -848,7 +727,7 @@ class CollectionState():
if found >= count:
return True
return False
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
Ignores duplicates of the same item."""
@@ -863,7 +742,7 @@ class CollectionState():
def count_from_list(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state."""
return sum(self.prog_items[player][item_name] for item_name in items)
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
@@ -909,16 +788,20 @@ class CollectionState():
)
# Item related
def collect(self, item: Item, prevent_sweep: bool = False, location: Optional[Location] = None) -> bool:
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
if location:
self.locations_checked.add(location)
changed = self.multiworld.worlds[item.player].collect(self, item)
if not changed and event:
self.prog_items[item.player][item.name] += 1
changed = True
self.stale[item.player] = True
if changed and not prevent_sweep:
self.sweep_for_advancements()
if changed and not event:
self.sweep_for_events()
return changed
@@ -942,13 +825,12 @@ class Entrance:
addresses = None
target = None
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None:
def __init__(self, player: int, name: str = '', parent: Region = None):
self.name = name
self.parent_region = parent
self.player = player
def can_reach(self, state: CollectionState) -> bool:
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
if self.parent_region.can_reach(state) and self.access_rule(state):
if not self.hide_path and not self in state.path:
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
@@ -963,6 +845,9 @@ class Entrance:
region.entrances.append(self)
def __repr__(self):
return self.__str__()
def __str__(self):
multiworld = self.parent_region.multiworld if self.parent_region else None
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
@@ -1088,7 +973,7 @@ class Region:
self.locations.append(location_type(self.player, location, address, self))
def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
"""
Connects this Region to another Region, placing the provided rule on the connection.
@@ -1128,6 +1013,9 @@ class Region:
rules[connecting_region] if rules and connecting_region in rules else None)
def __repr__(self):
return self.__str__()
def __str__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
@@ -1146,9 +1034,9 @@ class Location:
locked: bool = False
show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
always_allow = staticmethod(lambda state, item: False)
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
item_rule = staticmethod(lambda item: True)
item: Optional[Item] = None
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
@@ -1157,20 +1045,16 @@ class Location:
self.address = address
self.parent_region = parent
def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool:
return ((
self.always_allow(state, item)
and item.name not in state.multiworld.worlds[item.player].options.non_local_items
) or (
(self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
and self.item_rule(item)
and (not check_access or self.can_reach(state))
))
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
and self.item_rule(item)
and (not check_access or self.can_reach(state))))
def can_reach(self, state: CollectionState) -> bool:
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average
assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region"
return self.parent_region.can_reach(state) and self.access_rule(state)
# self.access_rule computes faster on average, so placing it first for faster abort
assert self.parent_region, "Can't reach location without region"
return self.access_rule(state) and self.parent_region.can_reach(state)
def place_locked_item(self, item: Item):
if self.item:
@@ -1180,6 +1064,9 @@ class Location:
self.locked = True
def __repr__(self):
return self.__str__()
def __str__(self):
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
@@ -1201,7 +1088,7 @@ class Location:
@property
def native_item(self) -> bool:
"""Returns True if the item in this location matches game."""
return self.item is not None and self.item.game == self.game
return self.item and self.item.game == self.game
@property
def hint_text(self) -> str:
@@ -1212,7 +1099,7 @@ class ItemClassification(IntFlag):
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
progression = 0b0001 # Item that is logically relevant
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
trap = 0b0100 # detrimental item
trap = 0b0100 # detrimental or entirely useless (nothing) item
skip_balancing = 0b1000 # should technically never occur on its own
# Item that is logically relevant, but progression balancing should not touch.
# Typically currency or other counted items.
@@ -1284,6 +1171,9 @@ class Item:
return hash((self.name, self.player))
def __repr__(self) -> str:
return self.__str__()
def __str__(self) -> str:
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
return self.location.parent_region.multiworld.get_name_string_for_object(self)
return f"{self.name} (Player {self.player})"
@@ -1361,9 +1251,9 @@ class Spoiler:
# 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
restore_later: Dict[Location, Item] = {}
restore_later = {}
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete: Set[Location] = set()
to_delete = set()
for location in sphere:
# 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,
@@ -1381,7 +1271,7 @@ class Spoiler:
sphere -= to_delete
# second phase, sphere 0
removed_precollected: List[Item] = []
removed_precollected = []
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
multiworld.precollected_items[item.player].remove(item)
@@ -1401,6 +1291,8 @@ class Spoiler:
state = CollectionState(multiworld)
collection_spheres = []
while required_locations:
state.sweep_for_events(key_only=True)
sphere = set(filter(state.can_reach, required_locations))
for location in sphere:
@@ -1462,7 +1354,7 @@ class Spoiler:
# Maybe move the big bomb over to the Event system instead?
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
for (_, exit_path) in path):
if multiworld.worlds[player].options.mode != 'inverted':
if multiworld.mode[player] != 'inverted':
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
get_path(state, multiworld.get_region('Big Bomb Shop', player))
else:
@@ -1534,9 +1426,9 @@ class Spoiler:
if self.paths:
outfile.write('\n\nPaths:\n\n')
path_listings: List[str] = []
path_listings = []
for location, path in sorted(self.paths.items()):
path_lines: List[str] = []
path_lines = []
for region, exit in path:
if exit is not None:
path_lines.append("{} -> {}".format(region, exit))

View File

@@ -1,10 +1,9 @@
from __future__ import annotations
import sys
import ModuleUpdate
ModuleUpdate.update()
from worlds._bizhawk.context import launch
if __name__ == "__main__":
launch(*sys.argv[1:])
launch()

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import collections
import copy
import logging
import asyncio
@@ -9,7 +8,6 @@ import sys
import typing
import time
import functools
import warnings
import ModuleUpdate
ModuleUpdate.update()
@@ -23,7 +21,7 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType)
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes)
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
@@ -45,21 +43,10 @@ def get_ssl_context():
class ClientCommandProcessor(CommandProcessor):
"""
The Command Processor will parse every method of the class that starts with "_cmd_" as a command to be called
when parsing user input, i.e. _cmd_exit will be called when the user sends the command "/exit".
The decorator @mark_raw can be imported from MultiServer and tells the parser to only split on the first
space after the command i.e. "/exit one two three" will be passed in as method("one two three") with mark_raw
and method("one", "two", "three") without.
In addition all docstrings for command methods will be displayed to the user on launch and when using "/help"
"""
def __init__(self, ctx: CommonContext):
self.ctx = ctx
def output(self, text: str):
"""Helper function to abstract logging to the CommonClient UI"""
logger.info(text)
def _cmd_exit(self) -> bool:
@@ -72,7 +59,6 @@ class ClientCommandProcessor(CommandProcessor):
if address:
self.ctx.server_address = None
self.ctx.username = None
self.ctx.password = None
elif not self.ctx.server_address:
self.output("Please specify an address.")
return False
@@ -175,96 +161,28 @@ class ClientCommandProcessor(CommandProcessor):
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def default(self, raw: str):
"""The default message parser to be used when parsing any messages that do not match a command"""
raw = self.ctx.on_user_say(raw)
if raw:
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext:
# The following attributes are used to Connect and should be adjusted as needed in subclasses
# Should be adjusted as needed in subclasses
tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None
want_slot_data: bool = True # should slot_data be retrieved via Connect
class NameLookupDict:
"""A specialized dict, with helper methods, for id -> name item/location data package lookups by game."""
def __init__(self, ctx: CommonContext, lookup_type: typing.Literal["item", "location"]):
self.ctx: CommonContext = ctx
self.lookup_type: typing.Literal["item", "location"] = lookup_type
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
self._archipelago_lookup: typing.Dict[int, str] = {}
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
self.warned: bool = False
# noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
if isinstance(key, int):
if not self.warned:
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
self.warned = True
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
f"backwards compatibility for now. If multiple games share the same id for a "
f"{self.lookup_type}, name could be incorrect. Please use "
f"`{self.lookup_type}_names.lookup_in_game()` or "
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
return self._flat_store[key] # type: ignore
return self._game_store[key]
def __len__(self) -> int:
return len(self._game_store)
def __iter__(self) -> typing.Iterator[str]:
return iter(self._game_store)
def __repr__(self) -> str:
return self._game_store.__repr__()
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
omitted.
"""
if game_name is None:
game_name = self.ctx.game
assert game_name is not None, f"Attempted to lookup {self.lookup_type} with no game name available."
return self._game_store[game_name][code]
def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str:
"""Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is
omitted.
Use of `lookup_in_slot` should not be used when not connected to a server. If looking in own game, set
`ctx.game` and use `lookup_in_game` method instead.
"""
if slot is None:
slot = self.ctx.slot
assert slot is not None, f"Attempted to lookup {self.lookup_type} with no slot info available."
return self.lookup_in_game(code, self.ctx.slot_info[slot].game)
def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int]) -> None:
"""Overrides existing lookup tables for a particular game."""
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
if game == "Archipelago":
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
# it updates in all chain maps automatically.
self._archipelago_lookup.clear()
self._archipelago_lookup.update(id_to_name_lookup_table)
# data package
# Contents in flux until connection to server is made, to download correct data for this multiworld.
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
# defaults
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
ui: typing.Optional["kvui.GameManager"] = None
ui = None
ui_task: typing.Optional["asyncio.Task[None]"] = None
input_task: typing.Optional["asyncio.Task[None]"] = None
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
@@ -313,7 +231,7 @@ class CommonContext:
# message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
# server state
self.server_address = server_address
self.username = None
@@ -353,9 +271,6 @@ class CommonContext:
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location")
self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self)
self.update_data_package(network_data_package)
@@ -441,10 +356,7 @@ class CommonContext:
self.auth = await self.console_input()
async def send_connect(self, **kwargs: typing.Any) -> None:
"""
Send a `Connect` packet to log in to the server,
additional keyword args can override any value in the connection packet
"""
""" send `Connect` packet to log in to server """
payload = {
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
@@ -454,7 +366,6 @@ class CommonContext:
if kwargs:
payload.update(kwargs)
await self.send_msgs([payload])
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
async def console_input(self) -> str:
if self.ui:
@@ -475,7 +386,6 @@ class CommonContext:
return False
def slot_concerns_self(self, slot) -> bool:
"""Helper function to abstract player groups, should be used instead of checking slot == self.slot directly."""
if slot == self.slot:
return True
if slot in self.slot_info:
@@ -483,7 +393,6 @@ class CommonContext:
return False
def is_echoed_chat(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out messages sent by self."""
return print_json_packet.get("type", "") == "Chat" \
and print_json_packet.get("team", None) == self.team \
and print_json_packet.get("slot", None) == self.slot
@@ -516,13 +425,7 @@ class CommonContext:
Returned text is sent, or sending is aborted if None is returned."""
return text
def on_ui_command(self, text: str) -> None:
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
The command processor is still called; this is just intended for command echoing."""
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
def update_permissions(self, permissions: typing.Dict[str, int]):
"""Internal method to parse and save server permissions from RoomInfo"""
for permission_name, permission_flag in permissions.items():
try:
flag = Permission(permission_flag)
@@ -534,7 +437,6 @@ class CommonContext:
async def shutdown(self):
self.server_address = ""
self.username = None
self.password = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed:
await self.server.socket.close()
@@ -584,17 +486,19 @@ class CommonContext:
or remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cached_game, game)
self.update_game(cached_game)
if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
def update_game(self, game_package: dict, game: str):
self.item_names.update_game(game, game_package["item_name_to_id"])
self.location_names.update_game(game, game_package["location_name_to_id"])
def update_game(self, game_package: dict):
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name
def update_data_package(self, data_package: dict):
for game, game_data in data_package["games"].items():
self.update_game(game_data, game)
self.update_game(game_data)
def consume_network_data_package(self, data_package: dict):
self.update_data_package(data_package)
@@ -632,7 +536,6 @@ class CommonContext:
logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""):
"""Helper function to send a deathlink using death_text as the unique death cause string."""
if self.server and self.server.socket:
logger.info("DeathLink: Sending death to your friends...")
self.last_death_link = time.time()
@@ -646,7 +549,6 @@ class CommonContext:
}])
async def update_death_link(self, death_link: bool):
"""Helper function to set Death Link connection tag on/off and update the connection if already connected."""
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
@@ -656,7 +558,7 @@ class CommonContext:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
"""Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework"""
"""Displays an error messagebox"""
if not self.ui:
return None
title = title or "Error"
@@ -683,19 +585,17 @@ class CommonContext:
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def make_gui(self) -> typing.Type["kvui.GameManager"]:
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Text Client"
return TextManager
def run_gui(self):
"""Import kivy UI system from make_gui() and start running it as self.ui_task."""
ui_class = self.make_gui()
self.ui = ui_class(self)
self.ui = TextManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def run_cli(self):
@@ -887,8 +787,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.team = args["team"]
ctx.slot = args["slot"]
# int keys get lost in JSON transfer
ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)}
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
@@ -1008,7 +907,6 @@ async def console_loop(ctx: CommonContext):
def get_base_parser(description: typing.Optional[str] = None):
"""Base argument parser to be reused for components subclassing off of CommonClient"""
import argparse
parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -1018,7 +916,7 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser
def run_as_textclient(*args):
def run_as_textclient():
class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry
tags = CommonContext.tags | {"TextOnly"}
@@ -1057,21 +955,16 @@ def run_as_textclient(*args):
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
parser.add_argument("url", nargs="?", help="Archipelago connection url")
args = parser.parse_args(args)
args = parser.parse_args()
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
if args.url:
url = urllib.parse.urlparse(args.url)
if url.scheme == "archipelago":
args.connect = url.netloc
if url.username:
args.name = urllib.parse.unquote(url.username)
if url.password:
args.password = urllib.parse.unquote(url.password)
else:
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
args.connect = url.netloc
if url.username:
args.name = urllib.parse.unquote(url.username)
if url.password:
args.password = urllib.parse.unquote(url.password)
# use colorama to display colored text highlighting on windows
colorama.init()
asyncio.run(main(args))
@@ -1080,4 +973,4 @@ def run_as_textclient(*args):
if __name__ == '__main__':
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
run_as_textclient(*sys.argv[1:]) # default value for parse_args
run_as_textclient()

54
Fill.py
View File

@@ -12,12 +12,7 @@ from worlds.generic.Rules import add_item_rule
class FillError(RuntimeError):
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None:
if "multiworld" in kwargs and isinstance(args[0], str):
placements = (args[0] + f"\nAll Placements:\n" +
f"{[(loc, loc.item) for loc in kwargs['multiworld'].get_filled_locations()]}")
args = (placements, *args[1:])
super().__init__(*args)
pass
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
@@ -29,7 +24,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
new_state = base_state.copy()
for item in itempool:
new_state.collect(item, True)
new_state.sweep_for_advancements(locations=locations)
new_state.sweep_for_events(locations=locations)
return new_state
@@ -217,7 +212,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
f"{', '.join(str(place) for place in placements)}")
item_pool.extend(unplaced_items)
@@ -304,7 +299,7 @@ def remaining_fill(multiworld: MultiWorld,
f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
f"{', '.join(str(place) for place in placements)}")
itempool.extend(unplaced_items)
@@ -329,8 +324,8 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
pool.append(location.item)
state.remove(location.item)
location.item = None
if location in state.advancements:
state.advancements.remove(location)
if location in state.events:
state.events.remove(location)
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
@@ -363,7 +358,7 @@ def distribute_early_items(multiworld: MultiWorld,
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set()
base_state = multiworld.state.copy()
base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
@@ -475,26 +470,28 @@ def distribute_items_restrictive(multiworld: MultiWorld,
nonlocal lock_later
lock_later.append(location)
single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations:
# "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
single_player_placement=multiworld.players == 1, swap=False, on_place=mark_for_locking,
name="Priority")
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
if progitempool:
# "advancement/progression fill"
if panic_method == "swap":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
name="Progression", single_player_placement=single_player)
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=True,
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
name="Progression", single_player_placement=single_player)
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=False,
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "start_inventory":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
allow_partial=True, name="Progression", single_player_placement=single_player)
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=False, allow_partial=True,
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
if progitempool:
for item in progitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
@@ -509,8 +506,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if progitempool:
raise FillError(
f"Not enough locations for progression items. "
f"There are {len(progitempool)} more progression items than there are available locations.",
multiworld=multiworld,
f"There are {len(progitempool)} more progression items than there are available locations."
)
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
@@ -527,8 +523,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if excludedlocations:
raise FillError(
f"Not enough filler items for excluded locations. "
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
multiworld=multiworld,
f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
)
restitempool = filleritempool + usefulitempool
@@ -556,7 +551,7 @@ def flood_items(multiworld: MultiWorld) -> None:
progress_done = False
# sweep once to pick up preplaced items
multiworld.state.sweep_for_advancements()
multiworld.state.sweep_for_events()
# fill multiworld from top of itempool while we can
while not progress_done:
@@ -594,7 +589,7 @@ def flood_items(multiworld: MultiWorld) -> None:
if candidate_item_to_place is not None:
item_to_place = candidate_item_to_place
else:
raise FillError('No more progress items left to place.', multiworld=multiworld)
raise FillError('No more progress items left to place.')
# find item to replace with progress item
location_list = multiworld.get_reachable_locations()
@@ -651,6 +646,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
sphere_state.sweep_for_events(key_only=True, locations=locations)
return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float:
@@ -744,7 +740,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
), items_to_test):
reducing_state.collect(location.item, True, location)
reducing_state.sweep_for_advancements(locations=locations_to_test)
reducing_state.sweep_for_events(locations=locations_to_test)
if multiworld.has_beaten_game(balancing_state):
if not multiworld.has_beaten_game(reducing_state):
@@ -827,7 +823,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
swept_state.sweep_for_events()
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)

View File

@@ -1,12 +1,10 @@
from __future__ import annotations
import argparse
import copy
import logging
import os
import random
import string
import sys
import urllib.parse
import urllib.request
from collections import Counter
@@ -17,16 +15,23 @@ import ModuleUpdate
ModuleUpdate.update()
import copy
import Utils
import Options
from BaseClasses import seeddigits, get_seed, PlandoOptions
from Main import main as ERmain
from settings import get_settings
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
from worlds.generic import PlandoConnection
from worlds import failed_world_loads
def mystery_argparse():
from settings import get_settings
settings = get_settings()
defaults = settings.generator
options = get_settings()
defaults = options.generator
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
@@ -38,15 +43,15 @@ def mystery_argparse():
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
parser.add_argument('--outputpath', default=settings.general_options.output_path,
parser.add_argument('--outputpath', default=options.general_options.output_path,
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
parser.add_argument('--log_level', default='info', help='Sets log level')
parser.add_argument("--csv_output", action="store_true",
help="Output rolled player options to csv (made for async multiworld).")
parser.add_argument("--plando", default=defaults.plando_options,
help="List of options that can be set manually. Can be combined, for example \"bosses, items\"")
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument('--plando', default=defaults.plando_options,
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.")
parser.add_argument("--skip_output", action="store_true",
@@ -58,23 +63,20 @@ def mystery_argparse():
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
return args
return args, options
def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None) -> Tuple[argparse.Namespace, int]:
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.")
def main(args=None, callback=ERmain):
if not args:
args = mystery_argparse()
args, options = mystery_argparse()
else:
options = get_settings()
seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
random.seed(seed)
seed_name = get_seed_name(random)
@@ -143,9 +145,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.")
from worlds.AutoWorld import AutoWorldRegister
from worlds.alttp.EntranceRandomizer import parse_arguments
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.plando_options = args.plando
@@ -155,8 +154,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
@@ -204,7 +201,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif player not in erargs.name: # if name was not specified, generate it from filename
elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
@@ -217,7 +214,29 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
return erargs, seed
if args.yaml_output:
import yaml
important = {}
for option, player_settings in vars(erargs).items():
if type(player_settings) == dict:
if all(type(value) != list for value in player_settings.values()):
if len(player_settings.values()) > 1:
important[option] = {player: value for player, value in player_settings.items() if
player <= args.yaml_output}
else:
logging.debug(f"No player settings defined for option '{option}'")
else:
if player_settings != "": # is not empty name
important[option] = player_settings
else:
logging.debug(f"No player settings defined for option '{option}'")
if args.outputpath:
os.makedirs(args.outputpath, exist_ok=True)
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f)
return callback(erargs, seed)
def read_weights_yamls(path) -> Tuple[Any, ...]:
@@ -342,8 +361,6 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
from worlds import AutoWorldRegister
if not game:
return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types:
@@ -421,13 +438,10 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
except Exception as e:
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
else:
from worlds import AutoWorldRegister
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
from worlds import AutoWorldRegister
if "linked_options" in weights:
weights = roll_linked_options(weights)
@@ -454,7 +468,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
ret.game = get_choice("game", weights)
if ret.game not in AutoWorldRegister.world_types:
from worlds import failed_world_loads
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
if picks[0] in failed_world_loads:
raise Exception(f"No functional world found to handle game {ret.game}. "
@@ -491,14 +504,37 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
continue
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights)
roll_alttp_settings(ret, game_weights, plando_options)
if PlandoOptions.connections in plando_options:
ret.plando_connections = []
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement, "both")
))
return ret
def roll_alttp_settings(ret: argparse.Namespace, weights):
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.plando_texts = {}
if PlandoOptions.texts in plando_options:
tt = TextTable()
tt.removeUnwantedText()
options = weights.get("plando_texts", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
at = str(get_choice_legacy("at", placement))
if at not in tt:
raise Exception(f"No text target \"{at}\" found.")
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
ret.sprite_pool = weights.get('sprite_pool', [])
ret.sprite = get_choice_legacy('sprite', weights, "Link")
if 'random_sprite_on_event' in weights:
@@ -526,9 +562,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights):
if __name__ == '__main__':
import atexit
confirmation = atexit.register(input, "Press enter to close.")
erargs, seed = main()
from Main import main as ERmain
multiworld = ERmain(erargs, seed)
multiworld = main()
if __debug__:
import gc
import sys

View File

@@ -1,9 +0,0 @@
if __name__ == '__main__':
import ModuleUpdate
ModuleUpdate.update()
import Utils
Utils.init_logging("KH1Client", exception_logger="Client")
from worlds.kh1.Client import launch
launch()

View File

@@ -16,11 +16,10 @@ import multiprocessing
import shlex
import subprocess
import sys
import urllib.parse
import webbrowser
from os.path import isfile
from shutil import which
from typing import Callable, Optional, Sequence, Tuple, Union
from typing import Sequence, Union, Optional
import Utils
import settings
@@ -108,81 +107,7 @@ components.extend([
])
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query)
launch_args = (path, *launch_args)
client_component = None
text_client_component = None
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:
if component.supports_uri and component.game_name == game:
client_component = component
elif component.display_name == "Text Client":
text_client_component = component
from kvui import App, Button, BoxLayout, Label, Clock, Window
class Popup(App):
timer_label: Label
remaining_time: Optional[int]
def __init__(self):
self.title = "Connect to Multiworld"
self.icon = r"data/icon.png"
super().__init__()
def build(self):
layout = BoxLayout(orientation="vertical")
if client_component is None:
self.remaining_time = 7
label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
f"Launching Text Client in 7 seconds...")
self.timer_label = Label(text=label_text)
layout.add_widget(self.timer_label)
Clock.schedule_interval(self.update_label, 1)
else:
layout.add_widget(Label(text="Select client to open and connect with."))
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
text_client_button = Button(
text=text_client_component.display_name,
on_release=lambda *args: run_component(text_client_component, *launch_args)
)
button_row.add_widget(text_client_button)
game_client_button = Button(
text=client_component.display_name,
on_release=lambda *args: run_component(client_component, *launch_args)
)
button_row.add_widget(game_client_button)
layout.add_widget(button_row)
return layout
def update_label(self, dt):
if self.remaining_time > 1:
# countdown the timer and string replace the number
self.remaining_time -= 1
self.timer_label.text = self.timer_label.text.replace(
str(self.remaining_time + 1), str(self.remaining_time)
)
else:
# our timer is finished so launch text client and close down
run_component(text_client_component, *launch_args)
Clock.unschedule(self.update_label)
App.get_running_app().stop()
Window.close()
Popup().run()
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
def identify(path: Union[None, str]):
if path is None:
return None, None
for component in components:
@@ -235,12 +160,8 @@ def launch(exe, in_terminal=False):
subprocess.Popen(exe)
refresh_components: Optional[Callable[[], None]] = None
def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
from kivy.core.window import Window
from kivy.uix.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout
@@ -248,8 +169,11 @@ def run_gui():
base_title: str = "Archipelago Launcher"
container: ContainerLayout
grid: GridLayout
_tool_layout: Optional[ScrollBox] = None
_client_layout: Optional[ScrollBox] = None
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
def __init__(self, ctx=None):
self.title = self.base_title
@@ -257,7 +181,18 @@ def run_gui():
self.icon = r"data/icon.png"
super().__init__()
def _refresh_components(self) -> None:
def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
tool_layout = ScrollBox()
tool_layout.layout.orientation = "vertical"
self.grid.add_widget(tool_layout)
client_layout = ScrollBox()
client_layout.layout.orientation = "vertical"
self.grid.add_widget(client_layout)
def build_button(component: Component) -> Widget:
"""
@@ -282,49 +217,14 @@ def run_gui():
return box_layout
return button
# clear before repopulating
assert self._tool_layout and self._client_layout, "must call `build` first"
tool_children = reversed(self._tool_layout.layout.children)
for child in tool_children:
self._tool_layout.layout.remove_widget(child)
client_children = reversed(self._client_layout.layout.children)
for child in client_children:
self._client_layout.layout.remove_widget(child)
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
for (tool, client) in itertools.zip_longest(itertools.chain(
_tools.items(), _miscs.items(), _adjusters.items()
), _clients.items()):
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
# column 1
if tool:
self._tool_layout.layout.add_widget(build_button(tool[1]))
tool_layout.layout.add_widget(build_button(tool[1]))
# column 2
if client:
self._client_layout.layout.add_widget(build_button(client[1]))
def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
self._tool_layout = ScrollBox()
self._tool_layout.layout.orientation = "vertical"
self.grid.add_widget(self._tool_layout)
self._client_layout = ScrollBox()
self._client_layout.layout.orientation = "vertical"
self.grid.add_widget(self._client_layout)
self._refresh_components()
global refresh_components
refresh_components = self._refresh_components
Window.bind(on_drop_file=self._on_drop_file)
client_layout.layout.add_widget(build_button(client[1]))
return self.container
@@ -335,14 +235,6 @@ def run_gui():
else:
launch(get_exe(button.component), button.component.cli)
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
""" When a patch file is dropped into the window, run the associated component. """
file, component = identify(filename.decode())
if file and component:
run_component(component, file)
else:
logging.warning(f"unable to identify component for {file}")
def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
# Closing the window explicitly cleans it up.
@@ -351,17 +243,10 @@ def run_gui():
Launcher().run()
# avoiding Launcher reference leak
# and don't try to do something with widgets after window closed
global refresh_components
refresh_components = None
def run_component(component: Component, *args):
if component.func:
component.func(*args)
if refresh_components:
refresh_components()
elif component.script_name:
subprocess.run([*get_exe(component.script_name), *args])
else:
@@ -374,24 +259,20 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
elif not args:
args = {}
path = args.get("Patch|Game|Component|url", None)
if path is not None:
if path.startswith("archipelago://"):
handle_uri(path, args.get("args", ()))
return
file, component = identify(path)
if args.get("Patch|Game|Component", None) is not None:
file, component = identify(args["Patch|Game|Component"])
if file:
args['file'] = file
if component:
args['component'] = component
if not component:
logging.warning(f"Could not identify Component responsible for {path}")
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
if args["update_settings"]:
update_settings()
if "file" in args:
if 'file' in args:
run_component(args["component"], args["file"], *args["args"])
elif "component" in args:
elif 'component' in args:
run_component(args["component"], *args["args"])
elif not args["update_settings"]:
run_gui()
@@ -401,16 +282,12 @@ if __name__ == '__main__':
init_logging('Launcher')
Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser(
description='Archipelago Launcher',
usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
)
parser = argparse.ArgumentParser(description='Archipelago Launcher')
run_group = parser.add_argument_group("Run")
run_group.add_argument("--update_settings", action="store_true",
help="Update host.yaml and exit.")
run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?",
help="Pass either a patch file, a generated game, the component name to run, or a url to "
"connect with.")
run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
help="Pass either a patch file, a generated game or the name of a component to run.")
run_group.add_argument("args", nargs="*",
help="Arguments to pass to component.")
main(parser.parse_args())

View File

@@ -467,8 +467,6 @@ class LinksAwakeningContext(CommonContext):
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient()
self.slot_data = {}
if magpie:
self.magpie_enabled = True
self.magpie = MagpieBridge()
@@ -566,8 +564,6 @@ class LinksAwakeningContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
self.slot_data = args.get("slot_data", {})
# TODO - use watcher_event
if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]):
@@ -632,7 +628,6 @@ class LinksAwakeningContext(CommonContext):
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker)
self.magpie.slot_data = self.slot_data
except Exception:
# Don't let magpie errors take out the client
pass

View File

@@ -14,7 +14,7 @@ import tkinter as tk
from argparse import Namespace
from concurrent.futures import as_completed, ThreadPoolExecutor
from glob import glob
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, BOTH, TOP, LabelFrame, \
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
from tkinter.constants import DISABLED, NORMAL
from urllib.parse import urlparse
@@ -29,8 +29,7 @@ from Utils import output_path, local_path, user_path, open_file, get_cert_none_s
GAME_ALTTP = "A Link to the Past"
WINDOW_MIN_HEIGHT = 525
WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object):
def __init__(self, sprite_pool):
@@ -243,17 +242,16 @@ def adjustGUI():
from argparse import Namespace
from Utils import __version__ as MWVersion
adjustWindow = Tk()
adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
set_icon(adjustWindow)
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
bottomFrame2 = Frame(adjustWindow, padx=8, pady=2)
bottomFrame2 = Frame(adjustWindow)
romFrame, romVar = get_rom_frame(adjustWindow)
romDialogFrame = Frame(adjustWindow, padx=8, pady=2)
romDialogFrame = Frame(adjustWindow)
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
romVar2 = StringVar()
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
@@ -263,9 +261,9 @@ def adjustGUI():
romVar2.set(rom)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
romDialogFrame.pack(side=TOP, expand=False, fill=X)
baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8))
romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
romDialogFrame.pack(side=TOP, expand=True, fill=X)
baseRomLabel2.pack(side=LEFT)
romEntry2.pack(side=LEFT, expand=True, fill=X)
romSelectButton2.pack(side=LEFT)
def adjustRom():
@@ -333,11 +331,12 @@ def adjustGUI():
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
rom_options_frame.pack(side=TOP, padx=8, pady=8, fill=BOTH, expand=True)
rom_options_frame.pack(side=TOP)
adjustButton.pack(side=LEFT, padx=(5,5))
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
saveButton.pack(side=LEFT, padx=(5,5))
bottomFrame2.pack(side=TOP, pady=(5,5))
tkinter_center_window(adjustWindow)
@@ -577,7 +576,7 @@ class AttachTooltip(object):
def get_rom_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
romFrame = Frame(parent, padx=8, pady=8)
romFrame = Frame(parent)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
romVar = StringVar(value=adjuster_settings.baserom)
romEntry = Entry(romFrame, textvariable=romVar)
@@ -597,19 +596,20 @@ def get_rom_frame(parent=None):
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
baseRomLabel.pack(side=LEFT)
romEntry.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
romEntry.pack(side=LEFT, expand=True, fill=X)
romSelectButton.pack(side=LEFT)
romFrame.pack(side=TOP, fill=X)
romFrame.pack(side=TOP, expand=True, fill=X)
return romFrame, romVar
def get_rom_options_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
romOptionsFrame = LabelFrame(parent, text="Rom options", padx=8, pady=8)
romOptionsFrame = LabelFrame(parent, text="Rom options")
romOptionsFrame.columnconfigure(0, weight=1)
romOptionsFrame.columnconfigure(1, weight=1)
for i in range(5):
romOptionsFrame.rowconfigure(i, weight=0, pad=4)
romOptionsFrame.rowconfigure(i, weight=1)
vars = Namespace()
vars.MusicVar = IntVar()
@@ -660,7 +660,7 @@ def get_rom_options_frame(parent=None):
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
baseSpriteLabel.pack(side=LEFT)
spriteEntry.pack(side=LEFT, expand=True, fill=X)
spriteEntry.pack(side=LEFT)
spriteSelectButton.pack(side=LEFT)
oofDialogFrame = Frame(romOptionsFrame)

116
Main.py
View File

@@ -11,8 +11,7 @@ from typing import Dict, List, Optional, Set, Tuple, Union
import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
flood_items
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple, get_settings
from settings import get_settings
@@ -46,9 +45,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
multiworld.sprite_pool = args.sprite_pool.copy()
multiworld.set_options(args)
if args.csv_output:
from Options import dump_player_options
dump_player_options(multiworld)
multiworld.set_item_links()
multiworld.state = CollectionState(multiworld)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
@@ -104,7 +100,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
multiworld.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early
if remaining_count > 0:
local_early = multiworld.local_early_items[player].get(item_name, 0)
local_early = multiworld.early_local_items[player].get(item_name, 0)
if local_early:
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early
@@ -128,19 +124,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in multiworld.player_ids:
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
world_excluded_locations = set()
for location_name in multiworld.worlds[player].options.priority_locations.value:
try:
location = multiworld.get_location(location_name, player)
except KeyError:
continue
if location.progress_type != LocationProgressType.EXCLUDED:
location.progress_type = LocationProgressType.PRIORITY
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
if location_name not in multiworld.worlds[player].location_name_to_id:
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
else:
logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.")
world_excluded_locations.add(location_name)
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
location.progress_type = LocationProgressType.PRIORITY
# Set local and non-local item rules.
if multiworld.players > 1:
@@ -155,7 +146,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# Because some worlds don't actually create items during create_items this has to be as late as possible.
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
new_items: List[Item] = []
old_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options,
"start_inventory_from_pool",
@@ -174,26 +164,97 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items
if not target:
old_items.extend(multiworld.itempool[i+1:])
new_items.extend(multiworld.itempool[i+1:])
break
else:
old_items.append(item)
new_items.append(item)
# leftovers?
if target:
for player, remaining_items in depletion_pool.items():
remaining_items = {name: count for name, count in remaining_items.items() if count}
if remaining_items:
logger.warning(f"{multiworld.get_player_name(player)}"
raise Exception(f"{multiworld.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}")
# find all filler we generated for the current player and remove until it matches
removables = [item for item in new_items if item.player == player]
for _ in range(sum(remaining_items.values())):
new_items.remove(removables.pop())
assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change."
multiworld.itempool[:] = new_items + old_items
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
multiworld.itempool[:] = new_items
multiworld.link_items()
# temporary home for item links, should be moved out of Main
for group_id, group in multiworld.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
]:
classifications: Dict[str, int] = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in multiworld.itempool:
if item.player in counters and item.name in shared_pool:
counters[item.player][item.name] += 1
classifications[item.name] |= item.classification
for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player)
del (counters[player])
if not players:
return None, None
for item in shared_pool:
count = min(counters[player][item] for player in players)
if count:
for player in players:
counters[player][item] = count
else:
for player in players:
del (counters[player][item])
return counters, classifications
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count:
continue
new_itempool: List[Item] = []
for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count):
new_item = group["world"].create_item(item_name)
# mangle together all original classification bits
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)
region = Region("Menu", group_id, multiworld, "ItemLink")
multiworld.regions.append(region)
locations = region.locations
for item in multiworld.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}",
None, region)
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
state.has(item_name, group_id_, count_)
locations.append(loc)
loc.place_locked_item(item)
common_item_count[item.player][item.name] -= 1
else:
new_itempool.append(item)
itemcount = len(multiworld.itempool)
multiworld.itempool = new_itempool
while itemcount > len(multiworld.itempool):
items_to_add = []
for player in group["players"]:
if group["link_replacement"]:
item_player = group_id
else:
item_player = player
if group["replacement_items"][player]:
items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player))
multiworld.random.shuffle(items_to_add)
multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)])
if any(multiworld.item_links.values()):
multiworld._all_state = None
@@ -338,7 +399,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"seed_name": multiworld.seed_name,
"spheres": spheres,
"datapackage": data_package,
"race_mode": int(multiworld.is_race),
}
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
@@ -351,7 +411,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result():
if not multiworld.can_beat_game():
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld)
raise Exception("Game appears as unbeatable. Aborting.")
else:
logger.warning("Location Accessibility requirements not fulfilled.")

View File

@@ -75,13 +75,13 @@ def update(yes: bool = False, force: bool = False) -> None:
if not update_ran:
update_ran = True
install_pkg_resources(yes=yes)
import pkg_resources
if force:
update_command()
return
install_pkg_resources(yes=yes)
import pkg_resources
prev = "" # if a line ends in \ we store here and merge later
for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import argparse
import asyncio
import collections
import contextlib
import copy
import datetime
import functools
@@ -15,7 +14,6 @@ import math
import operator
import pickle
import random
import shlex
import threading
import time
import typing
@@ -39,7 +37,7 @@ except ImportError:
import NetUtils
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
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore
@@ -68,21 +66,6 @@ def update_dict(dictionary, entries):
return dictionary
def queue_gc():
import gc
from threading import Thread
gc_thread: typing.Optional[Thread] = getattr(queue_gc, "_thread", None)
def async_collect():
time.sleep(2)
setattr(queue_gc, "_thread", None)
gc.collect()
if not gc_thread:
gc_thread = Thread(target=async_collect)
setattr(queue_gc, "_thread", gc_thread)
gc_thread.start()
# functions callable on storable data on the server by clients
modify_functions = {
# generic:
@@ -185,15 +168,13 @@ class Context:
slot_info: typing.Dict[int, NetworkSlot]
generator_version = Version(0, 0, 0)
checksums: typing.Dict[str, str]
item_names: typing.Dict[str, typing.Dict[int, str]] = (
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')))
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[str, typing.Dict[int, str]] = (
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')))
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
non_hintable_names: typing.Dict[str, typing.Set[str]]
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
""" each sphere is { player: { location_id, ... } } """
logger: logging.Logger
@@ -248,7 +229,7 @@ class Context:
self.embedded_blacklist = {"host", "port"}
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
self.auto_save_interval = 60 # in seconds
self.auto_saver_thread: typing.Optional[threading.Thread] = None
self.auto_saver_thread = None
self.save_dirty = False
self.tags = ['AP']
self.games: typing.Dict[int, str] = {}
@@ -285,31 +266,19 @@ class Context:
for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.non_hintable_names[world_name] = world.hint_blacklist
for game_package in self.gamespackage.values():
# remove groups from data sent to clients
del game_package["item_name_groups"]
del game_package["location_name_groups"]
def _init_game_data(self):
for game_name, game_package in self.gamespackage.items():
if "checksum" in game_package:
self.checksums[game_name] = game_package["checksum"]
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[game_name][item_id] = item_name
self.item_names[item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[game_name][location_id] = location_name
self.location_names[location_id] = location_name
self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
self.all_location_and_group_names[game_name] = \
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
archipelago_item_names = self.item_names["Archipelago"]
archipelago_location_names = self.location_names["Archipelago"]
for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
# Add Archipelago items and locations to each data package.
self.item_names[game].update(archipelago_item_names)
self.location_names[game].update(archipelago_location_names)
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
@@ -428,8 +397,6 @@ class Context:
use_embedded_server_options: bool):
self.read_data = {}
# there might be a better place to put this.
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
@@ -569,9 +536,6 @@ class Context:
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
else:
self.save_dirty = False
if not atexit_save: # if atexit is used, that keeps a reference anyway
queue_gc()
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start()
@@ -819,7 +783,10 @@ async def on_client_connected(ctx: Context, client: Client):
for slot, connected_clients in clients.items():
if connected_clients:
name = ctx.player_names[team, slot]
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
players.append(
NetworkPlayer(team, slot,
ctx.name_aliases.get((team, slot), name), name)
)
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago")
await ctx.send_msgs(client, [{
@@ -834,6 +801,8 @@ async def on_client_connected(ctx: Context, client: Client):
'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
'datapackage_versions': {game: game_data["version"] for game, game_data
in ctx.gamespackage.items() if game in games},
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
'seed_name': ctx.seed_name,
@@ -1012,7 +981,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
collect_player(ctx, team, group, True)
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[typing.Tuple[int, int]]:
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
@@ -1037,8 +1006,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
send_items_to(ctx, team, target_player, new_item)
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
ctx.player_names[(team, target_player)], ctx.location_names[location]))
info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text])
@@ -1092,8 +1061,8 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
f"{ctx.item_names[hint.item]} is " \
f"at {ctx.location_names[hint.location]} " \
f"in {ctx.player_names[team, hint.finding_player]}'s World"
if hint.entrance:
@@ -1122,6 +1091,28 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
"item": net_item}
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
picks = Utils.get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 100:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
elif dif > 5:
return picks[0][0], True, "Close Match"
else:
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
else:
if picks[0][1] > 90:
return picks[0][0], True, "Only Option Match"
else:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
class CommandMeta(type):
def __new__(cls, name, bases, attrs):
commands = attrs["commands"] = {}
@@ -1153,7 +1144,7 @@ class CommandProcessor(metaclass=CommandMeta):
if not raw:
return
try:
command = shlex.split(raw, comments=False)
command = raw.split()
basecommand = command[0]
if basecommand[0] == self.marker:
method = self.commands.get(basecommand[1:].lower(), None)
@@ -1224,10 +1215,6 @@ class CommonCommandProcessor(CommandProcessor):
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
@@ -1375,10 +1362,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled":
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
if rest_locations:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
for slot, item_id in rest_locations))
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
return True
@@ -1388,10 +1375,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
return False
else: # is goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
if rest_locations:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
for slot, item_id in rest_locations))
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
return True
@@ -1408,8 +1395,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
if locations:
game = self.ctx.slot_info[self.client.slot].game
names = [self.ctx.location_names[game][location] for location in locations]
names = [self.ctx.location_names[location] for location in locations]
if filter_text:
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
if filter_text in location_groups: # location group name
@@ -1434,8 +1420,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
if locations:
game = self.ctx.slot_info[self.client.slot].game
names = [self.ctx.location_names[game][location] for location in locations]
names = [self.ctx.location_names[location] for location in locations]
if filter_text:
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
if filter_text in location_groups: # location group name
@@ -1516,10 +1501,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
elif input_text.isnumeric():
game = self.ctx.games[self.client.slot]
hint_id = int(input_text)
hint_name = self.ctx.item_names[game][hint_id] \
if not for_location and hint_id in self.ctx.item_names[game] \
else self.ctx.location_names[game][hint_id] \
if for_location and hint_id in self.ctx.location_names[game] \
hint_name = self.ctx.item_names[hint_id] \
if not for_location and hint_id in self.ctx.item_names \
else self.ctx.location_names[hint_id] \
if for_location and hint_id in self.ctx.location_names \
else None
if hint_name in self.ctx.non_hintable_names[game]:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
@@ -1957,6 +1942,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool:
"""Shutdown the server"""
self.ctx.server.ws_server.close()
if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set()
return True
@@ -2064,8 +2051,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
item_name, usable, response = get_intended_text(item_name, names)
if usable:
amount: int = int(amount)
if amount > 100:
raise ValueError(f"{amount} is invalid. Maximum is 100.")
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
send_items_to(self.ctx, team, slot, *new_items)
@@ -2316,8 +2301,7 @@ def parse_args() -> argparse.Namespace:
async def auto_shutdown(ctx, to_cancel=None):
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)
await asyncio.sleep(ctx.auto_shutdown)
def inactivity_shutdown():
ctx.server.ws_server.close()
@@ -2337,8 +2321,7 @@ async def auto_shutdown(ctx, to_cancel=None):
if seconds < 0:
inactivity_shutdown()
else:
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(ctx.exit_event.wait(), seconds)
await asyncio.sleep(seconds)
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":

View File

@@ -79,7 +79,6 @@ class NetworkItem(typing.NamedTuple):
item: int
location: int
player: int
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
flags: int = 0
@@ -199,8 +198,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
"slateblue": "6D8BE8",
"plum": "AF99EF",
"salmon": "FA8072",
"white": "FFFFFF",
"orange": "FF7700",
"white": "FFFFFF"
}
def __init__(self, ctx):
@@ -249,7 +247,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_item_id(self, node: JSONMessagePart):
item_id = int(node["text"])
node["text"] = self.ctx.item_names.lookup_in_slot(item_id, node["player"])
node["text"] = self.ctx.item_names[item_id]
return self._handle_item_name(node)
def _handle_location_name(self, node: JSONMessagePart):
@@ -257,8 +255,8 @@ class JSONtoTextParser(metaclass=HandlerMeta):
return self._handle_color(node)
def _handle_location_id(self, node: JSONMessagePart):
location_id = int(node["text"])
node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"])
item_id = int(node["text"])
node["text"] = self.ctx.location_names[item_id]
return self._handle_location_name(node)
def _handle_entrance_name(self, node: JSONMessagePart):
@@ -273,8 +271,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47,
'plum': 35, 'slateblue': 34, 'salmon': 31,} # convert ui colors to terminal colors
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
def color_code(*args):
@@ -399,12 +396,12 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
location_id not in checked]
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
) -> typing.List[typing.Tuple[int, int]]:
) -> typing.List[int]:
checked = state[team, slot]
player_locations = self[slot]
return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for
location_id in player_locations if
location_id not in checked])
return sorted([player_locations[location_id][0] for
location_id in player_locations if
location_id not in checked])
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub

View File

@@ -8,17 +8,15 @@ import numbers
import random
import typing
import enum
from collections import defaultdict
from copy import deepcopy
from dataclasses import dataclass
from schema import And, Optional, Or, Schema
from typing_extensions import Self
from Utils import get_fuzzy_results, is_iterable_except_str, output_path
from Utils import get_fuzzy_results, is_iterable_except_str
if typing.TYPE_CHECKING:
from BaseClasses import MultiWorld, PlandoOptions
from BaseClasses import PlandoOptions
from worlds.AutoWorld import World
import pathlib
@@ -54,8 +52,8 @@ class AssembleOptions(abc.ABCMeta):
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options)
# apply aliases, without name_lookup
aliases = attrs["aliases"] = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")}
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")}
assert (
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
@@ -127,28 +125,10 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
# can be weighted between selections
supports_weighting = True
rich_text_doc: typing.Optional[bool] = None
"""Whether the WebHost should render the Option's docstring as rich text.
If this is True, the Option's docstring is interpreted as reStructuredText_,
the standard Python markup format. In the WebHost, it's rendered to HTML so
that lists, emphasis, and other rich text features are displayed properly.
If this is False, the docstring is instead interpreted as plain text, and
displayed as-is on the WebHost with whitespace preserved.
If this is None, it inherits the value of `World.rich_text_options_doc`. For
backwards compatibility, this defaults to False, but worlds are encouraged to
set it to True and use reStructuredText for their Option documentation.
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
"""
# filled by AssembleOptions:
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore
options: typing.ClassVar[typing.Dict[str, int]]
aliases: typing.ClassVar[typing.Dict[str, int]]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.current_option_name})"
@@ -754,12 +734,6 @@ class NamedRange(Range):
elif value > self.range_end and value not in self.special_range_names.values():
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}")
# See docstring
for key in self.special_range_names:
if key != key.lower():
raise Exception(f"{self.__class__.__name__} has an invalid special_range_names key: {key}. "
f"NamedRange keys must use only lowercase letters, and ideally should be snake_case.")
self.value = value
@classmethod
@@ -787,22 +761,17 @@ class VerifyKeys(metaclass=FreezeValidKeys):
verify_location_name: bool = False
value: typing.Any
def verify_keys(self) -> None:
if self.valid_keys:
data = set(self.value)
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
extra = dataset - self._valid_keys
@classmethod
def verify_keys(cls, data: typing.Iterable[str]) -> None:
if cls.valid_keys:
data = set(data)
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
extra = dataset - cls._valid_keys
if extra:
raise OptionError(
f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
f"Allowed keys: {self._valid_keys}."
)
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls._valid_keys}.")
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
try:
self.verify_keys()
except OptionError as validation_error:
raise OptionError(f"Player {player_name} has invalid option keys:\n{validation_error}")
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
@@ -839,6 +808,7 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
@classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
if type(data) == dict:
cls.verify_keys(data)
return cls(data)
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
@@ -884,6 +854,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -909,6 +880,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -924,297 +896,26 @@ class ItemSet(OptionSet):
convert_name_groups = True
class PlandoText(typing.NamedTuple):
at: str
text: typing.List[str]
percentage: int = 100
PlandoTextsFromAnyType = typing.Union[
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoText, typing.Any]], typing.Any
]
class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
default = ()
supports_weighting = False
display_name = "Plando Texts"
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
self.value = list(deepcopy(value))
super().__init__()
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
from BaseClasses import PlandoOptions
if self.value and not (PlandoOptions.texts & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando texts module is turned off, "
f"so text for {player_name} will be ignored.")
else:
super().verify(world, player_name, plando_options)
def verify_keys(self) -> None:
if self.valid_keys:
data = set(text.at for text in self)
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
extra = dataset - self._valid_keys
if extra:
raise OptionError(
f"Invalid \"at\" placement {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
f"Allowed placements: {self._valid_keys}."
)
@classmethod
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
texts: typing.List[PlandoText] = []
if isinstance(data, typing.Iterable):
for text in data:
if isinstance(text, typing.Mapping):
if random.random() < float(text.get("percentage", 100)/100):
at = text.get("at", None)
if at is not None:
if isinstance(at, dict):
if at:
at = random.choices(list(at.keys()),
weights=list(at.values()), k=1)[0]
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
given_text = text.get("text", [])
if isinstance(given_text, dict):
if not given_text:
given_text = []
else:
given_text = random.choices(list(given_text.keys()),
weights=list(given_text.values()), k=1)
if isinstance(given_text, str):
given_text = [given_text]
texts.append(PlandoText(
at,
given_text,
text.get("percentage", 100)
))
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText):
if random.random() < float(text.percentage/100):
texts.append(text)
else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
return cls(texts)
else:
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
@classmethod
def get_option_name(cls, value: typing.List[PlandoText]) -> str:
return str({text.at: " ".join(text.text) for text in value})
def __iter__(self) -> typing.Iterator[PlandoText]:
yield from self.value
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
return self.value.__getitem__(index)
def __len__(self) -> int:
return self.value.__len__()
class ConnectionsMeta(AssembleOptions):
def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any]):
if name != "PlandoConnections":
assert "entrances" in attrs, f"Please define valid entrances for {name}"
attrs["entrances"] = frozenset((connection.lower() for connection in attrs["entrances"]))
assert "exits" in attrs, f"Please define valid exits for {name}"
attrs["exits"] = frozenset((connection.lower() for connection in attrs["exits"]))
if "__doc__" not in attrs:
attrs["__doc__"] = PlandoConnections.__doc__
cls = super().__new__(mcs, name, bases, attrs)
return cls
class PlandoConnection(typing.NamedTuple):
class Direction:
entrance = "entrance"
exit = "exit"
both = "both"
entrance: str
exit: str
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
percentage: int = 100
PlandoConFromAnyType = typing.Union[
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoConnection, typing.Any]], typing.Any
]
class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=ConnectionsMeta):
"""Generic connections plando. Format is:
- entrance: "Entrance Name"
exit: "Exit Name"
direction: "Direction"
percentage: 100
Direction must be one of 'entrance', 'exit', or 'both', and defaults to 'both' if omitted.
Percentage is an integer from 1 to 100, and defaults to 100 when omitted."""
display_name = "Plando Connections"
default = ()
supports_weighting = False
entrances: typing.ClassVar[typing.AbstractSet[str]]
exits: typing.ClassVar[typing.AbstractSet[str]]
duplicate_exits: bool = False
"""Whether or not exits should be allowed to be duplicate."""
def __init__(self, value: typing.Iterable[PlandoConnection]):
self.value = list(deepcopy(value))
super(PlandoConnections, self).__init__()
@classmethod
def validate_entrance_name(cls, entrance: str) -> bool:
return entrance.lower() in cls.entrances
@classmethod
def validate_exit_name(cls, exit: str) -> bool:
return exit.lower() in cls.exits
@classmethod
def can_connect(cls, entrance: str, exit: str) -> bool:
"""Checks that a given entrance can connect to a given exit.
By default, this will always return true unless overridden."""
return True
@classmethod
def validate_plando_connections(cls, connections: typing.Iterable[PlandoConnection]) -> None:
used_entrances: typing.List[str] = []
used_exits: typing.List[str] = []
for connection in connections:
entrance = connection.entrance
exit = connection.exit
direction = connection.direction
if direction not in (PlandoConnection.Direction.entrance,
PlandoConnection.Direction.exit,
PlandoConnection.Direction.both):
raise ValueError(f"Unknown direction: {direction}")
if entrance in used_entrances:
raise ValueError(f"Duplicate Entrance {entrance} not allowed.")
if not cls.duplicate_exits and exit in used_exits:
raise ValueError(f"Duplicate Exit {exit} not allowed.")
used_entrances.append(entrance)
used_exits.append(exit)
if not cls.validate_entrance_name(entrance):
raise ValueError(f"{entrance.title()} is not a valid entrance.")
if not cls.validate_exit_name(exit):
raise ValueError(f"{exit.title()} is not a valid exit.")
if not cls.can_connect(entrance, exit):
raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")
@classmethod
def from_any(cls, data: PlandoConFromAnyType) -> Self:
if not isinstance(data, typing.Iterable):
raise Exception(f"Cannot create plando connections from non-List value, got {type(data)}.")
value: typing.List[PlandoConnection] = []
for connection in data:
if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100)
if random.random() < float(percentage / 100):
entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance))
exit = connection.get("exit", None)
if is_iterable_except_str(exit):
exit = random.choice(sorted(exit))
direction = connection.get("direction", "both")
if not entrance or not exit:
raise Exception("Plando connection must have an entrance and an exit.")
value.append(PlandoConnection(
entrance,
exit,
direction,
percentage
))
elif isinstance(connection, PlandoConnection):
if random.random() < float(connection.percentage / 100):
value.append(connection)
else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
cls.validate_plando_connections(value)
return cls(value)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
from BaseClasses import PlandoOptions
if self.value and not (PlandoOptions.connections & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando connections module is turned off, "
f"so connections for {player_name} will be ignored.")
@classmethod
def get_option_name(cls, value: typing.List[PlandoConnection]) -> str:
return ", ".join(["%s %s %s" % (connection.entrance,
"<=>" if connection.direction == PlandoConnection.Direction.both else
"<=" if connection.direction == PlandoConnection.Direction.exit else
"=>",
connection.exit) for connection in value])
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoConnection]:
yield from self.value
def __len__(self) -> int:
return len(self.value)
class Accessibility(Choice):
"""
Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
"""
"""Set rules for reachability of your items/locations.
Locations: ensure everything can be reached and acquired.
Items: ensure all logically relevant items can be acquired.
Minimal: ensure what is needed to reach your goal can be acquired."""
display_name = "Accessibility"
rich_text_doc = True
option_full = 0
option_locations = 0
option_items = 1
option_minimal = 2
alias_none = 2
alias_locations = 0
alias_items = 0
default = 0
class ItemsAccessibility(Accessibility):
"""
Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
**Items:** ensure all logically relevant items can be acquired. Some items, such as keys, may be self-locking, and
some locations may be inaccessible.
"""
option_items = 1
default = 1
class ProgressionBalancing(NamedRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
A lower setting means more getting stuck. A higher setting means less getting stuck.
"""
A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50
range_start = 0
range_end = 99
display_name = "Progression Balancing"
rich_text_doc = True
special_range_names = {
"disabled": 0,
"normal": 50,
@@ -1251,7 +952,6 @@ class CommonOptions(metaclass=OptionsMetaProperty):
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
"""
assert option_names, "options.as_dict() was used without any option names."
option_results = {}
for option_name in option_names:
if option_name in type(self).type_hints:
@@ -1280,36 +980,29 @@ class CommonOptions(metaclass=OptionsMetaProperty):
class LocalItems(ItemSet):
"""Forces these items to be in their native world."""
display_name = "Local Items"
rich_text_doc = True
class NonLocalItems(ItemSet):
"""Forces these items to be outside their native world."""
display_name = "Non-local Items"
rich_text_doc = True
display_name = "Not Local Items"
class StartInventory(ItemDict):
"""Start with these items."""
verify_item_name = True
display_name = "Start Inventory"
rich_text_doc = True
class StartInventoryPool(StartInventory):
"""Start with these items and don't place them in the world.
The game decides what the replacement items will be.
"""
The game decides what the replacement items will be."""
verify_item_name = True
display_name = "Start Inventory from Pool"
rich_text_doc = True
class StartHints(ItemSet):
"""Start with these item's locations prefilled into the ``!hint`` command."""
"""Start with these item's locations prefilled into the !hint command."""
display_name = "Start Hints"
rich_text_doc = True
class LocationSet(OptionSet):
@@ -1318,33 +1011,28 @@ class LocationSet(OptionSet):
class StartLocationHints(LocationSet):
"""Start with these locations and their item prefilled into the ``!hint`` command."""
"""Start with these locations and their item prefilled into the !hint command"""
display_name = "Start Location Hints"
rich_text_doc = True
class ExcludeLocations(LocationSet):
"""Prevent these locations from having an important item."""
"""Prevent these locations from having an important item"""
display_name = "Excluded Locations"
rich_text_doc = True
class PriorityLocations(LocationSet):
"""Prevent these locations from having an unimportant item."""
"""Prevent these locations from having an unimportant item"""
display_name = "Priority Locations"
rich_text_doc = True
class DeathLink(Toggle):
"""When you die, everyone who enabled death link dies. Of course, the reverse is true too."""
"""When you die, everyone dies. Of course the reverse is true too."""
display_name = "Death Link"
rich_text_doc = True
class ItemLinks(OptionList):
"""Share part of your item pool with other players."""
display_name = "Item Links"
rich_text_doc = True
default = []
schema = Schema([
{
@@ -1359,8 +1047,7 @@ class ItemLinks(OptionList):
])
@staticmethod
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world,
allow_item_groups: bool = True) -> typing.Set:
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
pool = set()
for item_name in items:
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
@@ -1411,7 +1098,6 @@ class ItemLinks(OptionList):
class Removed(FreeText):
"""This Option has been Removed."""
rich_text_doc = True
default = ""
visibility = Visibility.none
@@ -1514,18 +1200,14 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
return data, notes
def yaml_dump_scalar(scalar) -> str:
# yaml dump may add end of document marker and newlines.
return yaml.dump(scalar).replace("...\n", "").strip()
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
option_groups = get_option_groups(world)
grouped_options = get_option_groups(world)
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
option_groups=option_groups,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
option_groups=grouped_options,
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range,
)
@@ -1535,40 +1217,29 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
f.write(res)
def dump_player_options(multiworld: MultiWorld) -> None:
from csv import DictWriter
if __name__ == "__main__":
game_players = defaultdict(list)
for player, game in multiworld.game.items():
game_players[game].append(player)
game_players = dict(sorted(game_players.items()))
from worlds.alttp.Options import Logic
import argparse
output = []
per_game_option_names = [
getattr(option, "display_name", option_key)
for option_key, option in PerGameCommonOptions.type_hints.items()
]
all_option_names = per_game_option_names.copy()
for game, players in game_players.items():
game_option_names = per_game_option_names.copy()
for player in players:
world = multiworld.worlds[player]
player_output = {
"Game": multiworld.game[player],
"Name": multiworld.get_player_name(player),
}
output.append(player_output)
for option_key, option in world.options_dataclass.type_hints.items():
if issubclass(Removed, option):
continue
display_name = getattr(option, "display_name", option_key)
player_output[display_name] = getattr(world.options, option_key).current_option_name
if display_name not in game_option_names:
all_option_names.append(display_name)
game_option_names.append(display_name)
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
fields = ["Game", "Name", *all_option_names]
writer = DictWriter(file, fields)
writer.writeheader()
writer.writerows(output)
map_shuffle = Toggle
compass_shuffle = Toggle
key_shuffle = Toggle
big_key_shuffle = Toggle
hints = Toggle
test = argparse.Namespace()
test.logic = Logic.from_text("no_logic")
test.map_shuffle = map_shuffle.from_text("ON")
test.hints = hints.from_text('OFF')
try:
test.logic = Logic.from_text("overworld_glitches_typo")
except KeyError as e:
print(e)
try:
test.logic_owg = Logic.from_text("owg")
except KeyError as e:
print(e)
if test.map_shuffle:
print("map_shuffle is on")
print(f"Hints are {bool(test.hints)}")
print(test)

View File

@@ -72,10 +72,6 @@ Currently, the following games are supported:
* Aquaria
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
* A Hat in Time
* Old School Runescape
* Kingdom Hearts 1
* Mega Man 2
* Yacht Dice
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -29,7 +29,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
def _cmd_patch(self):
"""Patch the game. Only use this command if /auto_patch fails."""
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
self.ctx.patch_game()
self.output("Patched.")
@@ -43,7 +43,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None
@@ -62,7 +62,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll":
shutil.copy(os.path.join(tempInstall, file_name),
Utils.user_path("Undertale", file_name))
os.path.join(os.getcwd(), "Undertale", file_name))
self.ctx.patch_game()
self.output("Patching successful!")
@@ -111,12 +111,12 @@ class UndertaleContext(CommonContext):
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def patch_game(self):
with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f:
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
with open(Utils.user_path("Undertale", "data.win"), "wb") as f:
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f:
f.write(patchedFile)
os.makedirs(name=Utils.user_path("Undertale", "Custom Sprites"), exist_ok=True)
with open(os.path.expandvars(Utils.user_path("Undertale", "Custom Sprites",
os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites",
"Which Character.txt")), "w") as f:
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
"line other than this one.\n", "frisk"])
@@ -247,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
toDraw = ""
for i in range(20):
if i < len(str(ctx.item_names.lookup_in_game(l.item))):
toDraw += str(ctx.item_names.lookup_in_game(l.item))[i]
if i < len(str(ctx.item_names[l.item])):
toDraw += str(ctx.item_names[l.item])[i]
else:
break
f.write(toDraw)

View File

@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.5.1"
__version__ = "0.4.6"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -458,15 +458,6 @@ class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any]
def __init__(self,
default_factory: typing.Callable[[Any], Any] = None,
seq: typing.Union[typing.Mapping, typing.Iterable, None] = None,
**kwargs):
if seq is not None:
super().__init__(default_factory, seq, **kwargs)
else:
super().__init__(default_factory, **kwargs)
def __missing__(self, key):
self[key] = value = self.default_factory(key)
return value
@@ -553,7 +544,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
f"Archipelago ({__version__}) logging initialized"
f" on {platform.platform()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
f"{' (frozen)' if is_frozen() else ''}"
)
@@ -629,41 +619,6 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
)
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 100:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
elif dif > 5:
return picks[0][0], True, "Close Match"
else:
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
else:
if picks[0][1] > 90:
return picks[0][0], True, "Only Option Match"
else:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
if "did you mean " in text:
for question in ("Didn't find something that closely matches",
"Too many close matches"):
if text.startswith(question):
name = get_text_between(text, "did you mean '",
"'? (")
return f"!{command} {name}"
elif text.startswith("Missing: "):
return text.replace("Missing: ", "!hint_location ")
return None
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.")

View File

@@ -176,7 +176,7 @@ class WargrooveContext(CommonContext):
if not os.path.isfile(path):
open(path, 'w').close()
# Announcing commander unlocks
item_name = self.item_names.lookup_in_game(network_item.item)
item_name = self.item_names[network_item.item]
if item_name in faction_table.keys():
for commander in faction_table[item_name]:
logger.info(f"{commander.name} has been unlocked!")
@@ -197,7 +197,7 @@ class WargrooveContext(CommonContext):
open(print_path, 'w').close()
with open(print_path, 'w') as f:
f.write("Received " +
self.item_names.lookup_in_game(network_item.item) +
self.item_names[network_item.item] +
" from " +
self.player_names[network_item.player])
f.close()
@@ -267,7 +267,9 @@ class WargrooveContext(CommonContext):
def build(self):
container = super().build()
self.add_client_tab("Wargroove", self.build_tracker())
panel = TabbedPanelItem(text="Wargroove")
panel.content = self.build_tracker()
self.tabs.add_widget(panel)
return container
def build_tracker(self) -> TrackerLayout:
@@ -340,7 +342,7 @@ class WargrooveContext(CommonContext):
faction_items = 0
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
for network_item in self.items_received:
if self.item_names.lookup_in_game(network_item.item) in faction_item_names:
if self.item_names[network_item.item] in faction_item_names:
faction_items += 1
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
# Must be an integer larger than 0

View File

@@ -1,4 +1,3 @@
import argparse
import os
import multiprocessing
import logging
@@ -13,9 +12,6 @@ ModuleUpdate.update()
import Utils
import settings
if typing.TYPE_CHECKING:
from flask import Flask
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
settings.no_gui = True
configpath = os.path.abspath("config.yaml")
@@ -23,7 +19,7 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app() -> "Flask":
def get_app():
from WebHostLib import register, cache, app as raw_app
from WebHostLib.models import db
@@ -32,15 +28,6 @@ def get_app() -> "Flask":
import yaml
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
parser = argparse.ArgumentParser()
parser.add_argument('--config_override', default=None,
help="Path to yaml config file that overrules config.yaml.")
args = parser.parse_known_args()[0]
if args.config_override:
import yaml
app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
logging.info(f"Updated config from {args.config_override}")
if not app.config["HOST_ADDRESS"]:
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
@@ -68,7 +55,6 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items():
# copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, game)

View File

@@ -1,15 +1,78 @@
"""API endpoints package."""
from typing import List, Tuple
from uuid import UUID
from flask import Blueprint
from flask import Blueprint, abort, url_for
from ..models import Seed
import worlds.Files
from .. import cache
from ..models import Room, Seed
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
# unsorted/misc endpoints
def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots]
from . import datapackage, generate, room, user # trigger registration
@api_endpoints.route('/room_status/<suuid:room>')
def room_info(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
def supports_apdeltapatch(game: str):
return game in worlds.Files.AutoPatchRegister.patch_types
downloads = []
for slot in sorted(room.seed.slots):
if slot.data and not supports_apdeltapatch(slot.game):
slot_download = {
"slot": slot.player_id,
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
}
downloads.append(slot_download)
elif slot.data:
slot_download = {
"slot": slot.player_id,
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
}
downloads.append(slot_download)
return {
"tracker": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout,
"downloads": downloads,
}
@api_endpoints.route('/datapackage')
@cache.cached()
def get_datapackage():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage_version')
@cache.cached()
def get_datapackage_versions():
from worlds import AutoWorldRegister
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
return version_package
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package
from . import generate, user # trigger registration

View File

@@ -1,32 +0,0 @@
from flask import abort
from Utils import restricted_loads
from WebHostLib import cache
from WebHostLib.models import GameDataPackage
from . import api_endpoints
@api_endpoints.route('/datapackage')
@cache.cached()
def get_datapackage():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage/<string:checksum>')
@cache.memoize(timeout=3600)
def get_datapackage_by_checksum(checksum: str):
package = GameDataPackage.get(checksum=checksum)
if package:
return restricted_loads(package.data)
return abort(404)
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package

View File

@@ -1,42 +0,0 @@
from typing import Any, Dict
from uuid import UUID
from flask import abort, url_for
import worlds.Files
from . import api_endpoints, get_players
from ..models import Room
@api_endpoints.route('/room_status/<suuid:room_id>')
def room_info(room_id: UUID) -> Dict[str, Any]:
room = Room.get(id=room_id)
if room is None:
return abort(404)
def supports_apdeltapatch(game: str) -> bool:
return game in worlds.Files.AutoPatchRegister.patch_types
downloads = []
for slot in sorted(room.seed.slots):
if slot.data and not supports_apdeltapatch(slot.game):
slot_download = {
"slot": slot.player_id,
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
}
downloads.append(slot_download)
elif slot.data:
slot_download = {
"slot": slot.player_id,
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
}
downloads.append(slot_download)
return {
"tracker": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout,
"downloads": downloads,
}

View File

@@ -72,14 +72,6 @@ class WebHostContext(Context):
self.video = {}
self.tags = ["AP", "WebHost"]
def __del__(self):
try:
import psutil
from Utils import format_SI_prefix
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
except ImportError:
self.logger.debug("Context destroyed")
def _load_game_data(self):
for key, value in self.static_server_data.items():
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
@@ -176,28 +168,17 @@ def get_random_port():
def get_static_server_data() -> dict:
import worlds
data = {
"non_hintable_names": {
world_name: world.hint_blacklist
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"gamespackage": {
world_name: {
key: value
for key, value in game_package.items()
if key not in ("item_name_groups", "location_name_groups")
}
for world_name, game_package in worlds.network_data_package["games"].items()
},
"item_name_groups": {
world_name: world.item_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"location_name_groups": {
world_name: world.location_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"non_hintable_names": {},
"gamespackage": worlds.network_data_package["games"],
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["non_hintable_names"][world_name] = world.hint_blacklist
return data
@@ -257,7 +238,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
ctx = WebHostContext(static_server_data, logger)
ctx.load(room_id)
ctx.init_save()
assert ctx.server is None
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
@@ -286,16 +266,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
if ctx.saving:
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
assert ctx.shutdown_task is None
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
except (KeyboardInterrupt, SystemExit):
if ctx.saving:
ctx._save()
setattr(asyncio.current_task(), "save", None)
except Exception as e:
with db_session:
room = Room.get(id=room_id)
@@ -305,12 +281,8 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
else:
if ctx.saving:
ctx._save()
setattr(asyncio.current_task(), "save", None)
finally:
try:
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
with (db_session):
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
@@ -322,34 +294,13 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
rooms_shutting_down.put(room_id)
class Starter(threading.Thread):
_tasks: typing.List[asyncio.Future]
def __init__(self):
super().__init__()
self._tasks = []
def _done(self, task: asyncio.Future):
self._tasks.remove(task)
task.result()
def run(self):
while 1:
next_room = rooms_to_run.get(block=True, timeout=None)
gc.collect()
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task)
task.add_done_callback(self._done)
asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
logging.info(f"Starting room {next_room} on {name}.")
del task # delete reference to task object
starter = Starter()
starter.daemon = True
starter.start()
try:
loop.run_forever()
finally:
# save all tasks that want to be saved during shutdown
for task in asyncio.all_tasks(loop):
save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None)
if save:
save()
loop.run_forever()

View File

@@ -81,7 +81,6 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
f"If you have a larger group, please generate it yourself and upload it.")
return redirect(url_for(request.endpoint, **(request.view_args or {})))
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
@@ -135,7 +134,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
{"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False
erargs.skip_output = False
erargs.csv_output = False
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):

View File

@@ -1,6 +1,6 @@
import datetime
import os
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
from typing import List, Dict, Union
import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
@@ -97,91 +97,49 @@ def new_room(seed: UUID):
return redirect(url_for("host_room", room=room.id))
def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]:
marker = log.read(3) # skip optional BOM
if marker != b'\xEF\xBB\xBF':
log.seek(0, os.SEEK_SET)
log.seek(offset, os.SEEK_CUR)
yield from log
log.close() # free file handle as soon as possible
def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>')
def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
def display_log(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
file_path = os.path.join("logs", str(room.id) + ".txt")
try:
log = open(file_path, "rb")
range_header = request.headers.get("Range")
if range_header:
range_type, range_values = range_header.split('=')
start, end = map(str.strip, range_values.split('-', 1))
if range_type != "bytes" or end != "":
return "Unsupported range", 500
# NOTE: we skip Content-Range in the response here, which isn't great but works for our JS
return Response(_read_log(log, int(start)), mimetype="text/plain", status=206)
return Response(_read_log(log), mimetype="text/plain")
except FileNotFoundError:
return Response(f"Logfile {file_path} does not exist. "
f"Likely a crash during spinup of multiworld instance or it is still spinning up.",
mimetype="text/plain")
if os.path.exists(file_path):
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
return "Log File does not exist."
return "Access Denied", 403
@app.post("/room/<suuid:room>")
def host_room_command(room: UUID):
room: Room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
return redirect(url_for("host_room", room=room.id))
@app.get("/room/<suuid:room>")
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
room: Room = Room.get(id=room)
if room is None:
return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
return redirect(url_for("host_room", room=room.id))
now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running
browser_tokens = "Mozilla", "Chrome", "Safari"
automated = ("update" in request.args
or "Discordbot" in request.user_agent.string
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
def get_log(max_size: int = 0 if automated else 1024000) -> str:
if max_size == 0:
return ""
try:
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
raw_size = 0
fragments: List[str] = []
for block in _read_log(log):
if raw_size + len(block) > max_size:
fragments.append("")
break
raw_size += len(block)
fragments.append(block.decode("utf-8"))
return "".join(fragments)
except FileNotFoundError:
return ""
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
@app.route('/favicon.ico')

View File

@@ -3,7 +3,6 @@ import json
import os
from textwrap import dedent
from typing import Dict, Union
from docutils.core import publish_parts
import yaml
from flask import redirect, render_template, request, Response
@@ -67,22 +66,6 @@ def filter_dedent(text: str) -> str:
return dedent(text).strip("\n ")
@app.template_filter("rst_to_html")
def filter_rst_to_html(text: str) -> str:
"""Converts reStructuredText (such as a Python docstring) to HTML."""
if text.startswith(" ") or text.startswith("\t"):
text = dedent(text)
elif "\n" in text:
lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
'raw_enable': False,
'file_insertion_enabled': False,
'output_encoding': 'unicode'
})['body']
@app.template_test("ordered")
def test_ordered(obj):
return isinstance(obj, collections.abc.Sequence)
@@ -93,34 +76,6 @@ def test_ordered(obj):
def option_presets(game: str) -> Response:
world = AutoWorldRegister.world_types[game]
presets = {}
for preset_name, preset in world.web.options_presets.items():
presets[preset_name] = {}
for preset_option_name, preset_option in preset.items():
if preset_option == "random":
presets[preset_name][preset_option_name] = preset_option
continue
option = world.options_dataclass.type_hints[preset_option_name].from_any(preset_option)
if isinstance(option, Options.NamedRange) and isinstance(preset_option, str):
assert preset_option in option.special_range_names, \
f"Invalid preset value '{preset_option}' for '{preset_option_name}' in '{preset_name}'. " \
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
presets[preset_name][preset_option_name] = option.value
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
presets[preset_name][preset_option_name] = option.value
elif isinstance(preset_option, str):
# Ensure the option value is valid for Choice and Toggle options
assert option.name_lookup[option.value] == preset_option, \
f"Invalid option value '{preset_option}' for '{preset_option_name}' in preset '{preset_name}'. " \
f"Values must not be resolved to a different option via option.from_text (or an alias)."
# Use the name of the option
presets[preset_name][preset_option_name] = option.current_key
else:
# Use the name of the option
presets[preset_name][preset_option_name] = option.current_key
class SetEncoder(json.JSONEncoder):
def default(self, obj):
from collections.abc import Set
@@ -128,7 +83,7 @@ def option_presets(game: str) -> Response:
return list(obj)
return json.JSONEncoder.default(self, obj)
json_data = json.dumps(presets, cls=SetEncoder)
json_data = json.dumps(world.web.options_presets, cls=SetEncoder)
response = Response(json_data)
response.headers["Content-Type"] = "application/json"
return response
@@ -231,13 +186,6 @@ def generate_yaml(game: str):
del options[key]
# Detect keys which end with -range, indicating a NamedRange with a possible custom value
elif key_parts[-1].endswith("-range"):
if options[key_parts[-1][:-6]] == "custom":
options[key_parts[-1][:-6]] = val
del options[key]
# Detect random-* keys and set their options accordingly
for key, val in options.copy().items():
if key.startswith("random-"):

View File

@@ -1,11 +1,9 @@
flask>=3.0.3
werkzeug>=3.0.4
pony>=0.7.19
waitress>=3.0.0
Flask-Caching>=2.3.0
Flask-Compress>=1.15
Flask-Limiter>=3.8.0
flask>=3.0.0
pony>=0.7.17
waitress>=2.1.2
Flask-Caching>=2.1.0
Flask-Compress>=1.14
Flask-Limiter>=3.5.0
bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.4.3; python_version == '3.9'
bokeh>=3.5.2; python_version >= '3.10'
markupsafe>=2.1.5
bokeh>=3.3.2; python_version >= '3.9'
markupsafe>=2.1.3

View File

@@ -8,8 +8,7 @@ from . import cache
def robots():
# If this host is not official, do not allow search engine crawling
if not app.config["ASSET_RIGHTS"]:
# filename changed in case the path is intercepted and served by an outside service
return app.send_static_file('robots_file.txt')
return app.send_static_file('robots.txt')
# Send 404 if the host has affirmed this to be the official WebHost
abort(404)

View File

@@ -77,4 +77,4 @@ There, you will find examples of games in the `worlds` folder:
You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.

View File

@@ -27,7 +27,7 @@ const adjustTableHeight = () => {
* @returns {string}
*/
const secondsToHours = (seconds) => {
let hours = Math.floor(seconds / 3600);
let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
return `${hours}:${minutes}`;
};
@@ -38,18 +38,18 @@ window.addEventListener('load', () => {
info: false,
dom: "t",
stateSave: true,
stateSaveCallback: function (settings, data) {
stateSaveCallback: function(settings, data) {
delete data.search;
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
},
stateLoadCallback: function (settings) {
stateLoadCallback: function(settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
},
footerCallback: function (tfoot, data, start, end, display) {
footerCallback: function(tfoot, data, start, end, display) {
if (tfoot) {
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
}
},
columnDefs: [
@@ -123,64 +123,49 @@ window.addEventListener('load', () => {
event.preventDefault();
}
});
const target_second = parseInt(document.getElementById('tracker-wrapper').getAttribute('data-second')) + 3;
console.log("Target second of refresh: " + target_second);
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
function getSleepTimeSeconds() {
function getSleepTimeSeconds(){
// -40 % 60 is -40, which is absolutely wrong and should burn
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
return sleepSeconds || 60;
}
let update_on_view = false;
const update = () => {
if (document.hidden) {
console.log("Document reporting as not visible, not updating Tracker...");
update_on_view = true;
} else {
update_on_view = false;
const target = $("<div></div>");
console.log("Updating Tracker...");
target.load(location.href, function (response, status) {
if (status === "success") {
target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr");
const footer_tr = $(new_table).find("tfoot>tr");
const old_table = tables.eq(i);
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.clear();
if (footer_tr.length) {
$(old_table.table).find("tfoot").html(footer_tr);
}
old_table.rows.add(new_trs);
old_table.draw();
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
});
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
} else {
console.log("Failed to connect to Server, in order to update Table Data.");
console.log(response);
}
})
}
updater = setTimeout(update, getSleepTimeSeconds() * 1000);
const target = $("<div></div>");
console.log("Updating Tracker...");
target.load(location.href, function (response, status) {
if (status === "success") {
target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr");
const footer_tr = $(new_table).find("tfoot>tr");
const old_table = tables.eq(i);
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.clear();
if (footer_tr.length) {
$(old_table.table).find("tfoot").html(footer_tr);
}
old_table.rows.add(new_trs);
old_table.draw();
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
});
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
} else {
console.log("Failed to connect to Server, in order to update Table Data.");
console.log(response);
}
})
setTimeout(update, getSleepTimeSeconds()*1000);
}
let updater = setTimeout(update, getSleepTimeSeconds() * 1000);
setTimeout(update, getSleepTimeSeconds()*1000);
window.addEventListener('resize', () => {
adjustTableHeight();
tables.draw();
});
window.addEventListener('visibilitychange', () => {
if (!document.hidden && update_on_view) {
console.log("Page became visible, tracker should be refreshed.");
clearTimeout(updater);
update();
}
});
adjustTableHeight();
});

View File

@@ -58,28 +58,3 @@
overflow-y: auto;
max-height: 400px;
}
.loader{
display: inline-block;
visibility: hidden;
margin-left: 5px;
width: 40px;
aspect-ratio: 4;
--_g: no-repeat radial-gradient(circle closest-side,#fff 90%,#fff0);
background:
var(--_g) 0 50%,
var(--_g) 50% 50%,
var(--_g) 100% 50%;
background-size: calc(100%/3) 100%;
animation: l7 1s infinite linear;
}
.loader.loading{
visibility: visible;
}
@keyframes l7{
33%{background-size:calc(100%/3) 0% ,calc(100%/3) 100%,calc(100%/3) 100%}
50%{background-size:calc(100%/3) 100%,calc(100%/3) 0 ,calc(100%/3) 100%}
66%{background-size:calc(100%/3) 100%,calc(100%/3) 100%,calc(100%/3) 0 }
}

View File

@@ -15,7 +15,7 @@ html {
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
word-break: break-word;
word-break: break-all;
}
#player-options #player-options-header h1 {
margin-bottom: 0;

View File

@@ -16,7 +16,7 @@ html{
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
word-break: break-word;
word-break: break-all;
#player-options-header{
h1{

View File

@@ -12,12 +12,12 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
*/
/* Base styles for the element that has a tooltip */
[data-tooltip], .tooltip-container {
[data-tooltip], .tooltip {
position: relative;
}
/* Base styles for the entire tooltip */
[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after {
position: absolute;
visibility: hidden;
opacity: 0;
@@ -39,15 +39,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
pointer-events: none;
}
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
.tooltip-container:hover .tooltip {
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{
visibility: visible;
opacity: 1;
word-break: break-word;
}
/** Directional arrow styles */
[data-tooltip]:before, .tooltip-container:before {
.tooltip:before, [data-tooltip]:before {
z-index: 10000;
border: 6px solid transparent;
background: transparent;
@@ -55,7 +54,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
}
/** Content styles */
[data-tooltip]:after, .tooltip {
.tooltip:after, [data-tooltip]:after {
width: 260px;
z-index: 10000;
padding: 8px;
@@ -64,26 +63,24 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
background-color: hsla(0, 0%, 20%, 0.9);
color: #fff;
content: attr(data-tooltip);
white-space: pre-wrap;
font-size: 14px;
line-height: 1.2;
}
[data-tooltip]:after {
white-space: pre-wrap;
}
[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
[data-tooltip]:before, [data-tooltip]:after{
visibility: hidden;
opacity: 0;
pointer-events: none;
}
[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after,
.tooltip-top:before, .tooltip-top:after {
bottom: 100%;
left: 50%;
}
[data-tooltip]:before, .tooltip-container:before {
[data-tooltip]:before, .tooltip:before, .tooltip-top:before {
margin-left: -6px;
margin-bottom: -12px;
border-top-color: #000;
@@ -91,19 +88,19 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
}
/** Horizontally align tooltips on the top and bottom */
[data-tooltip]:after, .tooltip {
[data-tooltip]:after, .tooltip:after, .tooltip-top:after {
margin-left: -80px;
}
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
.tooltip-container:hover .tooltip {
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after,
.tooltip-top:hover:before, .tooltip-top:hover:after {
-webkit-transform: translateY(-12px);
-moz-transform: translateY(-12px);
transform: translateY(-12px);
}
/** Tooltips on the left */
.tooltip-left:before, [data-tooltip].tooltip-left:after, .tooltip-left .tooltip {
.tooltip-left:before, .tooltip-left:after {
right: 100%;
bottom: 50%;
left: auto;
@@ -118,14 +115,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-left-color: hsla(0, 0%, 20%, 0.9);
}
.tooltip-left:hover:before, [data-tooltip].tooltip-left:hover:after, .tooltip-left:hover .tooltip {
.tooltip-left:hover:before, .tooltip-left:hover:after {
-webkit-transform: translateX(-12px);
-moz-transform: translateX(-12px);
transform: translateX(-12px);
}
/** Tooltips on the bottom */
.tooltip-bottom:before, [data-tooltip].tooltip-bottom:after, .tooltip-bottom .tooltip {
.tooltip-bottom:before, .tooltip-bottom:after {
top: 100%;
bottom: auto;
left: 50%;
@@ -139,15 +136,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-bottom-color: hsla(0, 0%, 20%, 0.9);
}
.tooltip-bottom:hover:before, [data-tooltip].tooltip-bottom:hover:after,
.tooltip-bottom:hover .tooltip {
.tooltip-bottom:hover:before, .tooltip-bottom:hover:after {
-webkit-transform: translateY(12px);
-moz-transform: translateY(12px);
transform: translateY(12px);
}
/** Tooltips on the right */
.tooltip-right:before, [data-tooltip].tooltip-right:after, .tooltip-right .tooltip {
.tooltip-right:before, .tooltip-right:after {
bottom: 50%;
left: 100%;
}
@@ -160,8 +156,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-right-color: hsla(0, 0%, 20%, 0.9);
}
.tooltip-right:hover:before, [data-tooltip].tooltip-right:hover:after,
.tooltip-right:hover .tooltip {
.tooltip-right:hover:before, .tooltip-right:hover:after {
-webkit-transform: translateX(12px);
-moz-transform: translateX(12px);
transform: translateX(12px);
@@ -173,16 +168,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
}
/** Center content vertically for tooltips ont he left and right */
[data-tooltip].tooltip-left:after, [data-tooltip].tooltip-right:after,
.tooltip-left .tooltip, .tooltip-right .tooltip {
.tooltip-left:after, .tooltip-right:after {
margin-left: 0;
margin-bottom: -16px;
}
.tooltip ul, .tooltip ol {
padding-left: 1rem;
}
.tooltip :last-child {
margin-bottom: 0;
}

View File

@@ -99,18 +99,14 @@
{% if hint.finding_player == player %}
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
{{ player_names_with_alias[(team, hint.finding_player)] }}
</a>
{{ player_names_with_alias[(team, hint.finding_player)] }}
{% endif %}
</td>
<td>
{% if hint.receiving_player == player %}
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
{{ player_names_with_alias[(team, hint.receiving_player)] }}
</a>
{{ player_names_with_alias[(team, hint.receiving_player)] }}
{% endif %}
</td>
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>

View File

@@ -19,179 +19,60 @@
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="host-room">
<span id="host-room-info">
{% if room.owner == session["_id"] %}
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
<br />
{% endif %}
{% if room.tracker %}
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
<br />
{% endif %}
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
Should you wish to continue later,
anyone can simply refresh this page and the server will resume.<br>
{% if room.last_port == -1 %}
There was an error hosting this Room. Another attempt will be made on refreshing this page.
The most likely failure reason is that the multiworld is too old to be loaded now.
{% elif room.last_port %}
You can connect to this room by using <span class="interactive"
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
</span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
{% endif %}
</span>
{% if room.owner == session["_id"] %}
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
<br />
{% endif %}
{% if room.tracker %}
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
<br />
{% endif %}
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
Should you wish to continue later,
anyone can simply refresh this page and the server will resume.<br>
{% if room.last_port == -1 %}
There was an error hosting this Room. Another attempt will be made on refreshing this page.
The most likely failure reason is that the multiworld is too old to be loaded now.
{% elif room.last_port %}
You can connect to this room by using <span class="interactive"
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
</span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
{% endif %}
{{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %}
<div style="display: flex; align-items: center;">
<form method="post" id="command-form" style="flex-grow: 1; margin-right: 1em;">
<form method=post style="flex-grow: 1; margin-right: 1em;">
<div class="form-group">
<label for="cmd"></label>
<input class="form-control" type="text" id="cmd" name="cmd"
placeholder="Server Command. /help to list them, list gets appended to log.">
<span class="loader"></span>
</div>
</form>
<a href="{{ url_for("display_log", room=room.id) }}">
Open Log File...
</a>
</div>
{% set log = get_log() -%}
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
<div id="logger" style="white-space: pre">{{ log }}</div>
<script>
let url = '{{ url_for('display_log', room = room.id) }}';
let bytesReceived = {{ log_len }};
let updateLogTimeout;
let updateLogImmediately = false;
let awaitingCommandResponse = false;
let logger = document.getElementById("logger");
<div id="logger"></div>
<script type="application/ecmascript">
let xmlhttp = new XMLHttpRequest();
let url = '{{ url_for('display_log', room = room.id) }}';
function scrollToBottom(el) {
let bot = el.scrollHeight - el.clientHeight;
el.scrollTop += Math.ceil((bot - el.scrollTop)/10);
if (bot - el.scrollTop >= 1) {
window.clearTimeout(el.scrollTimer);
el.scrollTimer = window.setTimeout(() => {
scrollToBottom(el)
}, 16);
}
}
async function updateLog() {
try {
if (!document.hidden) {
updateLogImmediately = false;
let res = await fetch(url, {
headers: {
'Range': `bytes=${bytesReceived}-`,
}
});
if (res.ok) {
let text = await res.text();
if (text.length > 0) {
awaitingCommandResponse = false;
if (bytesReceived === 0 || res.status !== 206) {
logger.innerHTML = '';
}
if (res.status !== 206) {
bytesReceived = 0;
} else {
bytesReceived += new Blob([text]).size;
}
if (logger.innerHTML.endsWith('…')) {
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
}
logger.appendChild(document.createTextNode(text));
scrollToBottom(logger);
let loader = document.getElementById("command-form").getElementsByClassName("loader")[0];
loader.classList.remove("loading");
}
xmlhttp.onreadystatechange = function () {
if (this.readyState === 4 && this.status === 200) {
document.getElementById("logger").innerText = this.responseText;
}
} else {
updateLogImmediately = true;
}
}
finally {
window.clearTimeout(updateLogTimeout);
updateLogTimeout = window.setTimeout(updateLog, awaitingCommandResponse ? 500 : 10000);
}
}
};
async function postForm(ev) {
/** @type {HTMLInputElement} */
let cmd = document.getElementById("cmd");
if (cmd.value === "") {
ev.preventDefault();
return;
function request_new() {
xmlhttp.open("GET", url, true);
xmlhttp.send();
}
/** @type {HTMLFormElement} */
let form = document.getElementById("command-form");
let req = fetch(form.action || window.location.href, {
method: form.method,
body: new FormData(form),
redirect: "manual",
});
ev.preventDefault(); // has to happen before first await
form.reset();
let loader = form.getElementsByClassName("loader")[0];
loader.classList.add("loading");
try {
let res = await req;
if (res.ok || res.type === 'opaqueredirect') {
awaitingCommandResponse = true;
window.clearTimeout(updateLogTimeout);
updateLogTimeout = window.setTimeout(updateLog, 100);
} else {
loader.classList.remove("loading");
window.alert(res.statusText);
}
} catch (e) {
console.error(e);
loader.classList.remove("loading");
window.alert(e.message);
}
}
document.getElementById("command-form").addEventListener("submit", postForm);
updateLogTimeout = window.setTimeout(updateLog, 1000);
logger.scrollTop = logger.scrollHeight;
document.addEventListener("visibilitychange", () => {
if (!document.hidden && updateLogImmediately) {
updateLog();
}
})
window.setTimeout(request_new, 1000);
window.setInterval(request_new, 10000);
</script>
{% endif %}
<script>
function updateInfo() {
let url = new URL(window.location.href);
url.search = "?update";
fetch(url)
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error ${res.status}`);
}
return res.text()
})
.then(text => new DOMParser().parseFromString(text, 'text/html'))
.then(newDocument => {
let el = newDocument.getElementById("host-room-info");
document.getElementById("host-room-info").innerHTML = el.innerHTML;
});
}
if (document.querySelector("meta[http-equiv='refresh']")) {
console.log("Refresh!");
window.addEventListener('load', function () {
for (let i=0; i<3; i++) {
window.setTimeout(updateInfo, Math.pow(2, i) * 2000); // 2, 4, 8s
}
window.stop(); // cancel meta refresh
})
}
</script>
</div>
{% endblock %}

View File

@@ -22,7 +22,7 @@
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
<tr>
<td>{{ patch.player_id }}</td>
<td data-tooltip="Connect via Game Client"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}?game={{ patch.game }}&room={{ room.id | suuid }}">{{ patch.player_name }}</a></td>
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
<td>{{ patch.game }}</td>
<td>
{% if patch.data %}

View File

@@ -1,72 +0,0 @@
{% extends "tablepage.html" %}
{% block head %}
{{ super() }}
<title>Multiworld Sphere Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="styles/tracker.css") }}" />
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include "header/dirtHeader.html" %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search" />
<div class="info">
{% if tracker_data.get_spheres() %}
This tracker lists already found locations by their logical access sphere.
It ignores items that cannot be sent
and will therefore differ from the sphere numbers in the spoiler playthrough.
This tracker will automatically update itself periodically.
{% else %}
This Multiworld has no Sphere data, likely due to being too old, cannot display data.
{% endif %}
</div>
</div>
<div id="tables-container">
{%- for team, players in tracker_data.get_all_players().items() %}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th>Sphere</th>
{#- Mimicking hint table header for familiarity. #}
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Game</th>
</tr>
</thead>
<tbody>
{%- for sphere in tracker_data.get_spheres() %}
{%- set current_sphere = loop.index %}
{%- for player, sphere_location_ids in sphere.items() %}
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
{%- set finder_game = tracker_data.get_player_game(team, player) %}
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
<tr>
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
<td>{{ current_sphere }}</td>
<td>{{ tracker_data.get_player_name(team, player) }}</td>
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
<td>{{ finder_game }}</td>
</tr>
{%- endfor %}
{%- endfor %}
{%- endfor %}
</tbody>
</table>
</div>
{%- endfor -%}
</div>
</div>
{% endblock %}

View File

@@ -10,7 +10,7 @@
{% include "header/dirtHeader.html" %}
{% include "multitrackerNavigation.html" %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}" data-second="{{ saving_second }}">
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search" />

View File

@@ -0,0 +1,180 @@
<!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/ootTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/ootTracker.js') }}"></script>
</head>
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ ocarina_url }}" class="{{ 'acquired' if 'Ocarina' in acquired_items }}" title="Ocarina" /></td>
<td><img src="{{ icons['Bombs'] }}" class="{{ 'acquired' if 'Bomb Bag' in acquired_items }}" title="Bombs" /></td>
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Bow' in acquired_items }}" title="Fairy Bow" /></td>
<td><img src="{{ icons['Fire Arrows'] }}" class="{{ 'acquired' if 'Fire Arrows' in acquired_items }}" title="Fire Arrows" /></td>
<td><img src="{{ icons['Kokiri Sword'] }}" class="{{ 'acquired' if 'Kokiri Sword' in acquired_items }}" title="Kokiri Sword" /></td>
<td><img src="{{ icons['Biggoron Sword'] }}" class="{{ 'acquired' if 'Biggoron Sword' in acquired_items }}" title="Biggoron's Sword" /></td>
<td><img src="{{ icons['Mirror Shield'] }}" class="{{ 'acquired' if 'Mirror Shield' in acquired_items }}" title="Mirror Shield" /></td>
</tr>
<tr>
<td><img src="{{ icons['Slingshot'] }}" class="{{ 'acquired' if 'Slingshot' in acquired_items }}" title="Slingshot" /></td>
<td><img src="{{ icons['Bombchus'] }}" class="{{ 'acquired' if has_bombchus }}" title="Bombchus" /></td>
<td>
<div class="counted-item">
<img src="{{ hookshot_url }}" class="{{ 'acquired' if 'Progressive Hookshot' in acquired_items }}" title="Progressive Hookshot" />
<div class="item-count">{{ hookshot_length }}</div>
</div>
</td>
<td><img src="{{ icons['Ice Arrows'] }}" class="{{ 'acquired' if 'Ice Arrows' in acquired_items }}" title="Ice Arrows" /></td>
<td><img src="{{ strength_upgrade_url }}" class="{{ 'acquired' if 'Progressive Strength Upgrade' in acquired_items }}" title="Progressive Strength Upgrade" /></td>
<td><img src="{{ icons['Goron Tunic'] }}" class="{{ 'acquired' if 'Goron Tunic' in acquired_items }}" title="Goron Tunic" /></td>
<td><img src="{{ icons['Zora Tunic'] }}" class="{{ 'acquired' if 'Zora Tunic' in acquired_items }}" title="Zora Tunic" /></td>
</tr>
<tr>
<td><img src="{{ icons['Boomerang'] }}" class="{{ 'acquired' if 'Boomerang' in acquired_items }}" title="Boomerang" /></td>
<td><img src="{{ icons['Lens of Truth'] }}" class="{{ 'acquired' if 'Lens of Truth' in acquired_items }}" title="Lens of Truth" /></td>
<td><img src="{{ icons['Megaton Hammer'] }}" class="{{ 'acquired' if 'Megaton Hammer' in acquired_items }}" title="Megaton Hammer" /></td>
<td><img src="{{ icons['Light Arrows'] }}" class="{{ 'acquired' if 'Light Arrows' in acquired_items }}" title="Light Arrows" /></td>
<td><img src="{{ scale_url }}" class="{{ 'acquired' if 'Progressive Scale' in acquired_items }}" title="Progressive Scale" /></td>
<td><img src="{{ icons['Iron Boots'] }}" class="{{ 'acquired' if 'Iron Boots' in acquired_items }}" title="Iron Boots" /></td>
<td><img src="{{ icons['Hover Boots'] }}" class="{{ 'acquired' if 'Hover Boots' in acquired_items }}" title="Hover Boots" /></td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ bottle_url }}" class="{{ 'acquired' if bottle_count > 0 }}" title="Bottles" />
<div class="item-count">{{ bottle_count if bottle_count > 0 else '' }}</div>
</div>
</td>
<td><img src="{{ icons['Dins Fire'] }}" class="{{ 'acquired' if 'Dins Fire' in acquired_items }}" title="Din's Fire" /></td>
<td><img src="{{ icons['Farores Wind'] }}" class="{{ 'acquired' if 'Farores Wind' in acquired_items }}" title="Farore's Wind" /></td>
<td><img src="{{ icons['Nayrus Love'] }}" class="{{ 'acquired' if 'Nayrus Love' in acquired_items }}" title="Nayru's Love" /></td>
<td>
<div class="counted-item">
<img src="{{ wallet_url }}" class="{{ 'acquired' if 'Progressive Wallet' in acquired_items }}" title="Progressive Wallet" />
<div class="item-count">{{ wallet_size }}</div>
</div>
</td>
<td><img src="{{ magic_meter_url }}" class="{{ 'acquired' if 'Magic Meter' in acquired_items }}" title="Magic Meter" /></td>
<td><img src="{{ icons['Gerudo Membership Card'] }}" class="{{ 'acquired' if 'Gerudo Membership Card' in acquired_items }}" title="Gerudo Membership Card" /></td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ icons['Zeldas Lullaby'] }}" class="{{ 'acquired' if 'Zeldas Lullaby' in acquired_items }}" title="Zelda's Lullaby" id="lullaby"/>
<div class="item-count">Zelda</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Eponas Song'] }}" class="{{ 'acquired' if 'Eponas Song' in acquired_items }}" title="Epona's Song" id="epona" />
<div class="item-count">Epona</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Sarias Song'] }}" class="{{ 'acquired' if 'Sarias Song' in acquired_items }}" title="Saria's Song" id="saria"/>
<div class="item-count">Saria</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Suns Song'] }}" class="{{ 'acquired' if 'Suns Song' in acquired_items }}" title="Sun's Song" id="sun"/>
<div class="item-count">Sun</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Song of Time'] }}" class="{{ 'acquired' if 'Song of Time' in acquired_items }}" title="Song of Time" id="time"/>
<div class="item-count">Time</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Song of Storms'] }}" class="{{ 'acquired' if 'Song of Storms' in acquired_items }}" title="Song of Storms" />
<div class="item-count">Storms</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Gold Skulltula Token'] }}" class="{{ 'acquired' if token_count > 0 }}" title="Gold Skulltula Tokens" />
<div class="item-count">{{ token_count }}</div>
</div>
</td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ icons['Minuet of Forest'] }}" class="{{ 'acquired' if 'Minuet of Forest' in acquired_items }}" title="Minuet of Forest" />
<div class="item-count">Min</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Bolero of Fire'] }}" class="{{ 'acquired' if 'Bolero of Fire' in acquired_items }}" title="Bolero of Fire" />
<div class="item-count">Bol</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Serenade of Water'] }}" class="{{ 'acquired' if 'Serenade of Water' in acquired_items }}" title="Serenade of Water" />
<div class="item-count">Ser</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Requiem of Spirit'] }}" class="{{ 'acquired' if 'Requiem of Spirit' in acquired_items }}" title="Requiem of Spirit" />
<div class="item-count">Req</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Nocturne of Shadow'] }}" class="{{ 'acquired' if 'Nocturne of Shadow' in acquired_items }}" title="Nocturne of Shadow" />
<div class="item-count">Noc</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Prelude of Light'] }}" class="{{ 'acquired' if 'Prelude of Light' in acquired_items }}" title="Prelude of Light" />
<div class="item-count">Pre</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Triforce'] if game_finished else icons['Triforce Piece'] }}" class="{{ 'acquired' if game_finished or piece_count > 0 }}" title="{{ 'Triforce' if game_finished else 'Triforce Pieces' }}" id=triforce />
<div class="item-count">{{ piece_count if piece_count > 0 else '' }}</div>
</div>
</td>
</tr>
</table>
<table id="location-table">
<tr>
<td></td>
<td><img src="{{ icons['Small Key'] }}" title="Small Keys" /></td>
<td><img src="{{ icons['Boss Key'] }}" title="Boss Key" /></td>
<td class="right-align">Items</td>
</tr>
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="smallkeys">{{ small_key_counts.get(area, '-') }}</td>
<td class="bosskeys">{{ boss_key_counts.get(area, '-') }}</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></td>
<td></td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -54,27 +54,27 @@
{% macro NamedRange(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="named-range-container">
<select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
<select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% for key, val in option.special_range_names.items() %}
{% if option.default == val %}
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
<option value="{{ val }}" selected>{{ key }} ({{ val }})</option>
{% else %}
<option value="{{ val }}">{{ key|replace("_", " ")|title }} ({{ val }})</option>
<option value="{{ val }}">{{ key }} ({{ val }})</option>
{% endif %}
{% endfor %}
<option value="custom" hidden>Custom</option>
</select>
<div class="named-range-wrapper js-required">
<div class="named-range-wrapper">
<input
type="range"
id="{{ option_name }}"
name="{{ option_name }}-range"
name="{{ option_name }}"
min="{{ option.range_start }}"
max="{{ option.range_end }}"
value="{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}"
{{ "disabled" if option.default == "random" }}
/>
<span id="{{ option_name }}-value" class="range-value">
<span id="{{ option_name }}-value" class="range-value js-required">
{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}
</span>
{{ RandomizeButton(option_name, option) }}
@@ -111,7 +111,7 @@
</div>
{% endmacro %}
{% macro ItemDict(option_name, option) %}
{% macro ItemDict(option_name, option, world) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
@@ -135,7 +135,7 @@
</div>
{% endmacro %}
{% macro LocationSet(option_name, option) %}
{% macro LocationSet(option_name, option, world) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for group_name in world.location_name_groups.keys()|sort %}
@@ -158,7 +158,7 @@
</div>
{% endmacro %}
{% macro ItemSet(option_name, option) %}
{% macro ItemSet(option_name, option, world) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for group_name in world.item_name_groups.keys()|sort %}
@@ -196,18 +196,7 @@
{% macro OptionTitle(option_name, option) %}
<label for="{{ option_name }}">
{{ option.display_name|default(option_name) }}:
<span
class="interactive tooltip-container"
{% if not (option.rich_text_doc | default(world.web.rich_text_options_doc, true)) %}
data-tooltip="{{(option.__doc__ | default("Please document me!"))|replace('\n ', '\n')|escape|trim}}"
{% endif %}>
(?)
{% if option.rich_text_doc | default(world.web.rich_text_options_doc, true) %}
<div class="tooltip">
{{ option.__doc__ | default("**Please document me!**") | rst_to_html | safe }}
</div>
{% endif %}
</span>
<span class="interactive" data-tooltip="{% filter dedent %}{{(option.__doc__ | default("Please document me!"))|escape }}{% endfilter %}">(?)</span>
</label>
{% endmacro %}

View File

@@ -1,5 +1,5 @@
{% extends 'pageWrapper.html' %}
{% import 'playerOptions/macros.html' as inputs with context %}
{% import 'playerOptions/macros.html' as inputs %}
{% block head %}
<title>{{ world_name }} Options</title>
@@ -11,7 +11,7 @@
<noscript>
<style>
.js-required{
display: none !important;
display: none;
}
</style>
</noscript>
@@ -94,16 +94,16 @@
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option) }}
{{ inputs.ItemDict(option_name, option, world) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
{{ inputs.LocationSet(option_name, option) }}
{{ inputs.LocationSet(option_name, option, world) }}
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
{{ inputs.ItemSet(option_name, option) }}
{{ inputs.ItemSet(option_name, option, world) }}
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
{{ inputs.OptionSet(option_name, option) }}
@@ -134,16 +134,16 @@
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option) }}
{{ inputs.ItemDict(option_name, option, world) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
{{ inputs.LocationSet(option_name, option) }}
{{ inputs.LocationSet(option_name, option, world) }}
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
{{ inputs.ItemSet(option_name, option) }}
{{ inputs.ItemSet(option_name, option, world) }}
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
{{ inputs.OptionSet(option_name, option) }}

View File

@@ -1,21 +1,5 @@
{% extends 'tablepage.html' %}
{%- macro games(slots) -%}
{%- set gameList = [] -%}
{%- set maxGamesToShow = 10 -%}
{%- for slot in (slots|list|sort(attribute="player_id"))[:maxGamesToShow] -%}
{% set player = "#" + slot["player_id"]|string + " " + slot["player_name"] + " : " + slot["game"] -%}
{% set _ = gameList.append(player) -%}
{%- endfor -%}
{%- if slots|length > maxGamesToShow -%}
{% set _ = gameList.append("... and " + (slots|length - maxGamesToShow)|string + " more") -%}
{%- endif -%}
{{ gameList|join('\n') }}
{%- endmacro -%}
{% block head %}
{{ super() }}
<title>User Content</title>
@@ -49,12 +33,10 @@
<tr>
<td><a href="{{ url_for("view_seed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
<td><a href="{{ url_for("host_room", room=room.id) }}">{{ room.id|suuid }}</a></td>
<td title="{{ games(room.seed.slots) }}">
{{ room.seed.slots|length }}
</td>
<td>{{ room.seed.slots|length }}</td>
<td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
<td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td>
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</a></td>
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</td>
</tr>
{% endfor %}
</tbody>
@@ -78,21 +60,16 @@
{% for seed in seeds %}
<tr>
<td><a href="{{ url_for("view_seed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
<td title="{{ games(seed.slots) }}">
{% if seed.multidata %}
{{ seed.slots|length }}
{% else %}
1
{% endif %}
<td>{% if seed.multidata %}{{ seed.slots|length }}{% else %}1{% endif %}
</td>
<td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</a></td>
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
You have not generated any seeds yet!
You have no generated any seeds yet!
{% endif %}
</div>
</div>

View File

@@ -1,9 +1,9 @@
{% macro Toggle(option_name, option) %}
<table>
<tbody>
{{ RangeRow(option_name, option, "No", "false", False, "true" if option.default else "false") }}
{{ RangeRow(option_name, option, "Yes", "true", False, "true" if option.default else "false") }}
{{ RandomRow(option_name, option) }}
{{ RangeRow(option_name, option, "No", "false") }}
{{ RangeRow(option_name, option, "Yes", "true") }}
{{ RandomRows(option_name, option) }}
</tbody>
</table>
{% endmacro %}
@@ -18,14 +18,10 @@
<tbody>
{% for id, name in option.name_lookup.items() %}
{% if name != 'random' %}
{% if option.default != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
{% else %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{% endfor %}
{{ RandomRow(option_name, option) }}
{{ RandomRows(option_name, option) }}
</tbody>
</table>
{% endmacro %}
@@ -38,16 +34,16 @@
Normal range: {{ option.range_start }} - {{ option.range_end }}
{% if option.special_range_names %}
<br /><br />
The following values have special meanings, and may fall outside the normal range.
The following values has special meaning, and may fall outside the normal range.
<ul>
{% for name, value in option.special_range_names.items() %}
<li>{{ value }}: {{ name|replace("_", " ")|title }}</li>
<li>{{ value }}: {{ name }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="add-option-div">
<input type="number" class="range-option-value" data-option="{{ option_name }}" />
<button type="button" class="add-range-option-button" data-option="{{ option_name }}">Add</button>
<button class="add-range-option-button" data-option="{{ option_name }}">Add</button>
</div>
</div>
<table class="range-rows" data-option="{{ option_name }}">
@@ -72,13 +68,11 @@
This option allows custom values only. Please enter your desired values below.
<div class="custom-value-wrapper">
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button type="button" data-option="{{ option_name }}">Add</button>
<button data-option="{{ option_name }}">Add</button>
</div>
<table>
<tbody>
{% if option.default %}
{{ RangeRow(option_name, option, option.default, option.default) }}
{% endif %}
<!-- This table to be filled by JS -->
</tbody>
</table>
</div>
@@ -89,21 +83,17 @@
Custom values are also allowed for this option. To create one, enter it into the input box below.
<div class="custom-value-wrapper">
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button type="button" data-option="{{ option_name }}">Add</button>
<button data-option="{{ option_name }}">Add</button>
</div>
</div>
<table>
<tbody>
{% for id, name in option.name_lookup.items() %}
{% if name != 'random' %}
{% if option.default != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
{% else %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{% endfor %}
{{ RandomRow(option_name, option) }}
{{ RandomRows(option_name, option) }}
</tbody>
</table>
{% endmacro %}
@@ -122,7 +112,7 @@
type="number"
id="{{ option_name }}-{{ item_name }}-qty"
name="{{ option_name }}||{{ item_name }}"
value="{{ option.default[item_name] if item_name in option.default else "0" }}"
value="0"
/>
</div>
{% endfor %}
@@ -131,14 +121,13 @@
{% macro OptionList(option_name, option) %}
<div class="list-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
{% for key in option.valid_keys|sort %}
<div class="list-entry">
<input
type="checkbox"
id="{{ option_name }}-{{ key }}"
name="{{ option_name }}||{{ key }}"
value="1"
{{ "checked" if key in option.default }}
/>
<label for="{{ option_name }}-{{ key }}">
{{ key }}
@@ -194,7 +183,7 @@
{% macro OptionSet(option_name, option) %}
<div class="set-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
{% for key in option.valid_keys|sort %}
<div class="set-entry">
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}||{{ key }}" value="1" {{ "checked" if key in option.default }} />
<label for="{{ option_name }}-{{ key }}">{{ key }}</label>
@@ -211,17 +200,13 @@
</td>
{% endmacro %}
{% macro RandomRow(option_name, option, extra_column=False) %}
{{ RangeRow(option_name, option, "Random", "random") }}
{% endmacro %}
{% macro RandomRows(option_name, option, extra_column=False) %}
{% for key, value in {"Random": "random", "Random (Low)": "random-low", "Random (Middle)": "random-middle", "Random (High)": "random-high"}.items() %}
{{ RangeRow(option_name, option, key, value) }}
{% endfor %}
{% endmacro %}
{% macro RangeRow(option_name, option, display_value, value, can_delete=False, default_override=None) %}
{% macro RangeRow(option_name, option, display_value, value, can_delete=False) %}
<tr data-row="{{ option_name }}-{{ value }}-row" data-option-name="{{ option_name }}" data-value="{{ value }}">
<td class="td-left">
<label for="{{ option_name }}||{{ value }}">
@@ -235,7 +220,7 @@
name="{{ option_name }}||{{ value }}"
min="0"
max="50"
{% if option.default == value or default_override == value %}
{% if option.default == value %}
value="25"
{% else %}
value="0"
@@ -244,7 +229,7 @@
</td>
<td class="td-right">
<span id="{{ option_name }}||{{ value }}-value">
{% if option.default == value or default_override == value %}
{% if option.default == value %}
25
{% else %}
0

View File

@@ -3,9 +3,8 @@ import collections
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, NamedTuple, Counter
from uuid import UUID
from email.utils import parsedate_to_datetime
from flask import render_template, make_response, Response, request
from flask import render_template
from werkzeug.exceptions import abort
from MultiServer import Context, get_saving_second
@@ -79,7 +78,7 @@ class TrackerData:
# Normal lookup tables as well.
self.item_name_to_id[game] = game_package["item_name_to_id"]
self.location_name_to_id[game] = game_package["location_name_to_id"]
self.location_name_to_id[game] = game_package["item_name_to_id"]
def get_seed_name(self) -> str:
"""Retrieves the seed name."""
@@ -292,47 +291,47 @@ class TrackerData:
return video_feeds
@_cache_results
def get_spheres(self) -> List[List[int]]:
""" each sphere is { player: { location_id, ... } } """
return self._multidata.get("spheres", [])
@app.route("/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool = False) -> str:
key = f"{tracker}_{tracked_team}_{tracked_player}_{generic}"
tracker_page = cache.get(key)
if tracker_page:
return tracker_page
timeout, tracker_page = get_timeout_and_tracker(tracker, tracked_team, tracked_player, generic)
cache.set(key, tracker_page, timeout)
return tracker_page
def _process_if_request_valid(incoming_request, room: Optional[Room]) -> Optional[Response]:
@app.route("/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
def get_generic_game_tracker(tracker: UUID, tracked_team: int, tracked_player: int) -> str:
return get_player_tracker(tracker, tracked_team, tracked_player, True)
@app.route("/tracker/<suuid:tracker>", defaults={"game": "Generic"})
@app.route("/tracker/<suuid:tracker>/<game>")
@cache.memoize(timeout=TRACKER_CACHE_TIMEOUT_IN_SECONDS)
def get_multiworld_tracker(tracker: UUID, game: str):
# Room must exist.
room = Room.get(tracker=tracker)
if not room:
abort(404)
if_modified = incoming_request.headers.get("If-Modified-Since", None)
if if_modified:
if_modified = parsedate_to_datetime(if_modified)
# if_modified has less precision than last_activity, so we bring them to same precision
if if_modified >= room.last_activity.replace(microsecond=0):
return make_response("", 304)
tracker_data = TrackerData(room)
enabled_trackers = list(get_enabled_multiworld_trackers(room).keys())
if game not in _multiworld_trackers:
return render_generic_multiworld_tracker(tracker_data, enabled_trackers)
return _multiworld_trackers[game](tracker_data, enabled_trackers)
@app.route("/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool = False) -> Response:
key = f"{tracker}_{tracked_team}_{tracked_player}_{generic}"
response: Optional[Response] = cache.get(key)
if response:
return response
def get_timeout_and_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool) -> Tuple[int, str]:
# Room must exist.
room = Room.get(tracker=tracker)
if not room:
abort(404)
response = _process_if_request_valid(request, room)
if response:
return response
timeout, last_modified, tracker_page = get_timeout_and_player_tracker(room, tracked_team, tracked_player, generic)
response = make_response(tracker_page)
response.last_modified = last_modified
cache.set(key, response, timeout)
return response
def get_timeout_and_player_tracker(room: Room, tracked_team: int, tracked_player: int, generic: bool)\
-> Tuple[int, datetime.datetime, str]:
tracker_data = TrackerData(room)
# Load and render the game-specific player tracker, or fallback to generic tracker if none exists.
@@ -342,48 +341,7 @@ def get_timeout_and_player_tracker(room: Room, tracked_team: int, tracked_player
else:
tracker = render_generic_tracker(tracker_data, tracked_team, tracked_player)
return ((tracker_data.get_room_saving_second() - datetime.datetime.now().second)
% TRACKER_CACHE_TIMEOUT_IN_SECONDS or TRACKER_CACHE_TIMEOUT_IN_SECONDS, room.last_activity, tracker)
@app.route("/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
def get_generic_game_tracker(tracker: UUID, tracked_team: int, tracked_player: int) -> Response:
return get_player_tracker(tracker, tracked_team, tracked_player, True)
@app.route("/tracker/<suuid:tracker>", defaults={"game": "Generic"})
@app.route("/tracker/<suuid:tracker>/<game>")
def get_multiworld_tracker(tracker: UUID, game: str) -> Response:
key = f"{tracker}_{game}"
response: Optional[Response] = cache.get(key)
if response:
return response
# Room must exist.
room = Room.get(tracker=tracker)
response = _process_if_request_valid(request, room)
if response:
return response
timeout, last_modified, tracker_page = get_timeout_and_multiworld_tracker(room, game)
response = make_response(tracker_page)
response.last_modified = last_modified
cache.set(key, response, timeout)
return response
def get_timeout_and_multiworld_tracker(room: Room, game: str)\
-> Tuple[int, datetime.datetime, str]:
tracker_data = TrackerData(room)
enabled_trackers = list(get_enabled_multiworld_trackers(room).keys())
if game in _multiworld_trackers:
tracker = _multiworld_trackers[game](tracker_data, enabled_trackers)
else:
tracker = render_generic_multiworld_tracker(tracker_data, enabled_trackers)
return ((tracker_data.get_room_saving_second() - datetime.datetime.now().second)
% TRACKER_CACHE_TIMEOUT_IN_SECONDS or TRACKER_CACHE_TIMEOUT_IN_SECONDS, room.last_activity, tracker)
return (tracker_data.get_room_saving_second() - datetime.datetime.now().second) % 60 or 60, tracker
def get_enabled_multiworld_trackers(room: Room) -> Dict[str, Callable]:
@@ -453,30 +411,9 @@ def render_generic_multiworld_tracker(tracker_data: TrackerData, enabled_tracker
videos=tracker_data.get_room_videos(),
item_id_to_name=tracker_data.item_id_to_name,
location_id_to_name=tracker_data.location_id_to_name,
saving_second=tracker_data.get_room_saving_second(),
)
def render_generic_multiworld_sphere_tracker(tracker_data: TrackerData) -> str:
return render_template(
"multispheretracker.html",
room=tracker_data.room,
tracker_data=tracker_data,
)
@app.route("/sphere_tracker/<suuid:tracker>")
@cache.memoize(timeout=TRACKER_CACHE_TIMEOUT_IN_SECONDS)
def get_multiworld_sphere_tracker(tracker: UUID):
# Room must exist.
room = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
return render_generic_multiworld_sphere_tracker(tracker_data)
# TODO: This is a temporary solution until a proper Tracker API can be implemented for tracker templates and data to
# live in their respective world folders.
@@ -1366,28 +1303,28 @@ if "Starcraft 2" in network_data_package["games"]:
organics_icon_base_url = "https://0rganics.org/archipelago/sc2wol/"
icons = {
"Starting Minerals": github_icon_base_url + "blizzard/icon-mineral-nobg.png",
"Starting Vespene": github_icon_base_url + "blizzard/icon-gas-terran-nobg.png",
"Starting Minerals": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-mineral-protoss.png",
"Starting Vespene": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-gas-terran.png",
"Starting Supply": github_icon_base_url + "blizzard/icon-supply-terran_nobg.png",
"Terran Infantry Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel1.png",
"Terran Infantry Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel2.png",
"Terran Infantry Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel3.png",
"Terran Infantry Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel1.png",
"Terran Infantry Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel2.png",
"Terran Infantry Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel3.png",
"Terran Vehicle Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel1.png",
"Terran Vehicle Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel2.png",
"Terran Vehicle Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel3.png",
"Terran Vehicle Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel1.png",
"Terran Vehicle Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel2.png",
"Terran Vehicle Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel3.png",
"Terran Ship Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel1.png",
"Terran Ship Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel2.png",
"Terran Ship Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel3.png",
"Terran Ship Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel1.png",
"Terran Ship Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel2.png",
"Terran Ship Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel3.png",
"Terran Infantry Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel1.png",
"Terran Infantry Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel2.png",
"Terran Infantry Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel3.png",
"Terran Infantry Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel1.png",
"Terran Infantry Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel2.png",
"Terran Infantry Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel3.png",
"Terran Vehicle Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel1.png",
"Terran Vehicle Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel2.png",
"Terran Vehicle Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel3.png",
"Terran Vehicle Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel1.png",
"Terran Vehicle Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel2.png",
"Terran Vehicle Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel3.png",
"Terran Ship Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel1.png",
"Terran Ship Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel2.png",
"Terran Ship Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel3.png",
"Terran Ship Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel1.png",
"Terran Ship Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel2.png",
"Terran Ship Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel3.png",
"Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg",
"Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg",

View File

@@ -152,7 +152,7 @@ def get_payload(ctx: ZeldaContext):
def reconcile_shops(ctx: ZeldaContext):
checked_location_names = [ctx.location_names.lookup_in_game(location) for location in ctx.checked_locations]
checked_location_names = [ctx.location_names[location] for location in ctx.checked_locations]
shops = [location for location in checked_location_names if "Shop" in location]
left_slots = [shop for shop in shops if "Left" in shop]
middle_slots = [shop for shop in shops if "Middle" in shop]
@@ -190,7 +190,7 @@ async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone=
locations_checked = []
location = None
for location in ctx.missing_locations:
location_name = ctx.location_names.lookup_in_game(location)
location_name = ctx.location_names[location]
if location_name in Locations.overworld_locations and zone == "overworld":
status = locations_array[Locations.major_location_offsets[location_name]]

View File

@@ -1,6 +1,5 @@
#cython: language_level=3
#distutils: language = c
#distutils: depends = intset.h
#distutils: language = c++
"""
Provides faster implementation of some core parts.
@@ -14,6 +13,7 @@ from cpython cimport PyObject
from typing import Any, Dict, Iterable, Iterator, Generator, Sequence, Tuple, TypeVar, Union, Set, List, TYPE_CHECKING
from cymem.cymem cimport Pool
from libc.stdint cimport int64_t, uint32_t
from libcpp.set cimport set as std_set
from collections import defaultdict
cdef extern from *:
@@ -31,27 +31,6 @@ ctypedef int64_t ap_id_t
cdef ap_player_t MAX_PLAYER_ID = 1000000 # limit the size of indexing array
cdef size_t INVALID_SIZE = <size_t>(-1) # this is all 0xff... adding 1 results in 0, but it's not negative
# configure INTSET for player
cdef extern from *:
"""
#define INTSET_NAME ap_player_set
#define INTSET_TYPE uint32_t // has to match ap_player_t
"""
# create INTSET for player
cdef extern from "intset.h":
"""
#undef INTSET_NAME
#undef INTSET_TYPE
"""
ctypedef struct ap_player_set:
pass
ap_player_set* ap_player_set_new(size_t bucket_count) nogil
void ap_player_set_free(ap_player_set* set) nogil
bint ap_player_set_add(ap_player_set* set, ap_player_t val) nogil
bint ap_player_set_contains(ap_player_set* set, ap_player_t val) nogil
cdef struct LocationEntry:
# layout is so that
@@ -206,7 +185,7 @@ cdef class LocationStore:
def find_item(self, slots: Set[int], seeked_item_id: int) -> Generator[Tuple[int, int, int, int, int], None, None]:
cdef ap_id_t item = seeked_item_id
cdef ap_player_t receiver
cdef ap_player_set* receivers
cdef std_set[ap_player_t] receivers
cdef size_t slot_count = len(slots)
if slot_count == 1:
# specialized implementation for single slot
@@ -218,20 +197,13 @@ cdef class LocationStore:
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
elif slot_count:
# generic implementation with lookup in set
receivers = ap_player_set_new(min(1023, slot_count)) # limit top level struct to 16KB
if not receivers:
raise MemoryError()
try:
for receiver in slots:
if not ap_player_set_add(receivers, receiver):
raise MemoryError()
with nogil:
for entry in self.entries[:self.entry_count]:
if entry.item == item and ap_player_set_contains(receivers, entry.receiver):
with gil:
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
finally:
ap_player_set_free(receivers)
for receiver in slots:
receivers.insert(receiver)
with nogil:
for entry in self.entries[:self.entry_count]:
if entry.item == item and receivers.count(entry.receiver):
with gil:
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
def get_for_player(self, slot: int) -> Dict[int, Set[int]]:
cdef ap_player_t receiver = slot
@@ -287,15 +259,15 @@ cdef class LocationStore:
entry in self.entries[start:start + count] if
entry.location not in checked]
def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]:
def get_remaining(self, state: State, team: int, slot: int) -> List[int]:
cdef LocationEntry* entry
cdef ap_player_t sender = slot
cdef size_t start = self.sender_index[sender].start
cdef size_t count = self.sender_index[sender].count
cdef set checked = state[team, slot]
return sorted([(entry.receiver, entry.item) for
entry in self.entries[start:start+count] if
entry.location not in checked])
return sorted([entry.item for
entry in self.entries[start:start+count] if
entry.location not in checked])
@cython.auto_pickle(False)

View File

@@ -1,10 +1,8 @@
# This file is used when doing pyximport
import os
# This file is required to get pyximport to work with C++.
# Switching from std::set to a pure C implementation is still on the table to simplify everything.
def make_ext(modname, pyxfilename):
from distutils.extension import Extension
return Extension(name=modname,
sources=[pyxfilename],
depends=["intset.h"],
include_dirs=[os.getcwd()],
language="c")
language='c++')

View File

@@ -13,7 +13,6 @@
plum: "AF99EF" # typically progression item
salmon: "FA8072" # typically trap item
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
orange: "FF7700" # Used for command echo
<Label>:
color: "FFFFFF"
<TabbedPanel>:

View File

@@ -68,21 +68,21 @@ requires:
{%- elif option.options -%}
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
{{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
{%- endfor -%}
{%- if option.name_lookup[option.default] not in option.options %}
{{ yaml_dump(option.default) }}: 50
{{ option.default }}: 50
{%- endif -%}
{%- elif option.default is string %}
{{ yaml_dump(option.default) }}: 50
{{ option.default }}: 50
{%- elif option.default is iterable and option.default is not mapping %}
{{ option.default | list }}
{%- else %}
{{ yaml_dump(option.default) | indent(4, first=false) }}
{{ yaml_dump(option.default) | trim | indent(4, first=false) }}
{%- endif -%}
{{ "\n" }}
{%- endfor %}

View File

@@ -1,8 +1,8 @@
# Archipelago World Code Owners / Maintainers Document
#
# This file is used to notate the current "owners" or "maintainers" of any currently merged world folder as well as
# certain documentation. For any pull requests that modify these worlds/docs, a code owner must approve the PR in
# addition to a core maintainer. All other files and folders are owned and maintained by core maintainers directly.
# This file is used to notate the current "owners" or "maintainers" of any currently merged world folder. For any pull
# requests that modify these worlds, a code owner must approve the PR in addition to a core maintainer. This is not to
# be used for files/folders outside the /worlds folder, those will always need sign off from a core maintainer.
#
# All usernames must be GitHub usernames (and are case sensitive).
@@ -15,15 +15,15 @@
# A Link to the Past
/worlds/alttp/ @Berserker66
# Sudoku (APSudoku)
/worlds/apsudoku/ @EmilyV99
# Aquaria
/worlds/aquaria/ @tioui
# ArchipIDLE
/worlds/archipidle/ @LegendaryLinux
# Sudoku (BK Sudoku)
/worlds/bk_sudoku/ @Jarno458
# Blasphemous
/worlds/blasphemous/ @TRPG0
@@ -46,7 +46,7 @@
/worlds/clique/ @ThePhar
# Dark Souls III
/worlds/dark_souls_3/ @Marechal-L @nex3
/worlds/dark_souls_3/ @Marechal-L
# Donkey Kong Country 3
/worlds/dkc3/ @PoryGone
@@ -70,7 +70,7 @@
/worlds/heretic/ @Daivuk
# Hollow Knight
/worlds/hk/ @BadMagic100 @qwint
/worlds/hk/ @BadMagic100 @ThePhar
# Hylics 2
/worlds/hylics2/ @TRPG0
@@ -78,9 +78,6 @@
# Kirby's Dream Land 3
/worlds/kdl3/ @Silvris
# Kingdom Hearts
/worlds/kh1/ @gaithern
# Kingdom Hearts 2
/worlds/kh2/ @JaredWeakStrike
@@ -90,6 +87,9 @@
# Lingo
/worlds/lingo/ @hatkirby
# Links Awakening DX
/worlds/ladx/ @zig-for
# Lufia II Ancient Cave
/worlds/lufia2ac/ @el-u
/worlds/lufia2ac/docs/ @wordfcuk @el-u
@@ -106,9 +106,6 @@
# Minecraft
/worlds/minecraft/ @KonoTyran @espeon65536
# Mega Man 2
/worlds/mm2/ @Silvris
# MegaMan Battle Network 3
/worlds/mmbn3/ @digiholic
@@ -118,8 +115,8 @@
# Noita
/worlds/noita/ @ScipioWright @heinermann
# Old School Runescape
/worlds/osrs @digiholic
# Ocarina of Time
/worlds/oot/ @espeon65536
# Overcooked! 2
/worlds/overcooked2/ @toasterparty
@@ -199,9 +196,6 @@
# The Witness
/worlds/witness/ @NewSoupVi @blastron
# Yacht Dice
/worlds/yachtdice/ @spinerak
# Yoshi's Island
/worlds/yoshisisland/ @PinkSwitch
@@ -224,11 +218,6 @@
# Final Fantasy (1)
# /worlds/ff1/
# Links Awakening DX
# /worlds/ladx/
# Ocarina of Time
# /worlds/oot/
## Disabled Unmaintained Worlds
@@ -238,11 +227,3 @@
# Ori and the Blind Forest
# /worlds_disabled/oribf/
###################
## Documentation ##
###################
# Apworld Dev Faq
/docs/apworld_dev_faq.md @qwint @ScipioWright

View File

@@ -1,45 +0,0 @@
# APWorld Dev FAQ
This document is meant as a reference tool to show solutions to common problems when developing an apworld.
It is not intended to answer every question about Archipelago and it assumes you have read the other docs,
including [Contributing](contributing.md), [Adding Games](<adding games.md>), and [World API](<world api.md>).
---
### My game has a restrictive start that leads to fill errors
Hint to the Generator that an item needs to be in sphere one with local_early_items. Here, `1` represents the number of "Sword" items to attempt to place in sphere one.
```py
early_item_name = "Sword"
self.multiworld.local_early_items[self.player][early_item_name] = 1
```
Some alternative ways to try to fix this problem are:
* Add more locations to sphere one of your world, potentially only when there would be a restrictive start
* Pre-place items yourself, such as during `create_items`
* Put items into the player's starting inventory using `push_precollected`
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a restrictive start
---
### I have multiple settings that change the item/location pool counts and need to balance them out
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be unbalanced. But in real, complex situations, that might be unfeasible.
If that's the case, you can create extra filler based on the difference between your unfilled locations and your itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations) to your list of items to submit
Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names
```py
total_locations = len(self.multiworld.get_unfilled_locations(self.player))
item_pool = self.create_non_filler_items()
for _ in range(total_locations - len(item_pool)):
item_pool.append(self.create_filler())
self.multiworld.itempool += item_pool
```
A faster alternative to the `for` loop would be to use a [list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
```py
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
```

View File

@@ -53,7 +53,7 @@ Example:
```
## (Server -> Client)
These packets are sent from the multiworld server to the client. They are not messages which the server accepts.
These packets are are sent from the multiworld server to the client. They are not messages which the server accepts.
* [RoomInfo](#RoomInfo)
* [ConnectionRefused](#ConnectionRefused)
* [Connected](#Connected)
@@ -80,6 +80,7 @@ Sent to clients when they connect to an Archipelago server.
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
| games | list\[str\] | List of games present in this multiworld. |
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). **Deprecated. Use `datapackage_checksums` instead.** |
| datapackage_checksums | dict[str, str] | Checksum hash of the individual games' data packages the server will send. Used by newer clients to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents) for more information. |
| seed_name | str | Uniquely identifying name of this generation |
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
@@ -395,7 +396,6 @@ Some special keys exist with specific return data, all of them have the prefix `
| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. |
| location_name_groups_{game_name} | dict\[str, list\[str\]\] | location_name_groups belonging to the requested game. |
| client_status_{team}_{slot} | [ClientStatus](#ClientStatus) | The current game status of the requested player. |
| race_mode | int | 0 if race mode is disabled, and 1 if it's enabled. |
### Set
Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package.
@@ -500,9 +500,9 @@ In JSON this may look like:
{"item": 3, "location": 3, "player": 3, "flags": 0}
]
```
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
`item` is the item id of the item. Item ids are in the range of ± 2<sup>53</sup>-1.
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
`location` is the location id of the item inside the world. Location ids are in the range of ± 2<sup>53</sup>-1.
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
@@ -646,47 +646,15 @@ class Hint(typing.NamedTuple):
```
### Data Package Contents
A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago
server most easily and not maintain their own mappings. Some contents include:
A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago server most easily. Currently, this package is used to send ID to name mappings so that clients need not maintain their own mappings.
- Name to ID mappings for items and locations.
- A checksum of each game's data package for clients to tell if a cached package is invalid.
We encourage clients to cache the data package they receive on disk, or otherwise not tied to a session. You will know when your cache is outdated if the [RoomInfo](#RoomInfo) packet or the datapackage itself denote a different version. A special case is datapackage version 0, where it is expected the package is custom and should not be cached.
We encourage clients to cache the data package they receive on disk, or otherwise not tied to a session. You will know
when your cache is outdated if the [RoomInfo](#RoomInfo) packet or the datapackage itself denote a different checksum
than any locally cached ones.
**Important Notes about IDs and Names**:
* IDs ≤ 0 are reserved for "Archipelago" and should not be used by other world implementations.
* The IDs from the game "Archipelago" (in `worlds/generic`) may be used in any world.
* Especially Location ID `-1`: `Cheat Console` and `-2`: `Server` (typically Remote Start Inventory)
* Any names and IDs are only unique in its own world data package, but different games may reuse these names or IDs.
* At runtime, you will need to look up the game of the player to know which item or location ID/Name to lookup in the
data package. This can be easily achieved by reviewing the `slot_info` for a particular player ID prior to lookup.
* For example, a data package like this is valid (Some properties such as `checksum` were omitted):
```json
{
"games": {
"Game A": {
"location_name_to_id": {
"Boss Chest": 40
},
"item_name_to_id": {
"Item X": 12
}
},
"Game B": {
"location_name_to_id": {
"Minigame Prize": 40
},
"item_name_to_id": {
"Item X": 40
}
}
}
}
```
Note:
* Any ID is unique to its type across AP: Item 56 only exists once and Location 56 only exists once.
* Any Name is unique to its type across its own Game only: Single Arrow can exist in two games.
* The IDs from the game "Archipelago" may be used in any other game.
Especially Location ID -1: Cheat Console and -2: Server (typically Remote Start Inventory)
#### Contents
| Name | Type | Notes |
@@ -700,21 +668,18 @@ GameData is a **dict** but contains these keys and values. It's broken out into
|---------------------|----------------|-------------------------------------------------------------------------------------------------------------------------------|
| item_name_to_id | dict[str, int] | Mapping of all item names to their respective ID. |
| location_name_to_id | dict[str, int] | Mapping of all location names to their respective ID. |
| version | int | Version number of this game's data. Deprecated. Used by older clients to request an updated datapackage if cache is outdated. |
| checksum | str | A checksum hash of this game's data. |
### Tags
Tags are represented as a list of strings, the common client tags follow:
Tags are represented as a list of strings, the common Client tags follow:
| Name | Notes |
|-----------|--------------------------------------------------------------------------------------------------------------------------------------|
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets. |
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
| Name | Notes |
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
### DeathLink
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:

View File

@@ -24,7 +24,7 @@ display as `Value1` on the webhost.
files, and both will resolve as `value1`. This should be used when changing options around, i.e. changing a Toggle to a
Choice, and defining `alias_true = option_full`.
- All options with a fixed set of possible values (i.e. those which inherit from `Toggle`, `(Text)Choice` or
`(Named)Range`) support `random` as a generic option. `random` chooses from any of the available values for that
`(Named/Special)Range`) support `random` as a generic option. `random` chooses from any of the available values for that
option, and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`.
However, you can override `from_text` and handle `text == "random"` to customize its behavior or
implement it for additional option types.
@@ -85,91 +85,18 @@ class ExampleWorld(World):
options: ExampleGameOptions
```
### Option Documentation
Options' [docstrings] are used as their user-facing documentation. They're displayed on the WebHost setup page when a
user hovers over the yellow "(?)" icon, and included in the YAML templates generated for each game.
[docstrings]: /docs/world%20api.md#docstrings
The WebHost can display Option documentation either as plain text with all whitespace preserved (other than the base
indentation), or as HTML generated from the standard Python [reStructuredText] format. Although plain text is the
default for backwards compatibility, world authors are encouraged to write their Option documentation as
reStructuredText and enable rich text rendering by setting `World.rich_text_options_doc = True`.
[reStructuredText]: https://docutils.sourceforge.io/rst.html
```python
from worlds.AutoWorld import WebWorld
class ExampleWebWorld(WebWorld):
# Render all this world's options as rich text.
rich_text_options_doc = True
```
You can set a single option to use rich or plain text by setting
`Option.rich_text_doc`.
```python
from Options import Toggle, Range, Choice, PerGameCommonOptions
class Difficulty(Choice):
"""Sets overall game difficulty.
- **Easy:** All enemies die in one hit.
- **Normal:** Enemies and the player both have normal health bars.
- **Hard:** The player dies in one hit."""
display_name = "Difficulty"
rich_text_doc = True
option_easy = 0
option_normal = 1
option_hard = 2
default = 1
```
### Option Visibility
Every option has a Visibility IntFlag, defaulting to `all` (`0b1111`). This lets you choose where the option will be
displayed. This only impacts where options are displayed, not how they can be used. Hidden options are still valid
options in a yaml. The flags are as follows:
* `none` (`0b0000`): This option is not shown anywhere
* `template` (`0b0001`): This option shows up in template yamls
* `simple_ui` (`0b0010`): This option shows up on the options page
* `complex_ui` (`0b0100`): This option shows up on the advanced/weighted options page
* `spoiler` (`0b1000`): This option shows up in spoiler logs
```python
from Options import Choice, Visibility
class HiddenChoiceOption(Choice):
visibility = Visibility.none
```
### Option Groups
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment
with the group name at the beginning of each group of options. The `start_collapsed` Boolean only affects how the groups
appear on the WebHost, with the grouping being collapsed when this is `True`.
Options without a group name are categorized into a generic "Game Options" group, which is always the first group. If
every option for your world is in a group, this group will be removed. There is also an "Items & Location Options"
group, which is automatically created using certain specified `item_and_loc_options`. These specified options cannot be
removed from this group.
Both the "Game Options" and "Item & Location Options" groups can be overridden by creating your own groups with
those names, letting you add options to them and change whether they start collapsed. The "Item &
Location Options" group can also be moved to a different position in the group ordering, but "Game Options" will always
be first, regardless of where it is in your list.
Options may be categorized into groups for display on the WebHost. Option groups are displayed alphabetically on the
player-options and weighted-options pages. Options without a group name are categorized into a generic "Game Options"
group.
```python
from worlds.AutoWorld import WebWorld
from Options import OptionGroup
from . import Options
class MyWorldWeb(WebWorld):
option_groups = [
OptionGroup("Color Options", [
OptionGroup('Color Options', [
Options.ColorblindMode,
Options.FlashReduction,
Options.UIColors,
@@ -193,8 +120,7 @@ or if I need a boolean object, such as in my slot_data I can access it as:
start_with_sword = bool(self.options.starting_sword.value)
```
All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes,
strings that match the option attributes after "option_" is stripped, and the attributes themselves. The option can
also be checked to see if it exists within a collection, but this will fail for a set of strings due to hashing.
strings that match the option attributes after "option_" is stripped, and the attributes themselves.
```python
# options.py
class Logic(Choice):
@@ -206,12 +132,6 @@ class Logic(Choice):
alias_extra_hard = 2
crazy = 4 # won't be listed as an option and only exists as an attribute on the class
class Weapon(Choice):
option_none = 0
option_sword = 1
option_bow = 2
option_hammer = 3
# __init__.py
from .options import Logic
@@ -225,16 +145,6 @@ elif self.options.logic == Logic.option_extreme:
do_extreme_things()
elif self.options.logic == "crazy":
do_insane_things()
# check if the current option is in a collection of integers using the class attributes
if self.options.weapon in {Weapon.option_bow, Weapon.option_sword}:
do_stuff()
# in order to make a set of strings work, we have to compare against current_key
elif self.options.weapon.current_key in {"none", "hammer"}:
do_something_else()
# though it's usually better to just use a tuple instead
elif self.options.weapon in ("none", "hammer"):
do_something_else()
```
## Generic Option Classes
These options are generically available to every game automatically, but can be overridden for slightly different

View File

@@ -8,7 +8,7 @@ use that version. These steps are for developers or platforms without compiled r
What you'll need:
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
* Python 3.12.x is currently the newest supported version
* **Python 3.12 is currently unsupported**
* pip: included in downloads from python.org, separate in many Linux distributions
* Matching C compiler
* possibly optional, read operating system specific sections
@@ -31,14 +31,14 @@ After this, you should be able to run the programs.
Recommended steps
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
* [read above](#General) which versions are supported
* **Python 3.12 is currently unsupported**
* **Optional**: Download and install Visual Studio Build Tools from
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/).
* Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details.
Generally, selecting the box for "Desktop Development with C++" will provide what you need.
* Build tools are not required if all modules are installed pre-compiled. Pre-compiled modules are pinned on
[Discord in #ap-core-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
[Discord in #archipelago-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
* Run Generate.py which will prompt installation of missing modules, press enter to confirm

View File

@@ -56,12 +56,6 @@ webhost:
* `options_page` can be changed to a link instead of an AP-generated options page.
* `rich_text_options_doc` controls whether [Option documentation] uses plain text (`False`) or rich text (`True`). It
defaults to `False`, but world authors are encouraged to set it to `True` for nicer-looking documentation that looks
good on both the WebHost and the YAML template.
[Option documentation]: /docs/options%20api.md#option-documentation
* `theme` to be used for your game-specific AP pages. Available themes:
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone |
@@ -303,31 +297,6 @@ generation (entrance randomization).
An access rule is a function that returns `True` or `False` for a `Location` or `Entrance` based on the current `state`
(items that have been collected).
The two possible ways to make a [CollectionRule](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10) are:
- `def rule(state: CollectionState) -> bool:`
- `lambda state: ... boolean expression ...`
An access rule can be assigned through `set_rule(location, rule)`.
Access rules usually check for one of two things.
- Items that have been collected (e.g. `state.has("Sword", player)`)
- Locations, Regions or Entrances that have been reached (e.g. `state.can_reach_region("Boss Room")`)
Keep in mind that entrances and locations implicitly check for the accessibility of their parent region, so you do not need to check explicitly for it.
#### An important note on Entrance access rules:
When using `state.can_reach` within an entrance access condition, you must also use `multiworld.register_indirect_condition`.
For efficiency reasons, every time reachable regions are searched, every entrance is only checked once in a somewhat non-deterministic order.
This is fine when checking for items using `state.has`, because items do not change during a region sweep.
However, `state.can_reach` checks for the very same thing we are updating: Regions.
This can lead to non-deterministic behavior and, in the worst case, even generation failures.
Even doing `state.can_reach_location` or `state.can_reach_entrance` is problematic, as these functions call `state.can_reach_region` on the respective parent region.
**Therefore, it is considered unsafe to perform `state.can_reach` from within an access condition for an entrance**, unless you are checking for something that sits in the source region of the entrance.
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
### Item Rules
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
@@ -481,9 +450,8 @@ In addition, the following methods can be implemented and are called in this ord
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.
* `create_items(self)`
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 this step. Locations cannot be moved to different regions after this step.
called to place player's items into the MultiWorld's itempool. After this step all regions
and items have to be in the MultiWorld's regions and itempool, and these lists should not be modified afterward.
* `set_rules(self)`
called to set access and item rules on locations and entrances.
* `generate_basic(self)`
@@ -655,7 +623,7 @@ def set_rules(self) -> None:
Custom methods can be defined for your logic rules. The access rule that ultimately gets assigned to the Location or
Entrance should be
a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10).
a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L9).
Typically, this is done by defining a lambda expression on demand at the relevant bit, typically calling other
functions, but this can also be achieved by defining a method with the appropriate format and assigning it directly.
For an example, see [The Messenger](/worlds/messenger/rules.py).

View File

@@ -26,17 +26,8 @@ Unless these are shared between multiple people, we expect the following from ea
### Adding a World
When we merge your world into the core Archipelago repository, you automatically become world maintainer unless you
nominate someone else (i.e. there are multiple devs).
### Being added as a maintainer to an existing implementation
At any point, a world maintainer can approve the addition of another maintainer to their world.
In order to do this, either an existing maintainer or the new maintainer must open a PR updating the
[CODEOWNERS](/docs/CODEOWNERS) file.
This change must be approved by all existing maintainers of the affected world, the new maintainer candidate, and
one core maintainer.
To help the core team review the change, information about the new maintainer and their contributions should be
included in the PR description.
nominate someone else (i.e. there are multiple devs). You can define who is allowed to approve changes to your world
in the [CODEOWNERS](/docs/CODEOWNERS) document.
### Getting Voted
@@ -44,7 +35,7 @@ When a world is unmaintained, the [core maintainers](https://github.com/orgs/Arc
can vote for a new maintainer if there is a candidate.
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
The time limit is 1 week, but can end early if the majority is reached earlier.
Voting shall be conducted on Discord in #ap-core-dev.
Voting shall be conducted on Discord in #archipelago-dev.
## Dropping out
@@ -60,7 +51,7 @@ for example when they become unreachable.
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
The time limit is 2 weeks, but can end early if the majority is reached earlier AND the world maintainer was pinged and
made their case or was pinged and has been unreachable for more than 2 weeks already.
Voting shall be conducted on Discord in #ap-core-dev. Commits that are a direct result of the voting shall include
Voting shall be conducted on Discord in #archipelago-dev. Commits that are a direct result of the voting shall include
date, voting members and final result in the commit message.
## Handling of Unmaintained Worlds

View File

@@ -75,7 +75,7 @@ Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLaunc
[Run]
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}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Flags: nowait; Components: lttp_sprites
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
@@ -87,14 +87,7 @@ Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy"
Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy"
Type: files; Name: "{app}\lib\worlds\sc2wol.apworld"
Type: filesandordirs; Name: "{app}\lib\worlds\sc2wol"
Type: dirifempty; Name: "{app}\lib\worlds\sc2wol"
Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku"
Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku"
Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss"
@@ -186,11 +179,6 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
@@ -221,15 +209,10 @@ Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apworld"; ValueData: "{#MyAppName}worlddata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata"; ValueData: "Archipelago World Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoLauncher.exe,0";
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1""";
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1""";
[Code]
// See: https://stackoverflow.com/a/51614652/2287576

135
intset.h
View File

@@ -1,135 +0,0 @@
/* A specialized unordered_set implementation for literals, where bucket_count
* is defined at initialization rather than increased automatically.
*/
#include <stddef.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#ifndef INTSET_NAME
#error "Please #define INTSET_NAME ... before including intset.h"
#endif
#ifndef INTSET_TYPE
#error "Please #define INTSET_TYPE ... before including intset.h"
#endif
/* macros to generate unique names from INTSET_NAME */
#ifndef INTSET_CONCAT
#define INTSET_CONCAT_(a, b) a ## b
#define INTSET_CONCAT(a, b) INTSET_CONCAT_(a, b)
#define INTSET_FUNC_(a, b) INTSET_CONCAT(a, _ ## b)
#endif
#define INTSET_FUNC(name) INTSET_FUNC_(INTSET_NAME, name)
#define INTSET_BUCKET INTSET_CONCAT(INTSET_NAME, Bucket)
#define INTSET_UNION INTSET_CONCAT(INTSET_NAME, Union)
#if defined(_MSC_VER)
#pragma warning(push)
#pragma warning(disable : 4200)
#endif
typedef struct {
size_t count;
union INTSET_UNION {
INTSET_TYPE val;
INTSET_TYPE *data;
} v;
} INTSET_BUCKET;
typedef struct {
size_t bucket_count;
INTSET_BUCKET buckets[];
} INTSET_NAME;
static INTSET_NAME *INTSET_FUNC(new)(size_t buckets)
{
size_t i, size;
INTSET_NAME *set;
if (buckets < 1)
buckets = 1;
if ((SIZE_MAX - sizeof(INTSET_NAME)) / sizeof(INTSET_BUCKET) < buckets)
return NULL;
size = sizeof(INTSET_NAME) + buckets * sizeof(INTSET_BUCKET);
set = (INTSET_NAME*)malloc(size);
if (!set)
return NULL;
memset(set, 0, size); /* gcc -fanalyzer does not understand this sets all buckets' count to 0 */
for (i = 0; i < buckets; i++) {
set->buckets[i].count = 0;
}
set->bucket_count = buckets;
return set;
}
static void INTSET_FUNC(free)(INTSET_NAME *set)
{
size_t i;
if (!set)
return;
for (i = 0; i < set->bucket_count; i++) {
if (set->buckets[i].count > 1)
free(set->buckets[i].v.data);
}
free(set);
}
static bool INTSET_FUNC(contains)(INTSET_NAME *set, INTSET_TYPE val)
{
size_t i;
INTSET_BUCKET* bucket = &set->buckets[(size_t)val % set->bucket_count];
if (bucket->count == 1)
return bucket->v.val == val;
for (i = 0; i < bucket->count; ++i) {
if (bucket->v.data[i] == val)
return true;
}
return false;
}
static bool INTSET_FUNC(add)(INTSET_NAME *set, INTSET_TYPE val)
{
INTSET_BUCKET* bucket;
if (INTSET_FUNC(contains)(set, val))
return true; /* ok */
bucket = &set->buckets[(size_t)val % set->bucket_count];
if (bucket->count == 0) {
bucket->v.val = val;
bucket->count = 1;
} else if (bucket->count == 1) {
INTSET_TYPE old = bucket->v.val;
bucket->v.data = (INTSET_TYPE*)malloc(2 * sizeof(INTSET_TYPE));
if (!bucket->v.data) {
bucket->v.val = old;
return false; /* error */
}
bucket->v.data[0] = old;
bucket->v.data[1] = val;
bucket->count = 2;
} else {
size_t new_bucket_size;
INTSET_TYPE* new_bucket_data;
new_bucket_size = (bucket->count + 1) * sizeof(INTSET_TYPE);
new_bucket_data = (INTSET_TYPE*)realloc(bucket->v.data, new_bucket_size);
if (!new_bucket_data)
return false; /* error */
bucket->v.data = new_bucket_data;
bucket->v.data[bucket->count++] = val;
}
return true; /* success */
}
#if defined(_MSC_VER)
#pragma warning(pop)
#endif
#undef INTSET_FUNC
#undef INTSET_BUCKET
#undef INTSET_UNION

117
kvui.py
View File

@@ -3,9 +3,6 @@ import logging
import sys
import typing
import re
from collections import deque
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
if sys.platform == "win32":
import ctypes
@@ -67,7 +64,7 @@ from kivy.uix.popup import Popup
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
from Utils import async_start, get_input_text_from_response
from Utils import async_start
if typing.TYPE_CHECKING:
import CommonClient
@@ -243,9 +240,6 @@ class ServerLabel(HovererableLabel):
f"\nYou currently have {ctx.hint_points} points."
elif ctx.hint_cost == 0:
text += "\n!hint is free to use."
if ctx.stored_data and "_read_race_mode" in ctx.stored_data:
text += "\nRace mode is enabled." \
if ctx.stored_data["_read_race_mode"] else "\nRace mode is disabled."
else:
text += f"\nYou are not authenticated yet."
@@ -291,10 +285,16 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
temp = MarkupLabel(text=self.text).markup
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
cmdinput = App.get_running_app().textinput
if not cmdinput.text:
input_text = get_input_text_from_response(text, App.get_running_app().last_autofillable_command)
if input_text is not None:
cmdinput.text = input_text
if not cmdinput.text and " did you mean " in text:
for question in ("Didn't find something that closely matches, did you mean ",
"Too many close matches, did you mean "):
if text.startswith(question):
name = Utils.get_text_between(text, question,
"? (")
cmdinput.text = f"!{App.get_running_app().last_autofillable_command} {name}"
break
elif not cmdinput.text and text.startswith("Missing: "):
cmdinput.text = text.replace("Missing: ", "!hint_location ")
Clipboard.copy(text.replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]"))
return self.parent.select_with_touch(self.index, touch)
@@ -386,57 +386,6 @@ class ConnectBarTextInput(TextInput):
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
def is_command_input(string: str) -> bool:
return len(string) > 0 and string[0] in "/!"
class CommandPromptTextInput(TextInput):
MAXIMUM_HISTORY_MESSAGES = 50
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._command_history_index = -1
self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES)
def update_history(self, new_entry: str) -> None:
self._command_history_index = -1
if is_command_input(new_entry):
self._command_history.appendleft(new_entry)
def keyboard_on_key_down(
self,
window,
keycode: typing.Tuple[int, str],
text: typing.Optional[str],
modifiers: typing.List[str]
) -> bool:
"""
:param window: The kivy window object
:param keycode: A tuple of (keycode, keyname). Keynames are always lowercase
:param text: The text printed by this key, not accounting for modifiers, or `None` if no text.
Seems to pretty naively interpret the keycode as unicode, so numlock can return odd characters.
:param modifiers: A list of string modifiers, like `ctrl` or `numlock`
"""
if keycode[1] == 'up':
self._change_to_history_text_if_available(self._command_history_index + 1)
return True
if keycode[1] == 'down':
self._change_to_history_text_if_available(self._command_history_index - 1)
return True
return super().keyboard_on_key_down(window, keycode, text, modifiers)
def _change_to_history_text_if_available(self, new_index: int) -> None:
if new_index < -1:
return
if new_index >= len(self._command_history):
return
self._command_history_index = new_index
if new_index == -1:
self.text = ""
return
self.text = self._command_history[self._command_history_index]
class MessageBox(Popup):
class MessageBoxLabel(Label):
def __init__(self, **kwargs):
@@ -472,7 +421,7 @@ class GameManager(App):
self.commandprocessor = ctx.command_processor(ctx)
self.icon = r"data/icon.png"
self.json_to_kivy_parser = KivyJSONtoTextParser(ctx)
self.log_panels: typing.Dict[str, Widget] = {}
self.log_panels = {}
# keep track of last used command to autofill on click
self.last_autofillable_command = "hint"
@@ -539,8 +488,9 @@ class GameManager(App):
# show Archipelago tab if other logging is present
self.tabs.add_widget(panel)
hint_panel = self.add_client_tab("Hints", HintLog(self.json_to_kivy_parser))
self.log_panels["Hints"] = hint_panel.content
hint_panel = TabbedPanelItem(text="Hints")
self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser)
self.tabs.add_widget(hint_panel)
if len(self.logging_pairs) == 1:
self.tabs.default_tab_text = "Archipelago"
@@ -555,7 +505,7 @@ class GameManager(App):
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None)
info_button.bind(on_release=self.command_button_action)
bottom_layout.add_widget(info_button)
self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
self.textinput = TextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
self.textinput.bind(on_text_validate=self.on_message)
self.textinput.text_validate_unfocus = False
bottom_layout.add_widget(self.textinput)
@@ -574,14 +524,6 @@ class GameManager(App):
return self.container
def add_client_tab(self, title: str, content: Widget) -> Widget:
"""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."""
new_tab = TabbedPanelItem(text=title)
new_tab.content = content
self.tabs.add_widget(new_tab)
return new_tab
def update_texts(self, dt):
if hasattr(self.tabs.content.children[0], "fix_heights"):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
@@ -607,9 +549,8 @@ class GameManager(App):
"!help for server commands.")
def connect_button_action(self, button):
self.ctx.username = None
self.ctx.password = None
if self.ctx.server:
self.ctx.username = None
async_start(self.ctx.disconnect())
else:
async_start(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
@@ -622,18 +563,14 @@ class GameManager(App):
self.ctx.exit_event.set()
def on_message(self, textinput: CommandPromptTextInput):
def on_message(self, textinput: TextInput):
try:
input_text = textinput.text.strip()
textinput.text = ""
textinput.update_history(input_text)
if self.ctx.input_requests > 0:
self.ctx.input_requests -= 1
self.ctx.input_queue.put_nowait(input_text)
elif is_command_input(input_text):
self.ctx.on_ui_command(input_text)
self.commandprocessor(input_text)
elif input_text:
self.commandprocessor(input_text)
@@ -746,18 +683,10 @@ class HintLog(RecycleView):
for hint in hints:
data.append({
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
"item": {"text": self.parser.handle_node({
"type": "item_id",
"text": hint["item"],
"flags": hint["item_flags"],
"player": hint["receiving_player"],
})},
"item": {"text": self.parser.handle_node(
{"type": "item_id", "text": hint["item"], "flags": hint["item_flags"]})},
"finding": {"text": self.parser.handle_node({"type": "player_id", "text": hint["finding_player"]})},
"location": {"text": self.parser.handle_node({
"type": "location_id",
"text": hint["location"],
"player": hint["finding_player"],
})},
"location": {"text": self.parser.handle_node({"type": "location_id", "text": hint["location"]})},
"entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text",
"color": "blue", "text": hint["entrance"]
if hint["entrance"] else "Vanilla"})},
@@ -849,10 +778,6 @@ class KivyJSONtoTextParser(JSONtoTextParser):
return self._handle_text(node)
def _handle_text(self, node: JSONMessagePart):
# All other text goes through _handle_color, and we don't want to escape markup twice,
# or mess up text that already has intentional markup applied to it
if node.get("type", "text") == "text":
node["text"] = escape_markup(node["text"])
for ref in node.get("refs", []):
node["text"] = f"[ref={self.ref_count}|{ref}]{node['text']}[/ref]"
self.ref_count += 1

View File

@@ -1,14 +1,14 @@
colorama>=0.4.6
websockets>=13.0.1
PyYAML>=6.0.2
jellyfish>=1.1.0
jinja2>=3.1.4
schema>=0.7.7
websockets>=12.0
PyYAML>=6.0.1
jellyfish>=1.0.3
jinja2>=3.1.3
schema>=0.7.5
kivy>=2.3.0
bsdiff4>=1.2.4
platformdirs>=4.2.2
certifi>=2024.8.30
cython>=3.0.11
platformdirs>=4.1.0
certifi>=2023.11.17
cython>=3.0.8
cymem>=2.0.8
orjson>=3.10.7
typing_extensions>=4.12.2
orjson>=3.9.10
typing_extensions>=4.7.0

View File

@@ -3,7 +3,6 @@ Application settings / host.yaml interface using type hints.
This is different from player options.
"""
import os
import os.path
import shutil
import sys
@@ -12,6 +11,7 @@ import warnings
from enum import IntEnum
from threading import Lock
from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar
import os
__all__ = [
"get_settings", "fmt_doc", "no_gui",
@@ -798,7 +798,6 @@ class Settings(Group):
atexit.register(autosave)
def save(self, location: Optional[str] = None) -> None: # as above
from Utils import parse_yaml
location = location or self._filename
assert location, "No file specified"
temp_location = location + ".tmp" # not using tempfile to test expected file access
@@ -808,18 +807,10 @@ class Settings(Group):
# can't use utf-8-sig because it breaks backward compat: pyyaml on Windows with bytes does not strip the BOM
with open(temp_location, "w", encoding="utf-8") as f:
self.dump(f)
f.flush()
if hasattr(os, "fsync"):
os.fsync(f.fileno())
# validate new file is valid yaml
with open(temp_location, encoding="utf-8") as f:
parse_yaml(f.read())
# replace old with new, try atomic operation first
try:
os.rename(temp_location, location)
except (OSError, FileExistsError):
# replace old with new
if os.path.exists(location):
os.unlink(location)
os.rename(temp_location, location)
os.rename(temp_location, location)
self._filename = location
def dump(self, f: TextIO, level: int = 0) -> None:
@@ -841,6 +832,7 @@ def get_settings() -> Settings:
with _lock: # make sure we only have one instance
res = getattr(get_settings, "_cache", None)
if not res:
import os
from Utils import user_path, local_path
filenames = ("options.yaml", "host.yaml")
locations: List[str] = []

View File

@@ -21,7 +21,7 @@ from pathlib import Path
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
try:
requirement = 'cx-Freeze==7.2.0'
requirement = 'cx-Freeze==7.0.0'
import pkg_resources
try:
pkg_resources.require(requirement)
@@ -66,6 +66,7 @@ non_apworlds: set = {
"Adventure",
"ArchipIDLE",
"Archipelago",
"ChecksFinder",
"Clique",
"Final Fantasy",
"Lufia II Ancient Cave",
@@ -189,7 +190,7 @@ if is_windows:
c = next(component for component in components if component.script_name == "Launcher")
exes.append(cx_Freeze.Executable(
script=f"{c.script_name}.py",
target_name=f"{c.frozen_name}Debug.exe",
target_name=f"{c.frozen_name}(DEBUG).exe",
icon=resolve_icon(c.icon),
))

View File

@@ -23,8 +23,8 @@ class TestBase(unittest.TestCase):
state = CollectionState(self.multiworld)
for item in items:
item.classification = ItemClassification.progression
state.collect(item, prevent_sweep=True)
state.sweep_for_advancements()
state.collect(item, event=True)
state.sweep_for_events()
state.update_reachable_regions(1)
self._state_cache[self.multiworld, tuple(items)] = state
return state
@@ -221,8 +221,8 @@ class WorldTestBase(unittest.TestCase):
if isinstance(items, Item):
items = (items,)
for item in items:
if item.location and item.advancement and item.location in self.multiworld.state.advancements:
self.multiworld.state.advancements.remove(item.location)
if item.location and item.advancement and item.location in self.multiworld.state.events:
self.multiworld.state.events.remove(item.location)
self.multiworld.state.remove(item)
def can_reach_location(self, location: str) -> bool:
@@ -292,12 +292,14 @@ class WorldTestBase(unittest.TestCase):
"""Ensure all state can reach everything and complete the game with the defined options"""
if not (self.run_default_tests and self.constructed):
return
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
with self.subTest("Game", game=self.game):
excluded = self.multiworld.worlds[self.player].options.exclude_locations.value
state = self.multiworld.get_all_state(False)
for location in self.multiworld.get_locations():
with self.subTest("Location should be reached", location=location.name):
reachable = location.can_reach(state)
self.assertTrue(reachable, f"{location.name} unreachable")
if location.name not in excluded:
with self.subTest("Location should be reached", location=location):
reachable = location.can_reach(state)
self.assertTrue(reachable, f"{location.name} unreachable")
with self.subTest("Beatable"):
self.multiworld.state = state
self.assertBeatable(True)
@@ -306,7 +308,7 @@ class WorldTestBase(unittest.TestCase):
"""Ensure empty state can reach at least one location with the defined options"""
if not (self.run_default_tests and self.constructed):
return
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
with self.subTest("Game", game=self.game):
state = CollectionState(self.multiworld)
locations = self.multiworld.get_reachable_locations(state, self.player)
self.assertGreater(len(locations), 0,
@@ -327,7 +329,7 @@ class WorldTestBase(unittest.TestCase):
for n in range(len(locations) - 1, -1, -1):
if locations[n].can_reach(state):
sphere.append(locations.pop(n))
self.assertTrue(sphere or self.multiworld.worlds[1].options.accessibility == "minimal",
self.assertTrue(sphere or self.multiworld.accessibility[1] == "minimal",
f"Unreachable locations: {locations}")
if not sphere:
break

View File

@@ -1,49 +0,0 @@
cmake_minimum_required(VERSION 3.5)
project(ap-cpp-tests)
enable_testing()
find_package(GTest REQUIRED)
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
add_definitions("/source-charset:utf-8")
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
# enable static analysis for gcc
add_compile_options(-fanalyzer -Werror)
# disable stuff that gets triggered by googletest
add_compile_options(-Wno-analyzer-malloc-leak)
# enable asan for gcc
add_compile_options(-fsanitize=address)
add_link_options(-fsanitize=address)
endif ()
add_executable(test_default)
target_include_directories(test_default
PRIVATE
${GTEST_INCLUDE_DIRS}
)
target_link_libraries(test_default
${GTEST_BOTH_LIBRARIES}
)
add_test(
NAME test_default
COMMAND test_default
)
set_property(
TEST test_default
PROPERTY ENVIRONMENT "ASAN_OPTIONS=allocator_may_return_null=1"
)
file(GLOB ITEMS *)
foreach(item ${ITEMS})
if(IS_DIRECTORY ${item} AND EXISTS ${item}/CMakeLists.txt)
message(${item})
add_subdirectory(${item})
endif()
endforeach()

View File

@@ -1,32 +0,0 @@
# C++ tests
Test framework for C and C++ code in AP.
## Adding a Test
### GoogleTest
Adding GoogleTests is as simple as creating a directory with
* one or more `test_*.cpp` files that define tests using
[GoogleTest API](https://google.github.io/googletest/)
* a `CMakeLists.txt` that adds the .cpp files to `test_default` target using
[target_sources](https://cmake.org/cmake/help/latest/command/target_sources.html)
### CTest
If either GoogleTest is not suitable for the test or the build flags / sources / libraries are incompatible,
you can add another CTest to the project using add_target and add_test, similar to how it's done for `test_default`.
## Running Tests
* Install [CMake](https://cmake.org/).
* Build and/or install GoogleTest and make sure
[CMake can find it](https://cmake.org/cmake/help/latest/module/FindGTest.html), or
[create a parent `CMakeLists.txt` that fetches GoogleTest](https://google.github.io/googletest/quickstart-cmake.html).
* Enter the directory with the top-most `CMakeLists.txt` and run
```sh
mkdir build
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
cmake --build build/ --config Release && \
ctest --test-dir build/ -C Release --output-on-failure
```

View File

@@ -1,4 +0,0 @@
target_sources(test_default
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/test_intset.cpp
)

View File

@@ -1,105 +0,0 @@
#include <limits>
#include <cstdint>
#include <gtest/gtest.h>
// uint32Set
#define INTSET_NAME uint32Set
#define INTSET_TYPE uint32_t
#include "../../../intset.h"
#undef INTSET_NAME
#undef INTSET_TYPE
// int64Set
#define INTSET_NAME int64Set
#define INTSET_TYPE int64_t
#include "../../../intset.h"
TEST(IntsetTest, ZeroBuckets)
{
// trying to allocate with zero buckets has to either fail or be functioning
uint32Set *set = uint32Set_new(0);
if (!set)
return; // failed -> OK
EXPECT_FALSE(uint32Set_contains(set, 1));
EXPECT_TRUE(uint32Set_add(set, 1));
EXPECT_TRUE(uint32Set_contains(set, 1));
uint32Set_free(set);
}
TEST(IntsetTest, Duplicate)
{
// adding the same number again can't fail
uint32Set *set = uint32Set_new(2);
ASSERT_TRUE(set);
EXPECT_TRUE(uint32Set_add(set, 0));
EXPECT_TRUE(uint32Set_add(set, 0));
EXPECT_TRUE(uint32Set_contains(set, 0));
uint32Set_free(set);
}
TEST(IntsetTest, SetAllocFailure)
{
// try to allocate 100TB of RAM, should fail and return NULL
if (sizeof(size_t) < 8)
GTEST_SKIP() << "Alloc error not testable on 32bit";
int64Set *set = int64Set_new(6250000000000ULL);
EXPECT_FALSE(set);
int64Set_free(set);
}
TEST(IntsetTest, SetAllocOverflow)
{
// try to overflow argument passed to malloc
int64Set *set = int64Set_new(std::numeric_limits<size_t>::max());
EXPECT_FALSE(set);
int64Set_free(set);
}
TEST(IntsetTest, NullFree)
{
// free(NULL) should not try to free buckets
uint32Set_free(NULL);
int64Set_free(NULL);
}
TEST(IntsetTest, BucketRealloc)
{
// add a couple of values to the same bucket to test growing the bucket
uint32Set* set = uint32Set_new(1);
ASSERT_TRUE(set);
EXPECT_FALSE(uint32Set_contains(set, 0));
EXPECT_TRUE(uint32Set_add(set, 0));
EXPECT_TRUE(uint32Set_contains(set, 0));
for (uint32_t i = 1; i < 32; ++i) {
EXPECT_TRUE(uint32Set_add(set, i));
EXPECT_TRUE(uint32Set_contains(set, i - 1));
EXPECT_TRUE(uint32Set_contains(set, i));
EXPECT_FALSE(uint32Set_contains(set, i + 1));
}
uint32Set_free(set);
}
TEST(IntSet, Max)
{
constexpr auto n = std::numeric_limits<uint32_t>::max();
uint32Set *set = uint32Set_new(1);
ASSERT_TRUE(set);
EXPECT_FALSE(uint32Set_contains(set, n));
EXPECT_TRUE(uint32Set_add(set, n));
EXPECT_TRUE(uint32Set_contains(set, n));
uint32Set_free(set);
}
TEST(InsetTest, Negative)
{
constexpr auto n = std::numeric_limits<int64_t>::min();
static_assert(n < 0, "n not negative");
int64Set *set = int64Set_new(3);
ASSERT_TRUE(set);
EXPECT_FALSE(int64Set_contains(set, n));
EXPECT_TRUE(int64Set_add(set, n));
EXPECT_TRUE(int64Set_contains(set, n));
int64Set_free(set);
}

View File

@@ -2,7 +2,6 @@ from argparse import Namespace
from typing import List, Optional, Tuple, Type, Union
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
from worlds import network_data_package
from worlds.AutoWorld import World, call_all
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
@@ -61,10 +60,6 @@ class TestWorld(World):
hidden = True
# add our test world to the data package, so we can test it later
network_data_package["games"][TestWorld.game] = TestWorld.get_data_package_data()
def generate_test_multiworld(players: int = 1) -> MultiWorld:
"""
Generates a multiworld using a special Test Case World class, and seed of 0.

View File

@@ -1,23 +0,0 @@
import unittest
from Utils import get_intended_text, get_input_text_from_response
class TestClient(unittest.TestCase):
def test_autofill_hint_from_fuzzy_hint(self) -> None:
tests = (
("item", ["item1", "item2"]), # Multiple close matches
("itm", ["item1", "item21"]), # No close match, multiple option
("item", ["item1"]), # No close match, single option
("item", ["\"item\" 'item' (item)"]), # Testing different special characters
)
for input_text, possible_answers in tests:
item_name, usable, response = get_intended_text(input_text, possible_answers)
self.assertFalse(usable, "This test must be updated, it seems get_fuzzy_results behavior changed")
hint_command = get_input_text_from_response(response, "hint")
self.assertIsNotNone(hint_command,
"The response to fuzzy hints is no longer recognized by the hint autofill")
self.assertEqual(hint_command, f"!hint {item_name}",
"The hint command autofilled by the response is not correct")

View File

@@ -174,8 +174,8 @@ class TestFillRestrictive(unittest.TestCase):
player1 = generate_player_data(multiworld, 1, 3, 3)
player2 = generate_player_data(multiworld, 2, 3, 3)
multiworld.worlds[player1.id].options.accessibility.value = Accessibility.option_minimal
multiworld.worlds[player2.id].options.accessibility.value = Accessibility.option_full
multiworld.accessibility[player1.id].value = multiworld.accessibility[player1.id].option_minimal
multiworld.accessibility[player2.id].value = multiworld.accessibility[player2.id].option_locations
multiworld.completion_condition[player1.id] = lambda state: True
multiworld.completion_condition[player2.id] = lambda state: state.has(player2.prog_items[2].name, player2.id)
@@ -192,7 +192,7 @@ class TestFillRestrictive(unittest.TestCase):
location_pool = player1.locations[1:] + player2.locations
item_pool = player1.prog_items[:-1] + player2.prog_items
fill_restrictive(multiworld, multiworld.state, location_pool, item_pool)
multiworld.state.sweep_for_advancements() # collect everything
multiworld.state.sweep_for_events() # collect everything
# all of player2's locations and items should be accessible (not all of player1's)
for item in player2.prog_items:
@@ -443,8 +443,8 @@ class TestFillRestrictive(unittest.TestCase):
item = player1.prog_items[0]
item.code = None
location.place_locked_item(item)
multiworld.state.sweep_for_advancements()
multiworld.state.sweep_for_advancements()
multiworld.state.sweep_for_events()
multiworld.state.sweep_for_events()
self.assertTrue(multiworld.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed")
self.assertEqual(multiworld.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times")

View File

@@ -1,12 +1,11 @@
import os
import os.path
import unittest
from io import StringIO
from tempfile import TemporaryDirectory, TemporaryFile
from tempfile import TemporaryFile
from typing import Any, Dict, List, cast
import Utils
from settings import Group, Settings, ServerOptions
from settings import Settings, Group
class TestIDs(unittest.TestCase):
@@ -81,27 +80,3 @@ class TestSettingsDumper(unittest.TestCase):
self.assertEqual(value_spaces[2], value_spaces[0]) # start of sub-list
self.assertGreater(value_spaces[3], value_spaces[0],
f"{value_lines[3]} should have more indentation than {value_lines[0]} in {lines}")
class TestSettingsSave(unittest.TestCase):
def test_save(self) -> None:
"""Test that saving and updating works"""
with TemporaryDirectory() as d:
filename = os.path.join(d, "host.yaml")
new_release_mode = ServerOptions.ReleaseMode("enabled")
# create default host.yaml
settings = Settings(None)
settings.save(filename)
self.assertTrue(os.path.exists(filename),
"Default settings could not be saved")
self.assertNotEqual(settings.server_options.release_mode, new_release_mode,
"Unexpected default release mode")
# update host.yaml
settings.server_options.release_mode = new_release_mode
settings.save(filename)
self.assertFalse(os.path.exists(filename + ".tmp"),
"Temp file was not removed during save")
# read back host.yaml
settings = Settings(filename)
self.assertEqual(settings.server_options.release_mode, new_release_mode,
"Settings were not overwritten")

View File

@@ -1,12 +1,27 @@
import unittest
from Fill import distribute_items_restrictive
from worlds import network_data_package
from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld
class TestIDs(unittest.TestCase):
def test_unique_items(self):
"""Tests that every game has a unique ID per item in the datapackage"""
known_item_ids = set()
for gamename, world_type in AutoWorldRegister.world_types.items():
current = len(known_item_ids)
known_item_ids |= set(world_type.item_id_to_name)
self.assertEqual(len(known_item_ids) - len(world_type.item_id_to_name), current)
def test_unique_locations(self):
"""Tests that every game has a unique ID per location in the datapackage"""
known_location_ids = set()
for gamename, world_type in AutoWorldRegister.world_types.items():
current = len(known_location_ids)
known_location_ids |= set(world_type.location_id_to_name)
self.assertEqual(len(known_location_ids) - len(world_type.location_id_to_name), current)
def test_range_items(self):
"""There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision."""
for gamename, world_type in AutoWorldRegister.world_types.items():
@@ -85,4 +100,3 @@ class TestIDs(unittest.TestCase):
f"{loc_name} is not a valid item name for location_name_to_id")
self.assertIsInstance(loc_id, int,
f"{loc_id} for {loc_name} should be an int")
self.assertEqual(datapackage["checksum"], network_data_package["games"][gamename]["checksum"])

View File

@@ -1,6 +1,6 @@
import unittest
from BaseClasses import MultiWorld, PlandoOptions
from BaseClasses import PlandoOptions
from Options import ItemLinks
from worlds.AutoWorld import AutoWorldRegister
@@ -47,15 +47,3 @@ class TestOptions(unittest.TestCase):
self.assertIn("Bow", link.value[0]["item_pool"])
# TODO test that the group created using these options has the items
def test_item_links_resolve(self):
"""Test item link option resolves correctly."""
item_link_group = [{
"name": "ItemLinkTest",
"item_pool": ["Everything"],
"link_replacement": False,
"replacement_item": None,
}]
item_links = {1: ItemLinks.from_any(item_link_group), 2: ItemLinks.from_any(item_link_group)}
for link in item_links.values():
self.assertEqual(link.value[0], item_link_group[0])

View File

@@ -14,18 +14,6 @@ class TestBase(unittest.TestCase):
"Desert Northern Cliffs", # on top of mountain, only reachable via OWG
"Dark Death Mountain Bunny Descent Area" # OWG Mountain descent
},
# These Blasphemous regions are not reachable with default options
"Blasphemous": {
"D01Z04S13[SE]", # difficulty must be hard
"D01Z05S25[E]", # difficulty must be hard
"D02Z02S05[W]", # difficulty must be hard and purified_hand must be true
"D04Z01S06[E]", # purified_hand must be true
"D04Z02S02[NE]", # difficulty must be hard and purified_hand must be true
"D05Z01S11[SW]", # difficulty must be hard
"D06Z01S08[N]", # difficulty must be hard and purified_hand must be true
"D20Z02S11[NW]", # difficulty must be hard
"D20Z02S11[E]", # difficulty must be hard
},
"Ocarina of Time": {
"Prelude of Light Warp", # Prelude is not progression by default
"Serenade of Water Warp", # Serenade is not progression by default
@@ -49,17 +37,19 @@ class TestBase(unittest.TestCase):
unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set())
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type)
excluded = multiworld.worlds[1].options.exclude_locations.value
state = multiworld.get_all_state(False)
for location in multiworld.get_locations():
with self.subTest("Location should be reached", location=location.name):
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
if location.name not in excluded:
with self.subTest("Location should be reached", location=location):
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
for region in multiworld.get_regions():
if region.name in unreachable_regions:
with self.subTest("Region should be unreachable", region=region.name):
with self.subTest("Region should be unreachable", region=region):
self.assertFalse(region.can_reach(state))
else:
with self.subTest("Region should be reached", region=region.name):
with self.subTest("Region should be reached", region=region):
self.assertTrue(region.can_reach(state))
with self.subTest("Completion Condition"):

View File

@@ -1,191 +0,0 @@
# A bunch of tests to verify MultiServer and custom webhost server work as expected.
# This spawns processes and may modify your local AP, so this is not run as part of unit testing.
# Run with `python test/hosting` instead,
import logging
import traceback
from tempfile import TemporaryDirectory
from time import sleep
from typing import Any
from test.hosting.client import Client
from test.hosting.generate import generate_local
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
stop_autohost, upload_multidata)
from test.hosting.world import copy as copy_world, delete as delete_world
failure = False
fail_fast = True
def assert_true(condition: Any, msg: str = "") -> None:
global failure
if not condition:
failure = True
msg = f": {msg}" if msg else ""
raise AssertionError(f"Assertion failed{msg}")
def assert_equal(first: Any, second: Any, msg: str = "") -> None:
global failure
if first != second:
failure = True
msg = f": {msg}" if msg else ""
raise AssertionError(f"Assertion failed: {first} == {second}{msg}")
if fail_fast:
expect_true = assert_true
expect_equal = assert_equal
else:
def expect_true(condition: Any, msg: str = "") -> None:
global failure
if not condition:
failure = True
tb = "".join(traceback.format_stack()[:-1])
msg = f": {msg}" if msg else ""
logging.error(f"Expectation failed{msg}\n{tb}")
def expect_equal(first: Any, second: Any, msg: str = "") -> None:
global failure
if first != second:
failure = True
tb = "".join(traceback.format_stack()[:-1])
msg = f": {msg}" if msg else ""
logging.error(f"Expectation failed {first} == {second}{msg}\n{tb}")
if __name__ == "__main__":
import warnings
warnings.simplefilter("ignore", ResourceWarning)
warnings.simplefilter("ignore", UserWarning)
spacer = '=' * 80
with TemporaryDirectory() as tempdir:
multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]]
p1_games = []
data_paths = []
rooms = []
copy_world("Clique", "Temp World")
try:
for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)}")
multidata = generate_local(games, tempdir)
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
p1_games.append(games[0])
data_paths.append(multidata)
finally:
delete_world("Temp World")
webapp = get_app(tempdir)
webhost_client = webapp.test_client()
for n, multidata in enumerate(data_paths, 1):
seed = upload_multidata(webhost_client, multidata)
room = create_room(webhost_client, seed)
print(f"Uploaded [{n}] {multidata} as {room}\n")
rooms.append(room)
print("Starting autohost")
from WebHostLib.autolauncher import autohost
try:
autohost(webapp.config)
host: ServeGame
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
involved_games = {"Archipelago"} | set(multi_games)
for collected_items in range(3):
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Clique only has 2 Locations
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datap ackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
prev_host_adr: str
with WebHostServeGame(webhost_client, room) as host:
prev_host_adr = host.address
with Client(host.address, game, "Player1") as client:
web_data_packages = client.games_packages
web_collected_items = len(client.checked_locations)
if collected_items < 2: # Clique only has 2 Locations
client.collect_any()
if collected_items == 1:
sleep(1) # wait for the server to collect the item
stop_autohost(True) # simulate Ctrl+C
sleep(3)
autohost(webapp.config) # this will spin the room right up again
sleep(1) # make log less annoying
# if saving failed, the next iteration will fail below
# verify server shut down
try:
with Client(prev_host_adr, game, "Player1") as client:
assert_true(False, "Server did not shut down")
except ConnectionError:
pass
for game_name in sorted(involved_games):
expect_true(game_name in web_data_packages,
f"{game_name} missing from customserver data package")
expect_true("item_name_groups" not in web_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in customserver data for {game_name}")
expect_true("location_name_groups" not in web_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in customserver data for {game_name}")
for game_name in web_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from customserver")
assert_equal(web_collected_items, collected_items,
"customserver did not load or save correctly during/after "
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
# compare customserver to MultiServer
expect_equal(local_data_packages, web_data_packages,
"customserver datapackage differs from MultiServer")
sleep(5.5) # make sure all tasks actually stopped
# raise an exception in customserver and verify the save doesn't get destroyed
# local variables room is the last room's id here
old_data = get_multidata_for_room(webhost_client, room)
print(f"Destroying multidata for {room}")
set_multidata_for_room(webhost_client, room, bytes([0]))
try:
start_room(webhost_client, room, timeout=7)
except TimeoutError:
pass
else:
assert_true(False, "Room started with destroyed multidata")
print(f"Restoring multidata for {room}")
set_multidata_for_room(webhost_client, room, old_data)
with WebHostServeGame(webhost_client, room) as host:
with Client(host.address, game, "Player1") as client:
assert_equal(len(client.checked_locations), 2,
"Save was destroyed during exception in customserver")
print("Save file is not busted 🥳")
finally:
print("Stopping autohost")
stop_autohost(False)
if failure:
print("Some tests failed")
exit(1)
exit(0)

View File

@@ -1,110 +0,0 @@
import json
import sys
from typing import Any, Collection, Dict, Iterable, Optional
from websockets import ConnectionClosed
from websockets.sync.client import connect, ClientConnection
from threading import Thread
__all__ = [
"Client"
]
class Client:
"""Incomplete, minimalistic sync test client for AP network protocol"""
recv_timeout = 1.0
host: str
game: str
slot: str
password: Optional[str]
_ws: Optional[ClientConnection]
games: Iterable[str]
data_package_checksums: Dict[str, Any]
games_packages: Dict[str, Any]
missing_locations: Collection[int]
checked_locations: Collection[int]
def __init__(self, host: str, game: str, slot: str, password: Optional[str] = None) -> None:
self.host = host
self.game = game
self.slot = slot
self.password = password
self._ws = None
self.games = []
self.data_package_checksums = {}
self.games_packages = {}
self.missing_locations = []
self.checked_locations = []
def __enter__(self) -> "Client":
try:
self.connect()
except BaseException:
self.__exit__(*sys.exc_info())
raise
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
self.close()
def _poll(self) -> None:
assert self._ws
try:
while True:
self._ws.recv()
except (TimeoutError, ConnectionClosed, KeyboardInterrupt, SystemExit):
pass
def connect(self) -> None:
self._ws = connect(f"ws://{self.host}")
room_info = json.loads(self._ws.recv(self.recv_timeout))[0]
self.games = sorted(room_info["games"])
self.data_package_checksums = room_info["datapackage_checksums"]
self._ws.send(json.dumps([{
"cmd": "GetDataPackage",
"games": list(self.games),
}]))
data_package_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
self.games_packages = data_package_msg["data"]["games"]
self._ws.send(json.dumps([{
"cmd": "Connect",
"game": self.game,
"name": self.slot,
"password": self.password,
"uuid": "",
"version": {
"class": "Version",
"major": 0,
"minor": 4,
"build": 6,
},
"items_handling": 0,
"tags": [],
"slot_data": False,
}]))
connect_result_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
if connect_result_msg["cmd"] != "Connected":
raise ConnectionError(", ".join(connect_result_msg.get("errors", [connect_result_msg["cmd"]])))
self.missing_locations = connect_result_msg["missing_locations"]
self.checked_locations = connect_result_msg["checked_locations"]
def close(self) -> None:
if self._ws:
Thread(target=self._poll).start()
self._ws.close()
def collect(self, locations: Iterable[int]) -> None:
if not self._ws:
raise ValueError("Not connected")
self._ws.send(json.dumps([{
"cmd": "LocationChecks",
"locations": locations,
}]))
def collect_any(self) -> None:
self.collect([next(iter(self.missing_locations))])

View File

@@ -1,76 +0,0 @@
import json
import sys
import warnings
from pathlib import Path
from typing import Iterable, Union, TYPE_CHECKING
if TYPE_CHECKING:
from multiprocessing.managers import ListProxy # noqa
__all__ = [
"generate_local",
]
def _generate_local_inner(games: Iterable[str],
dest: Union[Path, str],
results: "ListProxy[Union[Path, BaseException]]") -> None:
original_argv = sys.argv
warnings.simplefilter("ignore")
try:
from tempfile import TemporaryDirectory
if not isinstance(dest, Path):
dest = Path(dest)
with TemporaryDirectory() as players_dir:
with TemporaryDirectory() as output_dir:
import Generate
import Main
for n, game in enumerate(games, 1):
player_path = Path(players_dir) / f"{n}.yaml"
with open(player_path, "w", encoding="utf-8") as f:
f.write(json.dumps({
"name": f"Player{n}",
"game": game,
game: {"hard_mode": "true"},
"description": f"generate_local slot {n} ('Player{n}'): {game}",
}))
# this is basically copied from test/programs/test_generate.py
# uses a reproducible seed that is different for each set of games
sys.argv = [sys.argv[0], "--seed", str(hash(tuple(games))),
"--player_files_path", players_dir,
"--outputpath", output_dir]
Main.main(*Generate.main())
output_files = list(Path(output_dir).glob('*.zip'))
assert len(output_files) == 1
final_file = dest / output_files[0].name
output_files[0].rename(final_file)
results.append(final_file)
except BaseException as e:
results.append(e)
raise e
finally:
sys.argv = original_argv
def generate_local(games: Iterable[str], dest: Union[Path, str]) -> Path:
from multiprocessing import Manager, Process, set_start_method
try:
set_start_method("spawn")
except RuntimeError:
pass
manager = Manager()
results: "ListProxy[Union[Path, Exception]]" = manager.list()
p = Process(target=_generate_local_inner, args=(games, dest, results))
p.start()
p.join()
result = results[0]
if isinstance(result, BaseException):
raise Exception("Could not generate multiworld") from result
return result

View File

@@ -1,115 +0,0 @@
import sys
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from threading import Event
from werkzeug.test import Client as FlaskClient
__all__ = [
"ServeGame",
"LocalServeGame",
"WebHostServeGame",
]
class ServeGame:
address: str
def _launch_multiserver(multidata: Path, ready: "Event", stop: "Event") -> None:
import os
import warnings
original_argv = sys.argv
original_stdin = sys.stdin
warnings.simplefilter("ignore")
try:
import asyncio
from MultiServer import main, parse_args
sys.argv = [sys.argv[0], str(multidata), "--host", "127.0.0.1"]
r, w = os.pipe()
sys.stdin = os.fdopen(r, "r")
async def set_ready() -> None:
await asyncio.sleep(.01) # switch back to other task once more
ready.set() # server should be up, set ready state
async def wait_stop() -> None:
await asyncio.get_event_loop().run_in_executor(None, stop.wait)
os.fdopen(w, "w").write("/exit")
async def run() -> None:
# this will run main() until first await, then switch to set_ready()
await asyncio.gather(
main(parse_args()),
set_ready(),
wait_stop(),
)
asyncio.run(run())
finally:
sys.argv = original_argv
sys.stdin = original_stdin
class LocalServeGame(ServeGame):
from multiprocessing import Process
_multidata: Path
_proc: Process
_stop: "Event"
def __init__(self, multidata: Path) -> None:
self.address = ""
self._multidata = multidata
def __enter__(self) -> "LocalServeGame":
from multiprocessing import Manager, Process, set_start_method
try:
set_start_method("spawn")
except RuntimeError:
pass
manager = Manager()
ready: "Event" = manager.Event()
self._stop = manager.Event()
self._proc = Process(target=_launch_multiserver, args=(self._multidata, ready, self._stop))
try:
self._proc.start()
ready.wait(30)
self.address = "localhost:38281"
return self
except BaseException:
self.__exit__(*sys.exc_info())
raise
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
try:
self._stop.set()
self._proc.join(30)
except TimeoutError:
self._proc.terminate()
self._proc.join()
class WebHostServeGame(ServeGame):
_client: "FlaskClient"
_room: str
def __init__(self, app_client: "FlaskClient", room: str) -> None:
self.address = ""
self._client = app_client
self._room = room
def __enter__(self) -> "WebHostServeGame":
from .webhost import start_room
self.address = start_room(self._client, self._room)
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
from .webhost import stop_room
stop_room(self._client, self._room, timeout=30)

View File

@@ -1,208 +0,0 @@
import re
from pathlib import Path
from typing import TYPE_CHECKING, Optional, cast
if TYPE_CHECKING:
from flask import Flask
from werkzeug.test import Client as FlaskClient
__all__ = [
"get_app",
"upload_multidata",
"create_room",
"start_room",
"stop_room",
"set_room_timeout",
"get_multidata_for_room",
"set_multidata_for_room",
"stop_autohost",
]
def get_app(tempdir: str) -> "Flask":
from WebHostLib import app as raw_app
from WebHost import get_app
raw_app.config["PONY"] = {
"provider": "sqlite",
"filename": str(Path(tempdir) / "host.db"),
"create_db": True,
}
raw_app.config.update({
"TESTING": True,
"HOST_ADDRESS": "localhost",
"HOSTERS": 1,
})
return get_app()
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
response = app_client.post("/uploads", data={
"file": multidata.open("rb"),
})
assert response.status_code < 400, f"Upload of {multidata} failed: status {response.status_code}"
assert "Location" in response.headers, f"Upload of {multidata} failed: no redirect"
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/seed/"), f"Upload of {multidata} failed: unexpected redirect"
return location[6:]
def create_room(app_client: "FlaskClient", seed: str, auto_start: bool = False) -> str:
response = app_client.get(f"/new_room/{seed}")
assert response.status_code < 400, f"Creating room for {seed} failed: status {response.status_code}"
assert "Location" in response.headers, f"Creating room for {seed} failed: no redirect"
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/room/"), f"Creating room for {seed} failed: unexpected redirect"
room_id = location[6:]
if not auto_start:
# by default, creating a room will auto-start it, so we update last activity here
stop_room(app_client, room_id, simulate_idle=False)
return room_id
def start_room(app_client: "FlaskClient", room_id: str, timeout: float = 30) -> str:
from time import sleep
import pony.orm
poll_interval = .2
print(f"Starting room {room_id}")
no_timeout = timeout <= 0
while no_timeout or timeout > 0:
try:
response = app_client.get(f"/room/{room_id}")
except pony.orm.core.OptimisticCheckError:
# hoster wrote to room during our transaction
continue
assert response.status_code == 200, f"Starting room for {room_id} failed: status {response.status_code}"
match = re.search(r"/connect ([\w:.\-]+)", response.text)
if match:
return match[1]
timeout -= poll_interval
sleep(poll_interval)
raise TimeoutError("Room did not start")
def stop_room(app_client: "FlaskClient",
room_id: str,
timeout: Optional[float] = None,
simulate_idle: bool = True) -> None:
from datetime import datetime, timedelta
from time import sleep
from pony.orm import db_session
from WebHostLib.models import Command, Room
from WebHostLib import app
poll_interval = 2
print(f"Stopping room {room_id}")
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
if timeout is not None:
sleep(.1) # should not be required, but other things might use threading
with db_session:
room: Room = Room.get(id=room_uuid)
if simulate_idle:
new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5)
else:
new_last_activity = datetime.utcnow() - timedelta(days=3)
room.last_activity = new_last_activity
address = f"localhost:{room.last_port}" if room.last_port > 0 else None
if address:
original_timeout = room.timeout
room.timeout = 1 # avoid spinning it up again
Command(room=room, commandtext="/exit")
try:
if address and timeout is not None:
print("waiting for shutdown")
import socket
host_str, port_str = tuple(address.split(":"))
address_tuple = host_str, int(port_str)
no_timeout = timeout <= 0
while no_timeout or timeout > 0:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.connect(address_tuple)
s.close()
except ConnectionRefusedError:
return
sleep(poll_interval)
timeout -= poll_interval
raise TimeoutError("Room did not stop")
finally:
with db_session:
room = Room.get(id=room_uuid)
room.last_port = 0 # easier to detect when the host is up this way
if address:
room.timeout = original_timeout
room.last_activity = new_last_activity
print("timeout restored")
def set_room_timeout(room_id: str, timeout: float) -> None:
from pony.orm import db_session
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
with db_session:
room: Room = Room.get(id=room_uuid)
room.timeout = timeout
def get_multidata_for_room(webhost_client: "FlaskClient", room_id: str) -> bytes:
from pony.orm import db_session
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
with db_session:
room: Room = Room.get(id=room_uuid)
return cast(bytes, room.seed.multidata)
def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: bytes) -> None:
from pony.orm import db_session
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
with db_session:
room: Room = Room.get(id=room_uuid)
room.seed.multidata = data
def stop_autohost(graceful: bool = True) -> None:
import os
import signal
import multiprocessing
from WebHostLib.autolauncher import stop
stop()
proc: multiprocessing.process.BaseProcess
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()):
if graceful and proc.pid:
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
else:
proc.kill()
try:
proc.join(30)
except TimeoutError:
proc.kill()
proc.join()

View File

@@ -1,42 +0,0 @@
import re
import shutil
from pathlib import Path
from typing import Dict
__all__ = ["copy", "delete"]
_new_worlds: Dict[str, str] = {}
def copy(src: str, dst: str) -> None:
from Utils import get_file_safe_name
from worlds import AutoWorldRegister
assert dst not in _new_worlds, "World already created"
if '"' in dst or "\\" in dst: # easier to reject than to escape
raise ValueError(f"Unsupported symbols in {dst}")
dst_folder_name = get_file_safe_name(dst.lower())
src_cls = AutoWorldRegister.world_types[src]
src_folder = Path(src_cls.__file__).parent
worlds_folder = src_folder.parent
if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir()
or not (worlds_folder / "generic").is_dir()):
raise ValueError(f"Unsupported layout for copy_world from {src}")
dst_folder = worlds_folder / dst_folder_name
if dst_folder.is_dir():
raise ValueError(f"Destination {dst_folder} already exists")
shutil.copytree(src_folder, dst_folder)
_new_worlds[dst] = str(dst_folder)
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
contents = f.read()
contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents)
def delete(name: str) -> None:
assert name in _new_worlds, "World not created by this script"
shutil.rmtree(_new_worlds[name])
del _new_worlds[name]

View File

@@ -55,7 +55,7 @@ class TestAllGamesMultiworld(MultiworldTestBase):
all_worlds = list(AutoWorldRegister.world_types.values())
self.multiworld = setup_multiworld(all_worlds, ())
for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_full
world.options.accessibility.value = Accessibility.option_locations
self.assertSteps(gen_steps)
with self.subTest("filling multiworld", seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
@@ -66,12 +66,12 @@ class TestAllGamesMultiworld(MultiworldTestBase):
class TestTwoPlayerMulti(MultiworldTestBase):
def test_two_player_single_game_fills(self) -> None:
"""Tests that a multiworld of two players for each registered game world can generate."""
for world_type in AutoWorldRegister.world_types.values():
self.multiworld = setup_multiworld([world_type, world_type], ())
for world in AutoWorldRegister.world_types.values():
self.multiworld = setup_multiworld([world, world], ())
for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_full
world.options.accessibility.value = Accessibility.option_locations
self.assertSteps(gen_steps)
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
with self.subTest("filling multiworld", seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")

View File

@@ -1,5 +1,4 @@
# Tests for _speedups.LocationStore and NetUtils._LocationStore
import os
import typing
import unittest
import warnings
@@ -8,8 +7,6 @@ from NetUtils import LocationStore, _LocationStore
State = typing.Dict[typing.Tuple[int, int], typing.Set[int]]
RawLocations = typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
ci = bool(os.environ.get("CI")) # always set in GitHub actions
sample_data: RawLocations = {
1: {
11: (21, 2, 7),
@@ -27,9 +24,6 @@ sample_data: RawLocations = {
3: {
9: (99, 4, 0),
},
5: {
9: (99, 5, 0),
}
}
empty_state: State = {
@@ -51,14 +45,14 @@ class Base:
store: typing.Union[LocationStore, _LocationStore]
def test_len(self) -> None:
self.assertEqual(len(self.store), 5)
self.assertEqual(len(self.store), 4)
self.assertEqual(len(self.store[1]), 3)
def test_key_error(self) -> None:
with self.assertRaises(KeyError):
_ = self.store[0]
with self.assertRaises(KeyError):
_ = self.store[6]
_ = self.store[5]
locations = self.store[1] # no Exception
with self.assertRaises(KeyError):
_ = locations[7]
@@ -77,7 +71,7 @@ class Base:
self.assertEqual(self.store[1].get(10, (None, None, None)), (None, None, None))
def test_iter(self) -> None:
self.assertEqual(sorted(self.store), [1, 2, 3, 4, 5])
self.assertEqual(sorted(self.store), [1, 2, 3, 4])
self.assertEqual(len(self.store), len(sample_data))
self.assertEqual(list(self.store[1]), [11, 12, 13])
self.assertEqual(len(self.store[1]), len(sample_data[1]))
@@ -91,26 +85,13 @@ class Base:
self.assertEqual(sorted(self.store[1].items())[0][1], self.store[1][11])
def test_find_item(self) -> None:
# empty player set
self.assertEqual(sorted(self.store.find_item(set(), 99)), [])
# no such player, single
self.assertEqual(sorted(self.store.find_item({6}, 99)), [])
# no such player, set
self.assertEqual(sorted(self.store.find_item({7, 8, 9}, 99)), [])
# no such item
self.assertEqual(sorted(self.store.find_item({3}, 1)), [])
# valid matches
self.assertEqual(sorted(self.store.find_item({5}, 99)), [])
self.assertEqual(sorted(self.store.find_item({3}, 99)),
[(4, 9, 99, 3, 0)])
self.assertEqual(sorted(self.store.find_item({3, 4}, 99)),
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
self.assertEqual(sorted(self.store.find_item({2, 3, 4}, 99)),
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
# test hash collision in set
self.assertEqual(sorted(self.store.find_item({3, 5}, 99)),
[(4, 9, 99, 3, 0), (5, 9, 99, 5, 0)])
self.assertEqual(sorted(self.store.find_item(set(range(2048)), 13)),
[(1, 13, 13, 1, 0)])
def test_get_for_player(self) -> None:
self.assertEqual(self.store.get_for_player(3), {4: {9}})
@@ -130,9 +111,9 @@ class Base:
def test_get_remaining(self) -> None:
self.assertEqual(self.store.get_remaining(full_state, 0, 1), [])
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [(1, 13), (2, 21)])
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [(1, 13), (2, 21), (2, 22)])
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [(4, 99)])
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [13, 21])
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [13, 21, 22])
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [99])
def test_location_set_intersection(self) -> None:
locations = {10, 11, 12}
@@ -215,20 +196,18 @@ class TestPurePythonLocationStoreConstructor(Base.TestLocationStoreConstructor):
super().setUp()
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
class TestSpeedupsLocationStore(Base.TestLocationStore):
"""Run base method tests for cython implementation."""
def setUp(self) -> None:
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
self.store = LocationStore(sample_data)
super().setUp()
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
class TestSpeedupsLocationStoreConstructor(Base.TestLocationStoreConstructor):
"""Run base constructor tests and tests the additional constraints for cython implementation."""
def setUp(self) -> None:
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
self.type = LocationStore
super().setUp()

View File

@@ -1,67 +0,0 @@
import unittest
from Options import Choice, DefaultOnToggle, Toggle
class TestNumericOptions(unittest.TestCase):
def test_numeric_option(self) -> None:
"""Tests the initialization and equivalency comparisons of the base Numeric Option class."""
class TestChoice(Choice):
option_zero = 0
option_one = 1
option_two = 2
alias_three = 1
non_option_attr = 2
class TestToggle(Toggle):
pass
class TestDefaultOnToggle(DefaultOnToggle):
pass
with self.subTest("choice"):
choice_option_default = TestChoice.from_any(TestChoice.default)
choice_option_string = TestChoice.from_any("one")
choice_option_int = TestChoice.from_any(2)
choice_option_alias = TestChoice.from_any("three")
choice_option_attr = TestChoice.from_any(TestChoice.option_two)
self.assertEqual(choice_option_default, TestChoice.option_zero,
"assigning default didn't match default value")
self.assertEqual(choice_option_string, "one")
self.assertEqual(choice_option_int, 2)
self.assertEqual(choice_option_alias, TestChoice.alias_three)
self.assertEqual(choice_option_attr, TestChoice.non_option_attr)
self.assertRaises(KeyError, TestChoice.from_any, "four")
self.assertIn(choice_option_int, [1, 2, 3])
self.assertIn(choice_option_int, {2})
self.assertIn(choice_option_int, (2,))
self.assertIn(choice_option_string, ["one", "two", "three"])
# this fails since the hash is derived from the value
self.assertNotIn(choice_option_string, {"one"})
self.assertIn(choice_option_string, ("one",))
with self.subTest("toggle"):
toggle_default = TestToggle.from_any(TestToggle.default)
toggle_string = TestToggle.from_any("false")
toggle_int = TestToggle.from_any(0)
toggle_alias = TestToggle.from_any("off")
self.assertFalse(toggle_default)
self.assertFalse(toggle_string)
self.assertFalse(toggle_int)
self.assertFalse(toggle_alias)
with self.subTest("on toggle"):
toggle_default = TestDefaultOnToggle.from_any(TestDefaultOnToggle.default)
toggle_string = TestDefaultOnToggle.from_any("true")
toggle_int = TestDefaultOnToggle.from_any(1)
toggle_alias = TestDefaultOnToggle.from_any("on")
self.assertTrue(toggle_default)
self.assertTrue(toggle_string)
self.assertTrue(toggle_int)
self.assertTrue(toggle_alias)

View File

@@ -1,106 +0,0 @@
import unittest
import NetUtils
from CommonClient import CommonContext
class TestCommonContext(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.ctx = CommonContext()
self.ctx.slot = 1 # Pretend we're player 1 for this.
self.ctx.slot_info.update({
1: NetUtils.NetworkSlot("Player 1", "__TestGame1", NetUtils.SlotType.player),
2: NetUtils.NetworkSlot("Player 2", "__TestGame1", NetUtils.SlotType.player),
3: NetUtils.NetworkSlot("Player 3", "__TestGame2", NetUtils.SlotType.player),
})
self.ctx.consume_players_package([
NetUtils.NetworkPlayer(1, 1, "Player 1", "Player 1"),
NetUtils.NetworkPlayer(1, 2, "Player 2", "Player 2"),
NetUtils.NetworkPlayer(1, 3, "Player 3", "Player 3"),
])
# Using IDs outside the "safe range" for testing purposes only. If this fails unit tests, it's because
# another world is not following the spec for allowed ID ranges.
self.ctx.update_data_package({
"games": {
"__TestGame1": {
"location_name_to_id": {
"Test Location 1 - Safe": 2**54 + 1,
"Test Location 2 - Duplicate": 2**54 + 2,
},
"item_name_to_id": {
"Test Item 1 - Safe": 2**54 + 1,
"Test Item 2 - Duplicate": 2**54 + 2,
},
},
"__TestGame2": {
"location_name_to_id": {
"Test Location 3 - Duplicate": 2**54 + 2,
},
"item_name_to_id": {
"Test Item 3 - Duplicate": 2**54 + 2,
},
},
},
})
async def test_archipelago_datapackage_lookups_exist(self):
assert "Archipelago" in self.ctx.item_names, "Archipelago item names entry does not exist"
assert "Archipelago" in self.ctx.location_names, "Archipelago location names entry does not exist"
async def test_implicit_name_lookups(self):
# Items
assert self.ctx.item_names[2**54 + 1] == "Test Item 1 - Safe"
assert self.ctx.item_names[2**54 + 3] == f"Unknown item (ID: {2**54+3})"
assert self.ctx.item_names[-1] == "Nothing"
# Locations
assert self.ctx.location_names[2**54 + 1] == "Test Location 1 - Safe"
assert self.ctx.location_names[2**54 + 3] == f"Unknown location (ID: {2**54+3})"
assert self.ctx.location_names[-1] == "Cheat Console"
async def test_explicit_name_lookups(self):
# Items
assert self.ctx.item_names["__TestGame1"][2**54+1] == "Test Item 1 - Safe"
assert self.ctx.item_names["__TestGame1"][2**54+2] == "Test Item 2 - Duplicate"
assert self.ctx.item_names["__TestGame1"][2**54+3] == f"Unknown item (ID: {2**54+3})"
assert self.ctx.item_names["__TestGame1"][-1] == "Nothing"
assert self.ctx.item_names["__TestGame2"][2**54+1] == f"Unknown item (ID: {2**54+1})"
assert self.ctx.item_names["__TestGame2"][2**54+2] == "Test Item 3 - Duplicate"
assert self.ctx.item_names["__TestGame2"][2**54+3] == f"Unknown item (ID: {2**54+3})"
assert self.ctx.item_names["__TestGame2"][-1] == "Nothing"
# Locations
assert self.ctx.location_names["__TestGame1"][2**54+1] == "Test Location 1 - Safe"
assert self.ctx.location_names["__TestGame1"][2**54+2] == "Test Location 2 - Duplicate"
assert self.ctx.location_names["__TestGame1"][2**54+3] == f"Unknown location (ID: {2**54+3})"
assert self.ctx.location_names["__TestGame1"][-1] == "Cheat Console"
assert self.ctx.location_names["__TestGame2"][2**54+1] == f"Unknown location (ID: {2**54+1})"
assert self.ctx.location_names["__TestGame2"][2**54+2] == "Test Location 3 - Duplicate"
assert self.ctx.location_names["__TestGame2"][2**54+3] == f"Unknown location (ID: {2**54+3})"
assert self.ctx.location_names["__TestGame2"][-1] == "Cheat Console"
async def test_lookup_helper_functions(self):
# Checking own slot.
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 1) == "Test Item 1 - Safe"
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 2) == "Test Item 2 - Duplicate"
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 3) == f"Unknown item (ID: {2 ** 54 + 3})"
assert self.ctx.item_names.lookup_in_slot(-1) == f"Nothing"
# Checking others' slots.
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 1, 2) == "Test Item 1 - Safe"
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 2, 2) == "Test Item 2 - Duplicate"
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 1, 3) == f"Unknown item (ID: {2 ** 54 + 1})"
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 2, 3) == "Test Item 3 - Duplicate"
# Checking by game.
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 1, "__TestGame1") == "Test Item 1 - Safe"
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 2, "__TestGame1") == "Test Item 2 - Duplicate"
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 3, "__TestGame1") == f"Unknown item (ID: {2 ** 54 + 3})"
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 1, "__TestGame2") == f"Unknown item (ID: {2 ** 54 + 1})"
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 2, "__TestGame2") == "Test Item 3 - Duplicate"
# Checking with Archipelago ids are valid in any game package.
assert self.ctx.item_names.lookup_in_slot(-1, 2) == "Nothing"
assert self.ctx.item_names.lookup_in_slot(-1, 3) == "Nothing"
assert self.ctx.item_names.lookup_in_game(-1, "__TestGame1") == "Nothing"
assert self.ctx.item_names.lookup_in_game(-1, "__TestGame2") == "Nothing"

View File

@@ -9,7 +9,6 @@ from pathlib import Path
from tempfile import TemporaryDirectory
import Generate
import Main
class TestGenerateMain(unittest.TestCase):
@@ -59,7 +58,7 @@ class TestGenerateMain(unittest.TestCase):
'--player_files_path', str(self.abs_input_dir),
'--outputpath', self.output_tempdir.name]
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
Main.main(*Generate.main())
Generate.main()
self.assertOutput(self.output_tempdir.name)
@@ -68,7 +67,7 @@ class TestGenerateMain(unittest.TestCase):
'--player_files_path', str(self.rel_input_dir),
'--outputpath', self.output_tempdir.name]
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
Main.main(*Generate.main())
Generate.main()
self.assertOutput(self.output_tempdir.name)
@@ -87,7 +86,7 @@ class TestGenerateMain(unittest.TestCase):
sys.argv = [sys.argv[0], '--seed', '0',
'--outputpath', self.output_tempdir.name]
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}, player_files_path={self.yaml_input_dir}')
Main.main(*Generate.main())
Generate.main()
finally:
user_path.cached_path = user_path_backup

View File

@@ -1,36 +0,0 @@
import unittest
import typing
from uuid import uuid4
from flask import Flask
from flask.testing import FlaskClient
class TestBase(unittest.TestCase):
app: typing.ClassVar[Flask]
client: FlaskClient
@classmethod
def setUpClass(cls) -> None:
from WebHostLib import app as raw_app
from WebHost import get_app
raw_app.config["PONY"] = {
"provider": "sqlite",
"filename": ":memory:",
"create_db": True,
}
raw_app.config.update({
"TESTING": True,
"DEBUG": True,
})
try:
cls.app = get_app()
except AssertionError as e:
# since we only have 1 global app object, this might fail, but luckily all tests use the same config
if "register_blueprint" not in e.args[0]:
raise
cls.app = raw_app
def setUp(self) -> None:
self.client = self.app.test_client()

View File

@@ -1,16 +1,31 @@
import io
import unittest
import json
import yaml
from . import TestBase
class TestDocs(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
from WebHostLib import app as raw_app
from WebHost import get_app
raw_app.config["PONY"] = {
"provider": "sqlite",
"filename": ":memory:",
"create_db": True,
}
raw_app.config.update({
"TESTING": True,
})
app = get_app()
class TestAPIGenerate(TestBase):
def test_correct_error_empty_request(self) -> None:
cls.client = app.test_client()
def test_correct_error_empty_request(self):
response = self.client.post("/api/generate")
self.assertIn("No options found. Expected file attachment or json weights.", response.text)
def test_generation_queued_weights(self) -> None:
def test_generation_queued_weights(self):
options = {
"Tester1":
{
@@ -28,7 +43,7 @@ class TestAPIGenerate(TestBase):
self.assertTrue(json_data["text"].startswith("Generation of seed "))
self.assertTrue(json_data["text"].endswith(" started successfully."))
def test_generation_queued_file(self) -> None:
def test_generation_queued_file(self):
options = {
"game": "Archipelago",
"name": "Tester",

View File

@@ -1,73 +0,0 @@
import zipfile
from io import BytesIO
from flask import url_for
from . import TestBase
class TestGenerate(TestBase):
def test_valid_yaml(self) -> None:
"""
Verify that posting a valid yaml will start generating a game.
"""
with self.app.app_context(), self.app.test_request_context():
yaml_data = """
name: Player1
game: Archipelago
Archipelago: {}
"""
response = self.client.post(url_for("generate"),
data={"file": (BytesIO(yaml_data.encode("utf-8")), "test.yaml")},
follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertTrue("/seed/" in response.request.path or
"/wait/" in response.request.path,
f"Response did not properly redirect ({response.request.path})")
def test_empty_zip(self) -> None:
"""
Verify that posting an empty zip will give an error.
"""
with self.app.app_context(), self.app.test_request_context():
zip_data = BytesIO()
zipfile.ZipFile(zip_data, "w").close()
zip_data.seek(0)
self.assertGreater(len(zip_data.read()), 0)
zip_data.seek(0)
response = self.client.post(url_for("generate"),
data={"file": (zip_data, "test.zip")},
follow_redirects=True)
self.assertIn("user-message", response.text,
"Request did not call flash()")
self.assertIn("not find any valid files", response.text,
"Response shows unexpected error")
self.assertIn("generate-game-form", response.text,
"Response did not get user back to the form")
def test_too_many_players(self) -> None:
"""
Verify that posting too many players will give an error.
"""
max_roll = self.app.config["MAX_ROLL"]
# validate that max roll has a sensible value, otherwise we probably changed how it works
self.assertIsInstance(max_roll, int)
self.assertGreater(max_roll, 1)
self.assertLess(max_roll, 100)
# create a yaml with max_roll+1 players and watch it fail
with self.app.app_context(), self.app.test_request_context():
yaml_data = "---\n".join([
f"name: Player{n}\n"
"game: Archipelago\n"
"Archipelago: {}\n"
for n in range(1, max_roll + 2)
])
response = self.client.post(url_for("generate"),
data={"file": (BytesIO(yaml_data.encode("utf-8")), "test.yaml")},
follow_redirects=True)
self.assertIn("user-message", response.text,
"Request did not call flash()")
self.assertIn("limited to", response.text,
"Response shows unexpected error")
self.assertIn("generate-game-form", response.text,
"Response did not get user back to the form")

View File

@@ -1,193 +0,0 @@
import os
from uuid import UUID, uuid4, uuid5
from flask import url_for
from . import TestBase
class TestHostFakeRoom(TestBase):
room_id: UUID
log_filename: str
def setUp(self) -> None:
from pony.orm import db_session
from Utils import user_path
from WebHostLib.models import Room, Seed
super().setUp()
with self.client.session_transaction() as session:
session["_id"] = uuid4()
with db_session:
# create an empty seed and a room from it
seed = Seed(multidata=b"", owner=session["_id"])
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
self.room_id = room.id
self.log_filename = user_path("logs", f"{self.room_id}.txt")
def tearDown(self) -> None:
from pony.orm import db_session, select
from WebHostLib.models import Command, Room
with db_session:
for command in select(command for command in Command if command.room.id == self.room_id): # type: ignore
command.delete()
room: Room = Room.get(id=self.room_id)
room.seed.delete()
room.delete()
try:
os.unlink(self.log_filename)
except FileNotFoundError:
pass
def test_display_log_missing_full(self) -> None:
"""
Verify that we get a 200 response even if log is missing.
This is required to not get an error for fetch.
"""
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("display_log", room=self.room_id))
self.assertEqual(response.status_code, 200)
def test_display_log_missing_range(self) -> None:
"""
Verify that we get a full response for missing log even if we asked for range.
This is required for the JS logic to differentiate between log update and log error message.
"""
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("display_log", room=self.room_id), headers={
"Range": "bytes=100-"
})
self.assertEqual(response.status_code, 200)
def test_display_log_denied(self) -> None:
"""Verify that only the owner can see the log."""
other_client = self.app.test_client()
with self.app.app_context(), self.app.test_request_context():
response = other_client.get(url_for("display_log", room=self.room_id))
self.assertEqual(response.status_code, 403)
def test_display_log_missing_room(self) -> None:
"""Verify log for missing room gives an error as opposed to missing log for existing room."""
missing_room_id = uuid5(uuid4(), "") # rooms are always uuid4, so this can't exist
other_client = self.app.test_client()
with self.app.app_context(), self.app.test_request_context():
response = other_client.get(url_for("display_log", room=missing_room_id))
self.assertEqual(response.status_code, 404)
def test_display_log_full(self) -> None:
"""Verify full log response."""
with open(self.log_filename, "w", encoding="utf-8") as f:
text = "x" * 200
f.write(text)
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("display_log", room=self.room_id))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_data(True), text)
def test_display_log_range(self) -> None:
"""Verify that Range header in request gives a range in response."""
with open(self.log_filename, "w", encoding="utf-8") as f:
f.write(" " * 100)
text = "x" * 100
f.write(text)
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("display_log", room=self.room_id), headers={
"Range": "bytes=100-"
})
self.assertEqual(response.status_code, 206)
self.assertEqual(response.get_data(True), text)
def test_display_log_range_bom(self) -> None:
"""Verify that a BOM in the log file is skipped for range."""
with open(self.log_filename, "w", encoding="utf-8-sig") as f:
f.write(" " * 100)
text = "x" * 100
f.write(text)
self.assertEqual(f.tell(), 203) # including BOM
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("display_log", room=self.room_id), headers={
"Range": "bytes=100-"
})
self.assertEqual(response.status_code, 206)
self.assertEqual(response.get_data(True), text)
def test_host_room_missing(self) -> None:
"""Verify that missing room gives a 404 response."""
missing_room_id = uuid5(uuid4(), "") # rooms are always uuid4, so this can't exist
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("host_room", room=missing_room_id))
self.assertEqual(response.status_code, 404)
def test_host_room_own(self) -> None:
"""Verify that own room gives the full output."""
with open(self.log_filename, "w", encoding="utf-8-sig") as f:
text = "* should be visible *"
f.write(text)
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("host_room", room=self.room_id),
headers={"User-Agent": "Mozilla/5.0"})
response_text = response.get_data(True)
self.assertEqual(response.status_code, 200)
self.assertIn("href=\"/seed/", response_text)
self.assertIn(text, response_text)
def test_host_room_other(self) -> None:
"""Verify that non-own room gives the reduced output."""
from pony.orm import db_session
from WebHostLib.models import Room
with db_session:
room: Room = Room.get(id=self.room_id)
room.last_port = 12345
with open(self.log_filename, "w", encoding="utf-8-sig") as f:
text = "* should not be visible *"
f.write(text)
other_client = self.app.test_client()
with self.app.app_context(), self.app.test_request_context():
response = other_client.get(url_for("host_room", room=self.room_id))
response_text = response.get_data(True)
self.assertEqual(response.status_code, 200)
self.assertNotIn("href=\"/seed/", response_text)
self.assertNotIn(text, response_text)
self.assertIn("/connect ", response_text)
self.assertIn(":12345", response_text)
def test_host_room_own_post(self) -> None:
"""Verify command from owner gets queued for the server and response is redirect."""
from pony.orm import db_session, select
from WebHostLib.models import Command
with self.app.app_context(), self.app.test_request_context():
response = self.client.post(url_for("host_room", room=self.room_id), data={
"cmd": "/help"
})
self.assertEqual(response.status_code, 302, response.text)\
with db_session:
commands = select(command for command in Command if command.room.id == self.room_id) # type: ignore
self.assertIn("/help", (command.commandtext for command in commands))
def test_host_room_other_post(self) -> None:
"""Verify command from non-owner does not get queued for the server."""
from pony.orm import db_session, select
from WebHostLib.models import Command
other_client = self.app.test_client()
with self.app.app_context(), self.app.test_request_context():
response = other_client.post(url_for("host_room", room=self.room_id), data={
"cmd": "/help"
})
self.assertLess(response.status_code, 500)
with db_session:
commands = select(command for command in Command if command.room.id == self.room_id) # type: ignore
self.assertNotIn("/help", (command.commandtext for command in commands))

View File

@@ -1,7 +1,7 @@
import unittest
from worlds import AutoWorldRegister
from Options import ItemDict, NamedRange, NumericOption, OptionList, OptionSet
from Options import Choice, NamedRange, Toggle, Range
class TestOptionPresets(unittest.TestCase):
@@ -14,7 +14,7 @@ class TestOptionPresets(unittest.TestCase):
with self.subTest(game=game_name, preset=preset_name, option=option_name):
try:
option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
supported_types = [NumericOption, OptionSet, OptionList, ItemDict]
supported_types = [Choice, Toggle, Range, NamedRange]
if not any([issubclass(option.__class__, t) for t in supported_types]):
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
f"is not a supported type for webhost. "

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