Compare commits

..

3 Commits

Author SHA1 Message Date
NewSoupVi
8d04239504 use url_for with _anchor (#3279) 2024-05-08 08:34:17 +02:00
NewSoupVi
59a000033e Merge branch 'main' into NewSoupVi-patch-1 2024-05-07 12:47:50 +02:00
NewSoupVi
baca95d49d WebHost: Fix setup guide link not working for games with special characters 2024-05-05 08:20:45 +02:00
998 changed files with 40345 additions and 131600 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: | run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip 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 Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build - name: Build
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python setup.py build_exe --yes 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] $NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z" $ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME" echo "$NAME -> $ZIP_NAME"
@@ -54,6 +49,12 @@ jobs:
Rename-Item "exe.$NAME" Archipelago Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_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 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 - name: Build Setup
run: | run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL & "${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 $contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name $SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV 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 - name: Store Setup
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.SETUP_NAME }} name: ${{ env.SETUP_NAME }}
path: setups/${{ env.SETUP_NAME }} path: setups/${{ env.SETUP_NAME }}
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2004: build-ubuntu2004:
@@ -136,7 +110,7 @@ jobs:
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz" 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 "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml - # - copy code above to release.yml -
@@ -144,36 +118,15 @@ jobs:
run: | run: |
source venv/bin/activate source venv/bin/activate
python setup.py build_exe --yes 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 - name: Store AppImage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.APPIMAGE_NAME }} name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }} path: dist/${{ env.APPIMAGE_NAME }}
if-no-files-found: error
retention-days: 7 retention-days: 7
- name: Store .tar.gz - name: Store .tar.gz
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.TAR_NAME }} name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }} path: dist/${{ env.TAR_NAME }}
compression-level: 0 # .gz is incompressible by zip
if-no-files-found: error
retention-days: 7 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`" echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz" 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 "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml - # - code above copied from build.yml -

View File

@@ -24,7 +24,7 @@ on:
- '.github/workflows/unittests.yml' - '.github/workflows/unittests.yml'
jobs: jobs:
unit: build:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }} name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
@@ -37,13 +37,12 @@ jobs:
- {version: '3.9'} - {version: '3.9'}
- {version: '3.10'} - {version: '3.10'}
- {version: '3.11'} - {version: '3.11'}
- {version: '3.12'}
include: include:
- python: {version: '3.8'} # win7 compat - python: {version: '3.8'} # win7 compat
os: windows-latest os: windows-latest
- python: {version: '3.12'} # current - python: {version: '3.11'} # current
os: windows-latest os: windows-latest
- python: {version: '3.12'} # current - python: {version: '3.11'} # current
os: macos-latest os: macos-latest
steps: steps:
@@ -61,32 +60,3 @@ jobs:
- name: Unittests - name: Unittests
run: | run: |
pytest -n auto 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 /installdelete.iss
/data/user.kv /data/user.kv
/datapackage /datapackage
/custom_worlds
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
@@ -150,7 +149,7 @@ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
*.code-workspace .code-workspace
shell.nix shell.nix
# Spyder project settings # Spyder project settings
@@ -178,7 +177,6 @@ dmypy.json
cython_debug/ cython_debug/
# Cython intermediates # Cython intermediates
_speedups.c
_speedups.cpp _speedups.cpp
_speedups.html _speedups.html

View File

@@ -1,8 +0,0 @@
from worlds.ahit.Client import launch
import Utils
import ModuleUpdate
ModuleUpdate.update()
if __name__ == "__main__":
Utils.init_logging("AHITClient", exception_logger="Client")
launch()

View File

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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import collections import copy
import itertools import itertools
import functools import functools
import logging import logging
@@ -11,10 +11,8 @@ from argparse import Namespace
from collections import Counter, deque from collections import Counter, deque
from collections.abc import Collection, MutableSequence from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple, from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
Optional, Protocol, Set, Tuple, Union, Type) TypedDict, Union, Type, ClassVar
from typing_extensions import NotRequired, TypedDict
import NetUtils import NetUtils
import Options import Options
@@ -24,16 +22,16 @@ if typing.TYPE_CHECKING:
from worlds import AutoWorld from worlds import AutoWorld
class Group(TypedDict): class Group(TypedDict, total=False):
name: str name: str
game: str game: str
world: "AutoWorld.World" world: "AutoWorld.World"
players: AbstractSet[int] players: Set[int]
item_pool: NotRequired[Set[str]] item_pool: Set[str]
replacement_items: NotRequired[Dict[int, Optional[str]]] replacement_items: Dict[int, Optional[str]]
local_items: NotRequired[Set[str]] local_items: Set[str]
non_local_items: NotRequired[Set[str]] non_local_items: Set[str]
link_replacement: NotRequired[bool] link_replacement: bool
class ThreadBarrierProxy: class ThreadBarrierProxy:
@@ -50,11 +48,6 @@ class ThreadBarrierProxy:
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.") "Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
class HasNameAndPlayer(Protocol):
name: str
player: int
class MultiWorld(): class MultiWorld():
debug_types = False debug_types = False
player_name: Dict[int, str] player_name: Dict[int, str]
@@ -70,6 +63,7 @@ class MultiWorld():
state: CollectionState state: CollectionState
plando_options: PlandoOptions plando_options: PlandoOptions
accessibility: Dict[int, Options.Accessibility]
early_items: Dict[int, Dict[str, int]] early_items: Dict[int, Dict[str, int]]
local_early_items: Dict[int, Dict[str, int]] local_early_items: Dict[int, Dict[str, int]]
local_items: Dict[int, Options.LocalItems] local_items: Dict[int, Options.LocalItems]
@@ -163,7 +157,7 @@ class MultiWorld():
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {} self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
for player in range(1, players + 1): for player in range(1, players + 1):
def set_player_attr(attr: str, val) -> None: def set_player_attr(attr, val):
self.__dict__.setdefault(attr, {})[player] = val self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('plando_items', []) set_player_attr('plando_items', [])
set_player_attr('plando_texts', {}) set_player_attr('plando_texts', {})
@@ -172,13 +166,13 @@ class MultiWorld():
set_player_attr('completion_condition', lambda state: True) set_player_attr('completion_condition', lambda state: True)
self.worlds = {} self.worlds = {}
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the " self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
"world's random object instead (usually self.random)") "world's random object instead (usually self.random)")
self.plando_options = PlandoOptions.none self.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]: def get_all_ids(self) -> Tuple[int, ...]:
return self.player_ids + tuple(self.groups) 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. """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.""" If a group of this name already exists, the set of players is extended instead of creating a new one."""
from worlds import AutoWorld from worlds import AutoWorld
@@ -194,9 +188,7 @@ class MultiWorld():
self.player_types[new_id] = NetUtils.SlotType.group self.player_types[new_id] = NetUtils.SlotType.group
world_type = AutoWorld.AutoWorldRegister.world_types[game] world_type = AutoWorld.AutoWorldRegister.world_types[game]
self.worlds[new_id] = world_type.create_group(self, new_id, players) 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_item = classmethod(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.player_name[new_id] = name self.player_name[new_id] = name
new_group = self.groups[new_id] = Group(name=name, game=game, players=players, new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
@@ -204,7 +196,7 @@ class MultiWorld():
return new_id, new_group 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"]} 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): 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"]), "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 current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
pool = set() pool = set()
local_items = set() local_items = set()
@@ -296,88 +288,6 @@ class MultiWorld():
group["non_local_items"] = item_link["non_local_items"] group["non_local_items"] = item_link["non_local_items"]
group["link_replacement"] = replacement_prio[item_link["link_replacement"]] 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): def secure(self):
self.random = ThreadBarrierProxy(secrets.SystemRandom()) self.random = ThreadBarrierProxy(secrets.SystemRandom())
self.is_race = True self.is_race = True
@@ -399,7 +309,7 @@ class MultiWorld():
return tuple(world for player, world in self.worlds.items() if return tuple(world for player, world in self.worlds.items() if
player not in self.groups and self.game[player] == game_name) 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)})' 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: def get_player_name(self, player: int) -> str:
@@ -441,7 +351,7 @@ class MultiWorld():
subworld = self.worlds[player] subworld = self.worlds[player]
for item in subworld.get_pre_fill_items(): for item in subworld.get_pre_fill_items():
subworld.collect(ret, item) subworld.collect(ret, item)
ret.sweep_for_advancements() ret.sweep_for_events()
if use_cache: if use_cache:
self._all_state = ret self._all_state = ret
@@ -450,7 +360,7 @@ class MultiWorld():
def get_items(self) -> List[Item]: def get_items(self) -> List[Item]:
return [loc.item for loc in self.get_filled_locations()] + self.itempool 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: if resolve_group_locations:
player_groups = self.get_player_groups(player) player_groups = self.get_player_groups(player)
return [location for location in self.get_locations() if return [location for location in self.get_locations() if
@@ -459,7 +369,7 @@ class MultiWorld():
return [location for location in self.get_locations() if return [location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player] 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 return next(location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player) location.item and location.item.name == item and location.item.player == player)
@@ -552,9 +462,9 @@ class MultiWorld():
return True return True
state = starting_state.copy() state = starting_state.copy()
else: else:
state = CollectionState(self) if self.has_beaten_game(self.state):
if self.has_beaten_game(state):
return True return True
state = CollectionState(self)
prog_locations = {location for location in self.get_locations() if location.item prog_locations = {location for location in self.get_locations() if location.item
and location.item.advancement and location not in state.locations_checked} and location.item.advancement and location not in state.locations_checked}
@@ -613,21 +523,26 @@ class MultiWorld():
players: Dict[str, Set[int]] = { players: Dict[str, Set[int]] = {
"minimal": set(), "minimal": set(),
"items": set(), "items": set(),
"full": set() "locations": set()
} }
for player, world in self.worlds.items(): for player, access in self.accessibility.items():
players[world.options.accessibility.current_key].add(player) players[access.current_key].add(player)
beatable_fulfilled = False 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""" """Determine if this location has to be accessible, location is already filtered by location_relevant"""
return location.player in players["full"] or \ if location.player in players["locations"] or (location.item and location.item.player not in
(location.item and location.item.player not in players["minimal"]) 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.""" """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: def all_done() -> bool:
"""Check if all access rules are fulfilled""" """Check if all access rules are fulfilled"""
@@ -672,7 +587,7 @@ class CollectionState():
multiworld: MultiWorld multiworld: MultiWorld
reachable_regions: Dict[int, Set[Region]] reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]] blocked_connections: Dict[int, Set[Entrance]]
advancements: Set[Location] events: Set[Location]
path: Dict[Union[Region, Entrance], PathValue] path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location] locations_checked: Set[Location]
stale: Dict[int, bool] stale: Dict[int, bool]
@@ -684,7 +599,7 @@ class CollectionState():
self.multiworld = parent self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.reachable_regions = {player: set() for player in parent.get_all_ids()}
self.blocked_connections = {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.path = {}
self.locations_checked = set() self.locations_checked = set()
self.stale = {player: True for player in parent.get_all_ids()} self.stale = {player: True for player in parent.get_all_ids()}
@@ -696,25 +611,17 @@ class CollectionState():
def update_reachable_regions(self, player: int): def update_reachable_regions(self, player: int):
self.stale[player] = False self.stale[player] = False
world: AutoWorld.World = self.multiworld.worlds[player]
reachable_regions = self.reachable_regions[player] reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
queue = deque(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 # init on first call - this can't be done on construction since the regions don't exist yet
if start not in reachable_regions: if start not in reachable_regions:
reachable_regions.add(start) reachable_regions.add(start)
self.blocked_connections[player].update(start.exits) blocked_connections.update(start.exits)
queue.extend(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 # run BFS on all connections, and keep track of those blocked by missing items
while queue: while queue:
connection = queue.popleft() connection = queue.popleft()
@@ -722,7 +629,7 @@ class CollectionState():
if new_region in reachable_regions: if new_region in reachable_regions:
blocked_connections.remove(connection) blocked_connections.remove(connection)
elif connection.can_reach(self): 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) reachable_regions.add(new_region)
blocked_connections.remove(connection) blocked_connections.remove(connection)
blocked_connections.update(new_region.exits) blocked_connections.update(new_region.exits)
@@ -734,39 +641,16 @@ class CollectionState():
if new_entrance in blocked_connections and new_entrance not in queue: if new_entrance in blocked_connections and new_entrance not in queue:
queue.append(new_entrance) 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: def copy(self) -> CollectionState:
ret = CollectionState(self.multiworld) ret = CollectionState(self.multiworld)
ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()} ret.prog_items = copy.deepcopy(self.prog_items)
ret.reachable_regions = {player: region_set.copy() for player, region_set in ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
self.reachable_regions.items()} self.reachable_regions}
ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
self.blocked_connections.items()} self.blocked_connections}
ret.advancements = self.advancements.copy() ret.events = copy.copy(self.events)
ret.path = self.path.copy() ret.path = copy.copy(self.path)
ret.locations_checked = self.locations_checked.copy() ret.locations_checked = copy.copy(self.locations_checked)
for function in self.additional_copy_functions: for function in self.additional_copy_functions:
ret = function(self, ret) ret = function(self, ret)
return ret return ret
@@ -796,25 +680,20 @@ class CollectionState():
def can_reach_region(self, spot: str, player: int) -> bool: def can_reach_region(self, spot: str, player: int) -> bool:
return self.multiworld.get_region(spot, player).can_reach(self) return self.multiworld.get_region(spot, player).can_reach(self)
def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None: def sweep_for_events(self, key_only: bool = False, 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:
if locations is None: if locations is None:
locations = self.multiworld.get_filled_locations() locations = self.multiworld.get_filled_locations()
reachable_advancements = True reachable_events = True
# since the loop has a good chance to run more than once, only filter the advancements once # 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.advancements} 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_advancements: while reachable_events:
reachable_advancements = {location for location in locations if location.can_reach(self)} reachable_events = {location for location in locations if location.can_reach(self)}
locations -= reachable_advancements locations -= reachable_events
for advancement in reachable_advancements: for event in reachable_events:
self.advancements.add(advancement) self.events.add(event)
assert isinstance(advancement.item, Item), "tried to collect Event with no Item" assert isinstance(event.item, Item), "tried to collect Event with no Item"
self.collect(advancement.item, True, advancement) self.collect(event.item, True, event)
# item name related # item name related
def has(self, item: str, player: int, count: int = 1) -> bool: def has(self, item: str, player: int, count: int = 1) -> bool:
@@ -839,6 +718,10 @@ class CollectionState():
def count(self, item: str, player: int) -> int: def count(self, item: str, player: int) -> int:
return self.prog_items[player][item] return self.prog_items[player][item]
def item_count(self, item: str, player: int) -> int:
Utils.deprecate("Use count instead.")
return self.count(item, player)
def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool: def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool:
"""Returns True if the state contains at least `count` items matching any of the item names from a list.""" """Returns True if the state contains at least `count` items matching any of the item names from a list."""
found: int = 0 found: int = 0
@@ -849,25 +732,10 @@ class CollectionState():
return True return True
return False 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."""
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in items:
found += player_prog_items[item_name] > 0
if found >= count:
return True
return False
def count_from_list(self, items: Iterable[str], player: int) -> int: def count_from_list(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state.""" """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) 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)
# item name group related # item name group related
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
"""Returns True if the state contains at least `count` items present in a specified item group.""" """Returns True if the state contains at least `count` items present in a specified item group."""
@@ -879,18 +747,6 @@ class CollectionState():
return True return True
return False return False
def has_group_unique(self, item_name_group: str, player: int, count: int = 1) -> bool:
"""Returns True if the state contains at least `count` items present in a specified item group.
Ignores duplicates of the same item.
"""
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
found += player_prog_items[item_name] > 0
if found >= count:
return True
return False
def count_group(self, item_name_group: str, player: int) -> int: def count_group(self, item_name_group: str, player: int) -> int:
"""Returns the cumulative count of items from an item group present in state.""" """Returns the cumulative count of items from an item group present in state."""
player_prog_items = self.prog_items[player] player_prog_items = self.prog_items[player]
@@ -899,26 +755,21 @@ class CollectionState():
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group] for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]
) )
def count_group_unique(self, item_name_group: str, player: int) -> int:
"""Returns the cumulative count of items from an item group present in state.
Ignores duplicates of the same item."""
player_prog_items = self.prog_items[player]
return sum(
player_prog_items[item_name] > 0
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]
)
# Item related # 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: if location:
self.locations_checked.add(location) self.locations_checked.add(location)
changed = self.multiworld.worlds[item.player].collect(self, item) 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 self.stale[item.player] = True
if changed and not prevent_sweep: if changed and not event:
self.sweep_for_advancements() self.sweep_for_events()
return changed return changed
@@ -942,13 +793,12 @@ class Entrance:
addresses = None addresses = None
target = 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.name = name
self.parent_region = parent self.parent_region = parent
self.player = player self.player = player
def can_reach(self, state: CollectionState) -> bool: 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 self.parent_region.can_reach(state) and self.access_rule(state):
if not self.hide_path and not self in state.path: 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))) state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
@@ -963,6 +813,9 @@ class Entrance:
region.entrances.append(self) region.entrances.append(self)
def __repr__(self): def __repr__(self):
return self.__str__()
def __str__(self):
multiworld = self.parent_region.multiworld if self.parent_region else None 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})' return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
@@ -1088,7 +941,7 @@ class Region:
self.locations.append(location_type(self.player, location, address, self)) self.locations.append(location_type(self.player, location, address, self))
def connect(self, connecting_region: Region, name: Optional[str] = None, 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. Connects this Region to another Region, placing the provided rule on the connection.
@@ -1128,6 +981,9 @@ class Region:
rules[connecting_region] if rules and connecting_region in rules else None) rules[connecting_region] if rules and connecting_region in rules else None)
def __repr__(self): 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})' return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
@@ -1146,9 +1002,9 @@ class Location:
locked: bool = False locked: bool = False
show_in_spoiler: bool = True show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT 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) 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 item: Optional[Item] = None
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None): def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
@@ -1157,20 +1013,16 @@ class Location:
self.address = address self.address = address
self.parent_region = parent self.parent_region = parent
def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool: def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return (( return ((self.always_allow(state, item) and item.name not in state.multiworld.non_local_items[item.player])
self.always_allow(state, item) or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
and item.name not in state.multiworld.worlds[item.player].options.non_local_items and self.item_rule(item)
) or ( and (not check_access or self.can_reach(state))))
(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: def can_reach(self, state: CollectionState) -> bool:
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average # self.access_rule computes faster on average, so placing it first for faster abort
assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region" assert self.parent_region, "Can't reach location without region"
return self.parent_region.can_reach(state) and self.access_rule(state) return self.access_rule(state) and self.parent_region.can_reach(state)
def place_locked_item(self, item: Item): def place_locked_item(self, item: Item):
if self.item: if self.item:
@@ -1180,6 +1032,9 @@ class Location:
self.locked = True self.locked = True
def __repr__(self): 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 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})' return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
@@ -1201,7 +1056,7 @@ class Location:
@property @property
def native_item(self) -> bool: def native_item(self) -> bool:
"""Returns True if the item in this location matches game.""" """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 @property
def hint_text(self) -> str: def hint_text(self) -> str:
@@ -1212,7 +1067,7 @@ class ItemClassification(IntFlag):
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc, filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
progression = 0b0001 # Item that is logically relevant progression = 0b0001 # Item that is logically relevant
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical 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 skip_balancing = 0b1000 # should technically never occur on its own
# Item that is logically relevant, but progression balancing should not touch. # Item that is logically relevant, but progression balancing should not touch.
# Typically currency or other counted items. # Typically currency or other counted items.
@@ -1284,6 +1139,9 @@ class Item:
return hash((self.name, self.player)) return hash((self.name, self.player))
def __repr__(self) -> str: 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: 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 self.location.parent_region.multiworld.get_name_string_for_object(self)
return f"{self.name} (Player {self.player})" return f"{self.name} (Player {self.player})"
@@ -1352,7 +1210,7 @@ class Spoiler:
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % ( logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
location.item.name, location.item.player, location.name, location.player) for location in location.item.name, location.item.player, location.name, location.player) for location in
sphere_candidates]) sphere_candidates])
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]): if any([multiworld.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). ' raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
f'Something went terribly wrong here.') f'Something went terribly wrong here.')
else: else:
@@ -1361,9 +1219,9 @@ class Spoiler:
# in the second phase, we cull each sphere such that the game is still beatable, # in the second phase, we cull each sphere such that the game is still beatable,
# reducing each range of influence to the bare minimum required inside it # reducing each range of influence to the bare minimum required inside it
restore_later: Dict[Location, Item] = {} restore_later = {}
for num, sphere in reversed(tuple(enumerate(collection_spheres))): for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete: Set[Location] = set() to_delete = set()
for location in sphere: for location in sphere:
# we remove the item at location and check if game is still beatable # we remove the item at location and check if game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name, logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
@@ -1381,7 +1239,7 @@ class Spoiler:
sphere -= to_delete sphere -= to_delete
# second phase, sphere 0 # 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): 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) logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
multiworld.precollected_items[item.player].remove(item) multiworld.precollected_items[item.player].remove(item)
@@ -1401,6 +1259,8 @@ class Spoiler:
state = CollectionState(multiworld) state = CollectionState(multiworld)
collection_spheres = [] collection_spheres = []
while required_locations: while required_locations:
state.sweep_for_events(key_only=True)
sphere = set(filter(state.can_reach, required_locations)) sphere = set(filter(state.can_reach, required_locations))
for location in sphere: for location in sphere:
@@ -1462,7 +1322,7 @@ class Spoiler:
# Maybe move the big bomb over to the Event system instead? # Maybe move the big bomb over to the Event system instead?
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values() if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
for (_, exit_path) in path): 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))] = \ self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
get_path(state, multiworld.get_region('Big Bomb Shop', player)) get_path(state, multiworld.get_region('Big Bomb Shop', player))
else: else:
@@ -1534,9 +1394,9 @@ class Spoiler:
if self.paths: if self.paths:
outfile.write('\n\nPaths:\n\n') outfile.write('\n\nPaths:\n\n')
path_listings: List[str] = [] path_listings = []
for location, path in sorted(self.paths.items()): for location, path in sorted(self.paths.items()):
path_lines: List[str] = [] path_lines = []
for region, exit in path: for region, exit in path:
if exit is not None: if exit is not None:
path_lines.append("{} -> {}".format(region, exit)) path_lines.append("{} -> {}".format(region, exit))

View File

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

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import collections
import copy import copy
import logging import logging
import asyncio import asyncio
@@ -9,7 +8,6 @@ import sys
import typing import typing
import time import time
import functools import functools
import warnings
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -23,7 +21,7 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor from MultiServer import CommandProcessor
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType) RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes)
from Utils import Version, stream_input, async_start from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
@@ -45,21 +43,10 @@ def get_ssl_context():
class ClientCommandProcessor(CommandProcessor): 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): def __init__(self, ctx: CommonContext):
self.ctx = ctx self.ctx = ctx
def output(self, text: str): def output(self, text: str):
"""Helper function to abstract logging to the CommonClient UI"""
logger.info(text) logger.info(text)
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
@@ -72,7 +59,6 @@ class ClientCommandProcessor(CommandProcessor):
if address: if address:
self.ctx.server_address = None self.ctx.server_address = None
self.ctx.username = None self.ctx.username = None
self.ctx.password = None
elif not self.ctx.server_address: elif not self.ctx.server_address:
self.output("Please specify an address.") self.output("Please specify an address.")
return False return False
@@ -175,96 +161,28 @@ class ClientCommandProcessor(CommandProcessor):
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def default(self, raw: str): def default(self, raw: str):
"""The default message parser to be used when parsing any messages that do not match a command"""
raw = self.ctx.on_user_say(raw) raw = self.ctx.on_user_say(raw)
if raw: if raw:
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say") async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext: 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"} tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None items_handling: typing.Optional[int] = None
want_slot_data: bool = True # should slot_data be retrieved via Connect want_slot_data: bool = True # should slot_data be retrieved via Connect
class NameLookupDict: # data package
"""A specialized dict, with helper methods, for id -> name item/location data package lookups by game.""" # Contents in flux until connection to server is made, to download correct data for this multiworld.
def __init__(self, ctx: CommonContext, lookup_type: typing.Literal["item", "location"]): item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
self.ctx: CommonContext = ctx location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
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)
# defaults # defaults
starting_reconnect_delay: int = 5 starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
ui: typing.Optional["kvui.GameManager"] = None ui = None
ui_task: typing.Optional["asyncio.Task[None]"] = None ui_task: typing.Optional["asyncio.Task[None]"] = None
input_task: typing.Optional["asyncio.Task[None]"] = None input_task: typing.Optional["asyncio.Task[None]"] = None
keep_alive_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 # message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None _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 # server state
self.server_address = server_address self.server_address = server_address
self.username = None self.username = None
@@ -353,11 +271,6 @@ class CommonContext:
self.exit_event = asyncio.Event() self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event() self.watcher_event = asyncio.Event()
self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self) self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self) self.rawjsontotextparser = RawJSONtoTextParser(self)
self.update_data_package(network_data_package) self.update_data_package(network_data_package)
@@ -443,10 +356,7 @@ class CommonContext:
self.auth = await self.console_input() self.auth = await self.console_input()
async def send_connect(self, **kwargs: typing.Any) -> None: async def send_connect(self, **kwargs: typing.Any) -> None:
""" """ send `Connect` packet to log in to server """
Send a `Connect` packet to log in to the server,
additional keyword args can override any value in the connection packet
"""
payload = { payload = {
'cmd': 'Connect', 'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
@@ -456,7 +366,6 @@ class CommonContext:
if kwargs: if kwargs:
payload.update(kwargs) payload.update(kwargs)
await self.send_msgs([payload]) await self.send_msgs([payload])
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
async def console_input(self) -> str: async def console_input(self) -> str:
if self.ui: if self.ui:
@@ -477,7 +386,6 @@ class CommonContext:
return False return False
def slot_concerns_self(self, slot) -> bool: 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: if slot == self.slot:
return True return True
if slot in self.slot_info: if slot in self.slot_info:
@@ -485,7 +393,6 @@ class CommonContext:
return False return False
def is_echoed_chat(self, print_json_packet: dict) -> bool: 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" \ return print_json_packet.get("type", "") == "Chat" \
and print_json_packet.get("team", None) == self.team \ and print_json_packet.get("team", None) == self.team \
and print_json_packet.get("slot", None) == self.slot and print_json_packet.get("slot", None) == self.slot
@@ -518,13 +425,7 @@ class CommonContext:
Returned text is sent, or sending is aborted if None is returned.""" Returned text is sent, or sending is aborted if None is returned."""
return text 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]): 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(): for permission_name, permission_flag in permissions.items():
try: try:
flag = Permission(permission_flag) flag = Permission(permission_flag)
@@ -536,7 +437,6 @@ class CommonContext:
async def shutdown(self): async def shutdown(self):
self.server_address = "" self.server_address = ""
self.username = None self.username = None
self.password = None
self.cancel_autoreconnect() self.cancel_autoreconnect()
if self.server and not self.server.socket.closed: if self.server and not self.server.socket.closed:
await self.server.socket.close() await self.server.socket.close()
@@ -573,38 +473,32 @@ class CommonContext:
needed_updates.add(game) needed_updates.add(game)
continue continue
cached_version: int = self.versions.get(game, 0) local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
cached_checksum: typing.Optional[str] = self.checksums.get(game) local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
# no action required if cached version is new enough # no action required if local version is new enough
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \ if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
or remote_checksum != cached_checksum: or remote_checksum != local_checksum:
local_version: int = network_data_package["games"].get(game, {}).get("version", 0) cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum") cache_version: int = cached_game.get("version", 0)
if ((remote_checksum or remote_version <= local_version and remote_version != 0) cache_checksum: typing.Optional[str] = cached_game.get("checksum")
and remote_checksum == local_checksum): # download remote version if cache is not new enough
self.update_game(network_data_package["games"][game], game) if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else: else:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum) self.update_game(cached_game)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cached_game, game)
if needed_updates: if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in 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): def update_game(self, game_package: dict):
self.item_names.update_game(game, game_package["item_name_to_id"]) for item_name, item_id in game_package["item_name_to_id"].items():
self.location_names.update_game(game, game_package["location_name_to_id"]) self.item_names[item_id] = item_name
self.versions[game] = game_package.get("version", 0) for location_name, location_id in game_package["location_name_to_id"].items():
self.checksums[game] = game_package.get("checksum") self.location_names[location_id] = location_name
def update_data_package(self, data_package: dict): def update_data_package(self, data_package: dict):
for game, game_data in data_package["games"].items(): 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): def consume_network_data_package(self, data_package: dict):
self.update_data_package(data_package) self.update_data_package(data_package)
@@ -642,7 +536,6 @@ class CommonContext:
logger.info(f"DeathLink: Received from {data['source']}") logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""): 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: if self.server and self.server.socket:
logger.info("DeathLink: Sending death to your friends...") logger.info("DeathLink: Sending death to your friends...")
self.last_death_link = time.time() self.last_death_link = time.time()
@@ -656,7 +549,6 @@ class CommonContext:
}]) }])
async def update_death_link(self, death_link: bool): 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() old_tags = self.tags.copy()
if death_link: if death_link:
self.tags.add("DeathLink") self.tags.add("DeathLink")
@@ -666,7 +558,7 @@ class CommonContext:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]) await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]: 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: if not self.ui:
return None return None
title = title or "Error" title = title or "Error"
@@ -693,19 +585,17 @@ class CommonContext:
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True}) logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1]) self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def make_gui(self) -> typing.Type["kvui.GameManager"]: def run_gui(self):
"""To return the Kivy App class needed for run_gui so it can be overridden before being built""" """Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager from kvui import GameManager
class TextManager(GameManager): class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Text Client" base_title = "Archipelago Text Client"
return TextManager self.ui = TextManager(self)
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_task = asyncio.create_task(self.ui.async_run(), name="UI") self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def run_cli(self): def run_cli(self):
@@ -897,8 +787,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.team = args["team"] ctx.team = args["team"]
ctx.slot = args["slot"] ctx.slot = args["slot"]
# int keys get lost in JSON transfer # int keys get lost in JSON transfer
ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)} ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
ctx.hint_points = args.get("hint_points", 0) ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"]) ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}") ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
@@ -1018,7 +907,6 @@ async def console_loop(ctx: CommonContext):
def get_base_parser(description: typing.Optional[str] = None): def get_base_parser(description: typing.Optional[str] = None):
"""Base argument parser to be reused for components subclassing off of CommonClient"""
import argparse import argparse
parser = argparse.ArgumentParser(description=description) parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.') parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -1028,7 +916,7 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser return parser
def run_as_textclient(*args): def run_as_textclient():
class TextContext(CommonContext): class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry # Text Mode to use !hint and such with games that have no text entry
tags = CommonContext.tags | {"TextOnly"} tags = CommonContext.tags | {"TextOnly"}
@@ -1067,21 +955,16 @@ def run_as_textclient(*args):
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.") 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('--name', default=None, help="Slot Name to connect as.")
parser.add_argument("url", nargs="?", help="Archipelago connection url") 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: if args.url:
url = urllib.parse.urlparse(args.url) url = urllib.parse.urlparse(args.url)
if url.scheme == "archipelago": args.connect = url.netloc
args.connect = url.netloc if url.username:
if url.username: args.name = urllib.parse.unquote(url.username)
args.name = urllib.parse.unquote(url.username) if url.password:
if url.password: args.password = urllib.parse.unquote(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")
# use colorama to display colored text highlighting on windows
colorama.init() colorama.init()
asyncio.run(main(args)) asyncio.run(main(args))
@@ -1090,4 +973,4 @@ def run_as_textclient(*args):
if __name__ == '__main__': if __name__ == '__main__':
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING 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()

98
Fill.py
View File

@@ -12,12 +12,7 @@ from worlds.generic.Rules import add_item_rule
class FillError(RuntimeError): class FillError(RuntimeError):
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None: pass
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)
def _log_fill_progress(name: str, placed: int, total_items: int) -> None: 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() new_state = base_state.copy()
for item in itempool: for item in itempool:
new_state.collect(item, True) new_state.collect(item, True)
new_state.sweep_for_advancements(locations=locations) new_state.sweep_for_events(locations=locations)
return new_state return new_state
@@ -40,8 +35,8 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
""" """
:param multiworld: Multiworld to be filled. :param multiworld: Multiworld to be filled.
:param base_state: State assumed before fill. :param base_state: State assumed before fill.
:param locations: Locations to be filled with item_pool, gets mutated by removing locations that get filled. :param locations: Locations to be filled with item_pool
:param item_pool: Items to fill into the locations, gets mutated by removing items that get placed. :param item_pool: Items to fill into the locations
:param single_player_placement: if true, can speed up placement if everything belongs to a single player :param single_player_placement: if true, can speed up placement if everything belongs to a single player
:param lock: locations are set to locked as they are filled :param lock: locations are set to locked as they are filled
:param swap: if true, swaps of already place items are done in the event of a dead end :param swap: if true, swaps of already place items are done in the event of a dead end
@@ -217,7 +212,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
f"Unfilled locations:\n" f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n" f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\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) item_pool.extend(unplaced_items)
@@ -225,8 +220,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
def remaining_fill(multiworld: MultiWorld, def remaining_fill(multiworld: MultiWorld,
locations: typing.List[Location], locations: typing.List[Location],
itempool: typing.List[Item], itempool: typing.List[Item],
name: str = "Remaining", name: str = "Remaining") -> None:
move_unplaceable_to_start_inventory: bool = False) -> None:
unplaced_items: typing.List[Item] = [] unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = [] placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
@@ -290,21 +284,13 @@ def remaining_fill(multiworld: MultiWorld,
if unplaced_items and locations: if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them # There are leftover unplaceable items and locations that won't accept them
if move_unplaceable_to_start_inventory: raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
last_batch = [] f"Unplaced items:\n"
for item in unplaced_items: f"{', '.join(str(item) for item in unplaced_items)}\n"
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.") f"Unfilled locations:\n"
multiworld.push_precollected(item) f"{', '.join(str(location) for location in locations)}\n"
last_batch.append(multiworld.worlds[item.player].create_filler()) f"Already placed {len(placements)}:\n"
remaining_fill(multiworld, locations, unplaced_items, name + " Start Inventory Retry") f"{', '.join(str(place) for place in placements)}")
else:
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
f"Unplaced items:\n"
f"{', '.join(str(item) for item in unplaced_items)}\n"
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)
itempool.extend(unplaced_items) itempool.extend(unplaced_items)
@@ -329,8 +315,8 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
pool.append(location.item) pool.append(location.item)
state.remove(location.item) state.remove(location.item)
location.item = None location.item = None
if location in state.advancements: if location in state.events:
state.advancements.remove(location) state.events.remove(location)
locations.append(location) locations.append(location)
if pool and locations: if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY) locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
@@ -363,7 +349,7 @@ def distribute_early_items(multiworld: MultiWorld,
early_priority_locations: typing.List[Location] = [] early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set() loc_indexes_to_remove: typing.Set[int] = set()
base_state = multiworld.state.copy() 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): for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state): if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY: if loc.progress_type == LocationProgressType.PRIORITY:
@@ -434,8 +420,7 @@ def distribute_early_items(multiworld: MultiWorld,
return fill_locations, itempool return fill_locations, itempool
def distribute_items_restrictive(multiworld: MultiWorld, def distribute_items_restrictive(multiworld: MultiWorld) -> None:
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
fill_locations = sorted(multiworld.get_unfilled_locations()) fill_locations = sorted(multiworld.get_unfilled_locations())
multiworld.random.shuffle(fill_locations) multiworld.random.shuffle(fill_locations)
# get items to distribute # get items to distribute
@@ -475,42 +460,22 @@ def distribute_items_restrictive(multiworld: MultiWorld,
nonlocal lock_later nonlocal lock_later
lock_later.append(location) lock_later.append(location)
single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations: if prioritylocations:
# "priority fill" # "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, 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) accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
if progitempool: if progitempool:
# "advancement/progression fill" # "advancement/progression fill"
if panic_method == "swap": fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, single_player_placement=multiworld.players == 1,
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True, name="Progression")
name="Progression", single_player_placement=single_player)
elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
name="Progression", single_player_placement=single_player)
elif panic_method == "start_inventory":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
allow_partial=True, name="Progression", single_player_placement=single_player)
if progitempool:
for item in progitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
filleritempool.append(multiworld.worlds[item.player].create_filler())
logging.warning(f"{len(progitempool)} items moved to start inventory,"
f" due to failure in Progression fill step.")
progitempool[:] = []
else:
raise ValueError(f"Generator Panic Method {panic_method} not recognized.")
if progitempool: if progitempool:
raise FillError( raise FillError(
f"Not enough locations for progression items. " f"Not enough locations for progression items. "
f"There are {len(progitempool)} more progression items than there are available locations.", f"There are {len(progitempool)} more progression items than there are available locations."
multiworld=multiworld,
) )
accessibility_corrections(multiworld, multiworld.state, defaultlocations) accessibility_corrections(multiworld, multiworld.state, defaultlocations)
@@ -521,20 +486,16 @@ def distribute_items_restrictive(multiworld: MultiWorld,
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations) inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded", remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded")
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
if excludedlocations: if excludedlocations:
raise FillError( raise FillError(
f"Not enough filler items for excluded locations. " f"Not enough filler items for excluded locations. "
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.", f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
multiworld=multiworld,
) )
restitempool = filleritempool + usefulitempool restitempool = filleritempool + usefulitempool
remaining_fill(multiworld, defaultlocations, restitempool, remaining_fill(multiworld, defaultlocations, restitempool)
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
unplaced = restitempool unplaced = restitempool
unfilled = defaultlocations unfilled = defaultlocations
@@ -556,7 +517,7 @@ def flood_items(multiworld: MultiWorld) -> None:
progress_done = False progress_done = False
# sweep once to pick up preplaced items # 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 # fill multiworld from top of itempool while we can
while not progress_done: while not progress_done:
@@ -594,7 +555,7 @@ def flood_items(multiworld: MultiWorld) -> None:
if candidate_item_to_place is not None: if candidate_item_to_place is not None:
item_to_place = candidate_item_to_place item_to_place = candidate_item_to_place
else: 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 # find item to replace with progress item
location_list = multiworld.get_reachable_locations() location_list = multiworld.get_reachable_locations()
@@ -651,6 +612,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
def get_sphere_locations(sphere_state: CollectionState, def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]: 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)} return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float: def item_percentage(player: int, num: int) -> float:
@@ -744,7 +706,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
), items_to_test): ), items_to_test):
reducing_state.collect(location.item, True, location) 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 multiworld.has_beaten_game(balancing_state):
if not multiworld.has_beaten_game(reducing_state): if not multiworld.has_beaten_game(reducing_state):
@@ -827,7 +789,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
warn(warning, force) warn(warning, force)
swept_state = multiworld.state.copy() swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements() swept_state.sweep_for_events()
reachable = frozenset(multiworld.get_reachable_locations(swept_state)) reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_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,32 +1,36 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import copy
import logging import logging
import os import os
import random import random
import string import string
import sys
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from collections import Counter from collections import Counter
from typing import Any, Dict, Tuple, Union from typing import Any, Dict, Tuple, Union
from itertools import chain
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
import copy
import Utils import Utils
import Options import Options
from BaseClasses import seeddigits, get_seed, PlandoOptions 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 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(): def mystery_argparse():
from settings import get_settings options = get_settings()
settings = get_settings() defaults = options.generator
defaults = settings.generator
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.") parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights_file_path', default=defaults.weights_file_path, parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
@@ -38,15 +42,15 @@ def mystery_argparse():
parser.add_argument('--seed', help='Define seed number to generate.', type=int) 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('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults.spoiler) 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 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('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path) 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('--log_level', default='info', help='Sets log level')
parser.add_argument("--csv_output", action="store_true", parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
help="Output rolled player options to csv (made for async multiworld).") help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument("--plando", default=defaults.plando_options, parser.add_argument('--plando', default=defaults.plando_options,
help="List of options that can be set manually. Can be combined, for example \"bosses, items\"") 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", parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.") help="Skip progression balancing step during generation.")
parser.add_argument("--skip_output", action="store_true", parser.add_argument("--skip_output", action="store_true",
@@ -58,23 +62,20 @@ def mystery_argparse():
if not os.path.isabs(args.meta_file_path): if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path) args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando) args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
return args return args, options
def get_seed_name(random_source) -> str: def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits) return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None) -> Tuple[argparse.Namespace, int]: def main(args=None, callback=ERmain):
# __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.")
if not args: if not args:
args = mystery_argparse() args, options = mystery_argparse()
else:
options = get_settings()
seed = get_seed(args.seed) seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
random.seed(seed) random.seed(seed)
seed_name = get_seed_name(random) seed_name = get_seed_name(random)
@@ -143,9 +144,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
raise Exception(f"No weights found. " raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. " f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.") 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 = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed erargs.seed = seed
erargs.plando_options = args.plando erargs.plando_options = args.plando
@@ -155,8 +153,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
erargs.outputpath = args.outputpath erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output erargs.skip_output = args.skip_output
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None) {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
@@ -204,7 +200,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 if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}" 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] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter) erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
@@ -217,7 +213,29 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name): if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}") 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, ...]: def read_weights_yamls(path) -> Tuple[Any, ...]:
@@ -301,34 +319,18 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
logging.debug(f'Applying {new_weights}') logging.debug(f'Applying {new_weights}')
cleaned_weights = {} cleaned_weights = {}
for option in new_weights: for option in new_weights:
option_name = option.lstrip("+-") option_name = option.lstrip("+")
if option.startswith("+") and option_name in weights: if option.startswith("+") and option_name in weights:
cleaned_value = weights[option_name] cleaned_value = weights[option_name]
new_value = new_weights[option] new_value = new_weights[option]
if isinstance(new_value, set): if isinstance(new_value, (set, dict)):
cleaned_value.update(new_value) cleaned_value.update(new_value)
elif isinstance(new_value, list): elif isinstance(new_value, list):
cleaned_value.extend(new_value) cleaned_value.extend(new_value)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
else: else:
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name}," raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.") f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value cleaned_weights[option_name] = cleaned_value
elif option.startswith("-") and option_name in weights:
cleaned_value = weights[option_name]
new_value = new_weights[option]
if isinstance(new_value, set):
cleaned_value.difference_update(new_value)
elif isinstance(new_value, list):
for element in new_value:
cleaned_value.remove(element)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
else:
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
else: else:
cleaned_weights[option_name] = new_weights[option] cleaned_weights[option_name] = new_weights[option]
new_options = set(cleaned_weights) - set(weights) new_options = set(cleaned_weights) - set(weights)
@@ -342,8 +344,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: def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
from worlds import AutoWorldRegister
if not game: if not game:
return get_choice(option_key, category_dict) return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types: if game in AutoWorldRegister.world_types:
@@ -378,7 +378,7 @@ def roll_linked_options(weights: dict) -> dict:
return weights return weights
def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict: def roll_triggers(weights: dict, triggers: list) -> dict:
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
weights["_Generator_Version"] = Utils.__version__ weights["_Generator_Version"] = Utils.__version__
for i, option_set in enumerate(triggers): for i, option_set in enumerate(triggers):
@@ -401,7 +401,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
if category_name: if category_name:
currently_targeted_weights = currently_targeted_weights[category_name] currently_targeted_weights = currently_targeted_weights[category_name]
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"]) update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
valid_keys.add(key)
except Exception as e: except Exception as e:
raise ValueError(f"Your trigger number {i + 1} is invalid. " raise ValueError(f"Your trigger number {i + 1} is invalid. "
f"Please fix your triggers.") from e f"Please fix your triggers.") from e
@@ -421,19 +421,15 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
except Exception as e: except Exception as e:
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
else: else:
from worlds import AutoWorldRegister
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options) player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses): def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
from worlds import AutoWorldRegister
if "linked_options" in weights: if "linked_options" in weights:
weights = roll_linked_options(weights) weights = roll_linked_options(weights)
valid_keys = set()
if "triggers" in weights: if "triggers" in weights:
weights = roll_triggers(weights, weights["triggers"], valid_keys) weights = roll_triggers(weights, weights["triggers"])
requirements = weights.get("requires", {}) requirements = weights.get("requires", {})
if requirements: if requirements:
@@ -454,7 +450,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
ret.game = get_choice("game", weights) ret.game = get_choice("game", weights)
if ret.game not in AutoWorldRegister.world_types: 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] picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
if picks[0] in failed_world_loads: if picks[0] in failed_world_loads:
raise Exception(f"No functional world found to handle game {ret.game}. " raise Exception(f"No functional world found to handle game {ret.game}. "
@@ -469,14 +464,12 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
world_type = AutoWorldRegister.world_types[ret.game] world_type = AutoWorldRegister.world_types[ret.game]
game_weights = weights[ret.game] game_weights = weights[ret.game]
for weight in chain(game_weights, weights): if any(weight.startswith("+") for weight in game_weights) or \
if weight.startswith("+"): any(weight.startswith("+") for weight in weights):
raise Exception(f"Merge tag cannot be used outside of trigger contexts. Found {weight}") raise Exception(f"Merge tag cannot be used outside of trigger contexts.")
if weight.startswith("-"):
raise Exception(f"Remove tag cannot be used outside of trigger contexts. Found {weight}")
if "triggers" in game_weights: if "triggers" in game_weights:
weights = roll_triggers(weights, game_weights["triggers"], valid_keys) weights = roll_triggers(weights, game_weights["triggers"])
game_weights = weights[ret.game] game_weights = weights[ret.game]
ret.name = get_choice('name', weights) ret.name = get_choice('name', weights)
@@ -485,20 +478,38 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
for option_key, option in world_type.options_dataclass.type_hints.items(): for option_key, option in world_type.options_dataclass.type_hints.items():
handle_option(ret, game_weights, option_key, option, plando_options) handle_option(ret, game_weights, option_key, option, plando_options)
valid_keys.add(option_key)
for option_key in game_weights:
if option_key in {"triggers", *valid_keys}:
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: 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": 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 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_pool = weights.get('sprite_pool', [])
ret.sprite = get_choice_legacy('sprite', weights, "Link") ret.sprite = get_choice_legacy('sprite', weights, "Link")
if 'random_sprite_on_event' in weights: if 'random_sprite_on_event' in weights:
@@ -526,9 +537,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights):
if __name__ == '__main__': if __name__ == '__main__':
import atexit import atexit
confirmation = atexit.register(input, "Press enter to close.") confirmation = atexit.register(input, "Press enter to close.")
erargs, seed = main() multiworld = main()
from Main import main as ERmain
multiworld = ERmain(erargs, seed)
if __debug__: if __debug__:
import gc import gc
import sys 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 shlex
import subprocess import subprocess
import sys import sys
import urllib.parse
import webbrowser import webbrowser
from os.path import isfile from os.path import isfile
from shutil import which from shutil import which
from typing import Callable, Optional, Sequence, Tuple, Union from typing import Sequence, Union, Optional
import Utils import Utils
import settings import settings
@@ -35,9 +34,7 @@ from Utils import is_frozen, user_path, local_path, init_logging, open_filename,
def open_host_yaml(): def open_host_yaml():
s = settings.get_settings() file = settings.get_settings().filename
file = s.filename
s.save()
assert file, "host.yaml missing" assert file, "host.yaml missing"
if is_linux: if is_linux:
exe = which('sensible-editor') or which('gedit') or \ exe = which('sensible-editor') or which('gedit') or \
@@ -110,81 +107,7 @@ components.extend([
]) ])
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: def identify(path: Union[None, str]):
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]]:
if path is None: if path is None:
return None, None return None, None
for component in components: for component in components:
@@ -237,12 +160,8 @@ def launch(exe, in_terminal=False):
subprocess.Popen(exe) subprocess.Popen(exe)
refresh_components: Optional[Callable[[], None]] = None
def run_gui(): def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget 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.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout from kivy.uix.relativelayout import RelativeLayout
@@ -250,8 +169,11 @@ def run_gui():
base_title: str = "Archipelago Launcher" base_title: str = "Archipelago Launcher"
container: ContainerLayout container: ContainerLayout
grid: GridLayout 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): def __init__(self, ctx=None):
self.title = self.base_title self.title = self.base_title
@@ -259,7 +181,18 @@ def run_gui():
self.icon = r"data/icon.png" self.icon = r"data/icon.png"
super().__init__() 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: def build_button(component: Component) -> Widget:
""" """
@@ -284,49 +217,14 @@ def run_gui():
return box_layout return box_layout
return button 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( for (tool, client) in itertools.zip_longest(itertools.chain(
_tools.items(), _miscs.items(), _adjusters.items() self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
), _clients.items()):
# column 1 # column 1
if tool: if tool:
self._tool_layout.layout.add_widget(build_button(tool[1])) tool_layout.layout.add_widget(build_button(tool[1]))
# column 2 # column 2
if client: if client:
self._client_layout.layout.add_widget(build_button(client[1])) 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)
return self.container return self.container
@@ -337,14 +235,6 @@ def run_gui():
else: else:
launch(get_exe(button.component), button.component.cli) 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): def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm. # ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
# Closing the window explicitly cleans it up. # Closing the window explicitly cleans it up.
@@ -353,17 +243,10 @@ def run_gui():
Launcher().run() 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): def run_component(component: Component, *args):
if component.func: if component.func:
component.func(*args) component.func(*args)
if refresh_components:
refresh_components()
elif component.script_name: elif component.script_name:
subprocess.run([*get_exe(component.script_name), *args]) subprocess.run([*get_exe(component.script_name), *args])
else: else:
@@ -376,24 +259,20 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
elif not args: elif not args:
args = {} args = {}
path = args.get("Patch|Game|Component|url", None) if "Patch|Game|Component" in args:
if path is not None: file, component = identify(args["Patch|Game|Component"])
if path.startswith("archipelago://"):
handle_uri(path, args.get("args", ()))
return
file, component = identify(path)
if file: if file:
args['file'] = file args['file'] = file
if component: if component:
args['component'] = component args['component'] = component
if not 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"]: if args["update_settings"]:
update_settings() update_settings()
if "file" in args: if 'file' in args:
run_component(args["component"], args["file"], *args["args"]) run_component(args["component"], args["file"], *args["args"])
elif "component" in args: elif 'component' in args:
run_component(args["component"], *args["args"]) run_component(args["component"], *args["args"])
elif not args["update_settings"]: elif not args["update_settings"]:
run_gui() run_gui()
@@ -403,16 +282,12 @@ if __name__ == '__main__':
init_logging('Launcher') init_logging('Launcher')
Utils.freeze_support() Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description='Archipelago Launcher')
description='Archipelago Launcher',
usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
)
run_group = parser.add_argument_group("Run") run_group = parser.add_argument_group("Run")
run_group.add_argument("--update_settings", action="store_true", run_group.add_argument("--update_settings", action="store_true",
help="Update host.yaml and exit.") help="Update host.yaml and exit.")
run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?", run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
help="Pass either a patch file, a generated game, the component name to run, or a url to " help="Pass either a patch file, a generated game or the name of a component to run.")
"connect with.")
run_group.add_argument("args", nargs="*", run_group.add_argument("args", nargs="*",
help="Arguments to pass to component.") help="Arguments to pass to component.")
main(parser.parse_args()) 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: def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient() self.client = LinksAwakeningClient()
self.slot_data = {}
if magpie: if magpie:
self.magpie_enabled = True self.magpie_enabled = True
self.magpie = MagpieBridge() self.magpie = MagpieBridge()
@@ -566,8 +564,6 @@ class LinksAwakeningContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == "Connected": if cmd == "Connected":
self.game = self.slot_info[self.slot].game self.game = self.slot_info[self.slot].game
self.slot_data = args.get("slot_data", {})
# TODO - use watcher_event # TODO - use watcher_event
if cmd == "ReceivedItems": if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]): 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) self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker) await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker) await self.magpie.send_gps(self.client.gps_tracker)
self.magpie.slot_data = self.slot_data
except Exception: except Exception:
# Don't let magpie errors take out the client # Don't let magpie errors take out the client
pass pass

View File

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

132
Main.py
View File

@@ -11,10 +11,9 @@ from typing import Dict, List, Optional, Set, Tuple, Union
import worlds import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \ from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
flood_items
from Options import StartInventoryPool from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple, get_settings from Utils import __version__, output_path, version_tuple
from settings import get_settings from settings import get_settings
from worlds import AutoWorld from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -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.sprite_pool = args.sprite_pool.copy()
multiworld.set_options(args) multiworld.set_options(args)
if args.csv_output:
from Options import dump_player_options
dump_player_options(multiworld)
multiworld.set_item_links() multiworld.set_item_links()
multiworld.state = CollectionState(multiworld) multiworld.state = CollectionState(multiworld)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed) 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) multiworld.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early remaining_count = count-early
if remaining_count > 0: 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: if local_early:
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count) multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early 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: for player in multiworld.player_ids:
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value) 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 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: for location_name in multiworld.worlds[player].options.priority_locations.value:
try: try:
location = multiworld.get_location(location_name, player) location = multiworld.get_location(location_name, player)
except KeyError: except KeyError as e: # failed to find the given location. Check if it's a legitimate location
continue 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
if location.progress_type != LocationProgressType.EXCLUDED:
location.progress_type = LocationProgressType.PRIORITY
else: else:
logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.") location.progress_type = LocationProgressType.PRIORITY
world_excluded_locations.add(location_name)
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
# Set local and non-local item rules. # Set local and non-local item rules.
if multiworld.players > 1: 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. # 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): if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
new_items: List[Item] = [] new_items: List[Item] = []
old_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = { depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options, player: getattr(multiworld.worlds[player].options,
"start_inventory_from_pool", "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 depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items # quick abort if we have found all items
if not target: if not target:
old_items.extend(multiworld.itempool[i+1:]) new_items.extend(multiworld.itempool[i+1:])
break break
else: else:
old_items.append(item) new_items.append(item)
# leftovers? # leftovers?
if target: if target:
for player, remaining_items in depletion_pool.items(): for player, remaining_items in depletion_pool.items():
remaining_items = {name: count for name, count in remaining_items.items() if count} remaining_items = {name: count for name, count in remaining_items.items() if count}
if remaining_items: 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}") 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 assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
removables = [item for item in new_items if item.player == player] multiworld.itempool[:] = new_items
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
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()): if any(multiworld.item_links.values()):
multiworld._all_state = None multiworld._all_state = None
@@ -211,7 +272,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if multiworld.algorithm == 'flood': if multiworld.algorithm == 'flood':
flood_items(multiworld) # different algo, biased towards early game progress items flood_items(multiworld) # different algo, biased towards early game progress items
elif multiworld.algorithm == 'balanced': elif multiworld.algorithm == 'balanced':
distribute_items_restrictive(multiworld, get_settings().generator.panic_method) distribute_items_restrictive(multiworld)
AutoWorld.call_all(multiworld, 'post_fill') AutoWorld.call_all(multiworld, 'post_fill')
@@ -311,17 +372,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {} checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
# get spheres -> filter address==None -> skip empty
spheres: List[Dict[int, Set[int]]] = []
for sphere in multiworld.get_spheres():
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
for sphere_location in sphere:
if type(sphere_location.address) is int:
current_sphere[sphere_location.player].add(sphere_location.address)
if current_sphere:
spheres.append(dict(current_sphere))
multidata = { multidata = {
"slot_data": slot_data, "slot_data": slot_data,
"slot_info": slot_info, "slot_info": slot_info,
@@ -336,9 +386,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"tags": ["AP"], "tags": ["AP"],
"minimum_versions": minimum_versions, "minimum_versions": minimum_versions,
"seed_name": multiworld.seed_name, "seed_name": multiworld.seed_name,
"spheres": spheres,
"datapackage": data_package, "datapackage": data_package,
"race_mode": int(multiworld.is_race),
} }
AutoWorld.call_all(multiworld, "modify_multidata", multidata) AutoWorld.call_all(multiworld, "modify_multidata", multidata)
@@ -351,7 +399,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_file_futures.append(pool.submit(write_multidata)) output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result(): if not check_accessibility_task.result():
if not multiworld.can_beat_game(): if not multiworld.can_beat_game():
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld) raise Exception("Game appears as unbeatable. Aborting.")
else: else:
logger.warning("Location Accessibility requirements not fulfilled.") 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: if not update_ran:
update_ran = True update_ran = True
install_pkg_resources(yes=yes)
import pkg_resources
if force: if force:
update_command() update_command()
return return
install_pkg_resources(yes=yes)
import pkg_resources
prev = "" # if a line ends in \ we store here and merge later prev = "" # if a line ends in \ we store here and merge later
for req_file in requirements_files: for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file) 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 argparse
import asyncio import asyncio
import collections import collections
import contextlib
import copy import copy
import datetime import datetime
import functools import functools
@@ -15,7 +14,6 @@ import math
import operator import operator
import pickle import pickle
import random import random
import shlex
import threading import threading
import time import time
import typing import typing
@@ -39,7 +37,7 @@ except ImportError:
import NetUtils import NetUtils
import Utils import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text from Utils import version_tuple, restricted_loads, Version, async_start
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore SlotType, LocationStore
@@ -68,21 +66,6 @@ def update_dict(dictionary, entries):
return dictionary 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 # functions callable on storable data on the server by clients
modify_functions = { modify_functions = {
# generic: # generic:
@@ -185,22 +168,18 @@ class Context:
slot_info: typing.Dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
generator_version = Version(0, 0, 0) generator_version = Version(0, 0, 0)
checksums: typing.Dict[str, str] checksums: typing.Dict[str, str]
item_names: typing.Dict[str, typing.Dict[int, str]] 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]]] item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[str, typing.Dict[int, str]] 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]]] location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: 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]] 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
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled", hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
log_network: bool = False, logger: logging.Logger = logging.getLogger()): log_network: bool = False):
self.logger = logger
super(Context, self).__init__() super(Context, self).__init__()
self.slot_info = {} self.slot_info = {}
self.log_network = log_network self.log_network = log_network
@@ -245,7 +224,7 @@ class Context:
self.embedded_blacklist = {"host", "port"} self.embedded_blacklist = {"host", "port"}
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {} self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
self.auto_save_interval = 60 # in seconds 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.save_dirty = False
self.tags = ['AP'] self.tags = ['AP']
self.games: typing.Dict[int, str] = {} self.games: typing.Dict[int, str] = {}
@@ -257,7 +236,6 @@ class Context:
self.stored_data = {} self.stored_data = {}
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet) self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
self.read_data = {} self.read_data = {}
self.spheres = []
# init empty to satisfy linter, I suppose # init empty to satisfy linter, I suppose
self.gamespackage = {} self.gamespackage = {}
@@ -266,10 +244,6 @@ class Context:
self.location_name_groups = {} self.location_name_groups = {}
self.all_item_and_group_names = {} self.all_item_and_group_names = {}
self.all_location_and_group_names = {} self.all_location_and_group_names = {}
self.item_names = collections.defaultdict(
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})'))
self.location_names = collections.defaultdict(
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
self.non_hintable_names = collections.defaultdict(frozenset) self.non_hintable_names = collections.defaultdict(frozenset)
self._load_game_data() self._load_game_data()
@@ -286,31 +260,19 @@ class Context:
for world_name, world in worlds.AutoWorldRegister.world_types.items(): for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.non_hintable_names[world_name] = world.hint_blacklist 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): def _init_game_data(self):
for game_name, game_package in self.gamespackage.items(): for game_name, game_package in self.gamespackage.items():
if "checksum" in game_package: if "checksum" in game_package:
self.checksums[game_name] = game_package["checksum"] self.checksums[game_name] = game_package["checksum"]
for item_name, item_id in game_package["item_name_to_id"].items(): 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(): 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] = \ self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name]) set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
self.all_location_and_group_names[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, [])) 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]]: 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 return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
@@ -325,12 +287,12 @@ class Context:
try: try:
await endpoint.socket.send(msg) await endpoint.socket.send(msg)
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
self.logger.exception(f"Exception during send_msgs, could not send {msg}") logging.exception(f"Exception during send_msgs, could not send {msg}")
await self.disconnect(endpoint) await self.disconnect(endpoint)
return False return False
else: else:
if self.log_network: if self.log_network:
self.logger.info(f"Outgoing message: {msg}") logging.info(f"Outgoing message: {msg}")
return True return True
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool: async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
@@ -339,12 +301,12 @@ class Context:
try: try:
await endpoint.socket.send(msg) await endpoint.socket.send(msg)
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
self.logger.exception("Exception during send_encoded_msgs") logging.exception("Exception during send_encoded_msgs")
await self.disconnect(endpoint) await self.disconnect(endpoint)
return False return False
else: else:
if self.log_network: if self.log_network:
self.logger.info(f"Outgoing message: {msg}") logging.info(f"Outgoing message: {msg}")
return True return True
async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint], msg: str) -> bool: async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint], msg: str) -> bool:
@@ -355,11 +317,11 @@ class Context:
try: try:
websockets.broadcast(sockets, msg) websockets.broadcast(sockets, msg)
except RuntimeError: except RuntimeError:
self.logger.exception("Exception during broadcast_send_encoded_msgs") logging.exception("Exception during broadcast_send_encoded_msgs")
return False return False
else: else:
if self.log_network: if self.log_network:
self.logger.info(f"Outgoing broadcast: {msg}") logging.info(f"Outgoing broadcast: {msg}")
return True return True
def broadcast_all(self, msgs: typing.List[dict]): def broadcast_all(self, msgs: typing.List[dict]):
@@ -368,7 +330,7 @@ class Context:
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast_text_all(self, text: str, additional_arguments: dict = {}): def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
self.logger.info("Notice (all): %s" % text) logging.info("Notice (all): %s" % text)
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}]) self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_team(self, team: int, msgs: typing.List[dict]): def broadcast_team(self, team: int, msgs: typing.List[dict]):
@@ -390,7 +352,7 @@ class Context:
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}): def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
if not client.auth: if not client.auth:
return return
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text)) logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}])) async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}): def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
@@ -429,8 +391,6 @@ class Context:
use_embedded_server_options: bool): use_embedded_server_options: bool):
self.read_data = {} 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"] mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple: if mdata_ver > version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}," raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
@@ -491,7 +451,7 @@ class Context:
for game_name, data in decoded_obj.get("datapackage", {}).items(): for game_name, data in decoded_obj.get("datapackage", {}).items():
if game_name in game_data_packages: if game_name in game_data_packages:
data = game_data_packages[game_name] data = game_data_packages[game_name]
self.logger.info(f"Loading embedded data package for game {game_name}") logging.info(f"Loading embedded data package for game {game_name}")
self.gamespackage[game_name] = data self.gamespackage[game_name] = data
self.item_name_groups[game_name] = data["item_name_groups"] self.item_name_groups[game_name] = data["item_name_groups"]
if "location_name_groups" in data: if "location_name_groups" in data:
@@ -504,9 +464,6 @@ class Context:
for game_name, data in self.location_name_groups.items(): for game_name, data in self.location_name_groups.items():
self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame] self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame]
# sorted access spheres
self.spheres = decoded_obj.get("spheres", [])
# saving # saving
def save(self, now=False) -> bool: def save(self, now=False) -> bool:
@@ -526,7 +483,7 @@ class Context:
with open(self.save_filename, "wb") as f: with open(self.save_filename, "wb") as f:
f.write(zlib.compress(encoded_save)) f.write(zlib.compress(encoded_save))
except Exception as e: except Exception as e:
self.logger.exception(e) logging.exception(e)
return False return False
else: else:
return True return True
@@ -544,12 +501,12 @@ class Context:
save_data = restricted_loads(zlib.decompress(f.read())) save_data = restricted_loads(zlib.decompress(f.read()))
self.set_save(save_data) self.set_save(save_data)
except FileNotFoundError: except FileNotFoundError:
self.logger.error('No save data found, starting a new game') logging.error('No save data found, starting a new game')
except Exception as e: except Exception as e:
self.logger.exception(e) logging.exception(e)
self._start_async_saving() self._start_async_saving()
def _start_async_saving(self, atexit_save: bool = True): def _start_async_saving(self):
if not self.auto_saver_thread: if not self.auto_saver_thread:
def save_regularly(): def save_regularly():
# time.time() is platform dependent, so using the expensive datetime method instead # time.time() is platform dependent, so using the expensive datetime method instead
@@ -563,22 +520,18 @@ class Context:
next_wakeup = (second - get_datetime_second()) % self.auto_save_interval next_wakeup = (second - get_datetime_second()) % self.auto_save_interval
time.sleep(max(1.0, next_wakeup)) time.sleep(max(1.0, next_wakeup))
if self.save_dirty: if self.save_dirty:
self.logger.debug("Saving via thread.") logging.debug("Saving via thread.")
self._save() self._save()
except OperationalError as e: except OperationalError as e:
self.logger.exception(e) logging.exception(e)
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.") logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
else: else:
self.save_dirty = False 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 = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start() self.auto_saver_thread.start()
if atexit_save: import atexit
import atexit atexit.register(self._save, True) # make sure we save on exit too
atexit.register(self._save, True) # make sure we save on exit too
def get_save(self) -> dict: def get_save(self) -> dict:
self.recheck_hints() self.recheck_hints()
@@ -645,7 +598,7 @@ class Context:
if "stored_data" in savedata: if "stored_data" in savedata:
self.stored_data = savedata["stored_data"] self.stored_data = savedata["stored_data"]
# count items and slots from lists for items_handling = remote # count items and slots from lists for items_handling = remote
self.logger.info( logging.info(
f'Loaded save file with {sum([len(v) for k, v in self.received_items.items() if k[2]])} received items ' f'Loaded save file with {sum([len(v) for k, v in self.received_items.items() if k[2]])} received items '
f'for {sum(k[2] for k in self.received_items)} players') f'for {sum(k[2] for k in self.received_items)} players')
@@ -668,16 +621,6 @@ class Context:
self.recheck_hints(team, slot) self.recheck_hints(team, slot)
return self.hints[team, slot] return self.hints[team, slot]
def get_sphere(self, player: int, location_id: int) -> int:
"""Get sphere of a location, -1 if spheres are not available."""
if self.spheres:
for i, sphere in enumerate(self.spheres):
if location_id in sphere.get(player, set()):
return i
raise KeyError(f"No Sphere found for location ID {location_id} belonging to player {player}. "
f"Location or player may not exist.")
return -1
def get_players_package(self): def get_players_package(self):
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()] return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
@@ -697,13 +640,13 @@ class Context:
try: try:
raise Exception(f"Could not set server option {key}, skipping.") from e raise Exception(f"Could not set server option {key}, skipping.") from e
except Exception as e: except Exception as e:
self.logger.exception(e) logging.exception(e)
self.logger.debug(f"Setting server option {key} to {value} from supplied multidata") logging.debug(f"Setting server option {key} to {value} from supplied multidata")
setattr(self, key, value) setattr(self, key, value)
elif key == "disable_item_cheat": elif key == "disable_item_cheat":
self.item_cheat = not bool(value) self.item_cheat = not bool(value)
else: else:
self.logger.debug(f"Unrecognized server option {key}") logging.debug(f"Unrecognized server option {key}")
def get_aliased_name(self, team: int, slot: int): def get_aliased_name(self, team: int, slot: int):
if (team, slot) in self.name_aliases: if (team, slot) in self.name_aliases:
@@ -737,7 +680,7 @@ class Context:
self.hints[team, player].add(hint) self.hints[team, player].add(hint)
new_hint_events.add(player) new_hint_events.add(player)
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint))) logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
for slot in new_hint_events: for slot in new_hint_events:
self.on_new_hint(team, slot) self.on_new_hint(team, slot)
for slot, hint_data in concerns.items(): for slot, hint_data in concerns.items():
@@ -796,21 +739,21 @@ async def server(websocket, path: str = "/", ctx: Context = None):
try: try:
if ctx.log_network: if ctx.log_network:
ctx.logger.info("Incoming connection") logging.info("Incoming connection")
await on_client_connected(ctx, client) await on_client_connected(ctx, client)
if ctx.log_network: if ctx.log_network:
ctx.logger.info("Sent Room Info") logging.info("Sent Room Info")
async for data in websocket: async for data in websocket:
if ctx.log_network: if ctx.log_network:
ctx.logger.info(f"Incoming message: {data}") logging.info(f"Incoming message: {data}")
for msg in decode(data): for msg in decode(data):
await process_client_cmd(ctx, client, msg) await process_client_cmd(ctx, client, msg)
except Exception as e: except Exception as e:
if not isinstance(e, websockets.WebSocketException): if not isinstance(e, websockets.WebSocketException):
ctx.logger.exception(e) logging.exception(e)
finally: finally:
if ctx.log_network: if ctx.log_network:
ctx.logger.info("Disconnected") logging.info("Disconnected")
await ctx.disconnect(client) await ctx.disconnect(client)
@@ -820,7 +763,10 @@ async def on_client_connected(ctx: Context, client: Client):
for slot, connected_clients in clients.items(): for slot, connected_clients in clients.items():
if connected_clients: if connected_clients:
name = ctx.player_names[team, slot] 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 = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago") games.add("Archipelago")
await ctx.send_msgs(client, [{ await ctx.send_msgs(client, [{
@@ -835,6 +781,8 @@ async def on_client_connected(ctx: Context, client: Client):
'permissions': get_permissions(ctx), 'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost, 'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points, '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 'datapackage_checksums': {game: game_data["checksum"] for game, game_data
in ctx.gamespackage.items() if game in games and "checksum" in game_data}, in ctx.gamespackage.items() if game in games and "checksum" in game_data},
'seed_name': ctx.seed_name, 'seed_name': ctx.seed_name,
@@ -1013,7 +961,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
collect_player(ctx, team, group, True) 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) return ctx.locations.get_remaining(ctx.location_checks, team, slot)
@@ -1037,9 +985,9 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
new_item = NetworkItem(item_id, location, slot, flags) new_item = NetworkItem(item_id, location, slot, flags)
send_items_to(ctx, team, target_player, new_item) send_items_to(ctx, team, target_player, new_item)
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % ( logging.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], team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location])) ctx.player_names[(team, target_player)], ctx.location_names[location]))
info_text = json_format_send_event(new_item, target_player) info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text]) ctx.broadcast_team(team, [info_text])
@@ -1093,8 +1041,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: def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \ 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"{ctx.item_names[hint.item]} is " \
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \ f"at {ctx.location_names[hint.location]} " \
f"in {ctx.player_names[team, hint.finding_player]}'s World" f"in {ctx.player_names[team, hint.finding_player]}'s World"
if hint.entrance: if hint.entrance:
@@ -1123,6 +1071,28 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
"item": net_item} "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): class CommandMeta(type):
def __new__(cls, name, bases, attrs): def __new__(cls, name, bases, attrs):
commands = attrs["commands"] = {} commands = attrs["commands"] = {}
@@ -1154,10 +1124,7 @@ class CommandProcessor(metaclass=CommandMeta):
if not raw: if not raw:
return return
try: try:
try: command = raw.split()
command = shlex.split(raw, comments=False)
except ValueError: # most likely: "ValueError: No closing quotation"
command = raw.split()
basecommand = command[0] basecommand = command[0]
if basecommand[0] == self.marker: if basecommand[0] == self.marker:
method = self.commands.get(basecommand[1:].lower(), None) method = self.commands.get(basecommand[1:].lower(), None)
@@ -1228,10 +1195,6 @@ class CommonCommandProcessor(CommandProcessor):
timer = int(seconds, 10) timer = int(seconds, 10)
except ValueError: except ValueError:
timer = 10 timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer)) async_start(countdown(self.ctx, timer))
return True return True
@@ -1379,10 +1342,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_remaining(self) -> bool: def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient""" """List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled": if self.ctx.remaining_mode == "enabled":
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if rest_locations: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id] self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
for slot, item_id in rest_locations)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
return True return True
@@ -1392,10 +1355,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
return False return False
else: # is goal else: # is goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_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) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if rest_locations: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id] self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
for slot, item_id in rest_locations)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
return True return True
@@ -1412,8 +1375,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot) locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
if locations: if locations:
game = self.ctx.slot_info[self.client.slot].game names = [self.ctx.location_names[location] for location in locations]
names = [self.ctx.location_names[game][location] for location in locations]
if filter_text: if filter_text:
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]] location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
if filter_text in location_groups: # location group name if filter_text in location_groups: # location group name
@@ -1438,8 +1400,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot) locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
if locations: if locations:
game = self.ctx.slot_info[self.client.slot].game names = [self.ctx.location_names[location] for location in locations]
names = [self.ctx.location_names[game][location] for location in locations]
if filter_text: if filter_text:
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]] location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
if filter_text in location_groups: # location group name if filter_text in location_groups: # location group name
@@ -1520,10 +1481,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
elif input_text.isnumeric(): elif input_text.isnumeric():
game = self.ctx.games[self.client.slot] game = self.ctx.games[self.client.slot]
hint_id = int(input_text) hint_id = int(input_text)
hint_name = self.ctx.item_names[game][hint_id] \ hint_name = self.ctx.item_names[hint_id] \
if not for_location and hint_id in self.ctx.item_names[game] \ if not for_location and hint_id in self.ctx.item_names \
else self.ctx.location_names[game][hint_id] \ else self.ctx.location_names[hint_id] \
if for_location and hint_id in self.ctx.location_names[game] \ if for_location and hint_id in self.ctx.location_names \
else None else None
if hint_name in self.ctx.non_hintable_names[game]: if hint_name in self.ctx.non_hintable_names[game]:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
@@ -1585,9 +1546,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.ctx.random.shuffle(not_found_hints) self.ctx.random.shuffle(not_found_hints)
# By popular vote, make hints prefer non-local placements # By popular vote, make hints prefer non-local placements
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player)) not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
# By another popular vote, prefer early sphere
not_found_hints.sort(key=lambda hint: self.ctx.get_sphere(hint.finding_player, hint.location),
reverse=True)
hints = found_hints + old_hints hints = found_hints + old_hints
while can_pay > 0: while can_pay > 0:
@@ -1597,10 +1555,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints.append(hint) hints.append(hint)
can_pay -= 1 can_pay -= 1
self.ctx.hints_used[self.client.team, self.client.slot] += 1 self.ctx.hints_used[self.client.team, self.client.slot] += 1
points_available = get_client_points(self.ctx, self.client)
self.ctx.notify_hints(self.client.team, hints) self.ctx.notify_hints(self.client.team, hints)
if not_found_hints: if not_found_hints:
points_available = get_client_points(self.ctx, self.client)
if hints and cost and int((points_available // cost) == 0): if hints and cost and int((points_available // cost) == 0):
self.output( self.output(
f"There may be more hintables, however, you cannot afford to pay for any more. " f"There may be more hintables, however, you cannot afford to pay for any more. "
@@ -1667,7 +1625,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
try: try:
cmd: str = args["cmd"] cmd: str = args["cmd"]
except: except:
ctx.logger.exception(f"Could not get command from {args}") logging.exception(f"Could not get command from {args}")
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", "original_cmd": None, await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", "original_cmd": None,
"text": f"Could not get command from {args} at `cmd`"}]) "text": f"Could not get command from {args} at `cmd`"}])
raise raise
@@ -1710,7 +1668,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if ctx.compatibility == 0 and args['version'] != version_tuple: if ctx.compatibility == 0 and args['version'] != version_tuple:
errors.add('IncompatibleVersion') errors.add('IncompatibleVersion')
if errors: if errors:
ctx.logger.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.") logging.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
await ctx.send_msgs(client, [{"cmd": "ConnectionRefused", "errors": list(errors)}]) await ctx.send_msgs(client, [{"cmd": "ConnectionRefused", "errors": list(errors)}])
else: else:
team, slot = ctx.connect_names[args['name']] team, slot = ctx.connect_names[args['name']]
@@ -1961,6 +1919,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
"""Shutdown the server""" """Shutdown the server"""
self.ctx.server.ws_server.close() self.ctx.server.ws_server.close()
if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set() self.ctx.exit_event.set()
return True return True
@@ -2068,8 +2028,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
item_name, usable, response = get_intended_text(item_name, names) item_name, usable, response = get_intended_text(item_name, names)
if usable: if usable:
amount: int = int(amount) 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))] new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
send_items_to(self.ctx, team, slot, *new_items) send_items_to(self.ctx, team, slot, *new_items)
@@ -2320,8 +2278,7 @@ def parse_args() -> argparse.Namespace:
async def auto_shutdown(ctx, to_cancel=None): async def auto_shutdown(ctx, to_cancel=None):
with contextlib.suppress(asyncio.TimeoutError): await asyncio.sleep(ctx.auto_shutdown)
await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)
def inactivity_shutdown(): def inactivity_shutdown():
ctx.server.ws_server.close() ctx.server.ws_server.close()
@@ -2329,7 +2286,7 @@ async def auto_shutdown(ctx, to_cancel=None):
if to_cancel: if to_cancel:
for task in to_cancel: for task in to_cancel:
task.cancel() task.cancel()
ctx.logger.info("Shutting down due to inactivity.") logging.info("Shutting down due to inactivity.")
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values(): if not ctx.client_activity_timers.values():
@@ -2341,8 +2298,7 @@ async def auto_shutdown(ctx, to_cancel=None):
if seconds < 0: if seconds < 0:
inactivity_shutdown() inactivity_shutdown()
else: else:
with contextlib.suppress(asyncio.TimeoutError): await asyncio.sleep(seconds)
await asyncio.wait_for(ctx.exit_event.wait(), seconds)
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext": 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 item: int
location: int location: int
player: int player: int
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
flags: int = 0 flags: int = 0
@@ -199,8 +198,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
"slateblue": "6D8BE8", "slateblue": "6D8BE8",
"plum": "AF99EF", "plum": "AF99EF",
"salmon": "FA8072", "salmon": "FA8072",
"white": "FFFFFF", "white": "FFFFFF"
"orange": "FF7700",
} }
def __init__(self, ctx): def __init__(self, ctx):
@@ -249,7 +247,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_item_id(self, node: JSONMessagePart): def _handle_item_id(self, node: JSONMessagePart):
item_id = int(node["text"]) 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) return self._handle_item_name(node)
def _handle_location_name(self, node: JSONMessagePart): def _handle_location_name(self, node: JSONMessagePart):
@@ -257,8 +255,8 @@ class JSONtoTextParser(metaclass=HandlerMeta):
return self._handle_color(node) return self._handle_color(node)
def _handle_location_id(self, node: JSONMessagePart): def _handle_location_id(self, node: JSONMessagePart):
location_id = int(node["text"]) item_id = int(node["text"])
node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"]) node["text"] = self.ctx.location_names[item_id]
return self._handle_location_name(node) return self._handle_location_name(node)
def _handle_entrance_name(self, node: JSONMessagePart): 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, 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, '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, 'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
'plum': 35, 'slateblue': 34, 'salmon': 31,} # convert ui colors to terminal colors
def color_code(*args): def color_code(*args):
@@ -399,12 +396,12 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
location_id not in checked] location_id not in checked]
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int 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] checked = state[team, slot]
player_locations = self[slot] player_locations = self[slot]
return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for return sorted([player_locations[location_id][0] for
location_id in player_locations if location_id in player_locations if
location_id not in checked]) location_id not in checked])
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub

View File

@@ -8,17 +8,15 @@ import numbers
import random import random
import typing import typing
import enum import enum
from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from schema import And, Optional, Or, Schema 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: if typing.TYPE_CHECKING:
from BaseClasses import MultiWorld, PlandoOptions from BaseClasses import PlandoOptions
from worlds.AutoWorld import World from worlds.AutoWorld import World
import pathlib import pathlib
@@ -26,7 +24,7 @@ if typing.TYPE_CHECKING:
class OptionError(ValueError): class OptionError(ValueError):
pass pass
class Visibility(enum.IntFlag): class Visibility(enum.IntFlag):
none = 0b0000 none = 0b0000
template = 0b0001 template = 0b0001
@@ -54,8 +52,8 @@ class AssembleOptions(abc.ABCMeta):
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()}) attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options) options.update(new_options)
# apply aliases, without name_lookup # apply aliases, without name_lookup
aliases = attrs["aliases"] = {name[6:].lower(): option_id for name, option_id in attrs.items() if aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")} name.startswith("alias_")}
assert ( assert (
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default 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 # can be weighted between selections
supports_weighting = True 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: # filled by AssembleOptions:
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore # https://github.com/python/typing/discussions/1460 the reason for this type: ignore
options: typing.ClassVar[typing.Dict[str, int]] options: typing.ClassVar[typing.Dict[str, int]]
aliases: typing.ClassVar[typing.Dict[str, int]]
def __repr__(self) -> str: def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.current_option_name})" return f"{self.__class__.__name__}({self.current_option_name})"
@@ -160,6 +140,12 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
def current_key(self) -> str: def current_key(self) -> str:
return self.name_lookup[self.value] return self.name_lookup[self.value]
def get_current_option_name(self) -> str:
"""Deprecated. use current_option_name instead. TODO remove around 0.4"""
logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated."
f" use current_option_name instead. Worlds should use {self}.current_key"))
return self.current_option_name
@property @property
def current_option_name(self) -> str: def current_option_name(self) -> str:
"""For display purposes. Worlds should be using current_key.""" """For display purposes. Worlds should be using current_key."""
@@ -754,12 +740,6 @@ class NamedRange(Range):
elif value > self.range_end and value not in self.special_range_names.values(): 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__} " + 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}") 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 self.value = value
@classmethod @classmethod
@@ -770,9 +750,39 @@ class NamedRange(Range):
return super().from_text(text) return super().from_text(text)
class SpecialRange(NamedRange):
special_range_cutoff = 0
# TODO: remove class SpecialRange, earliest 3 releases after 0.4.3
def __new__(cls, value: int) -> SpecialRange:
from Utils import deprecate
deprecate(f"Option type {cls.__name__} is a subclass of SpecialRange, which is deprecated and pending removal. "
"Consider switching to NamedRange, which supports all use-cases of SpecialRange, and more. In "
"NamedRange, range_start specifies the lower end of the regular range, while special values can be "
"placed anywhere (below, inside, or above the regular range).")
return super().__new__(cls)
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
elif text == "random-high":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end))
elif text == "random-middle":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end))
elif text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.special_range_cutoff, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
class FreezeValidKeys(AssembleOptions): class FreezeValidKeys(AssembleOptions):
def __new__(mcs, name, bases, attrs): def __new__(mcs, name, bases, attrs):
assert not "_valid_keys" in attrs, "'_valid_keys' gets set by FreezeValidKeys, define 'valid_keys' instead."
if "valid_keys" in attrs: if "valid_keys" in attrs:
attrs["_valid_keys"] = frozenset(attrs["valid_keys"]) attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs) return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
@@ -787,22 +797,17 @@ class VerifyKeys(metaclass=FreezeValidKeys):
verify_location_name: bool = False verify_location_name: bool = False
value: typing.Any value: typing.Any
def verify_keys(self) -> None: @classmethod
if self.valid_keys: def verify_keys(cls, data: typing.Iterable[str]) -> None:
data = set(self.value) if cls.valid_keys:
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data) data = set(data)
extra = dataset - self._valid_keys dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
extra = dataset - cls._valid_keys
if extra: if extra:
raise OptionError( raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. " f"Allowed keys: {cls._valid_keys}.")
f"Allowed keys: {self._valid_keys}."
)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: 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: if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value: for item_name in self.value:
@@ -839,6 +844,7 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
@classmethod @classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict: def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
if type(data) == dict: if type(data) == dict:
cls.verify_keys(data)
return cls(data) return cls(data)
else: else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}") raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
@@ -884,6 +890,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
@classmethod @classmethod
def from_any(cls, data: typing.Any): def from_any(cls, data: typing.Any):
if is_iterable_except_str(data): if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data) return cls(data)
return cls.from_text(str(data)) return cls.from_text(str(data))
@@ -909,6 +916,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
@classmethod @classmethod
def from_any(cls, data: typing.Any): def from_any(cls, data: typing.Any):
if is_iterable_except_str(data): if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data) return cls(data)
return cls.from_text(str(data)) return cls.from_text(str(data))
@@ -924,297 +932,26 @@ class ItemSet(OptionSet):
convert_name_groups = True 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): class Accessibility(Choice):
""" """Set rules for reachability of your items/locations.
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.
**Full:** ensure everything can be reached and acquired. Minimal: ensure what is needed to reach your goal can be acquired."""
**Minimal:** ensure what is needed to reach your goal can be acquired.
"""
display_name = "Accessibility" display_name = "Accessibility"
rich_text_doc = True option_locations = 0
option_full = 0 option_items = 1
option_minimal = 2 option_minimal = 2
alias_none = 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 default = 1
class ProgressionBalancing(NamedRange): class ProgressionBalancing(NamedRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. """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 default = 50
range_start = 0 range_start = 0
range_end = 99 range_end = 99
display_name = "Progression Balancing" display_name = "Progression Balancing"
rich_text_doc = True
special_range_names = { special_range_names = {
"disabled": 0, "disabled": 0,
"normal": 50, "normal": 50,
@@ -1247,11 +984,10 @@ class CommonOptions(metaclass=OptionsMetaProperty):
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]: def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
""" """
Returns a dictionary of [str, Option.value] Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return :param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab` :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 = {} option_results = {}
for option_name in option_names: for option_name in option_names:
if option_name in type(self).type_hints: if option_name in type(self).type_hints:
@@ -1280,36 +1016,29 @@ class CommonOptions(metaclass=OptionsMetaProperty):
class LocalItems(ItemSet): class LocalItems(ItemSet):
"""Forces these items to be in their native world.""" """Forces these items to be in their native world."""
display_name = "Local Items" display_name = "Local Items"
rich_text_doc = True
class NonLocalItems(ItemSet): class NonLocalItems(ItemSet):
"""Forces these items to be outside their native world.""" """Forces these items to be outside their native world."""
display_name = "Non-local Items" display_name = "Not Local Items"
rich_text_doc = True
class StartInventory(ItemDict): class StartInventory(ItemDict):
"""Start with these items.""" """Start with these items."""
verify_item_name = True verify_item_name = True
display_name = "Start Inventory" display_name = "Start Inventory"
rich_text_doc = True
class StartInventoryPool(StartInventory): class StartInventoryPool(StartInventory):
"""Start with these items and don't place them in the world. """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 verify_item_name = True
display_name = "Start Inventory from Pool" display_name = "Start Inventory from Pool"
rich_text_doc = True
class StartHints(ItemSet): 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" display_name = "Start Hints"
rich_text_doc = True
class LocationSet(OptionSet): class LocationSet(OptionSet):
@@ -1318,33 +1047,28 @@ class LocationSet(OptionSet):
class StartLocationHints(LocationSet): 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" display_name = "Start Location Hints"
rich_text_doc = True
class ExcludeLocations(LocationSet): class ExcludeLocations(LocationSet):
"""Prevent these locations from having an important item.""" """Prevent these locations from having an important item"""
display_name = "Excluded Locations" display_name = "Excluded Locations"
rich_text_doc = True
class PriorityLocations(LocationSet): class PriorityLocations(LocationSet):
"""Prevent these locations from having an unimportant item.""" """Prevent these locations from having an unimportant item"""
display_name = "Priority Locations" display_name = "Priority Locations"
rich_text_doc = True
class DeathLink(Toggle): 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" display_name = "Death Link"
rich_text_doc = True
class ItemLinks(OptionList): class ItemLinks(OptionList):
"""Share part of your item pool with other players.""" """Share part of your item pool with other players."""
display_name = "Item Links" display_name = "Item Links"
rich_text_doc = True
default = [] default = []
schema = Schema([ schema = Schema([
{ {
@@ -1359,8 +1083,7 @@ class ItemLinks(OptionList):
]) ])
@staticmethod @staticmethod
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
allow_item_groups: bool = True) -> typing.Set:
pool = set() pool = set()
for item_name in items: 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): if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
@@ -1411,7 +1134,6 @@ class ItemLinks(OptionList):
class Removed(FreeText): class Removed(FreeText):
"""This Option has been Removed.""" """This Option has been Removed."""
rich_text_doc = True
default = "" default = ""
visibility = Visibility.none visibility = Visibility.none
@@ -1438,47 +1160,7 @@ class DeathLinkMixin:
death_link: DeathLink death_link: DeathLink
class OptionGroup(typing.NamedTuple): def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
"""Define a grouping of options."""
name: str
"""Name of the group to categorize these options in for display on the WebHost and in generated YAMLS."""
options: typing.List[typing.Type[Option[typing.Any]]]
"""Options to be in the defined group."""
start_collapsed: bool = False
"""Whether the group will start collapsed on the WebHost options pages."""
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
"""
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
it.
"""
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
"""Generates and returns a dictionary for the option groups of a specified world."""
option_groups = {option: option_group.name
for option_group in world.web.option_groups
for option in option_group.options}
# add a default option group for uncategorized options to get thrown into
ordered_groups = ["Game Options"]
[ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
grouped_options = {group: {} for group in ordered_groups}
for option_name, option in world.options_dataclass.type_hints.items():
if visibility_level & option.visibility:
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
# if the world doesn't have any ungrouped options, this group will be empty so just remove it
if not grouped_options["Game Options"]:
del grouped_options["Game Options"]
return grouped_options
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
import os import os
import yaml import yaml
@@ -1514,18 +1196,18 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
return data, notes 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(): for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden: if not world.hidden or generate_hidden:
option_groups = get_option_groups(world) all_options: typing.Dict[str, AssembleOptions] = {
option_name: option for option_name, option in world.options_dataclass.type_hints.items()
if option.visibility & Visibility.template
}
with open(local_path("data", "options.yaml")) as f: with open(local_path("data", "options.yaml")) as f:
file_data = f.read() file_data = f.read()
res = Template(file_data).render( res = Template(file_data).render(
option_groups=option_groups, options=all_options,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar, __version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range, dictify_range=dictify_range,
) )
@@ -1535,40 +1217,29 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
f.write(res) f.write(res)
def dump_player_options(multiworld: MultiWorld) -> None: if __name__ == "__main__":
from csv import DictWriter
game_players = defaultdict(list) from worlds.alttp.Options import Logic
for player, game in multiworld.game.items(): import argparse
game_players[game].append(player)
game_players = dict(sorted(game_players.items()))
output = [] map_shuffle = Toggle
per_game_option_names = [ compass_shuffle = Toggle
getattr(option, "display_name", option_key) key_shuffle = Toggle
for option_key, option in PerGameCommonOptions.type_hints.items() big_key_shuffle = Toggle
] hints = Toggle
all_option_names = per_game_option_names.copy() test = argparse.Namespace()
for game, players in game_players.items(): test.logic = Logic.from_text("no_logic")
game_option_names = per_game_option_names.copy() test.map_shuffle = map_shuffle.from_text("ON")
for player in players: test.hints = hints.from_text('OFF')
world = multiworld.worlds[player] try:
player_output = { test.logic = Logic.from_text("overworld_glitches_typo")
"Game": multiworld.game[player], except KeyError as e:
"Name": multiworld.get_player_name(player), print(e)
} try:
output.append(player_output) test.logic_owg = Logic.from_text("owg")
for option_key, option in world.options_dataclass.type_hints.items(): except KeyError as e:
if issubclass(Removed, option): print(e)
continue if test.map_shuffle:
display_name = getattr(option, "display_name", option_key) print("map_shuffle is on")
player_output[display_name] = getattr(world.options, option_key).current_option_name print(f"Hints are {bool(test.hints)}")
if display_name not in game_option_names: print(test)
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)

View File

@@ -1,10 +1,8 @@
# [Archipelago](https://archipelago.gg) ![Discord Shield](https://discordapp.com/api/guilds/731205301247803413/widget.png?style=shield) | [Install](https://github.com/ArchipelagoMW/Archipelago/releases) # [Archipelago](https://archipelago.gg) ![Discord Shield](https://discordapp.com/api/guilds/731205301247803413/widget.png?style=shield) | [Install](https://github.com/ArchipelagoMW/Archipelago/releases)
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, presently, Archipelago is also the randomizer itself.
presently, Archipelago is also the randomizer itself.
Currently, the following games are supported: Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past * The Legend of Zelda: A Link to the Past
* Factorio * Factorio
* Minecraft * Minecraft
@@ -68,14 +66,6 @@ Currently, the following games are supported:
* A Short Hike * A Short Hike
* Yoshi's Island * Yoshi's Island
* Mario & Luigi: Superstar Saga * Mario & Luigi: Superstar Saga
* Bomb Rush Cyberfunk
* 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/). For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
@@ -83,57 +73,36 @@ windows binaries.
## History ## History
Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here. Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here. The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are:
The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are:
* [bonta0's MultiWorld](https://github.com/Bonta0/ALttPEntranceRandomizer/tree/multiworld_31) * [bonta0's MultiWorld](https://github.com/Bonta0/ALttPEntranceRandomizer/tree/multiworld_31)
* [AmazingAmpharos' Entrance Randomizer](https://github.com/AmazingAmpharos/ALttPEntranceRandomizer) * [AmazingAmpharos' Entrance Randomizer](https://github.com/AmazingAmpharos/ALttPEntranceRandomizer)
* [VT Web Randomizer](https://github.com/sporchia/alttp_vt_randomizer) * [VT Web Randomizer](https://github.com/sporchia/alttp_vt_randomizer)
* [Dessyreqt's alttprandomizer](https://github.com/Dessyreqt/alttprandomizer) * [Dessyreqt's alttprandomizer](https://github.com/Dessyreqt/alttprandomizer)
* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89) * [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89) and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make the vast majority of Enemizer contributions.
and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make up the
vast majority of Enemizer contributions.
We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the path. Just because one person's name may be in a repository title does not mean that only one person made that project happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago but we hope to honor them fairly.
path. Just because one person's name may be in a repository title does not mean that only one person made that project
happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago, but we hope to honor
them fairly.
### Path to the Archipelago ### Path to the Archipelago
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a
long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to
_MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as
"Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository
(as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
## Running Archipelago ## Running Archipelago
For most people, all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer, or AppImage for Linux-based systems.
For most people, all you need to do is head over to If you are a developer or are running on a platform with no compiled releases available, please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
the [releases page](https://github.com/ArchipelagoMW/Archipelago/releases), then download and run the appropriate
installer, or AppImage for Linux-based systems.
If you are a developer or are running on a platform with no compiled releases available, please see our doc on
[running Archipelago from source](docs/running%20from%20source.md).
## Related Repositories ## Related Repositories
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the
contributions of their developers, past and present.
* [z3randomizer](https://github.com/ArchipelagoMW/z3randomizer) * [z3randomizer](https://github.com/ArchipelagoMW/z3randomizer)
* [Enemizer](https://github.com/Ijwu/Enemizer) * [Enemizer](https://github.com/Ijwu/Enemizer)
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer) * [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
## Contributing ## Contributing
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
To contribute to Archipelago, including the WebHost, core program, or by adding a new game, see our
[Contributing guidelines](/docs/contributing.md).
## FAQ ## FAQ
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
For Frequently asked questions, please see the website's [FAQ Page](https://archipelago.gg/faq/en/).
## Code of Conduct ## Code of Conduct
Please refer to our [code of conduct.](/docs/code_of_conduct.md)
Please refer to our [code of conduct](/docs/code_of_conduct.md).

View File

@@ -29,7 +29,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
def _cmd_patch(self): def _cmd_patch(self):
"""Patch the game. Only use this command if /auto_patch fails.""" """Patch the game. Only use this command if /auto_patch fails."""
if isinstance(self.ctx, UndertaleContext): 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.ctx.patch_game()
self.output("Patched.") self.output("Patched.")
@@ -43,7 +43,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically.""" """Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext): 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 tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")): if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None tempInstall = None
@@ -62,7 +62,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
for file_name in os.listdir(tempInstall): for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll": if file_name != "steam_api.dll":
shutil.copy(os.path.join(tempInstall, file_name), 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.ctx.patch_game()
self.output("Patching successful!") self.output("Patching successful!")
@@ -111,12 +111,12 @@ class UndertaleContext(CommonContext):
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def patch_game(self): 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")) 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) f.write(patchedFile)
os.makedirs(name=Utils.user_path("Undertale", "Custom Sprites"), exist_ok=True) os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
with open(os.path.expandvars(Utils.user_path("Undertale", "Custom Sprites", with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites",
"Which Character.txt")), "w") as f: "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 " 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"]) "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: with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
toDraw = "" toDraw = ""
for i in range(20): for i in range(20):
if i < len(str(ctx.item_names.lookup_in_game(l.item))): if i < len(str(ctx.item_names[l.item])):
toDraw += str(ctx.item_names.lookup_in_game(l.item))[i] toDraw += str(ctx.item_names[l.item])[i]
else: else:
break break
f.write(toDraw) f.write(toDraw)

107
Utils.py
View File

@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.5.1" __version__ = "0.4.6"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -101,7 +101,8 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
@functools.wraps(function) @functools.wraps(function)
def wrap(self: S, arg: T) -> RetType: def wrap(self: S, arg: T) -> RetType:
cache: Optional[Dict[T, RetType]] = getattr(self, cache_name, None) cache: Optional[Dict[T, RetType]] = typing.cast(Optional[Dict[T, RetType]],
getattr(self, cache_name, None))
if cache is None: if cache is None:
res = function(self, arg) res = function(self, arg)
setattr(self, cache_name, {arg: res}) setattr(self, cache_name, {arg: res})
@@ -208,11 +209,10 @@ def output_path(*path: str) -> str:
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
if is_windows: if is_windows:
os.startfile(filename) # type: ignore os.startfile(filename)
else: else:
from shutil import which from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open")) open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
assert open_command, "Didn't find program for open_file! Please report this together with system details."
subprocess.call([open_command, filename]) subprocess.call([open_command, filename])
@@ -300,21 +300,21 @@ def get_options() -> Settings:
return get_settings() return get_settings()
def persistent_store(category: str, key: str, value: typing.Any): def persistent_store(category: str, key: typing.Any, value: typing.Any):
path = user_path("_persistent_storage.yaml") path = user_path("_persistent_storage.yaml")
storage = persistent_load() storage: dict = persistent_load()
category_dict = storage.setdefault(category, {}) category = storage.setdefault(category, {})
category_dict[key] = value category[key] = value
with open(path, "wt") as f: with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper)) f.write(dump(storage, Dumper=Dumper))
def persistent_load() -> Dict[str, Dict[str, Any]]: def persistent_load() -> typing.Dict[str, dict]:
storage: Union[Dict[str, Dict[str, Any]], None] = getattr(persistent_load, "storage", None) storage = getattr(persistent_load, "storage", None)
if storage: if storage:
return storage return storage
path = user_path("_persistent_storage.yaml") path = user_path("_persistent_storage.yaml")
storage = {} storage: dict = {}
if os.path.exists(path): if os.path.exists(path):
try: try:
with open(path, "r") as f: with open(path, "r") as f:
@@ -323,7 +323,7 @@ def persistent_load() -> Dict[str, Dict[str, Any]]:
logging.debug(f"Could not read store: {e}") logging.debug(f"Could not read store: {e}")
if storage is None: if storage is None:
storage = {} storage = {}
setattr(persistent_load, "storage", storage) persistent_load.storage = storage
return storage return storage
@@ -365,7 +365,6 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
except Exception as e: except Exception as e:
logging.debug(f"Could not store data package: {e}") logging.debug(f"Could not store data package: {e}")
def get_default_adjuster_settings(game_name: str) -> Namespace: def get_default_adjuster_settings(game_name: str) -> Namespace:
import LttPAdjuster import LttPAdjuster
adjuster_settings = Namespace() adjuster_settings = Namespace()
@@ -384,9 +383,7 @@ def get_adjuster_settings(game_name: str) -> Namespace:
default_settings = get_default_adjuster_settings(game_name) default_settings = get_default_adjuster_settings(game_name)
# Fill in any arguments from the argparser that we haven't seen before # Fill in any arguments from the argparser that we haven't seen before
return Namespace(**vars(adjuster_settings), **{ return Namespace(**vars(adjuster_settings), **{k:v for k,v in vars(default_settings).items() if k not in vars(adjuster_settings)})
k: v for k, v in vars(default_settings).items() if k not in vars(adjuster_settings)
})
@cache_argsless @cache_argsless
@@ -410,20 +407,20 @@ safe_builtins = frozenset((
class RestrictedUnpickler(pickle.Unpickler): class RestrictedUnpickler(pickle.Unpickler):
generic_properties_module: Optional[object] generic_properties_module: Optional[object]
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args, **kwargs):
super(RestrictedUnpickler, self).__init__(*args, **kwargs) super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options") self.options_module = importlib.import_module("Options")
self.net_utils_module = importlib.import_module("NetUtils") self.net_utils_module = importlib.import_module("NetUtils")
self.generic_properties_module = None self.generic_properties_module = None
def find_class(self, module: str, name: str) -> type: def find_class(self, module, name):
if module == "builtins" and name in safe_builtins: if module == "builtins" and name in safe_builtins:
return getattr(builtins, name) return getattr(builtins, name)
# used by MultiServer -> savegame/multidata # used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}: if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
return getattr(self.net_utils_module, name) return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate # Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name == "PlandoItem": if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
if not self.generic_properties_module: if not self.generic_properties_module:
self.generic_properties_module = importlib.import_module("worlds.generic") self.generic_properties_module = importlib.import_module("worlds.generic")
return getattr(self.generic_properties_module, name) return getattr(self.generic_properties_module, name)
@@ -434,13 +431,13 @@ class RestrictedUnpickler(pickle.Unpickler):
else: else:
mod = importlib.import_module(module) mod = importlib.import_module(module)
obj = getattr(mod, name) obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)): if issubclass(obj, self.options_module.Option):
return obj return obj
# Forbid everything else. # Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
def restricted_loads(s: bytes) -> Any: def restricted_loads(s):
"""Helper function analogous to pickle.loads().""" """Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load() return RestrictedUnpickler(io.BytesIO(s)).load()
@@ -458,15 +455,6 @@ class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory""" """defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any] 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): def __missing__(self, key):
self[key] = value = self.default_factory(key) self[key] = value = self.default_factory(key)
return value return value
@@ -505,7 +493,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
file_handler.setFormatter(logging.Formatter(log_format)) file_handler.setFormatter(logging.Formatter(log_format))
class Filter(logging.Filter): class Filter(logging.Filter):
def __init__(self, filter_name: str, condition: typing.Callable[[logging.LogRecord], bool]) -> None: def __init__(self, filter_name, condition):
super().__init__(filter_name) super().__init__(filter_name)
self.condition = condition self.condition = condition
@@ -553,11 +541,10 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
f"Archipelago ({__version__}) logging initialized" f"Archipelago ({__version__}) logging initialized"
f" on {platform.platform()}" f" on {platform.platform()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
f"{' (frozen)' if is_frozen() else ''}"
) )
def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"): def stream_input(stream, queue):
def queuer(): def queuer():
while 1: while 1:
try: try:
@@ -585,7 +572,7 @@ class VersionException(Exception):
pass pass
def chaining_prefix(index: int, labels: typing.Sequence[str]) -> str: def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
text = "" text = ""
max_label = len(labels) - 1 max_label = len(labels) - 1
while index > max_label: while index > max_label:
@@ -608,7 +595,7 @@ def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}" return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit: typing.Optional[int] = None) \ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
-> typing.List[typing.Tuple[str, int]]: -> typing.List[typing.Tuple[str, int]]:
import jellyfish import jellyfish
@@ -616,55 +603,21 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower()) return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2))) / max(len(word1), len(word2)))
limit = limit if limit else len(word_list) limit: int = limit if limit else len(wordlist)
return list( return list(
map( map(
lambda container: (container[0], int(container[1]*100)), # convert up to limit to int % lambda container: (container[0], int(container[1]*100)), # convert up to limit to int %
sorted( sorted(
map(lambda candidate: (candidate, get_fuzzy_ratio(input_word, candidate)), word_list), map(lambda candidate:
(candidate, get_fuzzy_ratio(input_word, candidate)),
wordlist),
key=lambda element: element[1], key=lambda element: element[1],
reverse=True reverse=True)[0:limit]
)[0:limit]
) )
) )
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]: def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: 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]: -> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.") logging.info(f"Opening file input dialog for {title}.")
@@ -781,7 +734,7 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
root.update() root.update()
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))): def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning.""" """Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: Union[str, Dict[str, Any]]) -> str: def sorter(element: Union[str, Dict[str, Any]]) -> str:
if (not isinstance(element, str)): if (not isinstance(element, str)):
@@ -835,7 +788,7 @@ class DeprecateDict(dict):
log_message: str log_message: str
should_error: bool should_error: bool
def __init__(self, message: str, error: bool = False) -> None: def __init__(self, message, error: bool = False) -> None:
self.log_message = message self.log_message = message
self.should_error = error self.should_error = error
super().__init__() super().__init__()

View File

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

View File

@@ -1,4 +1,3 @@
import argparse
import os import os
import multiprocessing import multiprocessing
import logging import logging
@@ -13,9 +12,6 @@ ModuleUpdate.update()
import Utils import Utils
import settings 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 Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
settings.no_gui = True settings.no_gui = True
configpath = os.path.abspath("config.yaml") 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')) 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 import register, cache, app as raw_app
from WebHostLib.models import db from WebHostLib.models import db
@@ -32,15 +28,6 @@ def get_app() -> "Flask":
import yaml import yaml
app.config.from_file(configpath, yaml.safe_load) app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}") 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"]: if not app.config["HOST_ADDRESS"]:
logging.info("Getting public IP, as HOST_ADDRESS is empty.") logging.info("Getting public IP, as HOST_ADDRESS is empty.")
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4() 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 worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs") base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items(): for game, world in worlds.items():
# copy files from world's docs folder to the generated folder # copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, game) target_path = os.path.join(base_target_path, game)
@@ -131,7 +117,7 @@ if __name__ == "__main__":
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen, stop from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.options import create as create_options_files from WebHostLib.options import create as create_options_files
try: try:
@@ -152,11 +138,3 @@ if __name__ == "__main__":
else: else:
from waitress import serve from waitress import serve
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"]) serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
else:
from time import sleep
try:
while True:
sleep(1) # wait for process to be killed
except (SystemExit, KeyboardInterrupt):
pass
stop() # stop worker threads

View File

@@ -23,7 +23,6 @@ app.jinja_env.filters['all'] = all
app.config["SELFHOST"] = True # application process is in charge of running the websites app.config["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens app.config["GENERATORS"] = 8 # maximum concurrent world gens
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms. app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
@@ -84,6 +83,6 @@ def register():
from WebHostLib.customserver import run_server_process from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it # to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots
app.register_blueprint(api.api_endpoints) app.register_blueprint(api.api_endpoints)

View File

@@ -1,15 +1,78 @@
"""API endpoints package.""" """API endpoints package."""
from typing import List, Tuple 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") api_endpoints = Blueprint('api', __name__, url_prefix="/api")
# unsorted/misc endpoints
def get_players(seed: Seed) -> List[Tuple[str, str]]: def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots] 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

@@ -3,25 +3,26 @@ from __future__ import annotations
import json import json
import logging import logging
import multiprocessing import multiprocessing
import threading
import time
import typing import typing
from datetime import timedelta, datetime
from threading import Event, Thread
from uuid import UUID from uuid import UUID
from datetime import timedelta, datetime
from pony.orm import db_session, select, commit from pony.orm import db_session, select, commit
from Utils import restricted_loads from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException from .locker import Locker, AlreadyRunningException
_stop_event = Event()
def launch_room(room: Room, config: dict):
# requires db_session!
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
multiworld = multiworlds.get(room.id, None)
if not multiworld:
multiworld = MultiworldInstance(room, config)
def stop(): multiworld.start()
"""Stops previously launched threads"""
global _stop_event
stop_event = _stop_event
_stop_event = Event() # new event for new threads
stop_event.set()
def handle_generation_success(seed_id): def handle_generation_success(seed_id):
@@ -58,50 +59,39 @@ def init_db(pony_config: dict):
db.generate_mapping() db.generate_mapping()
def cleanup():
"""delete unowned user-content"""
with db_session:
# >>> bool(uuid.UUID(int=0))
# True
rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
# Command gets deleted by ponyorm Cascade Delete, as Room is Required
if rooms or seeds or slots:
logging.info(f"{rooms} Rooms, {seeds} Seeds and {slots} Slots have been deleted.")
def autohost(config: dict): def autohost(config: dict):
def keep_running(): def keep_running():
stop_event = _stop_event
try: try:
with Locker("autohost"): with Locker("autohost"):
cleanup() # delete unowned user-content
hosters = [] with db_session:
for x in range(config["HOSTERS"]): # >>> bool(uuid.UUID(int=0))
hoster = MultiworldInstance(config, x) # True
hosters.append(hoster) rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
hoster.start() seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
while not stop_event.wait(0.1): # Command gets deleted by ponyorm Cascade Delete, as Room is Required
if rooms or seeds or slots:
logging.info(f"{rooms} Rooms, {seeds} Seeds and {slots} Slots have been deleted.")
run_guardian()
while 1:
time.sleep(0.1)
with db_session: with db_session:
rooms = select( rooms = select(
room for room in Room if room for room in Room if
room.last_activity >= datetime.utcnow() - timedelta(days=3)) room.last_activity >= datetime.utcnow() - timedelta(days=3))
for room in rooms: for room in rooms:
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled. launch_room(room, config)
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
hosters[room.id.int % len(hosters)].start_room(room.id)
except AlreadyRunningException: except AlreadyRunningException:
logging.info("Autohost reports as already running, not starting another.") logging.info("Autohost reports as already running, not starting another.")
Thread(target=keep_running, name="AP_Autohost").start() import threading
threading.Thread(target=keep_running, name="AP_Autohost").start()
def autogen(config: dict): def autogen(config: dict):
def keep_running(): def keep_running():
stop_event = _stop_event
try: try:
with Locker("autogen"): with Locker("autogen"):
@@ -122,7 +112,8 @@ def autogen(config: dict):
commit() commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete() select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
while not stop_event.wait(0.1): while 1:
time.sleep(0.1)
with db_session: with db_session:
# for update locks the database row(s) during transaction, preventing writes from elsewhere # for update locks the database row(s) during transaction, preventing writes from elsewhere
to_start = select( to_start = select(
@@ -133,45 +124,37 @@ def autogen(config: dict):
except AlreadyRunningException: except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.") logging.info("Autogen reports as already running, not starting another.")
Thread(target=keep_running, name="AP_Autogen").start() import threading
threading.Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {} multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
class MultiworldInstance(): class MultiworldInstance():
def __init__(self, config: dict, id: int): def __init__(self, room: Room, config: dict):
self.room_ids = set() self.room_id = room.id
self.process: typing.Optional[multiprocessing.Process] = None self.process: typing.Optional[multiprocessing.Process] = None
with guardian_lock:
multiworlds[self.room_id] = self
self.ponyconfig = config["PONY"] self.ponyconfig = config["PONY"]
self.cert = config["SELFLAUNCHCERT"] self.cert = config["SELFLAUNCHCERT"]
self.key = config["SELFLAUNCHKEY"] self.key = config["SELFLAUNCHKEY"]
self.host = config["HOST_ADDRESS"] self.host = config["HOST_ADDRESS"]
self.rooms_to_start = multiprocessing.Queue()
self.rooms_shutting_down = multiprocessing.Queue()
self.name = f"MultiHoster{id}"
def start(self): def start(self):
if self.process and self.process.is_alive(): if self.process and self.process.is_alive():
return False return False
logging.info(f"Spinning up {self.room_id}")
process = multiprocessing.Process(group=None, target=run_server_process, process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.name, self.ponyconfig, get_static_server_data(), args=(self.room_id, self.ponyconfig, get_static_server_data(),
self.cert, self.key, self.host, self.cert, self.key, self.host),
self.rooms_to_start, self.rooms_shutting_down), name="MultiHost")
name=self.name)
process.start() process.start()
# bind after start to prevent thread sync issues with guardian.
self.process = process self.process = process
def start_room(self, room_id):
while not self.rooms_shutting_down.empty():
self.room_ids.remove(self.rooms_shutting_down.get(block=True, timeout=None))
if room_id in self.room_ids:
pass # should already be hosted currently.
else:
self.room_ids.add(room_id)
self.rooms_to_start.put(room_id)
def stop(self): def stop(self):
if self.process: if self.process:
self.process.terminate() self.process.terminate()
@@ -185,6 +168,40 @@ class MultiworldInstance():
self.process = None self.process = None
guardian = None
guardian_lock = threading.Lock()
def run_guardian():
global guardian
global multiworlds
with guardian_lock:
if not guardian:
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# Each Server is another file handle, so request as many as we can from the system
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
# set soft limit to hard limit
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
def guard():
while 1:
time.sleep(1)
done = []
with guardian_lock:
for key, instance in multiworlds.items():
if instance.done():
instance.collect()
done.append(key)
for key in done:
del (multiworlds[key])
guardian = threading.Thread(name="Guardian", target=guard)
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed, Slot from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed, Slot
from .customserver import run_server_process, get_static_server_data from .customserver import run_server_process, get_static_server_data
from .generate import gen_game from .generate import gen_game

View File

@@ -5,7 +5,6 @@ import collections
import datetime import datetime
import functools import functools
import logging import logging
import multiprocessing
import pickle import pickle
import random import random
import socket import socket
@@ -54,35 +53,24 @@ del MultiServer
class DBCommandProcessor(ServerCommandProcessor): class DBCommandProcessor(ServerCommandProcessor):
def output(self, text: str): def output(self, text: str):
self.ctx.logger.info(text) logging.info(text)
class WebHostContext(Context): class WebHostContext(Context):
room_id: int room_id: int
def __init__(self, static_server_data: dict, logger: logging.Logger): def __init__(self, static_server_data: dict):
# static server data is used during _load_game_data to load required data, # static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory # without needing to import worlds system, which takes quite a bit of memory
self.static_server_data = static_server_data self.static_server_data = static_server_data
super(WebHostContext, self).__init__("", 0, "", "", 1, super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
40, True, "enabled", "enabled",
"enabled", 0, 2, logger=logger)
del self.static_server_data del self.static_server_data
self.main_loop = asyncio.get_running_loop() self.main_loop = asyncio.get_running_loop()
self.video = {} self.video = {}
self.tags = ["AP", "WebHost"] 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): def _load_game_data(self):
for key, value in self.static_server_data.items(): 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
setattr(self, key, value) setattr(self, key, value)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names) self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
@@ -110,37 +98,18 @@ class WebHostContext(Context):
multidata = self.decompress(room.seed.multidata) multidata = self.decompress(room.seed.multidata)
game_data_packages = {} game_data_packages = {}
static_gamespackage = self.gamespackage # this is shared across all rooms
static_item_name_groups = self.item_name_groups
static_location_name_groups = self.location_name_groups
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
for game in list(multidata.get("datapackage", {})): for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game] game_data = multidata["datapackage"][game]
if "checksum" in game_data: if "checksum" in game_data:
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]: if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata and use static data # non-custom. remove from multidata
# games package could be dropped from static data once all rooms embed data package # games package could be dropped from static data once all rooms embed data package
del multidata["datapackage"][game] del multidata["datapackage"][game]
else: else:
row = GameDataPackage.get(checksum=game_data["checksum"]) row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = Utils.restricted_loads(row.data) game_data_packages[game] = Utils.restricted_loads(row.data)
continue
else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
self.gamespackage[game] = static_gamespackage.get(game, {})
self.item_name_groups[game] = static_item_name_groups.get(game, {})
self.location_name_groups[game] = static_location_name_groups.get(game, {})
if not game_data_packages:
# all static -> use the static dicts directly
self.gamespackage = static_gamespackage
self.item_name_groups = static_item_name_groups
self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True) return self._load(multidata, game_data_packages, True)
@db_session @db_session
@@ -150,7 +119,7 @@ class WebHostContext(Context):
savegame_data = Room.get(id=self.room_id).multisave savegame_data = Room.get(id=self.room_id).multisave
if savegame_data: if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave)) self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self._start_async_saving(atexit_save=False) self._start_async_saving()
threading.Thread(target=self.listen_to_db_commands, daemon=True).start() threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session @db_session
@@ -176,180 +145,86 @@ def get_random_port():
def get_static_server_data() -> dict: def get_static_server_data() -> dict:
import worlds import worlds
data = { data = {
"non_hintable_names": { "non_hintable_names": {},
world_name: world.hint_blacklist "gamespackage": worlds.network_data_package["games"],
for world_name, world in worlds.AutoWorldRegister.world_types.items() "item_name_groups": {world_name: world.item_name_groups for world_name, world in
}, worlds.AutoWorldRegister.world_types.items()},
"gamespackage": { "location_name_groups": {world_name: world.location_name_groups for world_name, world in
world_name: { worlds.AutoWorldRegister.world_types.items()},
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()
},
} }
for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["non_hintable_names"][world_name] = world.hint_blacklist
return data return data
def set_up_logging(room_id) -> logging.Logger: def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
import os
# logger setup
logger = logging.getLogger(f"RoomLogger {room_id}")
# this *should* be empty, but just in case.
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
file_handler = logging.FileHandler(
os.path.join(Utils.user_path("logs"), f"{room_id}.txt"),
"a",
encoding="utf-8-sig")
file_handler.setFormatter(logging.Formatter("[%(asctime)s]: %(message)s"))
logger.setLevel(logging.INFO)
logger.addHandler(file_handler)
return logger
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str], cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue): host: str):
Utils.init_logging(name)
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# Each Server is another file handle, so request as many as we can from the system
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
# set soft limit to hard limit
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
del resource, file_limit
# establish DB connection for multidata and multisave # establish DB connection for multidata and multisave
db.bind(**ponyconfig) db.bind(**ponyconfig)
db.generate_mapping(check_tables=False) db.generate_mapping(check_tables=False)
if "worlds" in sys.modules: async def main():
raise Exception("Worlds system should not be loaded in the custom server.") if "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded in the custom server.")
import gc import gc
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None Utils.init_logging(str(room_id), write_mode="a")
del cert_file, cert_key_file, ponyconfig ctx = WebHostContext(static_server_data)
gc.collect() # free intermediate objects used during setup ctx.load(room_id)
ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
gc.collect() # free intermediate objects used during setup
try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
loop = asyncio.get_event_loop() await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
async def start_room(room_id): await ctx.server
with Locker(f"RoomLocker {room_id}"): port = 0
try: for wssocket in ctx.server.ws_server.sockets:
logger = set_up_logging(room_id) socketname = wssocket.getsockname()
ctx = WebHostContext(static_server_data, logger) if wssocket.family == socket.AF_INET6:
ctx.load(room_id) # Prefer IPv4, as most users seem to not have working ipv6 support
ctx.init_save() if not port:
assert ctx.server is None port = socketname[1]
try: elif wssocket.family == socket.AF_INET:
ctx.server = websockets.serve( port = socketname[1]
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) if port:
logging.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
logging.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
await ctx.server # ensure auto launch is on the same page in regard to room activity.
except OSError: # likely port in use with db_session:
ctx.server = websockets.serve( room: Room = Room.get(id=ctx.room_id)
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context) room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)
await ctx.server logging.info("Shutting down")
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
port = socketname[1]
if port:
ctx.logger.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
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): with Locker(room_id):
if ctx.saving: try:
ctx._save() asyncio.run(main())
setattr(asyncio.current_task(), "save", None) except (KeyboardInterrupt, SystemExit):
except Exception as e: with db_session:
with db_session: room = Room.get(id=room_id)
room = Room.get(id=room_id) # ensure the Room does not spin up again on its own, minute of safety buffer
room.last_port = -1 room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
logger.exception(e) except Exception:
raise with db_session:
else: room = Room.get(id=room_id)
if ctx.saving: room.last_port = -1
ctx._save() # ensure the Room does not spin up again on its own, minute of safety buffer
setattr(asyncio.current_task(), "save", None) room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
finally: raise
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)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
logging.info(f"Shutting down room {room_id} on {name}.")
finally:
await asyncio.sleep(5)
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)
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()

View File

@@ -6,7 +6,7 @@ import random
import tempfile import tempfile
import zipfile import zipfile
from collections import Counter from collections import Counter
from typing import Any, Dict, List, Optional, Union, Set from typing import Any, Dict, List, Optional, Union
from flask import flash, redirect, render_template, request, session, url_for from flask import flash, redirect, render_template, request, session, url_for
from pony.orm import commit, db_session from pony.orm import commit, db_session
@@ -16,7 +16,6 @@ from Generate import PlandoOptions, handle_name
from Main import main as ERmain from Main import main as ERmain
from Utils import __version__ from Utils import __version__
from WebHostLib import app from WebHostLib import app
from settings import ServerOptions, GeneratorOptions
from worlds.alttp.EntranceRandomizer import parse_arguments from worlds.alttp.EntranceRandomizer import parse_arguments
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
@@ -24,22 +23,25 @@ from .upload import upload_zip_to_db
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]: def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
plando_options: Set[str] = set() plando_options = {
for substr in ("bosses", "items", "connections", "texts"): options_source.get("plando_bosses", ""),
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options): options_source.get("plando_items", ""),
plando_options.add(substr) options_source.get("plando_connections", ""),
options_source.get("plando_texts", "")
}
plando_options -= {""}
server_options = { server_options = {
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)), "hint_cost": int(options_source.get("hint_cost", 10)),
"release_mode": options_source.get("release_mode", ServerOptions.release_mode), "release_mode": options_source.get("release_mode", "goal"),
"remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode), "remaining_mode": options_source.get("remaining_mode", "disabled"),
"collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode), "collect_mode": options_source.get("collect_mode", "disabled"),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))), "item_cheat": bool(int(options_source.get("item_cheat", 1))),
"server_password": options_source.get("server_password", None), "server_password": options_source.get("server_password", None),
} }
generator_options = { generator_options = {
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)), "spoiler": int(options_source.get("spoiler", 0)),
"race": race, "race": race
} }
if race: if race:
@@ -68,42 +70,37 @@ def generate(race=False):
flash(options) flash(options)
else: else:
meta = get_meta(request.form, race) meta = get_meta(request.form, race)
return start_generation(options, meta) results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
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.")
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
return render_template("generate.html", race=race, version=__version__) return render_template("generate.html", race=race, version=__version__)
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
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()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None): def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta: if not meta:
meta: Dict[str, Any] = {} meta: Dict[str, Any] = {}
@@ -135,7 +132,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
{"bosses", "items", "connections", "texts"})) {"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False erargs.skip_prog_balancing = False
erargs.skip_output = False erargs.skip_output = False
erargs.csv_output = False
name_counter = Counter() name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1): for player, (playerfile, settings) in enumerate(gen_options.items(), 1):

View File

@@ -1,11 +1,10 @@
import datetime import datetime
import os import os
from typing import Any, IO, Dict, Iterator, List, Tuple, Union from typing import List, Dict, Union
import jinja2.exceptions import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from pony.orm import count, commit, db_session from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from . import app, cache from . import app, cache
@@ -38,6 +37,25 @@ def start_playing():
return render_template(f"startPlaying.html") return render_template(f"startPlaying.html")
# TODO for back compat. remove around 0.4.5
@app.route("/weighted-settings")
def weighted_settings():
return redirect("weighted-options", 301)
@app.route("/weighted-options")
@cache.cached()
def weighted_options():
return render_template("weighted-options.html")
# Player options pages
@app.route("/games/<string:game>/player-options")
@cache.cached()
def player_options(game: str):
return render_template("player-options.html", game=game, theme=get_world_theme(game))
# Game Info Pages # Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>') @app.route('/games/<string:game>/info/<string:lang>')
@cache.cached() @cache.cached()
@@ -70,28 +88,14 @@ def tutorial_landing():
@app.route('/faq/<string:lang>/') @app.route('/faq/<string:lang>/')
@cache.cached() @cache.cached()
def faq(lang: str): def faq(lang):
import markdown return render_template("faq.html", lang=lang)
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template(
"markdown_document.html",
title="Frequently Asked Questions",
html_from_markdown=markdown.markdown(document, extensions=["mdx_breakless_lists"]),
)
@app.route('/glossary/<string:lang>/') @app.route('/glossary/<string:lang>/')
@cache.cached() @cache.cached()
def glossary(lang: str): def terms(lang):
import markdown return render_template("glossary.html", lang=lang)
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template(
"markdown_document.html",
title="Glossary",
html_from_markdown=markdown.markdown(document, extensions=["mdx_breakless_lists"]),
)
@app.route('/seed/<suuid:seed>') @app.route('/seed/<suuid:seed>')
@@ -112,91 +116,49 @@ def new_room(seed: UUID):
return redirect(url_for("host_room", room=room.id)) return redirect(url_for("host_room", room=room.id))
def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]: def _read_log(path: str):
marker = log.read(3) # skip optional BOM if os.path.exists(path):
if marker != b'\xEF\xBB\xBF': with open(path, encoding="utf-8-sig") as log:
log.seek(0, os.SEEK_SET) yield from log
log.seek(offset, os.SEEK_CUR) else:
yield from log yield f"Logfile {path} does not exist. " \
log.close() # free file handle as soon as possible f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>') @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) room = Room.get(id=room)
if room is None: if room is None:
return abort(404) return abort(404)
if room.owner == session["_id"]: if room.owner == session["_id"]:
file_path = os.path.join("logs", str(room.id) + ".txt") file_path = os.path.join("logs", str(room.id) + ".txt")
try: if os.path.exists(file_path):
log = open(file_path, "rb") return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
range_header = request.headers.get("Range") return "Log File does not exist."
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")
return "Access Denied", 403 return "Access Denied", 403
@app.post("/room/<suuid:room>") @app.route('/room/<suuid:room>', methods=['GET', 'POST'])
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>")
def host_room(room: UUID): def host_room(room: UUID):
room: Room = Room.get(id=room) room: Room = Room.get(id=room)
if room is None: if room is None:
return abort(404) 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() now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port # indicate that the page should reload to get the assigned port
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)) should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
with db_session: with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running room.last_activity = now # will trigger a spinup, if it's not already running
browser_tokens = "Mozilla", "Chrome", "Safari" return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
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)
@app.route('/favicon.ico') @app.route('/favicon.ico')

View File

@@ -1,281 +1,205 @@
import collections.abc
import json import json
import logging
import os import os
from textwrap import dedent import typing
from typing import Dict, Union
from docutils.core import publish_parts
import yaml
from flask import redirect, render_template, request, Response
import Options import Options
from Utils import local_path from Utils import local_path
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
from .generate import get_meta handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations", "priority_locations"}
def create() -> None: def create():
target_folder = local_path("WebHostLib", "static", "generated") target_folder = local_path("WebHostLib", "static", "generated")
yaml_folder = os.path.join(target_folder, "configs") yaml_folder = os.path.join(target_folder, "configs")
Options.generate_yaml_templates(yaml_folder) Options.generate_yaml_templates(yaml_folder)
def get_html_doc(option_type: type(Options.Option)) -> str:
if not option_type.__doc__:
return "Please document me!"
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
def get_world_theme(game_name: str) -> str: weighted_options = {
if game_name in AutoWorldRegister.world_types: "baseOptions": {
return AutoWorldRegister.world_types[game_name].web.theme "description": "Generated by https://archipelago.gg/",
return 'grass' "name": "",
"game": {},
},
"games": {},
}
for game_name, world in AutoWorldRegister.world_types.items():
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]: all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints
world = AutoWorldRegister.world_types[world_name]
if world.hidden or world.web.options_page is False:
return redirect("games")
visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui
start_collapsed = {"Game Options": False} # Generate JSON files for player-options pages
for group in world.web.option_groups: player_options = {
start_collapsed[group.name] = group.start_collapsed "baseOptions": {
"description": f"Generated by https://archipelago.gg/ for {game_name}",
return render_template( "game": game_name,
template, "name": "",
world_name=world_name, },
world=world,
option_groups=Options.get_option_groups(world, visibility_level=visibility_flag),
start_collapsed=start_collapsed,
issubclass=issubclass,
Options=Options,
theme=get_world_theme(world_name),
)
def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
from .generate import start_generation
return start_generation(options, get_meta({}))
def send_yaml(player_name: str, formatted_options: dict) -> Response:
response = Response(yaml.dump(formatted_options, sort_keys=False))
response.headers["Content-Type"] = "text/yaml"
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
return response
@app.template_filter("dedent")
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)
@app.route("/games/<string:game>/option-presets", methods=["GET"])
@cache.cached()
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
if isinstance(obj, Set):
return list(obj)
return json.JSONEncoder.default(self, obj)
json_data = json.dumps(presets, cls=SetEncoder)
response = Response(json_data)
response.headers["Content-Type"] = "application/json"
return response
@app.route("/weighted-options")
def weighted_options_old():
return redirect("games", 301)
@app.route("/games/<string:game>/weighted-options")
@cache.cached()
def weighted_options(game: str):
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
def generate_weighted_yaml(game: str):
if request.method == "POST":
intent_generate = False
options = {}
for key, val in request.form.items():
if "||" not in key:
if len(str(val)) == 0:
continue
options[key] = val
else:
if int(val) == 0:
continue
[option, setting] = key.split("||")
options.setdefault(option, {})[setting] = int(val)
# Error checking
if "name" not in options:
return "Player name is required."
# Remove POST data irrelevant to YAML
if "intent-generate" in options:
intent_generate = True
del options["intent-generate"]
if "intent-export" in options:
del options["intent-export"]
# Properly format YAML output
player_name = options["name"]
del options["name"]
formatted_options = {
"name": player_name,
"game": game,
"description": f"Generated by https://archipelago.gg/ for {game}",
game: options,
} }
if intent_generate: game_options = {}
return generate_game({player_name: formatted_options}) visible: typing.Set[str] = set()
visible_weighted: typing.Set[str] = set()
else: for option_name, option in all_options.items():
return send_yaml(player_name, formatted_options) if option.visibility & Options.Visibility.simple_ui:
visible.add(option_name)
if option.visibility & Options.Visibility.complex_ui:
visible_weighted.add(option_name)
if option_name in handled_in_js:
pass
# Player options pages elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
@app.route("/games/<string:game>/player-options") game_options[option_name] = this_option = {
@cache.cached() "type": "select",
def player_options(game: str): "displayName": option.display_name if hasattr(option, "display_name") else option_name,
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False) "description": get_html_doc(option),
"defaultValue": None,
"options": []
}
for sub_option_id, sub_option_name in option.name_lookup.items():
if sub_option_name != "random":
this_option["options"].append({
"name": option.get_option_name(sub_option_id),
"value": sub_option_name,
})
if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name
if not this_option["defaultValue"]:
this_option["defaultValue"] = "random"
elif issubclass(option, Options.Range):
game_options[option_name] = {
"type": "range",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": option.default if hasattr(
option, "default") and option.default != "random" else option.range_start,
"min": option.range_start,
"max": option.range_end,
}
if issubclass(option, Options.NamedRange):
game_options[option_name]["type"] = 'named_range'
game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items():
game_options[option_name]["value_names"][key] = val
elif issubclass(option, Options.ItemSet):
game_options[option_name] = {
"type": "items-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": list(option.default)
}
elif issubclass(option, Options.LocationSet):
game_options[option_name] = {
"type": "locations-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": list(option.default)
}
elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict):
if option.valid_keys:
game_options[option_name] = {
"type": "custom-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"options": list(option.valid_keys),
"defaultValue": list(option.default) if hasattr(option, "default") else []
}
# YAML generator for player-options
@app.route("/games/<string:game>/generate-yaml", methods=["POST"])
def generate_yaml(game: str):
if request.method == "POST":
options = {}
intent_generate = False
for key, val in request.form.items(multi=True):
if key in options:
if not isinstance(options[key], list):
options[key] = [options[key]]
options[key].append(val)
else: else:
options[key] = val logging.debug(f"{option} not exported to Web Options.")
for key, val in options.copy().items(): player_options["presetOptions"] = {}
key_parts = key.rsplit("||", 2) for preset_name, preset in world.web.options_presets.items():
# Detect and build ItemDict options from their name pattern player_options["presetOptions"][preset_name] = {}
if key_parts[-1] == "qty": for option_name, option_value in preset.items():
if key_parts[0] not in options: # Random range type settings are not valid.
options[key_parts[0]] = {} assert (not str(option_value).startswith("random-")), \
if val != "0": f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. Special random " \
options[key_parts[0]][key_parts[1]] = int(val) f"values are not supported for presets."
del options[key]
# Detect keys which end with -custom, indicating a TextChoice with a possible custom value # Normal random is supported, but needs to be handled explicitly.
elif key_parts[-1].endswith("-custom"): if option_value == "random":
if val: player_options["presetOptions"][preset_name][option_name] = option_value
options[key_parts[-1][:-7]] = val continue
del options[key] option = world.options_dataclass.type_hints[option_name].from_any(option_value)
if isinstance(option, Options.NamedRange) and isinstance(option_value, str):
assert option_value in option.special_range_names, \
f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. " \
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
# Detect keys which end with -range, indicating a NamedRange with a possible custom value # Still use the true value for the option, not the name.
elif key_parts[-1].endswith("-range"): player_options["presetOptions"][preset_name][option_name] = option.value
if options[key_parts[-1][:-6]] == "custom": elif isinstance(option, Options.Range):
options[key_parts[-1][:-6]] = val player_options["presetOptions"][preset_name][option_name] = option.value
elif isinstance(option_value, str):
# For Choice and Toggle options, the value should be the name of the option. This is to prevent
# setting a preset for an option with an overridden from_text method that would normally be okay,
# but would not be okay for the webhost's current implementation of player options UI.
assert option.name_lookup[option.value] == option_value, \
f"Invalid option value '{option_value}' for '{option_name}' in preset '{preset_name}'. " \
f"Values must not be resolved to a different option via option.from_text (or an alias)."
player_options["presetOptions"][preset_name][option_name] = option.current_key
else:
# int and bool values are fine, just resolve them to the current key for webhost.
player_options["presetOptions"][preset_name][option_name] = option.current_key
del options[key] os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True)
# Detect random-* keys and set their options accordingly filtered_player_options = player_options
for key, val in options.copy().items(): filtered_player_options["gameOptions"] = {
if key.startswith("random-"): option_name: option_data for option_name, option_data in game_options.items()
options[key.removeprefix("random-")] = "random" if option_name in visible
del options[key]
# Error checking
if not options["name"]:
return "Player name is required."
# Remove POST data irrelevant to YAML
preset_name = 'default'
if "intent-generate" in options:
intent_generate = True
del options["intent-generate"]
if "intent-export" in options:
del options["intent-export"]
if "game-options-preset" in options:
preset_name = options["game-options-preset"]
del options["game-options-preset"]
# Properly format YAML output
player_name = options["name"]
del options["name"]
description = f"Generated by https://archipelago.gg/ for {game}"
if preset_name != 'default' and preset_name != 'custom':
description += f" using {preset_name} preset"
formatted_options = {
"name": player_name,
"game": game,
"description": description,
game: options,
} }
if intent_generate: with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f:
return generate_game({player_name: formatted_options}) json.dump(filtered_player_options, f, indent=2, separators=(',', ': '))
filtered_player_options["gameOptions"] = {
option_name: option_data for option_name, option_data in game_options.items()
if option_name in visible_weighted
}
if not world.hidden and world.web.options_page is True:
# Add the random option to Choice, TextChoice, and Toggle options
for option in filtered_player_options["gameOptions"].values():
if option["type"] == "select":
option["options"].append({"name": "Random", "value": "random"})
if not option["defaultValue"]:
option["defaultValue"] = "random"
weighted_options["baseOptions"]["game"][game_name] = 0
weighted_options["games"][game_name] = {
"gameSettings": filtered_player_options["gameOptions"],
"gameItems": tuple(world.item_names),
"gameItemGroups": [
group for group in world.item_name_groups.keys() if group != "Everything"
],
"gameItemDescriptions": world.item_descriptions,
"gameLocations": tuple(world.location_names),
"gameLocationGroups": [
group for group in world.location_name_groups.keys() if group != "Everywhere"
],
"gameLocationDescriptions": world.location_descriptions,
}
with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f:
json.dump(weighted_options, f, indent=2, separators=(',', ': '))
else:
return send_yaml(player_name, formatted_options)

View File

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

View File

@@ -8,8 +8,7 @@ from . import cache
def robots(): def robots():
# If this host is not official, do not allow search engine crawling # If this host is not official, do not allow search engine crawling
if not app.config["ASSET_RIGHTS"]: 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.txt')
return app.send_static_file('robots_file.txt')
# Send 404 if the host has affirmed this to be the official WebHost # Send 404 if the host has affirmed this to be the official WebHost
abort(404) abort(404)

View File

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

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: You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs). [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions, 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

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

View File

@@ -0,0 +1,523 @@
let gameName = null;
window.addEventListener('load', () => {
gameName = document.getElementById('player-options').getAttribute('data-game');
// Update game name on page
document.getElementById('game-name').innerText = gameName;
fetchOptionData().then((results) => {
let optionHash = localStorage.getItem(`${gameName}-hash`);
if (!optionHash) {
// If no hash data has been set before, set it now
optionHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, optionHash);
localStorage.removeItem(gameName);
}
if (optionHash !== md5(JSON.stringify(results))) {
showUserMessage(
'Your options are out of date! Click here to update them! Be aware this will reset them all to default.'
);
document.getElementById('user-message').addEventListener('click', resetOptions);
}
// Page setup
createDefaultOptions(results);
buildUI(results);
adjustHeaderWidth();
// Event listeners
document.getElementById('export-options').addEventListener('click', () => exportOptions());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerOptions = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseOption(event));
nameInput.value = playerOptions.name;
// Presets
const presetSelect = document.getElementById('game-options-preset');
presetSelect.addEventListener('change', (event) => setPresets(results, event.target.value));
for (const preset in results['presetOptions']) {
const presetOption = document.createElement('option');
presetOption.innerText = preset;
presetSelect.appendChild(presetOption);
}
presetSelect.value = localStorage.getItem(`${gameName}-preset`);
results['presetOptions']['__default'] = {};
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
});
const resetOptions = () => {
localStorage.removeItem(gameName);
localStorage.removeItem(`${gameName}-hash`);
localStorage.removeItem(`${gameName}-preset`);
window.location.reload();
};
const fetchOptionData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject(ajax.responseText);
return;
}
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/generated/player-options/${gameName}.json`, true);
ajax.send();
});
const createDefaultOptions = (optionData) => {
if (!localStorage.getItem(gameName)) {
const newOptions = {
[gameName]: {},
};
for (let baseOption of Object.keys(optionData.baseOptions)){
newOptions[baseOption] = optionData.baseOptions[baseOption];
}
for (let gameOption of Object.keys(optionData.gameOptions)){
newOptions[gameName][gameOption] = optionData.gameOptions[gameOption].defaultValue;
}
localStorage.setItem(gameName, JSON.stringify(newOptions));
}
if (!localStorage.getItem(`${gameName}-preset`)) {
localStorage.setItem(`${gameName}-preset`, '__default');
}
};
const buildUI = (optionData) => {
// Game Options
const leftGameOpts = {};
const rightGameOpts = {};
Object.keys(optionData.gameOptions).forEach((key, index) => {
if (index < Object.keys(optionData.gameOptions).length / 2) {
leftGameOpts[key] = optionData.gameOptions[key];
} else {
rightGameOpts[key] = optionData.gameOptions[key];
}
});
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
};
const buildOptionsTable = (options, romOpts = false) => {
const currentOptions = JSON.parse(localStorage.getItem(gameName));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(options).forEach((option) => {
const tr = document.createElement('tr');
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.textContent = `${options[option].displayName}: `;
label.setAttribute('for', option);
const questionSpan = document.createElement('span');
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', options[option].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label);
tr.appendChild(tdl);
// td Right
const tdr = document.createElement('td');
let element = null;
const randomButton = document.createElement('button');
switch(options[option].type) {
case 'select':
element = document.createElement('div');
element.classList.add('select-container');
let select = document.createElement('select');
select.setAttribute('id', option);
select.setAttribute('data-key', option);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
options[option].options.forEach((opt) => {
const optionElement = document.createElement('option');
optionElement.setAttribute('value', opt.value);
optionElement.innerText = opt.name;
if ((isNaN(currentOptions[gameName][option]) &&
(parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) ||
(opt.value === currentOptions[gameName][option]))
{
optionElement.selected = true;
}
select.appendChild(optionElement);
});
select.addEventListener('change', (event) => updateGameOption(event.target));
element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
element.appendChild(randomButton);
break;
case 'range':
element = document.createElement('div');
element.classList.add('range-container');
let range = document.createElement('input');
range.setAttribute('id', option);
range.setAttribute('type', 'range');
range.setAttribute('data-key', option);
range.setAttribute('min', options[option].min);
range.setAttribute('max', options[option].max);
range.value = currentOptions[gameName][option];
range.addEventListener('change', (event) => {
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
element.appendChild(range);
let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${option}-value`);
rangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
currentOptions[gameName][option] : options[option].defaultValue;
element.appendChild(rangeVal);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
element.appendChild(randomButton);
break;
case 'named_range':
element = document.createElement('div');
element.classList.add('named-range-container');
// Build the select element
let namedRangeSelect = document.createElement('select');
namedRangeSelect.setAttribute('data-key', option);
Object.keys(options[option].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = options[option].value_names[presetName];
const words = presetOption.innerText.split('_');
for (let i = 0; i < words.length; i++) {
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
}
presetOption.innerText = words.join(' ');
namedRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
customOption.value = 'custom';
customOption.selected = true;
namedRangeSelect.appendChild(customOption);
if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) {
namedRangeSelect.value = Number(currentOptions[gameName][option]);
}
// Build range element
let namedRangeWrapper = document.createElement('div');
namedRangeWrapper.classList.add('named-range-wrapper');
let namedRange = document.createElement('input');
namedRange.setAttribute('type', 'range');
namedRange.setAttribute('data-key', option);
namedRange.setAttribute('min', options[option].min);
namedRange.setAttribute('max', options[option].max);
namedRange.value = currentOptions[gameName][option];
// Build rage value element
let namedRangeVal = document.createElement('span');
namedRangeVal.classList.add('range-value');
namedRangeVal.setAttribute('id', `${option}-value`);
namedRangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
currentOptions[gameName][option] : options[option].defaultValue;
// Configure select event listener
namedRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
namedRange.value = event.target.value;
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
// Configure range event handler
namedRange.addEventListener('change', (event) => {
// Update select element
namedRangeSelect.value =
(Object.values(options[option].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
element.appendChild(namedRangeSelect);
namedRangeWrapper.appendChild(namedRange);
namedRangeWrapper.appendChild(namedRangeVal);
element.appendChild(namedRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, namedRange, namedRangeSelect)
);
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
namedRange.disabled = true;
namedRangeSelect.disabled = true;
}
namedRangeWrapper.appendChild(randomButton);
break;
default:
console.error(`Ignoring unknown option type: ${options[option].type} with name ${option}`);
return;
}
tdr.appendChild(element);
tr.appendChild(tdr);
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
};
const setPresets = (optionsData, presetName) => {
const defaults = optionsData['gameOptions'];
const preset = optionsData['presetOptions'][presetName];
localStorage.setItem(`${gameName}-preset`, presetName);
if (!preset) {
console.error(`No presets defined for preset name: '${presetName}'`);
return;
}
const updateOptionElement = (option, presetValue) => {
const optionElement = document.querySelector(`#${option}[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
if (presetValue === 'random') {
randomElement.classList.add('active');
optionElement.disabled = true;
updateGameOption(randomElement, false);
} else {
optionElement.value = presetValue;
randomElement.classList.remove('active');
optionElement.disabled = undefined;
updateGameOption(optionElement, false);
}
};
for (const option in defaults) {
let presetValue = preset[option];
if (presetValue === undefined) {
// Using the default value if not set in presets.
presetValue = defaults[option]['defaultValue'];
}
switch (defaults[option].type) {
case 'range':
const numberElement = document.querySelector(`#${option}-value`);
if (presetValue === 'random') {
numberElement.innerText = defaults[option]['defaultValue'] === 'random'
? defaults[option]['min'] // A fallback so we don't print 'random' in the UI.
: defaults[option]['defaultValue'];
} else {
numberElement.innerText = presetValue;
}
updateOptionElement(option, presetValue);
break;
case 'select': {
updateOptionElement(option, presetValue);
break;
}
case 'named_range': {
const selectElement = document.querySelector(`select[data-key='${option}']`);
const rangeElement = document.querySelector(`input[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
if (presetValue === 'random') {
randomElement.classList.add('active');
selectElement.disabled = true;
rangeElement.disabled = true;
updateGameOption(randomElement, false);
} else {
rangeElement.value = presetValue;
selectElement.value = Object.values(defaults[option]['value_names']).includes(parseInt(presetValue)) ?
parseInt(presetValue) : 'custom';
document.getElementById(`${option}-value`).innerText = presetValue;
randomElement.classList.remove('active');
selectElement.disabled = undefined;
rangeElement.disabled = undefined;
updateGameOption(rangeElement, false);
}
break;
}
default:
console.warn(`Ignoring preset value for unknown option type: ${defaults[option].type} with name ${option}`);
break;
}
}
};
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
inputElement.disabled = undefined;
if (optionalSelectElement) {
optionalSelectElement.disabled = undefined;
}
} else {
randomButton.classList.add('active');
inputElement.disabled = true;
if (optionalSelectElement) {
optionalSelectElement.disabled = true;
}
}
updateGameOption(active ? inputElement : randomButton);
};
const updateBaseOption = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value);
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameOption = (optionElement, toggleCustomPreset = true) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (toggleCustomPreset) {
localStorage.setItem(`${gameName}-preset`, '__custom');
const presetElement = document.getElementById('game-options-preset');
presetElement.value = '__custom';
}
if (optionElement.classList.contains('randomize-button')) {
// If the event passed in is the randomize button, then we know what we must do.
options[gameName][optionElement.getAttribute('data-key')] = 'random';
} else {
options[gameName][optionElement.getAttribute('data-key')] = isNaN(optionElement.value) ?
optionElement.value : parseInt(optionElement.value, 10);
}
localStorage.setItem(gameName, JSON.stringify(options));
};
const exportOptions = () => {
const options = JSON.parse(localStorage.getItem(gameName));
const preset = localStorage.getItem(`${gameName}-preset`);
switch (preset) {
case '__default':
options['description'] = `Generated by https://archipelago.gg with the default preset.`;
break;
case '__custom':
options['description'] = `Generated by https://archipelago.gg.`;
break;
default:
options['description'] = `Generated by https://archipelago.gg with the ${preset} preset.`;
}
if (!options.name || options.name.toString().trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const generateGame = (raceMode = false) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
axios.post('/api/generate', {
weights: { player: options },
presetData: { player: options },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
let userMessage = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage += ' ' + error.response.data.text;
}
showUserMessage(userMessage);
console.error(error);
});
};
const showUserMessage = (message) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = message;
userMessage.classList.add('visible');
window.scrollTo(0, 0);
userMessage.addEventListener('click', () => {
userMessage.classList.remove('visible');
userMessage.addEventListener('click', hideUserMessage);
});
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.classList.remove('visible');
userMessage.removeEventListener('click', hideUserMessage);
};

View File

@@ -1,340 +0,0 @@
let presets = {};
window.addEventListener('load', async () => {
// Load settings from localStorage, if available
loadSettings();
// Fetch presets if available
await fetchPresets();
// Handle changes to range inputs
document.querySelectorAll('input[type=range]').forEach((range) => {
const optionName = range.getAttribute('id');
range.addEventListener('change', () => {
document.getElementById(`${optionName}-value`).innerText = range.value;
// Handle updating named range selects to "custom" if appropriate
const select = document.querySelector(`select[data-option-name=${optionName}]`);
if (select) {
let updated = false;
select?.childNodes.forEach((option) => {
if (option.value === range.value) {
select.value = range.value;
updated = true;
}
});
if (!updated) {
select.value = 'custom';
}
}
});
});
// Handle changes to named range selects
document.querySelectorAll('.named-range-container select').forEach((select) => {
const optionName = select.getAttribute('data-option-name');
select.addEventListener('change', (evt) => {
document.getElementById(optionName).value = evt.target.value;
document.getElementById(`${optionName}-value`).innerText = evt.target.value;
});
});
// Handle changes to randomize checkboxes
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
const optionName = checkbox.getAttribute('data-option-name');
checkbox.addEventListener('change', () => {
const optionInput = document.getElementById(optionName);
const namedRangeSelect = document.querySelector(`select[data-option-name=${optionName}]`);
const customInput = document.getElementById(`${optionName}-custom`);
if (checkbox.checked) {
optionInput.setAttribute('disabled', '1');
namedRangeSelect?.setAttribute('disabled', '1');
if (customInput) {
customInput.setAttribute('disabled', '1');
}
} else {
optionInput.removeAttribute('disabled');
namedRangeSelect?.removeAttribute('disabled');
if (customInput) {
customInput.removeAttribute('disabled');
}
}
});
});
// Handle changes to TextChoice input[type=text]
document.querySelectorAll('.text-choice-container input[type=text]').forEach((input) => {
const optionName = input.getAttribute('data-option-name');
input.addEventListener('input', () => {
const select = document.getElementById(optionName);
const optionValues = [];
select.childNodes.forEach((option) => optionValues.push(option.value));
select.value = (optionValues.includes(input.value)) ? input.value : 'custom';
});
});
// Handle changes to TextChoice select
document.querySelectorAll('.text-choice-container select').forEach((select) => {
const optionName = select.getAttribute('id');
select.addEventListener('change', () => {
document.getElementById(`${optionName}-custom`).value = '';
});
});
// Update the "Option Preset" select to read "custom" when changes are made to relevant inputs
const presetSelect = document.getElementById('game-options-preset');
document.querySelectorAll('input, select').forEach((input) => {
if ( // Ignore inputs which have no effect on yaml generation
(input.id === 'player-name') ||
(input.id === 'game-options-preset') ||
(input.classList.contains('group-toggle')) ||
(input.type === 'submit')
) {
return;
}
input.addEventListener('change', () => {
presetSelect.value = 'custom';
});
});
// Handle changes to presets select
document.getElementById('game-options-preset').addEventListener('change', choosePreset);
// Save settings to localStorage when form is submitted
document.getElementById('options-form').addEventListener('submit', (evt) => {
const playerName = document.getElementById('player-name');
if (!playerName.value.trim()) {
evt.preventDefault();
window.scrollTo(0, 0);
showUserMessage('You must enter a player name!');
}
saveSettings();
});
});
// Save all settings to localStorage
const saveSettings = () => {
const options = {
inputs: {},
checkboxes: {},
};
document.querySelectorAll('input, select').forEach((input) => {
if (input.type === 'submit') {
// Ignore submit inputs
}
else if (input.type === 'checkbox') {
options.checkboxes[input.id] = input.checked;
}
else {
options.inputs[input.id] = input.value
}
});
const game = document.getElementById('player-options').getAttribute('data-game');
localStorage.setItem(game, JSON.stringify(options));
};
// Load all options from localStorage
const loadSettings = () => {
const game = document.getElementById('player-options').getAttribute('data-game');
const options = JSON.parse(localStorage.getItem(game));
if (options) {
if (!options.inputs || !options.checkboxes) {
localStorage.removeItem(game);
return;
}
// Restore value-based inputs and selects
Object.keys(options.inputs).forEach((key) => {
try{
document.getElementById(key).value = options.inputs[key];
const rangeValue = document.getElementById(`${key}-value`);
if (rangeValue) {
rangeValue.innerText = options.inputs[key];
}
} catch (err) {
console.error(`Unable to restore value to input with id ${key}`);
}
});
// Restore checkboxes
Object.keys(options.checkboxes).forEach((key) => {
try{
if (options.checkboxes[key]) {
document.getElementById(key).setAttribute('checked', '1');
}
} catch (err) {
console.error(`Unable to restore value to input with id ${key}`);
}
});
}
// Ensure any input for which the randomize checkbox is checked by default, the relevant inputs are disabled
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
const optionName = checkbox.getAttribute('data-option-name');
if (checkbox.checked) {
const input = document.getElementById(optionName);
if (input) {
input.setAttribute('disabled', '1');
}
const customInput = document.getElementById(`${optionName}-custom`);
if (customInput) {
customInput.setAttribute('disabled', '1');
}
}
});
};
/**
* Fetch the preset data for this game and apply the presets if localStorage indicates one was previously chosen
* @returns {Promise<void>}
*/
const fetchPresets = async () => {
const response = await fetch('option-presets');
presets = await response.json();
const presetSelect = document.getElementById('game-options-preset');
presetSelect.removeAttribute('disabled');
const game = document.getElementById('player-options').getAttribute('data-game');
const presetToApply = localStorage.getItem(`${game}-preset`);
const playerName = localStorage.getItem(`${game}-player`);
if (presetToApply) {
localStorage.removeItem(`${game}-preset`);
presetSelect.value = presetToApply;
applyPresets(presetToApply);
}
if (playerName) {
document.getElementById('player-name').value = playerName;
localStorage.removeItem(`${game}-player`);
}
};
/**
* Clear the localStorage for this game and set a preset to be loaded upon page reload
* @param evt
*/
const choosePreset = (evt) => {
if (evt.target.value === 'custom') { return; }
const game = document.getElementById('player-options').getAttribute('data-game');
localStorage.removeItem(game);
localStorage.setItem(`${game}-player`, document.getElementById('player-name').value);
if (evt.target.value !== 'default') {
localStorage.setItem(`${game}-preset`, evt.target.value);
}
document.querySelectorAll('#options-form input, #options-form select').forEach((input) => {
if (input.id === 'player-name') { return; }
input.removeAttribute('value');
});
window.location.replace(window.location.href);
};
const applyPresets = (presetName) => {
// Ignore the "default" preset, because it gets set automatically by Jinja
if (presetName === 'default') {
saveSettings();
return;
}
if (!presets[presetName]) {
console.error(`Unknown preset ${presetName} chosen`);
return;
}
const preset = presets[presetName];
Object.keys(preset).forEach((optionName) => {
const optionValue = preset[optionName];
// Handle List and Set options
if (Array.isArray(optionValue)) {
document.querySelectorAll(`input[type=checkbox][name=${optionName}]`).forEach((checkbox) => {
if (optionValue.includes(checkbox.value)) {
checkbox.setAttribute('checked', '1');
} else {
checkbox.removeAttribute('checked');
}
});
return;
}
// Handle Dict options
if (typeof(optionValue) === 'object' && optionValue !== null) {
const itemNames = Object.keys(optionValue);
document.querySelectorAll(`input[type=number][data-option-name=${optionName}]`).forEach((input) => {
const itemName = input.getAttribute('data-item-name');
input.value = (itemNames.includes(itemName)) ? optionValue[itemName] : 0
});
return;
}
// Identify all possible elements
const normalInput = document.getElementById(optionName);
const customInput = document.getElementById(`${optionName}-custom`);
const rangeValue = document.getElementById(`${optionName}-value`);
const randomizeInput = document.getElementById(`random-${optionName}`);
const namedRangeSelect = document.getElementById(`${optionName}-select`);
// It is possible for named ranges to use name of a value rather than the value itself. This is accounted for here
let trueValue = optionValue;
if (namedRangeSelect) {
namedRangeSelect.querySelectorAll('option').forEach((opt) => {
if (opt.innerText.startsWith(optionValue)) {
trueValue = opt.value;
}
});
namedRangeSelect.value = trueValue;
// It is also possible for a preset to use an unnamed value. If this happens, set the dropdown to "Custom"
if (namedRangeSelect.selectedIndex == -1)
{
namedRangeSelect.value = "custom";
}
}
// Handle options whose presets are "random"
if (optionValue === 'random') {
normalInput.setAttribute('disabled', '1');
randomizeInput.setAttribute('checked', '1');
if (customInput) {
customInput.setAttribute('disabled', '1');
}
if (rangeValue) {
rangeValue.innerText = normalInput.value;
}
if (namedRangeSelect) {
namedRangeSelect.setAttribute('disabled', '1');
}
return;
}
// Handle normal (text, number, select, etc.) and custom inputs (custom inputs exist with TextChoice only)
normalInput.value = trueValue;
normalInput.removeAttribute('disabled');
randomizeInput.removeAttribute('checked');
if (customInput) {
document.getElementById(`${optionName}-custom`).removeAttribute('disabled');
}
if (rangeValue) {
rangeValue.innerText = trueValue;
}
});
saveSettings();
};
const showUserMessage = (text) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = text;
userMessage.addEventListener('click', hideUserMessage);
userMessage.style.display = 'block';
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.removeEventListener('click', hideUserMessage);
userMessage.style.display = 'none';
};

View File

@@ -1,16 +1,18 @@
window.addEventListener('load', () => { window.addEventListener('load', () => {
// Add toggle listener to all elements with .collapse-toggle // Add toggle listener to all elements with .collapse-toggle
const toggleButtons = document.querySelectorAll('details'); const toggleButtons = document.querySelectorAll('.collapse-toggle');
toggleButtons.forEach((e) => e.addEventListener('click', toggleCollapse));
// Handle game filter input // Handle game filter input
const gameSearch = document.getElementById('game-search'); const gameSearch = document.getElementById('game-search');
gameSearch.value = ''; gameSearch.value = '';
gameSearch.addEventListener('input', (evt) => { gameSearch.addEventListener('input', (evt) => {
if (!evt.target.value.trim()) { if (!evt.target.value.trim()) {
// If input is empty, display all games as collapsed // If input is empty, display all collapsed games
return toggleButtons.forEach((header) => { return toggleButtons.forEach((header) => {
header.style.display = null; header.style.display = null;
header.removeAttribute('open'); header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
}); });
} }
@@ -19,10 +21,12 @@ window.addEventListener('load', () => {
// If the game name includes the search string, display the game. If not, hide it // If the game name includes the search string, display the game. If not, hide it
if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) { if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
header.style.display = null; header.style.display = null;
header.setAttribute('open', '1'); header.firstElementChild.innerText = '▼';
header.nextElementSibling.classList.remove('collapsed');
} else { } else {
header.style.display = 'none'; header.style.display = 'none';
header.removeAttribute('open'); header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
} }
}); });
}); });
@@ -31,14 +35,30 @@ window.addEventListener('load', () => {
document.getElementById('collapse-all').addEventListener('click', collapseAll); document.getElementById('collapse-all').addEventListener('click', collapseAll);
}); });
const toggleCollapse = (evt) => {
const gameArrow = evt.target.firstElementChild;
const gameInfo = evt.target.nextElementSibling;
if (gameInfo.classList.contains('collapsed')) {
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
} else {
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
}
};
const expandAll = () => { const expandAll = () => {
document.querySelectorAll('details').forEach((detail) => { document.querySelectorAll('.collapse-toggle').forEach((header) => {
detail.setAttribute('open', '1'); if (header.style.display === 'none') { return; }
header.firstElementChild.innerText = '▼';
header.nextElementSibling.classList.remove('collapsed');
}); });
}; };
const collapseAll = () => { const collapseAll = () => {
document.querySelectorAll('details').forEach((detail) => { document.querySelectorAll('.collapse-toggle').forEach((header) => {
detail.removeAttribute('open'); if (header.style.display === 'none') { return; }
header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
}); });
}; };

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,223 +0,0 @@
let deletedOptions = {};
window.addEventListener('load', () => {
const worldName = document.querySelector('#weighted-options').getAttribute('data-game');
// Generic change listener. Detecting unique qualities and acting on them here reduces initial JS initialisation time
// and handles dynamically created elements
document.addEventListener('change', (evt) => {
// Handle updates to range inputs
if (evt.target.type === 'range') {
// Update span containing range value. All ranges have a corresponding `{rangeId}-value` span
document.getElementById(`${evt.target.id}-value`).innerText = evt.target.value;
// If the changed option was the name of a game, determine whether to show or hide that game's div
if (evt.target.id.startsWith('game||')) {
const gameName = evt.target.id.split('||')[1];
const gameDiv = document.getElementById(`${gameName}-container`);
if (evt.target.value > 0) {
gameDiv.classList.remove('hidden');
} else {
gameDiv.classList.add('hidden');
}
}
}
});
// Generic click listener
document.addEventListener('click', (evt) => {
// Handle creating new rows for Range options
if (evt.target.classList.contains('add-range-option-button')) {
const optionName = evt.target.getAttribute('data-option');
addRangeRow(optionName);
}
// Handle deleting range rows
if (evt.target.classList.contains('range-option-delete')) {
const targetRow = document.querySelector(`tr[data-row="${evt.target.getAttribute('data-target')}"]`);
setDeletedOption(
targetRow.getAttribute('data-option-name'),
targetRow.getAttribute('data-value'),
);
targetRow.parentElement.removeChild(targetRow);
}
});
// Listen for enter presses on inputs intended to add range rows
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
}
if (evt.key === 'Enter' && evt.target.classList.contains('range-option-value')) {
const optionName = evt.target.getAttribute('data-option');
addRangeRow(optionName);
}
});
// Detect form submission
document.getElementById('weighted-options-form').addEventListener('submit', (evt) => {
// Save data to localStorage
const weightedOptions = {};
document.querySelectorAll('input[name]').forEach((input) => {
const keys = input.getAttribute('name').split('||');
// Determine keys
const optionName = keys[0] ?? null;
const subOption = keys[1] ?? null;
// Ensure keys exist
if (!weightedOptions[optionName]) { weightedOptions[optionName] = {}; }
if (subOption && !weightedOptions[optionName][subOption]) {
weightedOptions[optionName][subOption] = null;
}
if (subOption) { return weightedOptions[optionName][subOption] = determineValue(input); }
if (optionName) { return weightedOptions[optionName] = determineValue(input); }
});
localStorage.setItem(`${worldName}-weights`, JSON.stringify(weightedOptions));
localStorage.setItem(`${worldName}-deletedOptions`, JSON.stringify(deletedOptions));
});
// Remove all deleted values as specified by localStorage
deletedOptions = JSON.parse(localStorage.getItem(`${worldName}-deletedOptions`) || '{}');
Object.keys(deletedOptions).forEach((optionName) => {
deletedOptions[optionName].forEach((value) => {
const targetRow = document.querySelector(`tr[data-row="${value}-row"]`);
targetRow.parentElement.removeChild(targetRow);
});
});
// Populate all settings from localStorage on page initialisation
const previousSettingsJson = localStorage.getItem(`${worldName}-weights`);
if (previousSettingsJson) {
const previousSettings = JSON.parse(previousSettingsJson);
Object.keys(previousSettings).forEach((option) => {
if (typeof previousSettings[option] === 'string') {
return document.querySelector(`input[name="${option}"]`).value = previousSettings[option];
}
Object.keys(previousSettings[option]).forEach((value) => {
const input = document.querySelector(`input[name="${option}||${value}"]`);
if (!input?.type) {
return console.error(`Unable to populate option with name ${option}||${value}.`);
}
switch (input.type) {
case 'checkbox':
input.checked = (parseInt(previousSettings[option][value], 10) === 1);
break;
case 'range':
input.value = parseInt(previousSettings[option][value], 10);
break;
case 'number':
input.value = previousSettings[option][value].toString();
break;
default:
console.error(`Found unsupported input type: ${input.type}`);
}
});
});
}
});
const addRangeRow = (optionName) => {
const inputQuery = `input[type=number][data-option="${optionName}"].range-option-value`;
const inputTarget = document.querySelector(inputQuery);
const newValue = inputTarget.value;
if (!/^-?\d+$/.test(newValue)) {
alert('Range values must be a positive or negative integer!');
return;
}
inputTarget.value = '';
const tBody = document.querySelector(`table[data-option="${optionName}"].range-rows tbody`);
const tr = document.createElement('tr');
tr.setAttribute('data-row', `${optionName}-${newValue}-row`);
tr.setAttribute('data-option-name', optionName);
tr.setAttribute('data-value', newValue);
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
const label = document.createElement('label');
label.setAttribute('for', `${optionName}||${newValue}`);
label.innerText = newValue.toString();
tdLeft.appendChild(label);
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('min', '0');
range.setAttribute('max', '50');
range.setAttribute('value', '0');
range.setAttribute('id', `${optionName}||${newValue}`);
range.setAttribute('name', `${optionName}||${newValue}`);
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.classList.add('td-right');
const valueSpan = document.createElement('span');
valueSpan.setAttribute('id', `${optionName}||${newValue}-value`);
valueSpan.innerText = '0';
tdRight.appendChild(valueSpan);
tr.appendChild(tdRight);
const tdDelete = document.createElement('td');
const deleteSpan = document.createElement('span');
deleteSpan.classList.add('range-option-delete');
deleteSpan.classList.add('js-required');
deleteSpan.setAttribute('data-target', `${optionName}-${newValue}-row`);
deleteSpan.innerText = '❌';
tdDelete.appendChild(deleteSpan);
tr.appendChild(tdDelete);
tBody.appendChild(tr);
// Remove this option from the set of deleted options if it exists
unsetDeletedOption(optionName, newValue);
};
/**
* Determines the value of an input element, or returns a 1 or 0 if the element is a checkbox
*
* @param {object} input - The input element.
* @returns {number} The value of the input element.
*/
const determineValue = (input) => {
switch (input.type) {
case 'checkbox':
return (input.checked ? 1 : 0);
case 'range':
return parseInt(input.value, 10);
default:
return input.value;
}
};
/**
* Sets the deleted option value for a given world and option name.
* If the world or option does not exist, it creates the necessary entries.
*
* @param {string} optionName - The name of the option.
* @param {*} value - The value to be set for the deleted option.
* @returns {void}
*/
const setDeletedOption = (optionName, value) => {
deletedOptions[optionName] = deletedOptions[optionName] || [];
deletedOptions[optionName].push(`${optionName}-${value}`);
};
/**
* Removes a specific value from the deletedOptions object.
*
* @param {string} optionName - The name of the option.
* @param {*} value - The value to be removed
* @returns {void}
*/
const unsetDeletedOption = (optionName, value) => {
if (!deletedOptions.hasOwnProperty(optionName)) { return; }
if (deletedOptions[optionName].includes(`${optionName}-${value}`)) {
deletedOptions[optionName].splice(deletedOptions[optionName].indexOf(`${optionName}-${value}`), 1);
}
if (deletedOptions[optionName].length === 0) {
delete deletedOptions[optionName];
}
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 229 KiB

View File

@@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 240 38" style="enable-background:new 0 0 240 38;" xml:space="preserve">
<style type="text/css">
.st0{fill:#316B84;}
</style>
<g>
<g>
<path class="st0" d="M59.72,27.96L53.03,4.21L42.25,4.04l1.42,4.37l1.41-0.26l-7.9,24.22h8.44l-0.56-2.27l-0.81-3.27l8.9-5.7
l1.78,11.24h7.97v-4.73L59.72,27.96z M45.62,20.21l3.13-10.84h1.5l2.02,7.44L45.62,20.21z"/>
<path class="st0" d="M78.67,27.96V20.4l-4.11-2.5l3.29-3.78l-0.47-7.46l-2.82-2.45H56.65v5.27l3.81-1.11l2.31,13.36l-2.79,0.73
L61,26.34l5.06-0.52l0.36-6.15l4.32,0.13l3.16,3.62v8.94l12.89,1.49v-5.34L78.67,27.96z M73.27,13.33l-2.18,1.45h-4.64l-0.42-6.57
h5.68l1.55,1.37V13.33z"/>
<polygon class="st0" points="84.65,4.21 93.01,4.21 95.75,6.46 96.26,10.9 92.23,12.43 91.77,9.74 88.97,8.28 85.86,9.82
83.88,15.02 85.51,20.94 88.49,22.38 91.99,20.59 91.99,18.59 96.26,16.96 95.85,22.85 91.81,26.87 84.17,26.55 80.79,23.58
78.87,14.87 80.79,6.94 "/>
<polygon class="st0" points="97.62,4.21 103.33,4.21 102.96,21.08 108.7,20.14 108.34,6.42 113.85,3.28 113.9,19.9 115.75,19.71
115.27,25.86 113.88,25.86 114.27,32.36 108.7,32.36 108.7,26.39 102.96,26.39 102.96,32.36 91.77,33.85 92.2,28.85 97.88,27.96
"/>
<polygon class="st0" points="147.43,28.86 147.43,32.36 162.85,32.36 162.48,25.36 159.5,26 158.89,27.68 154.1,27.24
154.1,21.51 160.81,20.85 160.81,16.48 153.86,16.54 153.86,9.18 158.62,8.43 159.22,9.77 161.85,10.06 162.59,4 147.43,4
147.43,6.54 148.68,7.46 148.68,28.4 "/>
<polygon class="st0" points="163.89,9.24 163.89,4 172.31,4 170.35,26.87 179.55,24.74 179.55,32.36 164.51,32.34 164.65,28.71
165.73,27.84 165.73,9.59 "/>
<path class="st0" d="M193.69,32.36l-0.63-2.51l-2.84-1.89l-4.29-20.14L185.9,4h-11.27l-0.03,3.2l1.87-0.34l-2.79,14.07l-1.37,0.57
v2.85l6.29-1.33l0.4-2.7l4.65-0.89l1.69,12.93H193.69z M179.39,15.11l1.65-6.52l0.89,0.25l0.92,5.45L179.39,15.11z"/>
<polygon class="st0" points="208.47,21.68 210.62,21.12 210.04,18.15 200.51,17.46 198.87,21.13 203.56,21.9 203.32,23.91
200.58,25.19 196.44,23.77 194.48,17.19 196.2,10.02 200.08,8.52 203.31,9.62 202.85,11.75 207.79,13.6 208.83,9.69 204.71,4.21
195.57,4.21 191.24,7.36 189.29,16.87 192.06,27.54 199.03,30.53 203.2,29.3 203.09,32.36 209.01,32.36 209.4,29.95 207.38,28.99
"/>
<path class="st0" d="M230.45,6.26L226.39,4l-8.59-0.01l-4.07,2.86l-2.58,8.9l1.52,11.82l5.61,4.73l7.65,0.01l5.72-4.59l2.47-12.46
L230.45,6.26z M228.23,21.75l-3.95,5.45l-2.16,0.43l-4.6-3.46L216,15.72l2.4-7.02l5.14-0.48l2.97,1.79l1.74,5.83L228.23,21.75z"/>
<path class="st0" d="M116.13,27.48l-0.24,4.88l12.26,0.09l-0.83-5.01l-2.86-0.48l0.14-17.62l2.45-0.42l-0.14-4.85l-10.92,0.36
l0.1,4.6l3.2,0.63l-0.42,17.67L116.13,27.48z"/>
<path class="st0" d="M141.34,4.21l-12.88-0.39v4.26l1.95,0.62v25.15l-1.8,1.41l-0.02,2.63h8.23L136,27.96h6.09l4.57-4.46V7.27
L141.34,4.21z M141.38,20.51l-2.54,1.89l-3.23,0.16L135.4,9.32h3.88l2.1,1.68V20.51z"/>
</g>
<g>
<path class="st0" d="M14.14,11.28c0,0.35-0.02,0.71-0.07,1.05c0.38,0.07,0.76,0.11,1.16,0.11s0.79-0.04,1.16-0.11
c-0.05-0.34-0.07-0.7-0.07-1.05c0-3.23,1.94-6.02,4.72-7.25C20.17,1.68,17.9,0,15.24,0S10.3,1.68,9.42,4.03
C12.2,5.26,14.14,8.04,14.14,11.28z"/>
<path class="st0" d="M18.04,11.28c0,0.16,0.01,0.32,0.02,0.48c0.02,0.3,0.06,0.6,0.13,0.88c0.06,0.28,0.15,0.56,0.25,0.83
c0.11,0.3,0.24,0.58,0.39,0.85c1.42-1.33,3.33-2.15,5.42-2.15s4.01,0.82,5.42,2.15c0.51-0.9,0.79-1.94,0.79-3.04
c0-3.42-2.79-6.22-6.22-6.22c-0.4,0-0.79,0.04-1.16,0.11c-0.28,0.06-0.56,0.13-0.83,0.22c-0.28,0.09-0.56,0.21-0.83,0.35
C19.42,6.77,18.04,8.87,18.04,11.28z"/>
<path class="st0" d="M6.22,12.16c2.1,0,4.01,0.82,5.42,2.15c0.15-0.27,0.28-0.55,0.39-0.85c0.1-0.27,0.19-0.54,0.25-0.83
c0.06-0.28,0.11-0.58,0.13-0.88c0.02-0.15,0.02-0.32,0.02-0.48c0-2.41-1.38-4.51-3.39-5.54C8.77,5.6,8.5,5.49,8.21,5.39
c-0.27-0.1-0.55-0.17-0.83-0.22C7,5.1,6.61,5.06,6.22,5.06C2.79,5.06,0,7.85,0,11.28c0,1.1,0.28,2.14,0.79,3.04
C2.21,12.98,4.12,12.16,6.22,12.16z"/>
<path class="st0" d="M29.21,16.33c-0.18-0.23-0.36-0.44-0.57-0.65c-1.12-1.12-2.67-1.81-4.38-1.81c-1.71,0-3.25,0.69-4.38,1.81
c-0.2,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72c-0.2,0.34-0.36,0.71-0.48,1.09c2.83,1.21,4.81,4.02,4.81,7.28
c0,0.26-0.01,0.52-0.04,0.78c0.37,0.07,0.75,0.1,1.13,0.1c3.43,0,6.22-2.79,6.22-6.22c0-1.11-0.29-2.14-0.8-3.04
C29.54,16.8,29.38,16.56,29.21,16.33z"/>
<path class="st0" d="M12.12,18.14c-0.13-0.38-0.28-0.75-0.48-1.09c-0.14-0.26-0.3-0.5-0.47-0.72c-0.17-0.23-0.36-0.44-0.56-0.64
c-1.12-1.12-2.67-1.81-4.38-1.81s-3.26,0.69-4.38,1.81c-0.21,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72
C0.29,17.94,0,18.98,0,20.08c0,3.43,2.79,6.22,6.22,6.22c0.39,0,0.76-0.03,1.13-0.1c-0.03-0.26-0.04-0.52-0.04-0.78
C7.31,22.15,9.29,19.34,12.12,18.14z"/>
<path class="st0" d="M18.04,19.87c-0.27-0.14-0.55-0.26-0.84-0.35c-0.27-0.09-0.55-0.17-0.84-0.22c-0.37-0.07-0.75-0.1-1.13-0.1
s-0.76,0.03-1.13,0.1c-0.28,0.05-0.57,0.13-0.84,0.22c-0.29,0.1-0.57,0.22-0.84,0.35C10.4,20.9,9.02,23,9.02,25.42
c0,0.07,0,0.14,0.01,0.21c0.01,0.31,0.04,0.61,0.1,0.9c0.05,0.28,0.12,0.57,0.21,0.84c0.82,2.48,3.16,4.27,5.9,4.27
s5.08-1.79,5.9-4.27c0.09-0.27,0.17-0.55,0.21-0.84c0.06-0.3,0.09-0.6,0.1-0.91c0.01-0.07,0.01-0.14,0.01-0.21
C21.45,23,20.07,20.9,18.04,19.87z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -1 +1,66 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 240 38" style="enable-background:new 0 0 240 38" xml:space="preserve"><style>.st0{fill:#316b84}</style><path class="st0" d="M59.72 27.96 53.03 4.21l-10.78-.17 1.42 4.37 1.41-.26-7.9 24.22h8.44l-.56-2.27-.81-3.27 8.9-5.7 1.78 11.24h7.97v-4.73l-3.18.32zm-14.1-7.75 3.13-10.84h1.5l2.02 7.44-6.65 3.4z"/><path class="st0" d="M78.67 27.96V20.4l-4.11-2.5 3.29-3.78-.47-7.46-2.82-2.45H56.65v5.27l3.81-1.11 2.31 13.36-2.79.73L61 26.34l5.06-.52.36-6.15 4.32.13 3.16 3.62v8.94l12.89 1.49v-5.34l-8.12-.55zm-5.4-14.63-2.18 1.45h-4.64l-.42-6.57h5.68l1.55 1.37v3.75z"/><path class="st0" d="M84.65 4.21h8.36l2.74 2.25.51 4.44-4.03 1.53-.46-2.69-2.8-1.46-3.11 1.54-1.98 5.2 1.63 5.92 2.98 1.44 3.5-1.79v-2l4.27-1.63-.41 5.89-4.04 4.02-7.64-.32-3.38-2.97-1.92-8.71 1.92-7.93z"/><path class="st0" d="M97.62 4.21h5.71l-.37 16.87 5.74-.94-.36-13.72 5.51-3.14.05 16.62 1.85-.19-.48 6.15h-1.39l.39 6.5h-5.57v-5.97h-5.74v5.97l-11.19 1.49.43-5 5.68-.89zm49.81 24.65v3.5h15.42l-.37-7-2.98.64-.61 1.68-4.79-.44v-5.73l6.71-.66v-4.37l-6.95.06V9.18l4.76-.75.6 1.34 2.63.29.74-6.06h-15.16v2.54l1.25.92V28.4zm16.46-19.62V4h8.42l-1.96 22.87 9.2-2.13v7.62l-15.04-.02.14-3.63 1.08-.87V9.59z"/><path class="st0" d="m193.69 32.36-.63-2.51-2.84-1.89-4.29-20.14L185.9 4h-11.27l-.03 3.2 1.87-.34-2.79 14.07-1.37.57v2.85l6.29-1.33.4-2.7 4.65-.89 1.69 12.93h8.35zm-14.3-17.25 1.65-6.52.89.25.92 5.45-3.46.82z"/><path class="st0" d="m208.47 21.68 2.15-.56-.58-2.97-9.53-.69-1.64 3.67 4.69.77-.24 2.01-2.74 1.28-4.14-1.42-1.96-6.58 1.72-7.17 3.88-1.5 3.23 1.1-.46 2.13 4.94 1.85 1.04-3.91-4.12-5.48h-9.14l-4.33 3.15-1.95 9.51 2.77 10.67 6.97 2.99 4.17-1.23-.11 3.06h5.92l.39-2.41-2.02-.96zm21.98-15.42L226.39 4l-8.59-.01-4.07 2.86-2.58 8.9 1.52 11.82 5.61 4.73 7.65.01 5.72-4.59 2.47-12.46-3.67-9zm-2.22 15.49-3.95 5.45-2.16.43-4.6-3.46-1.52-8.45 2.4-7.02 5.14-.48 2.97 1.79 1.74 5.83-.02 5.91zm-112.1 5.73-.24 4.88 12.26.09-.83-5.01-2.86-.48.14-17.62 2.45-.42-.14-4.85-10.92.36.1 4.6 3.2.63-.42 17.67-2.74.15zm25.21-23.27-12.88-.39v4.26l1.95.62v25.15l-1.8 1.41-.02 2.63h8.23l-.82-9.93h6.09l4.57-4.46V7.27l-5.32-3.06zm.04 16.3-2.54 1.89-3.23.16-.21-13.24h3.88l2.1 1.68v9.51zM14.14 11.28c0 .35-.02.71-.07 1.05.38.07.76.11 1.16.11s.79-.04 1.16-.11a7.933 7.933 0 0 1 4.65-8.3C20.17 1.68 17.9 0 15.24 0S10.3 1.68 9.42 4.03a7.922 7.922 0 0 1 4.72 7.25z"/><path class="st0" d="M18.04 11.28c0 .16.01.32.02.48.02.3.06.6.13.88.06.28.15.56.25.83.11.3.24.58.39.85 1.42-1.33 3.33-2.15 5.42-2.15s4.01.82 5.42 2.15c.51-.9.79-1.94.79-3.04 0-3.42-2.79-6.22-6.22-6.22-.4 0-.79.04-1.16.11-.28.06-.56.13-.83.22-.28.09-.56.21-.83.35a6.24 6.24 0 0 0-3.38 5.54zm-11.82.88c2.1 0 4.01.82 5.42 2.15.15-.27.28-.55.39-.85.1-.27.19-.54.25-.83.06-.28.11-.58.13-.88.02-.15.02-.32.02-.48a6.23 6.23 0 0 0-3.39-5.54c-.27-.13-.54-.24-.83-.34-.27-.1-.55-.17-.83-.22a6.42 6.42 0 0 0-1.16-.11 6.227 6.227 0 0 0-5.43 9.26 7.885 7.885 0 0 1 5.43-2.16z"/><path class="st0" d="M29.21 16.33c-.18-.23-.36-.44-.57-.65a6.174 6.174 0 0 0-4.38-1.81 6.192 6.192 0 0 0-4.94 2.45c-.18.23-.34.47-.47.72-.2.34-.36.71-.48 1.09a7.923 7.923 0 0 1 4.77 8.06c.37.07.75.1 1.13.1 3.43 0 6.22-2.79 6.22-6.22 0-1.11-.29-2.14-.8-3.04-.15-.23-.31-.47-.48-.7zm-17.09 1.81c-.13-.38-.28-.75-.48-1.09-.14-.26-.3-.5-.47-.72-.17-.23-.36-.44-.56-.64-1.12-1.12-2.67-1.81-4.38-1.81s-3.26.69-4.38 1.81c-.21.2-.39.42-.56.64-.18.23-.34.47-.47.72-.53.89-.82 1.93-.82 3.03 0 3.43 2.79 6.22 6.22 6.22.39 0 .76-.03 1.13-.1a7.902 7.902 0 0 1 4.77-8.06z"/><path class="st0" d="M18.04 19.87c-.27-.14-.55-.26-.84-.35-.27-.09-.55-.17-.84-.22-.37-.07-.75-.1-1.13-.1s-.76.03-1.13.1c-.28.05-.57.13-.84.22-.29.1-.57.22-.84.35a6.225 6.225 0 0 0-3.4 5.55c0 .07 0 .14.01.21.01.31.04.61.1.9.05.28.12.57.21.84.82 2.48 3.16 4.27 5.9 4.27s5.08-1.79 5.9-4.27c.09-.27.17-.55.21-.84.06-.3.09-.6.1-.91.01-.07.01-.14.01-.21a6.24 6.24 0 0 0-3.42-5.54z"/></svg> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 240 38" style="enable-background:new 0 0 240 38;" xml:space="preserve">
<style type="text/css">
.st0{fill:#316B84;}
</style>
<g>
<g>
<path class="st0" d="M59.72,27.96L53.03,4.21L42.25,4.04l1.42,4.37l1.41-0.26l-7.9,24.22h8.44l-0.56-2.27l-0.81-3.27l8.9-5.7
l1.78,11.24h7.97v-4.73L59.72,27.96z M45.62,20.21l3.13-10.84h1.5l2.02,7.44L45.62,20.21z"/>
<path class="st0" d="M78.67,27.96V20.4l-4.11-2.5l3.29-3.78l-0.47-7.46l-2.82-2.45H56.65v5.27l3.81-1.11l2.31,13.36l-2.79,0.73
L61,26.34l5.06-0.52l0.36-6.15l4.32,0.13l3.16,3.62v8.94l12.89,1.49v-5.34L78.67,27.96z M73.27,13.33l-2.18,1.45h-4.64l-0.42-6.57
h5.68l1.55,1.37V13.33z"/>
<polygon class="st0" points="84.65,4.21 93.01,4.21 95.75,6.46 96.26,10.9 92.23,12.43 91.77,9.74 88.97,8.28 85.86,9.82
83.88,15.02 85.51,20.94 88.49,22.38 91.99,20.59 91.99,18.59 96.26,16.96 95.85,22.85 91.81,26.87 84.17,26.55 80.79,23.58
78.87,14.87 80.79,6.94 "/>
<polygon class="st0" points="97.62,4.21 103.33,4.21 102.96,21.08 108.7,20.14 108.34,6.42 113.85,3.28 113.9,19.9 115.75,19.71
115.27,25.86 113.88,25.86 114.27,32.36 108.7,32.36 108.7,26.39 102.96,26.39 102.96,32.36 91.77,33.85 92.2,28.85 97.88,27.96
"/>
<polygon class="st0" points="147.43,28.86 147.43,32.36 162.85,32.36 162.48,25.36 159.5,26 158.89,27.68 154.1,27.24
154.1,21.51 160.81,20.85 160.81,16.48 153.86,16.54 153.86,9.18 158.62,8.43 159.22,9.77 161.85,10.06 162.59,4 147.43,4
147.43,6.54 148.68,7.46 148.68,28.4 "/>
<polygon class="st0" points="163.89,9.24 163.89,4 172.31,4 170.35,26.87 179.55,24.74 179.55,32.36 164.51,32.34 164.65,28.71
165.73,27.84 165.73,9.59 "/>
<path class="st0" d="M193.69,32.36l-0.63-2.51l-2.84-1.89l-4.29-20.14L185.9,4h-11.27l-0.03,3.2l1.87-0.34l-2.79,14.07l-1.37,0.57
v2.85l6.29-1.33l0.4-2.7l4.65-0.89l1.69,12.93H193.69z M179.39,15.11l1.65-6.52l0.89,0.25l0.92,5.45L179.39,15.11z"/>
<polygon class="st0" points="208.47,21.68 210.62,21.12 210.04,18.15 200.51,17.46 198.87,21.13 203.56,21.9 203.32,23.91
200.58,25.19 196.44,23.77 194.48,17.19 196.2,10.02 200.08,8.52 203.31,9.62 202.85,11.75 207.79,13.6 208.83,9.69 204.71,4.21
195.57,4.21 191.24,7.36 189.29,16.87 192.06,27.54 199.03,30.53 203.2,29.3 203.09,32.36 209.01,32.36 209.4,29.95 207.38,28.99
"/>
<path class="st0" d="M230.45,6.26L226.39,4l-8.59-0.01l-4.07,2.86l-2.58,8.9l1.52,11.82l5.61,4.73l7.65,0.01l5.72-4.59l2.47-12.46
L230.45,6.26z M228.23,21.75l-3.95,5.45l-2.16,0.43l-4.6-3.46L216,15.72l2.4-7.02l5.14-0.48l2.97,1.79l1.74,5.83L228.23,21.75z"/>
<path class="st0" d="M116.13,27.48l-0.24,4.88l12.26,0.09l-0.83-5.01l-2.86-0.48l0.14-17.62l2.45-0.42l-0.14-4.85l-10.92,0.36
l0.1,4.6l3.2,0.63l-0.42,17.67L116.13,27.48z"/>
<path class="st0" d="M141.34,4.21l-12.88-0.39v4.26l1.95,0.62v25.15l-1.8,1.41l-0.02,2.63h8.23L136,27.96h6.09l4.57-4.46V7.27
L141.34,4.21z M141.38,20.51l-2.54,1.89l-3.23,0.16L135.4,9.32h3.88l2.1,1.68V20.51z"/>
</g>
<g>
<path class="st0" d="M14.14,11.28c0,0.35-0.02,0.71-0.07,1.05c0.38,0.07,0.76,0.11,1.16,0.11s0.79-0.04,1.16-0.11
c-0.05-0.34-0.07-0.7-0.07-1.05c0-3.23,1.94-6.02,4.72-7.25C20.17,1.68,17.9,0,15.24,0S10.3,1.68,9.42,4.03
C12.2,5.26,14.14,8.04,14.14,11.28z"/>
<path class="st0" d="M18.04,11.28c0,0.16,0.01,0.32,0.02,0.48c0.02,0.3,0.06,0.6,0.13,0.88c0.06,0.28,0.15,0.56,0.25,0.83
c0.11,0.3,0.24,0.58,0.39,0.85c1.42-1.33,3.33-2.15,5.42-2.15s4.01,0.82,5.42,2.15c0.51-0.9,0.79-1.94,0.79-3.04
c0-3.42-2.79-6.22-6.22-6.22c-0.4,0-0.79,0.04-1.16,0.11c-0.28,0.06-0.56,0.13-0.83,0.22c-0.28,0.09-0.56,0.21-0.83,0.35
C19.42,6.77,18.04,8.87,18.04,11.28z"/>
<path class="st0" d="M6.22,12.16c2.1,0,4.01,0.82,5.42,2.15c0.15-0.27,0.28-0.55,0.39-0.85c0.1-0.27,0.19-0.54,0.25-0.83
c0.06-0.28,0.11-0.58,0.13-0.88c0.02-0.15,0.02-0.32,0.02-0.48c0-2.41-1.38-4.51-3.39-5.54C8.77,5.6,8.5,5.49,8.21,5.39
c-0.27-0.1-0.55-0.17-0.83-0.22C7,5.1,6.61,5.06,6.22,5.06C2.79,5.06,0,7.85,0,11.28c0,1.1,0.28,2.14,0.79,3.04
C2.21,12.98,4.12,12.16,6.22,12.16z"/>
<path class="st0" d="M29.21,16.33c-0.18-0.23-0.36-0.44-0.57-0.65c-1.12-1.12-2.67-1.81-4.38-1.81c-1.71,0-3.25,0.69-4.38,1.81
c-0.2,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72c-0.2,0.34-0.36,0.71-0.48,1.09c2.83,1.21,4.81,4.02,4.81,7.28
c0,0.26-0.01,0.52-0.04,0.78c0.37,0.07,0.75,0.1,1.13,0.1c3.43,0,6.22-2.79,6.22-6.22c0-1.11-0.29-2.14-0.8-3.04
C29.54,16.8,29.38,16.56,29.21,16.33z"/>
<path class="st0" d="M12.12,18.14c-0.13-0.38-0.28-0.75-0.48-1.09c-0.14-0.26-0.3-0.5-0.47-0.72c-0.17-0.23-0.36-0.44-0.56-0.64
c-1.12-1.12-2.67-1.81-4.38-1.81s-3.26,0.69-4.38,1.81c-0.21,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72
C0.29,17.94,0,18.98,0,20.08c0,3.43,2.79,6.22,6.22,6.22c0.39,0,0.76-0.03,1.13-0.1c-0.03-0.26-0.04-0.52-0.04-0.78
C7.31,22.15,9.29,19.34,12.12,18.14z"/>
<path class="st0" d="M18.04,19.87c-0.27-0.14-0.55-0.26-0.84-0.35c-0.27-0.09-0.55-0.17-0.84-0.22c-0.37-0.07-0.75-0.1-1.13-0.1
s-0.76,0.03-1.13,0.1c-0.28,0.05-0.57,0.13-0.84,0.22c-0.29,0.1-0.57,0.22-0.84,0.35C10.4,20.9,9.02,23,9.02,25.42
c0,0.07,0,0.14,0.01,0.21c0.01,0.31,0.04,0.61,0.1,0.9c0.05,0.28,0.12,0.57,0.21,0.84c0.82,2.48,3.16,4.27,5.9,4.27
s5.08-1.79,5.9-4.27c0.09-0.27,0.17-0.55,0.21-0.84c0.06-0.3,0.09-0.6,0.1-0.91c0.01-0.07,0.01-0.14,0.01-0.21
C21.45,23,20.07,20.9,18.04,19.87z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -44,7 +44,7 @@ a{
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
} }
button, input[type=submit]{ button{
font-weight: 500; font-weight: 500;
font-size: 0.9rem; font-size: 0.9rem;
padding: 10px 17px 11px 16px; /* top right bottom left */ padding: 10px 17px 11px 16px; /* top right bottom left */
@@ -57,7 +57,7 @@ button, input[type=submit]{
cursor: pointer; cursor: pointer;
} }
button:active, input[type=submit]:active{ button:active{
border-right: 1px solid rgba(0, 0, 0, 0.5); border-right: 1px solid rgba(0, 0, 0, 0.5);
border-bottom: 1px solid rgba(0, 0, 0, 0.5); border-bottom: 1px solid rgba(0, 0, 0, 0.5);
padding-right: 16px; padding-right: 16px;
@@ -66,11 +66,11 @@ button:active, input[type=submit]:active{
margin-bottom: 2px; margin-bottom: 2px;
} }
button.button-grass, input[type=submit].button-grass{ button.button-grass{
border: 1px solid black; border: 1px solid black;
} }
button.button-dirt, input[type=submit].button-dirt{ button.button-dirt{
border: 1px solid black; border: 1px solid black;
} }
@@ -111,4 +111,4 @@ h5, h6{
.interactive{ .interactive{
color: #ffef00; color: #ffef00;
} }

View File

@@ -58,28 +58,3 @@
overflow-y: auto; overflow-y: auto;
max-height: 400px; 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

@@ -23,7 +23,7 @@
.markdown a{} .markdown a{}
.markdown h1, .markdown details summary.h1{ .markdown h1{
font-size: 52px; font-size: 52px;
font-weight: normal; font-weight: normal;
font-family: LondrinaSolid-Regular, sans-serif; font-family: LondrinaSolid-Regular, sans-serif;
@@ -33,7 +33,7 @@
text-shadow: 1px 1px 4px #000000; text-shadow: 1px 1px 4px #000000;
} }
.markdown h2, .markdown details summary.h2{ .markdown h2{
font-size: 38px; font-size: 38px;
font-weight: normal; font-weight: normal;
font-family: LondrinaSolid-Light, sans-serif; font-family: LondrinaSolid-Light, sans-serif;
@@ -45,7 +45,7 @@
text-shadow: 1px 1px 2px #000000; text-shadow: 1px 1px 2px #000000;
} }
.markdown h3, .markdown details summary.h3{ .markdown h3{
font-size: 26px; font-size: 26px;
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
text-transform: none; text-transform: none;
@@ -55,7 +55,7 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.markdown h4, .markdown details summary.h4{ .markdown h4{
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
text-transform: none; text-transform: none;
font-size: 24px; font-size: 24px;
@@ -63,21 +63,21 @@
margin-bottom: 24px; margin-bottom: 24px;
} }
.markdown h5, .markdown details summary.h5{ .markdown h5{
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
text-transform: none; text-transform: none;
font-size: 22px; font-size: 22px;
cursor: pointer; cursor: pointer;
} }
.markdown h6, .markdown details summary.h6{ .markdown h6{
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
text-transform: none; text-transform: none;
font-size: 20px; font-size: 20px;
cursor: pointer;; cursor: pointer;;
} }
.markdown h4, .markdown h5, .markdown h6{ .markdown h4, .markdown h5,.markdown h6{
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }

View File

@@ -0,0 +1,244 @@
html{
background-image: url('../static/backgrounds/grass.png');
background-repeat: repeat;
background-size: 650px 650px;
}
#player-options{
box-sizing: border-box;
max-width: 1024px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
}
#player-options #player-options-button-row{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#player-options code{
background-color: #d9cd8e;
border-radius: 4px;
padding-left: 0.25rem;
padding-right: 0.25rem;
color: #000000;
}
#player-options #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
}
#player-options #user-message.visible{
display: block;
cursor: pointer;
}
#player-options h1{
font-size: 2.5rem;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-shadow: 1px 1px 4px #000000;
}
#player-options h2{
font-size: 40px;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-transform: lowercase;
text-shadow: 1px 1px 2px #000000;
}
#player-options h3, #player-options h4, #player-options h5, #player-options h6{
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
#player-options input:not([type]){
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
}
#player-options input:not([type]):focus{
border: 1px solid #ffffff;
}
#player-options select{
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
background-color: #ffffff;
}
#player-options #game-options, #player-options #rom-options{
display: flex;
flex-direction: row;
}
#player-options #meta-options {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 3px;
}
#player-options div {
display: flex;
flex-grow: 1;
}
#player-options #meta-options label {
display: inline-block;
min-width: 180px;
flex-grow: 1;
}
#player-options #meta-options input,
#player-options #meta-options select {
box-sizing: border-box;
min-width: 150px;
width: 50%;
}
#player-options .left, #player-options .right{
flex-grow: 1;
}
#player-options .left{
margin-right: 10px;
}
#player-options .right{
margin-left: 10px;
}
#player-options table{
margin-bottom: 30px;
width: 100%;
}
#player-options table .select-container{
display: flex;
flex-direction: row;
}
#player-options table .select-container select{
min-width: 200px;
flex-grow: 1;
}
#player-options table select:disabled{
background-color: lightgray;
}
#player-options table .range-container{
display: flex;
flex-direction: row;
}
#player-options table .range-container input[type=range]{
flex-grow: 1;
}
#player-options table .range-value{
min-width: 20px;
margin-left: 0.25rem;
}
#player-options table .named-range-container{
display: flex;
flex-direction: column;
}
#player-options table .named-range-wrapper{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
#player-options table .named-range-wrapper input[type=range]{
flex-grow: 1;
}
#player-options table .randomize-button {
max-height: 24px;
line-height: 16px;
padding: 2px 8px;
margin: 0 0 0 0.25rem;
font-size: 12px;
border: 1px solid black;
border-radius: 3px;
}
#player-options table .randomize-button.active {
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
}
#player-options table .randomize-button[data-tooltip]::after {
left: unset;
right: 0;
}
#player-options table label{
display: block;
min-width: 200px;
margin-right: 4px;
cursor: default;
}
#player-options th, #player-options td{
border: none;
padding: 3px;
font-size: 17px;
vertical-align: top;
}
@media all and (max-width: 1024px) {
#player-options {
border-radius: 0;
}
#player-options #meta-options {
flex-direction: column;
justify-content: flex-start;
gap: 6px;
}
#player-options #game-options{
justify-content: flex-start;
flex-wrap: wrap;
}
#player-options .left,
#player-options .right {
margin: 0;
}
#game-options table {
margin-bottom: 0;
}
#game-options table label{
display: block;
min-width: 200px;
}
#game-options table tr td {
width: 50%;
}
}

View File

@@ -1,310 +0,0 @@
@import "../markdown.css";
html {
background-image: url("../../static/backgrounds/grass.png");
background-repeat: repeat;
background-size: 650px 650px;
overflow-x: hidden;
}
#player-options {
box-sizing: border-box;
max-width: 1024px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
word-break: break-word;
}
#player-options #player-options-header h1 {
margin-bottom: 0;
padding-bottom: 0;
}
#player-options #player-options-header h1:nth-child(2) {
font-size: 1.4rem;
margin-top: -8px;
margin-bottom: 0.5rem;
}
#player-options .js-warning-banner {
width: calc(100% - 1rem);
padding: 0.5rem;
border-radius: 4px;
background-color: #f3f309;
color: #000000;
margin-bottom: 0.5rem;
text-align: center;
}
#player-options .group-container {
padding: 0;
margin: 0;
}
#player-options .group-container h2 {
user-select: none;
cursor: unset;
}
#player-options .group-container h2 label {
cursor: pointer;
}
#player-options #player-options-button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#player-options #user-message {
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
cursor: pointer;
}
#player-options h1 {
font-size: 2.5rem;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-shadow: 1px 1px 4px #000000;
}
#player-options h2 {
font-size: 40px;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-transform: lowercase;
text-shadow: 1px 1px 2px #000000;
}
#player-options h3, #player-options h4, #player-options h5, #player-options h6 {
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
#player-options input:not([type]) {
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
}
#player-options input:not([type]):focus {
border: 1px solid #ffffff;
}
#player-options select {
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
background-color: #ffffff;
text-overflow: ellipsis;
}
#player-options .game-options {
display: flex;
flex-direction: row;
}
#player-options .game-options .left, #player-options .game-options .right {
display: grid;
grid-template-columns: 12rem auto;
grid-row-gap: 0.5rem;
grid-auto-rows: min-content;
align-items: start;
min-width: 480px;
width: 50%;
}
#player-options #meta-options {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 3px;
}
#player-options #meta-options input, #player-options #meta-options select {
box-sizing: border-box;
width: 200px;
}
#player-options .left, #player-options .right {
flex-grow: 1;
margin-bottom: 0.5rem;
}
#player-options .left {
margin-right: 20px;
}
#player-options .select-container {
display: flex;
flex-direction: row;
max-width: 270px;
}
#player-options .select-container select {
min-width: 200px;
flex-grow: 1;
}
#player-options .select-container select:disabled {
background-color: lightgray;
}
#player-options .range-container {
display: flex;
flex-direction: row;
max-width: 270px;
}
#player-options .range-container input[type=range] {
flex-grow: 1;
}
#player-options .range-container .range-value {
min-width: 20px;
margin-left: 0.25rem;
}
#player-options .named-range-container {
display: flex;
flex-direction: column;
max-width: 270px;
}
#player-options .named-range-container .named-range-wrapper {
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
#player-options .named-range-container .named-range-wrapper input[type=range] {
flex-grow: 1;
}
#player-options .free-text-container {
display: flex;
flex-direction: column;
max-width: 270px;
}
#player-options .free-text-container input[type=text] {
flex-grow: 1;
}
#player-options .text-choice-container {
display: flex;
flex-direction: column;
max-width: 270px;
}
#player-options .text-choice-container .text-choice-wrapper {
display: flex;
flex-direction: row;
margin-bottom: 0.25rem;
}
#player-options .text-choice-container .text-choice-wrapper select {
flex-grow: 1;
}
#player-options .option-container {
display: flex;
flex-direction: column;
background-color: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(20, 20, 20, 0.25);
border-radius: 3px;
color: #ffffff;
max-height: 10rem;
min-width: 14.5rem;
overflow-y: auto;
padding-right: 0.25rem;
padding-left: 0.25rem;
}
#player-options .option-container .option-divider {
width: 100%;
height: 2px;
background-color: rgba(20, 20, 20, 0.25);
margin-top: 0.125rem;
margin-bottom: 0.125rem;
}
#player-options .option-container .option-entry {
display: flex;
flex-direction: row;
align-items: flex-start;
margin-bottom: 0.125rem;
margin-top: 0.125rem;
user-select: none;
}
#player-options .option-container .option-entry:hover {
background-color: rgba(20, 20, 20, 0.25);
}
#player-options .option-container .option-entry input[type=checkbox] {
margin-right: 0.25rem;
}
#player-options .option-container .option-entry input[type=number] {
max-width: 1.5rem;
max-height: 1rem;
margin-left: 0.125rem;
text-align: center;
/* Hide arrows on input[type=number] fields */
-moz-appearance: textfield;
}
#player-options .option-container .option-entry input[type=number]::-webkit-outer-spin-button, #player-options .option-container .option-entry input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
#player-options .option-container .option-entry label {
flex-grow: 1;
margin-right: 0;
min-width: unset;
display: unset;
}
#player-options .randomize-button {
display: flex;
flex-direction: column;
justify-content: center;
height: 22px;
max-width: 30px;
margin: 0 0 0 0.25rem;
font-size: 14px;
border: 1px solid black;
border-radius: 3px;
background-color: #d3d3d3;
user-select: none;
}
#player-options .randomize-button:hover {
background-color: #c0c0c0;
cursor: pointer;
}
#player-options .randomize-button label {
line-height: 22px;
padding-left: 5px;
padding-right: 2px;
margin-right: 4px;
width: 100%;
height: 100%;
min-width: unset;
}
#player-options .randomize-button label:hover {
cursor: pointer;
}
#player-options .randomize-button input[type=checkbox] {
display: none;
}
#player-options .randomize-button:has(input[type=checkbox]:checked) {
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
}
#player-options .randomize-button:has(input[type=checkbox]:checked):hover {
background-color: #eedd27;
}
#player-options .randomize-button[data-tooltip]::after {
left: unset;
right: 0;
}
#player-options label {
display: block;
margin-right: 4px;
cursor: default;
word-break: break-word;
}
#player-options th, #player-options td {
border: none;
padding: 3px;
font-size: 17px;
vertical-align: top;
}
@media all and (max-width: 1024px) {
#player-options {
border-radius: 0;
}
#player-options #meta-options {
flex-direction: column;
justify-content: flex-start;
gap: 6px;
}
#player-options .game-options {
justify-content: flex-start;
flex-wrap: wrap;
}
}
/*# sourceMappingURL=playerOptions.css.map */

View File

@@ -1 +0,0 @@
{"version":3,"sourceRoot":"","sources":["playerOptions.scss"],"names":[],"mappings":"AAAQ;AAER;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGI;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;AAEA;EACA;;AACA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;;AAIR;EACI;;AAGJ;EACI;;AAEA;EACI;;AAIR;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;;AAIR;EACI;IACI;;EAEA;IACI;IACA;IACA;;EAGJ;IACI;IACA","file":"playerOptions.css"}

View File

@@ -1,364 +0,0 @@
@import "../markdown.css";
html{
background-image: url('../../static/backgrounds/grass.png');
background-repeat: repeat;
background-size: 650px 650px;
overflow-x: hidden;
}
#player-options{
box-sizing: border-box;
max-width: 1024px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
word-break: break-word;
#player-options-header{
h1{
margin-bottom: 0;
padding-bottom: 0;
}
h1:nth-child(2){
font-size: 1.4rem;
margin-top: -8px;
margin-bottom: 0.5rem;
}
}
.js-warning-banner{
width: calc(100% - 1rem);
padding: 0.5rem;
border-radius: 4px;
background-color: #f3f309;
color: #000000;
margin-bottom: 0.5rem;
text-align: center;
}
.group-container{
padding: 0;
margin: 0;
h2{
user-select: none;
cursor: unset;
label{
cursor: pointer;
}
}
}
#player-options-button-row{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
cursor: pointer;
}
h1{
font-size: 2.5rem;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-shadow: 1px 1px 4px #000000;
}
h2{
font-size: 40px;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-transform: lowercase;
text-shadow: 1px 1px 2px #000000;
}
h3, h4, h5, h6{
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
input:not([type]){
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
&:focus{
border: 1px solid #ffffff;
}
}
select{
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
background-color: #ffffff;
text-overflow: ellipsis;
}
.game-options{
display: flex;
flex-direction: row;
.left, .right{
display: grid;
grid-template-columns: 12rem auto;
grid-row-gap: 0.5rem;
grid-auto-rows: min-content;
align-items: start;
min-width: 480px;
width: 50%;
}
}
#meta-options{
display: flex;
justify-content: space-between;
gap: 20px;
padding: 3px;
input, select{
box-sizing: border-box;
width: 200px;
}
}
.left, .right{
flex-grow: 1;
margin-bottom: 0.5rem;
}
.left{
margin-right: 20px;
}
.select-container{
display: flex;
flex-direction: row;
max-width: 270px;
select{
min-width: 200px;
flex-grow: 1;
&:disabled{
background-color: lightgray;
}
}
}
.range-container{
display: flex;
flex-direction: row;
max-width: 270px;
input[type=range]{
flex-grow: 1;
}
.range-value{
min-width: 20px;
margin-left: 0.25rem;
}
}
.named-range-container{
display: flex;
flex-direction: column;
max-width: 270px;
.named-range-wrapper{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
input[type=range]{
flex-grow: 1;
}
}
}
.free-text-container{
display: flex;
flex-direction: column;
max-width: 270px;
input[type=text]{
flex-grow: 1;
}
}
.text-choice-container{
display: flex;
flex-direction: column;
max-width: 270px;
.text-choice-wrapper{
display: flex;
flex-direction: row;
margin-bottom: 0.25rem;
select{
flex-grow: 1;
}
}
}
.option-container{
display: flex;
flex-direction: column;
background-color: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(20, 20, 20, 0.25);
border-radius: 3px;
color: #ffffff;
max-height: 10rem;
min-width: 14.5rem;
overflow-y: auto;
padding-right: 0.25rem;
padding-left: 0.25rem;
.option-divider{
width: 100%;
height: 2px;
background-color: rgba(20, 20, 20, 0.25);
margin-top: 0.125rem;
margin-bottom: 0.125rem;
}
.option-entry{
display: flex;
flex-direction: row;
align-items: flex-start;
margin-bottom: 0.125rem;
margin-top: 0.125rem;
user-select: none;
&:hover{
background-color: rgba(20, 20, 20, 0.25);
}
input[type=checkbox]{
margin-right: 0.25rem;
}
input[type=number]{
max-width: 1.5rem;
max-height: 1rem;
margin-left: 0.125rem;
text-align: center;
/* Hide arrows on input[type=number] fields */
-moz-appearance: textfield;
&::-webkit-outer-spin-button, &::-webkit-inner-spin-button{
-webkit-appearance: none;
margin: 0;
}
}
label{
flex-grow: 1;
margin-right: 0;
min-width: unset;
display: unset;
}
}
}
.randomize-button{
display: flex;
flex-direction: column;
justify-content: center;
height: 22px;
max-width: 30px;
margin: 0 0 0 0.25rem;
font-size: 14px;
border: 1px solid black;
border-radius: 3px;
background-color: #d3d3d3;
user-select: none;
&:hover{
background-color: #c0c0c0;
cursor: pointer;
}
label{
line-height: 22px;
padding-left: 5px;
padding-right: 2px;
margin-right: 4px;
width: 100%;
height: 100%;
min-width: unset;
&:hover{
cursor: pointer;
}
}
input[type=checkbox]{
display: none;
}
&:has(input[type=checkbox]:checked){
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
&:hover{
background-color: #eedd27;
}
}
&[data-tooltip]::after{
left: unset;
right: 0;
}
}
label{
display: block;
margin-right: 4px;
cursor: default;
word-break: break-word;
}
th, td{
border: none;
padding: 3px;
font-size: 17px;
vertical-align: top;
}
}
@media all and (max-width: 1024px) {
#player-options {
border-radius: 0;
#meta-options {
flex-direction: column;
justify-content: flex-start;
gap: 6px;
}
.game-options{
justify-content: flex-start;
flex-wrap: wrap;
}
}
}

View File

@@ -8,15 +8,30 @@
cursor: unset; cursor: unset;
} }
#games h1, #games details summary.h1{ #games h1{
font-size: 60px; font-size: 60px;
cursor: unset; cursor: unset;
} }
#games h2, #games details summary.h2{ #games h2{
color: #93dcff; color: #93dcff;
margin-bottom: 2px; margin-bottom: 2px;
text-transform: none; }
#games .collapse-toggle{
cursor: pointer;
}
#games h2 .collapse-arrow{
font-size: 20px;
display: inline-block; /* make vertical-align work */
padding-bottom: 9px;
vertical-align: middle;
padding-right: 8px;
}
#games p.collapsed{
display: none;
} }
#games a{ #games a{

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