mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-07 23:25:51 -08:00
Compare commits
3 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d04239504 | ||
|
|
59a000033e | ||
|
|
baca95d49d |
60
.github/workflows/build.yml
vendored
60
.github/workflows/build.yml
vendored
@@ -40,10 +40,6 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python setup.py build_exe --yes
|
||||
if ( $? -eq $false ) {
|
||||
Write-Error "setup.py failed!"
|
||||
exit 1
|
||||
}
|
||||
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||
echo "$NAME -> $ZIP_NAME"
|
||||
@@ -53,6 +49,12 @@ jobs:
|
||||
Rename-Item "exe.$NAME" Archipelago
|
||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.ZIP_NAME }}
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
- name: Build Setup
|
||||
run: |
|
||||
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
||||
@@ -63,38 +65,11 @@ jobs:
|
||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||
$SETUP_NAME=$contents[0].Name
|
||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||
- name: Check build loads expected worlds
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
mv Players/Templates/meta.yaml .
|
||||
ls -1 Players/Templates | sort > setup-player-templates.txt
|
||||
rm -R Players/Templates
|
||||
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
|
||||
ls -1 Players/Templates | sort > generated-player-templates.txt
|
||||
cmp setup-player-templates.txt generated-player-templates.txt \
|
||||
|| diff setup-player-templates.txt generated-player-templates.txt
|
||||
mv meta.yaml Players/Templates/
|
||||
- name: Test Generate
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
cp Players/Templates/Clique.yaml Players/
|
||||
timeout 30 ./ArchipelagoGenerate
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.ZIP_NAME }}
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
compression-level: 0 # .7z is incompressible by zip
|
||||
if-no-files-found: error
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
- name: Store Setup
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.SETUP_NAME }}
|
||||
path: setups/${{ env.SETUP_NAME }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
|
||||
build-ubuntu2004:
|
||||
@@ -135,7 +110,7 @@ jobs:
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - copy code above to release.yml -
|
||||
@@ -143,36 +118,15 @@ jobs:
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
python setup.py build_exe --yes
|
||||
- name: Check build loads expected worlds
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
mv Players/Templates/meta.yaml .
|
||||
ls -1 Players/Templates | sort > setup-player-templates.txt
|
||||
rm -R Players/Templates
|
||||
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
|
||||
ls -1 Players/Templates | sort > generated-player-templates.txt
|
||||
cmp setup-player-templates.txt generated-player-templates.txt \
|
||||
|| diff setup-player-templates.txt generated-player-templates.txt
|
||||
mv meta.yaml Players/Templates/
|
||||
- name: Test Generate
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
cp Players/Templates/Clique.yaml Players/
|
||||
timeout 30 ./ArchipelagoGenerate
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.APPIMAGE_NAME }}
|
||||
path: dist/${{ env.APPIMAGE_NAME }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: Store .tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.TAR_NAME }}
|
||||
path: dist/${{ env.TAR_NAME }}
|
||||
compression-level: 0 # .gz is incompressible by zip
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
54
.github/workflows/ctest.yml
vendored
54
.github/workflows/ctest.yml
vendored
@@ -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
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - code above copied from build.yml -
|
||||
|
||||
31
.github/workflows/unittests.yml
vendored
31
.github/workflows/unittests.yml
vendored
@@ -24,7 +24,7 @@ on:
|
||||
- '.github/workflows/unittests.yml'
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
|
||||
|
||||
@@ -60,32 +60,3 @@ jobs:
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest -n auto
|
||||
|
||||
hosting:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
python:
|
||||
- {version: '3.11'} # 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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -62,7 +62,6 @@ Output Logs/
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
/datapackage
|
||||
/custom_worlds
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -178,7 +177,6 @@ dmypy.json
|
||||
cython_debug/
|
||||
|
||||
# Cython intermediates
|
||||
_speedups.c
|
||||
_speedups.cpp
|
||||
_speedups.html
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -80,7 +80,7 @@ class AdventureContext(CommonContext):
|
||||
self.local_item_locations = {}
|
||||
self.dragon_speed_info = {}
|
||||
|
||||
options = Utils.get_settings()
|
||||
options = Utils.get_options()
|
||||
self.display_msgs = options["adventure_options"]["display_msgs"]
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
@@ -102,7 +102,7 @@ class AdventureContext(CommonContext):
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.locations_array = None
|
||||
if Utils.get_settings()["adventure_options"].get("death_link", False):
|
||||
if Utils.get_options()["adventure_options"].get("death_link", False):
|
||||
self.set_deathlink = True
|
||||
async_start(self.get_freeincarnates_used())
|
||||
elif cmd == "RoomInfo":
|
||||
@@ -112,7 +112,7 @@ class AdventureContext(CommonContext):
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "ReceivedItems":
|
||||
msg = f"Received {', '.join([self.item_names.lookup_in_slot(item.item) for item in args['items']])}"
|
||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "Retrieved":
|
||||
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
|
||||
@@ -415,8 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
|
||||
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
|
||||
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
|
||||
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
|
||||
@@ -718,6 +718,10 @@ class CollectionState():
|
||||
def count(self, item: str, player: int) -> int:
|
||||
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:
|
||||
"""Returns True if the state contains at least `count` items matching any of the item names from a list."""
|
||||
found: int = 0
|
||||
@@ -727,25 +731,10 @@ class CollectionState():
|
||||
if found >= count:
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
|
||||
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
|
||||
Ignores duplicates of the same item."""
|
||||
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:
|
||||
"""Returns the cumulative count of items from a list present in state."""
|
||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
||||
|
||||
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
||||
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
||||
|
||||
# item name group related
|
||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||
@@ -758,18 +747,6 @@ class CollectionState():
|
||||
return True
|
||||
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:
|
||||
"""Returns the cumulative count of items from an item group present in state."""
|
||||
player_prog_items = self.prog_items[player]
|
||||
@@ -778,15 +755,6 @@ class CollectionState():
|
||||
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
|
||||
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
||||
if location:
|
||||
@@ -1046,7 +1014,7 @@ class Location:
|
||||
self.parent_region = parent
|
||||
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||
return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
|
||||
return ((self.always_allow(state, item) and item.name not in state.multiworld.non_local_items[item.player])
|
||||
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||
and self.item_rule(item)
|
||||
and (not check_access or self.can_reach(state))))
|
||||
@@ -1242,7 +1210,7 @@ class Spoiler:
|
||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||
location.item.name, location.item.player, location.name, location.player) for location in
|
||||
sphere_candidates])
|
||||
if 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}). '
|
||||
f'Something went terribly wrong here.')
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import logging
|
||||
import asyncio
|
||||
@@ -9,7 +8,6 @@ import sys
|
||||
import typing
|
||||
import time
|
||||
import functools
|
||||
import warnings
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
@@ -175,74 +173,10 @@ class CommonContext:
|
||||
items_handling: typing.Optional[int] = None
|
||||
want_slot_data: bool = True # should slot_data be retrieved via Connect
|
||||
|
||||
class NameLookupDict:
|
||||
"""A specialized dict, with helper methods, for id -> name item/location data package lookups by game."""
|
||||
def __init__(self, ctx: CommonContext, lookup_type: typing.Literal["item", "location"]):
|
||||
self.ctx: CommonContext = ctx
|
||||
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
||||
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
||||
self._archipelago_lookup: typing.Dict[int, str] = {}
|
||||
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
|
||||
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
||||
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
||||
self.warned: bool = False
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
||||
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
|
||||
if isinstance(key, int):
|
||||
if not self.warned:
|
||||
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
|
||||
self.warned = True
|
||||
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
|
||||
f"backwards compatibility for now. If multiple games share the same id for a "
|
||||
f"{self.lookup_type}, name could be incorrect. Please use "
|
||||
f"`{self.lookup_type}_names.lookup_in_game()` or "
|
||||
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
|
||||
return self._flat_store[key] # type: ignore
|
||||
|
||||
return self._game_store[key]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._game_store)
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return iter(self._game_store)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self._game_store.__repr__()
|
||||
|
||||
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
|
||||
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
|
||||
omitted.
|
||||
"""
|
||||
if game_name is None:
|
||||
game_name = self.ctx.game
|
||||
assert game_name is not None, f"Attempted to lookup {self.lookup_type} with no game name available."
|
||||
|
||||
return self._game_store[game_name][code]
|
||||
|
||||
def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str:
|
||||
"""Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is
|
||||
omitted.
|
||||
"""
|
||||
if slot is None:
|
||||
slot = self.ctx.slot
|
||||
assert slot is not None, f"Attempted to lookup {self.lookup_type} with no slot info available."
|
||||
|
||||
return self.lookup_in_game(code, self.ctx.slot_info[slot].game)
|
||||
|
||||
def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int]) -> None:
|
||||
"""Overrides existing lookup tables for a particular game."""
|
||||
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
||||
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
||||
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
||||
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
|
||||
if game == "Archipelago":
|
||||
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
||||
# it updates in all chain maps automatically.
|
||||
self._archipelago_lookup.clear()
|
||||
self._archipelago_lookup.update(id_to_name_lookup_table)
|
||||
# data package
|
||||
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
||||
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||
|
||||
# defaults
|
||||
starting_reconnect_delay: int = 5
|
||||
@@ -297,7 +231,7 @@ class CommonContext:
|
||||
# message box reporting a loss of connection
|
||||
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
||||
|
||||
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
|
||||
# server state
|
||||
self.server_address = server_address
|
||||
self.username = None
|
||||
@@ -337,9 +271,6 @@ class CommonContext:
|
||||
self.exit_event = asyncio.Event()
|
||||
self.watcher_event = asyncio.Event()
|
||||
|
||||
self.item_names = self.NameLookupDict(self, "item")
|
||||
self.location_names = self.NameLookupDict(self, "location")
|
||||
|
||||
self.jsontotextparser = JSONtoTextParser(self)
|
||||
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
||||
self.update_data_package(network_data_package)
|
||||
@@ -493,11 +424,6 @@ class CommonContext:
|
||||
"""Gets called before sending a Say to the server from the user.
|
||||
Returned text is sent, or sending is aborted if None is returned."""
|
||||
return text
|
||||
|
||||
def on_ui_command(self, text: str) -> None:
|
||||
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
|
||||
The command processor is still called; this is just intended for command echoing."""
|
||||
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
|
||||
|
||||
def update_permissions(self, permissions: typing.Dict[str, int]):
|
||||
for permission_name, permission_flag in permissions.items():
|
||||
@@ -560,17 +486,19 @@ class CommonContext:
|
||||
or remote_checksum != cache_checksum:
|
||||
needed_updates.add(game)
|
||||
else:
|
||||
self.update_game(cached_game, game)
|
||||
self.update_game(cached_game)
|
||||
if needed_updates:
|
||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
|
||||
|
||||
def update_game(self, game_package: dict, game: str):
|
||||
self.item_names.update_game(game, game_package["item_name_to_id"])
|
||||
self.location_names.update_game(game, game_package["location_name_to_id"])
|
||||
def update_game(self, game_package: dict):
|
||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||
self.item_names[item_id] = item_name
|
||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||
self.location_names[location_id] = location_name
|
||||
|
||||
def update_data_package(self, data_package: dict):
|
||||
for game, game_data in data_package["games"].items():
|
||||
self.update_game(game_data, game)
|
||||
self.update_game(game_data)
|
||||
|
||||
def consume_network_data_package(self, data_package: dict):
|
||||
self.update_data_package(data_package)
|
||||
|
||||
64
Fill.py
64
Fill.py
@@ -35,8 +35,8 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
"""
|
||||
:param multiworld: Multiworld to be filled.
|
||||
: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 item_pool: Items to fill into the locations, gets mutated by removing items that get placed.
|
||||
:param locations: Locations to be filled with item_pool
|
||||
: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 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
|
||||
@@ -220,8 +220,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
def remaining_fill(multiworld: MultiWorld,
|
||||
locations: typing.List[Location],
|
||||
itempool: typing.List[Item],
|
||||
name: str = "Remaining",
|
||||
move_unplaceable_to_start_inventory: bool = False) -> None:
|
||||
name: str = "Remaining") -> None:
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||
@@ -285,21 +284,13 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
|
||||
if unplaced_items and locations:
|
||||
# There are leftover unplaceable items and locations that won't accept them
|
||||
if move_unplaceable_to_start_inventory:
|
||||
last_batch = []
|
||||
for item in unplaced_items:
|
||||
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
||||
multiworld.push_precollected(item)
|
||||
last_batch.append(multiworld.worlds[item.player].create_filler())
|
||||
remaining_fill(multiworld, locations, unplaced_items, name + " Start Inventory Retry")
|
||||
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)}")
|
||||
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)}")
|
||||
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
@@ -429,8 +420,7 @@ def distribute_early_items(multiworld: MultiWorld,
|
||||
return fill_locations, itempool
|
||||
|
||||
|
||||
def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
||||
def distribute_items_restrictive(multiworld: MultiWorld) -> None:
|
||||
fill_locations = sorted(multiworld.get_unfilled_locations())
|
||||
multiworld.random.shuffle(fill_locations)
|
||||
# get items to distribute
|
||||
@@ -480,29 +470,8 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
|
||||
if progitempool:
|
||||
# "advancement/progression fill"
|
||||
if panic_method == "swap":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=True,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
elif panic_method == "raise":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=False,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
elif panic_method == "start_inventory":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=False, allow_partial=True,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
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.")
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, single_player_placement=multiworld.players == 1,
|
||||
name="Progression")
|
||||
if progitempool:
|
||||
raise FillError(
|
||||
f"Not enough locations for progression items. "
|
||||
@@ -517,9 +486,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
|
||||
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
|
||||
|
||||
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
|
||||
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
|
||||
|
||||
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded")
|
||||
if excludedlocations:
|
||||
raise FillError(
|
||||
f"Not enough filler items for excluded locations. "
|
||||
@@ -528,8 +495,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
|
||||
restitempool = filleritempool + usefulitempool
|
||||
|
||||
remaining_fill(multiworld, defaultlocations, restitempool,
|
||||
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
|
||||
remaining_fill(multiworld, defaultlocations, restitempool)
|
||||
|
||||
unplaced = restitempool
|
||||
unfilled = defaultlocations
|
||||
|
||||
112
Generate.py
112
Generate.py
@@ -1,32 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, Tuple, Union
|
||||
from itertools import chain
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
import copy
|
||||
import Utils
|
||||
import Options
|
||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
from Main import main as ERmain
|
||||
from settings import get_settings
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from worlds.generic import PlandoConnection
|
||||
from worlds import failed_world_loads
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
from settings import get_settings
|
||||
settings = get_settings()
|
||||
defaults = settings.generator
|
||||
options = get_settings()
|
||||
defaults = options.generator
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
|
||||
@@ -38,7 +42,7 @@ def mystery_argparse():
|
||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
|
||||
parser.add_argument('--outputpath', default=settings.general_options.output_path,
|
||||
parser.add_argument('--outputpath', default=options.general_options.output_path,
|
||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||
@@ -58,23 +62,20 @@ def mystery_argparse():
|
||||
if not os.path.isabs(args.meta_file_path):
|
||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||
return args
|
||||
return args, options
|
||||
|
||||
|
||||
def get_seed_name(random_source) -> str:
|
||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||
|
||||
|
||||
def main(args=None):
|
||||
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
||||
if __name__ == "__main__" and "worlds" in sys.modules:
|
||||
raise Exception("Worlds system should not be loaded before logging init.")
|
||||
|
||||
def main(args=None, callback=ERmain):
|
||||
if not args:
|
||||
args = mystery_argparse()
|
||||
args, options = mystery_argparse()
|
||||
else:
|
||||
options = get_settings()
|
||||
|
||||
seed = get_seed(args.seed)
|
||||
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
random.seed(seed)
|
||||
seed_name = get_seed_name(random)
|
||||
@@ -143,9 +144,6 @@ def main(args=None):
|
||||
raise Exception(f"No weights found. "
|
||||
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
f"A mix is also permitted.")
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.plando_options = args.plando
|
||||
@@ -237,8 +235,7 @@ def main(args=None):
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||
yaml.dump(important, f)
|
||||
|
||||
from Main import main as ERmain
|
||||
return ERmain(erargs, seed)
|
||||
return callback(erargs, seed)
|
||||
|
||||
|
||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||
@@ -322,34 +319,18 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
||||
logging.debug(f'Applying {new_weights}')
|
||||
cleaned_weights = {}
|
||||
for option in new_weights:
|
||||
option_name = option.lstrip("+-")
|
||||
option_name = option.lstrip("+")
|
||||
if option.startswith("+") and option_name in weights:
|
||||
cleaned_value = weights[option_name]
|
||||
new_value = new_weights[option]
|
||||
if isinstance(new_value, set):
|
||||
if isinstance(new_value, (set, dict)):
|
||||
cleaned_value.update(new_value)
|
||||
elif isinstance(new_value, list):
|
||||
cleaned_value.extend(new_value)
|
||||
elif isinstance(new_value, dict):
|
||||
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
|
||||
else:
|
||||
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
||||
f" received {type(new_value).__name__}.")
|
||||
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:
|
||||
cleaned_weights[option_name] = new_weights[option]
|
||||
new_options = set(cleaned_weights) - set(weights)
|
||||
@@ -363,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:
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
if not game:
|
||||
return get_choice(option_key, category_dict)
|
||||
if game in AutoWorldRegister.world_types:
|
||||
@@ -399,7 +378,7 @@ def roll_linked_options(weights: dict) -> dict:
|
||||
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["_Generator_Version"] = Utils.__version__
|
||||
for i, option_set in enumerate(triggers):
|
||||
@@ -422,7 +401,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
||||
if category_name:
|
||||
currently_targeted_weights = currently_targeted_weights[category_name]
|
||||
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
|
||||
valid_keys.add(key)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Your trigger number {i + 1} is invalid. "
|
||||
f"Please fix your triggers.") from e
|
||||
@@ -442,19 +421,15 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
||||
except Exception as e:
|
||||
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
|
||||
else:
|
||||
from worlds import AutoWorldRegister
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
valid_keys = set()
|
||||
if "triggers" in weights:
|
||||
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
||||
weights = roll_triggers(weights, weights["triggers"])
|
||||
|
||||
requirements = weights.get("requires", {})
|
||||
if requirements:
|
||||
@@ -475,7 +450,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
|
||||
ret.game = get_choice("game", weights)
|
||||
if ret.game not in AutoWorldRegister.world_types:
|
||||
from worlds import failed_world_loads
|
||||
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
|
||||
if picks[0] in failed_world_loads:
|
||||
raise Exception(f"No functional world found to handle game {ret.game}. "
|
||||
@@ -490,14 +464,12 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
world_type = AutoWorldRegister.world_types[ret.game]
|
||||
game_weights = weights[ret.game]
|
||||
|
||||
for weight in chain(game_weights, weights):
|
||||
if weight.startswith("+"):
|
||||
raise Exception(f"Merge tag cannot be used outside of trigger contexts. Found {weight}")
|
||||
if weight.startswith("-"):
|
||||
raise Exception(f"Remove tag cannot be used outside of trigger contexts. Found {weight}")
|
||||
if any(weight.startswith("+") for weight in game_weights) or \
|
||||
any(weight.startswith("+") for weight in weights):
|
||||
raise Exception(f"Merge tag cannot be used outside of trigger contexts.")
|
||||
|
||||
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]
|
||||
|
||||
ret.name = get_choice('name', weights)
|
||||
@@ -506,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():
|
||||
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:
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
if ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights)
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
if PlandoOptions.connections in plando_options:
|
||||
ret.plando_connections = []
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement, "both")
|
||||
))
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights):
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
|
||||
ret.plando_texts = {}
|
||||
if PlandoOptions.texts in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
options = weights.get("plando_texts", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
at = str(get_choice_legacy("at", placement))
|
||||
if at not in tt:
|
||||
raise Exception(f"No text target \"{at}\" found.")
|
||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||
|
||||
ret.sprite_pool = weights.get('sprite_pool', [])
|
||||
ret.sprite = get_choice_legacy('sprite', weights, "Link")
|
||||
if 'random_sprite_on_event' in weights:
|
||||
|
||||
84
Launcher.py
84
Launcher.py
@@ -19,7 +19,7 @@ import sys
|
||||
import webbrowser
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Callable, Sequence, Union, Optional
|
||||
from typing import Sequence, Union, Optional
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
@@ -160,12 +160,8 @@ def launch(exe, in_terminal=False):
|
||||
subprocess.Popen(exe)
|
||||
|
||||
|
||||
refresh_components: Optional[Callable[[], None]] = None
|
||||
|
||||
|
||||
def run_gui():
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
|
||||
from kivy.core.window import Window
|
||||
from kivy.uix.image import AsyncImage
|
||||
from kivy.uix.relativelayout import RelativeLayout
|
||||
|
||||
@@ -173,8 +169,11 @@ def run_gui():
|
||||
base_title: str = "Archipelago Launcher"
|
||||
container: ContainerLayout
|
||||
grid: GridLayout
|
||||
_tool_layout: Optional[ScrollBox] = None
|
||||
_client_layout: Optional[ScrollBox] = None
|
||||
|
||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
self.title = self.base_title
|
||||
@@ -182,7 +181,18 @@ def run_gui():
|
||||
self.icon = r"data/icon.png"
|
||||
super().__init__()
|
||||
|
||||
def _refresh_components(self) -> None:
|
||||
def build(self):
|
||||
self.container = ContainerLayout()
|
||||
self.grid = GridLayout(cols=2)
|
||||
self.container.add_widget(self.grid)
|
||||
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
||||
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
||||
tool_layout = ScrollBox()
|
||||
tool_layout.layout.orientation = "vertical"
|
||||
self.grid.add_widget(tool_layout)
|
||||
client_layout = ScrollBox()
|
||||
client_layout.layout.orientation = "vertical"
|
||||
self.grid.add_widget(client_layout)
|
||||
|
||||
def build_button(component: Component) -> Widget:
|
||||
"""
|
||||
@@ -207,49 +217,14 @@ def run_gui():
|
||||
return box_layout
|
||||
return button
|
||||
|
||||
# clear before repopulating
|
||||
assert self._tool_layout and self._client_layout, "must call `build` first"
|
||||
tool_children = reversed(self._tool_layout.layout.children)
|
||||
for child in tool_children:
|
||||
self._tool_layout.layout.remove_widget(child)
|
||||
client_children = reversed(self._client_layout.layout.children)
|
||||
for child in client_children:
|
||||
self._client_layout.layout.remove_widget(child)
|
||||
|
||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
||||
|
||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||
_tools.items(), _miscs.items(), _adjusters.items()
|
||||
), _clients.items()):
|
||||
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
|
||||
# column 1
|
||||
if tool:
|
||||
self._tool_layout.layout.add_widget(build_button(tool[1]))
|
||||
tool_layout.layout.add_widget(build_button(tool[1]))
|
||||
# column 2
|
||||
if client:
|
||||
self._client_layout.layout.add_widget(build_button(client[1]))
|
||||
|
||||
def build(self):
|
||||
self.container = ContainerLayout()
|
||||
self.grid = GridLayout(cols=2)
|
||||
self.container.add_widget(self.grid)
|
||||
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
||||
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
||||
self._tool_layout = ScrollBox()
|
||||
self._tool_layout.layout.orientation = "vertical"
|
||||
self.grid.add_widget(self._tool_layout)
|
||||
self._client_layout = ScrollBox()
|
||||
self._client_layout.layout.orientation = "vertical"
|
||||
self.grid.add_widget(self._client_layout)
|
||||
|
||||
self._refresh_components()
|
||||
|
||||
global refresh_components
|
||||
refresh_components = self._refresh_components
|
||||
|
||||
Window.bind(on_drop_file=self._on_drop_file)
|
||||
client_layout.layout.add_widget(build_button(client[1]))
|
||||
|
||||
return self.container
|
||||
|
||||
@@ -260,14 +235,6 @@ def run_gui():
|
||||
else:
|
||||
launch(get_exe(button.component), button.component.cli)
|
||||
|
||||
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
|
||||
""" When a patch file is dropped into the window, run the associated component. """
|
||||
file, component = identify(filename.decode())
|
||||
if file and component:
|
||||
run_component(component, file)
|
||||
else:
|
||||
logging.warning(f"unable to identify component for {filename}")
|
||||
|
||||
def _stop(self, *largs):
|
||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||
# Closing the window explicitly cleans it up.
|
||||
@@ -276,17 +243,10 @@ def run_gui():
|
||||
|
||||
Launcher().run()
|
||||
|
||||
# avoiding Launcher reference leak
|
||||
# and don't try to do something with widgets after window closed
|
||||
global refresh_components
|
||||
refresh_components = None
|
||||
|
||||
|
||||
def run_component(component: Component, *args):
|
||||
if component.func:
|
||||
component.func(*args)
|
||||
if refresh_components:
|
||||
refresh_components()
|
||||
elif component.script_name:
|
||||
subprocess.run([*get_exe(component.script_name), *args])
|
||||
else:
|
||||
@@ -299,7 +259,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
elif not args:
|
||||
args = {}
|
||||
|
||||
if args.get("Patch|Game|Component", None) is not None:
|
||||
if "Patch|Game|Component" in args:
|
||||
file, component = identify(args["Patch|Game|Component"])
|
||||
if file:
|
||||
args['file'] = file
|
||||
|
||||
16
Main.py
16
Main.py
@@ -13,7 +13,7 @@ import worlds
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
||||
from Options import StartInventoryPool
|
||||
from Utils import __version__, output_path, version_tuple, get_settings
|
||||
from Utils import __version__, output_path, version_tuple
|
||||
from settings import get_settings
|
||||
from worlds import AutoWorld
|
||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||
@@ -272,7 +272,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
if multiworld.algorithm == 'flood':
|
||||
flood_items(multiworld) # different algo, biased towards early game progress items
|
||||
elif multiworld.algorithm == 'balanced':
|
||||
distribute_items_restrictive(multiworld, get_settings().generator.panic_method)
|
||||
distribute_items_restrictive(multiworld)
|
||||
|
||||
AutoWorld.call_all(multiworld, 'post_fill')
|
||||
|
||||
@@ -372,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]]]] = {}
|
||||
|
||||
# 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 = {
|
||||
"slot_data": slot_data,
|
||||
"slot_info": slot_info,
|
||||
@@ -397,7 +386,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": multiworld.seed_name,
|
||||
"spheres": spheres,
|
||||
"datapackage": data_package,
|
||||
}
|
||||
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
||||
|
||||
183
MultiServer.py
183
MultiServer.py
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import asyncio
|
||||
import collections
|
||||
import contextlib
|
||||
import copy
|
||||
import datetime
|
||||
import functools
|
||||
@@ -38,7 +37,7 @@ except ImportError:
|
||||
|
||||
import NetUtils
|
||||
import Utils
|
||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
||||
from Utils import version_tuple, restricted_loads, Version, async_start
|
||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||
SlotType, LocationStore
|
||||
|
||||
@@ -169,25 +168,18 @@ class Context:
|
||||
slot_info: typing.Dict[int, NetworkSlot]
|
||||
generator_version = Version(0, 0, 0)
|
||||
checksums: typing.Dict[str, str]
|
||||
item_names: typing.Dict[str, typing.Dict[int, str]] = (
|
||||
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')))
|
||||
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||
location_names: typing.Dict[str, typing.Dict[int, str]] = (
|
||||
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')))
|
||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
|
||||
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
|
||||
""" each sphere is { player: { location_id, ... } } """
|
||||
logger: logging.Logger
|
||||
|
||||
non_hintable_names: typing.Dict[str, typing.Set[str]]
|
||||
|
||||
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",
|
||||
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
||||
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
||||
self.logger = logger
|
||||
log_network: bool = False):
|
||||
super(Context, self).__init__()
|
||||
self.slot_info = {}
|
||||
self.log_network = log_network
|
||||
@@ -232,7 +224,7 @@ class Context:
|
||||
self.embedded_blacklist = {"host", "port"}
|
||||
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
|
||||
self.auto_save_interval = 60 # in seconds
|
||||
self.auto_saver_thread: typing.Optional[threading.Thread] = None
|
||||
self.auto_saver_thread = None
|
||||
self.save_dirty = False
|
||||
self.tags = ['AP']
|
||||
self.games: typing.Dict[int, str] = {}
|
||||
@@ -244,7 +236,6 @@ class Context:
|
||||
self.stored_data = {}
|
||||
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
|
||||
self.read_data = {}
|
||||
self.spheres = []
|
||||
|
||||
# init empty to satisfy linter, I suppose
|
||||
self.gamespackage = {}
|
||||
@@ -269,31 +260,19 @@ class Context:
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
self.non_hintable_names[world_name] = world.hint_blacklist
|
||||
|
||||
for game_package in self.gamespackage.values():
|
||||
# remove groups from data sent to clients
|
||||
del game_package["item_name_groups"]
|
||||
del game_package["location_name_groups"]
|
||||
|
||||
def _init_game_data(self):
|
||||
for game_name, game_package in self.gamespackage.items():
|
||||
if "checksum" in game_package:
|
||||
self.checksums[game_name] = game_package["checksum"]
|
||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||
self.item_names[game_name][item_id] = item_name
|
||||
self.item_names[item_id] = item_name
|
||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||
self.location_names[game_name][location_id] = location_name
|
||||
self.location_names[location_id] = location_name
|
||||
self.all_item_and_group_names[game_name] = \
|
||||
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
||||
self.all_location_and_group_names[game_name] = \
|
||||
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
|
||||
|
||||
archipelago_item_names = self.item_names["Archipelago"]
|
||||
archipelago_location_names = self.location_names["Archipelago"]
|
||||
for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
|
||||
# Add Archipelago items and locations to each data package.
|
||||
self.item_names[game].update(archipelago_item_names)
|
||||
self.location_names[game].update(archipelago_location_names)
|
||||
|
||||
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
|
||||
|
||||
@@ -308,12 +287,12 @@ class Context:
|
||||
try:
|
||||
await endpoint.socket.send(msg)
|
||||
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)
|
||||
return False
|
||||
else:
|
||||
if self.log_network:
|
||||
self.logger.info(f"Outgoing message: {msg}")
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
return True
|
||||
|
||||
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
|
||||
@@ -322,12 +301,12 @@ class Context:
|
||||
try:
|
||||
await endpoint.socket.send(msg)
|
||||
except websockets.ConnectionClosed:
|
||||
self.logger.exception("Exception during send_encoded_msgs")
|
||||
logging.exception("Exception during send_encoded_msgs")
|
||||
await self.disconnect(endpoint)
|
||||
return False
|
||||
else:
|
||||
if self.log_network:
|
||||
self.logger.info(f"Outgoing message: {msg}")
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
return True
|
||||
|
||||
async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint], msg: str) -> bool:
|
||||
@@ -338,11 +317,11 @@ class Context:
|
||||
try:
|
||||
websockets.broadcast(sockets, msg)
|
||||
except RuntimeError:
|
||||
self.logger.exception("Exception during broadcast_send_encoded_msgs")
|
||||
logging.exception("Exception during broadcast_send_encoded_msgs")
|
||||
return False
|
||||
else:
|
||||
if self.log_network:
|
||||
self.logger.info(f"Outgoing broadcast: {msg}")
|
||||
logging.info(f"Outgoing broadcast: {msg}")
|
||||
return True
|
||||
|
||||
def broadcast_all(self, msgs: typing.List[dict]):
|
||||
@@ -351,7 +330,7 @@ class Context:
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
|
||||
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}])
|
||||
|
||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||
@@ -373,7 +352,7 @@ class Context:
|
||||
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
||||
if not client.auth:
|
||||
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}]))
|
||||
|
||||
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
||||
@@ -472,7 +451,7 @@ class Context:
|
||||
for game_name, data in decoded_obj.get("datapackage", {}).items():
|
||||
if game_name in game_data_packages:
|
||||
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.item_name_groups[game_name] = data["item_name_groups"]
|
||||
if "location_name_groups" in data:
|
||||
@@ -485,9 +464,6 @@ class Context:
|
||||
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]
|
||||
|
||||
# sorted access spheres
|
||||
self.spheres = decoded_obj.get("spheres", [])
|
||||
|
||||
# saving
|
||||
|
||||
def save(self, now=False) -> bool:
|
||||
@@ -507,7 +483,7 @@ class Context:
|
||||
with open(self.save_filename, "wb") as f:
|
||||
f.write(zlib.compress(encoded_save))
|
||||
except Exception as e:
|
||||
self.logger.exception(e)
|
||||
logging.exception(e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
@@ -525,12 +501,12 @@ class Context:
|
||||
save_data = restricted_loads(zlib.decompress(f.read()))
|
||||
self.set_save(save_data)
|
||||
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:
|
||||
self.logger.exception(e)
|
||||
logging.exception(e)
|
||||
self._start_async_saving()
|
||||
|
||||
def _start_async_saving(self, atexit_save: bool = True):
|
||||
def _start_async_saving(self):
|
||||
if not self.auto_saver_thread:
|
||||
def save_regularly():
|
||||
# time.time() is platform dependent, so using the expensive datetime method instead
|
||||
@@ -544,19 +520,18 @@ class Context:
|
||||
next_wakeup = (second - get_datetime_second()) % self.auto_save_interval
|
||||
time.sleep(max(1.0, next_wakeup))
|
||||
if self.save_dirty:
|
||||
self.logger.debug("Saving via thread.")
|
||||
logging.debug("Saving via thread.")
|
||||
self._save()
|
||||
except OperationalError as e:
|
||||
self.logger.exception(e)
|
||||
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
||||
logging.exception(e)
|
||||
logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
||||
else:
|
||||
self.save_dirty = False
|
||||
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
||||
self.auto_saver_thread.start()
|
||||
|
||||
if atexit_save:
|
||||
import atexit
|
||||
atexit.register(self._save, True) # make sure we save on exit too
|
||||
import atexit
|
||||
atexit.register(self._save, True) # make sure we save on exit too
|
||||
|
||||
def get_save(self) -> dict:
|
||||
self.recheck_hints()
|
||||
@@ -623,7 +598,7 @@ class Context:
|
||||
if "stored_data" in savedata:
|
||||
self.stored_data = savedata["stored_data"]
|
||||
# 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'for {sum(k[2] for k in self.received_items)} players')
|
||||
|
||||
@@ -646,16 +621,6 @@ class Context:
|
||||
self.recheck_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):
|
||||
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
|
||||
|
||||
@@ -675,13 +640,13 @@ class Context:
|
||||
try:
|
||||
raise Exception(f"Could not set server option {key}, skipping.") from e
|
||||
except Exception as e:
|
||||
self.logger.exception(e)
|
||||
self.logger.debug(f"Setting server option {key} to {value} from supplied multidata")
|
||||
logging.exception(e)
|
||||
logging.debug(f"Setting server option {key} to {value} from supplied multidata")
|
||||
setattr(self, key, value)
|
||||
elif key == "disable_item_cheat":
|
||||
self.item_cheat = not bool(value)
|
||||
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):
|
||||
if (team, slot) in self.name_aliases:
|
||||
@@ -715,7 +680,7 @@ class Context:
|
||||
self.hints[team, player].add(hint)
|
||||
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:
|
||||
self.on_new_hint(team, slot)
|
||||
for slot, hint_data in concerns.items():
|
||||
@@ -774,21 +739,21 @@ async def server(websocket, path: str = "/", ctx: Context = None):
|
||||
|
||||
try:
|
||||
if ctx.log_network:
|
||||
ctx.logger.info("Incoming connection")
|
||||
logging.info("Incoming connection")
|
||||
await on_client_connected(ctx, client)
|
||||
if ctx.log_network:
|
||||
ctx.logger.info("Sent Room Info")
|
||||
logging.info("Sent Room Info")
|
||||
async for data in websocket:
|
||||
if ctx.log_network:
|
||||
ctx.logger.info(f"Incoming message: {data}")
|
||||
logging.info(f"Incoming message: {data}")
|
||||
for msg in decode(data):
|
||||
await process_client_cmd(ctx, client, msg)
|
||||
except Exception as e:
|
||||
if not isinstance(e, websockets.WebSocketException):
|
||||
ctx.logger.exception(e)
|
||||
logging.exception(e)
|
||||
finally:
|
||||
if ctx.log_network:
|
||||
ctx.logger.info("Disconnected")
|
||||
logging.info("Disconnected")
|
||||
await ctx.disconnect(client)
|
||||
|
||||
|
||||
@@ -798,7 +763,10 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
for slot, connected_clients in clients.items():
|
||||
if connected_clients:
|
||||
name = ctx.player_names[team, slot]
|
||||
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
|
||||
players.append(
|
||||
NetworkPlayer(team, slot,
|
||||
ctx.name_aliases.get((team, slot), name), name)
|
||||
)
|
||||
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
|
||||
games.add("Archipelago")
|
||||
await ctx.send_msgs(client, [{
|
||||
@@ -813,6 +781,8 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
'permissions': get_permissions(ctx),
|
||||
'hint_cost': ctx.hint_cost,
|
||||
'location_check_points': ctx.location_check_points,
|
||||
'datapackage_versions': {game: game_data["version"] for game, game_data
|
||||
in ctx.gamespackage.items() if game in games},
|
||||
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
|
||||
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
|
||||
'seed_name': ctx.seed_name,
|
||||
@@ -1015,9 +985,9 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
||||
new_item = NetworkItem(item_id, location, slot, flags)
|
||||
send_items_to(ctx, team, target_player, new_item)
|
||||
|
||||
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
|
||||
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
|
||||
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
|
||||
ctx.player_names[(team, target_player)], ctx.location_names[location]))
|
||||
info_text = json_format_send_event(new_item, target_player)
|
||||
ctx.broadcast_team(team, [info_text])
|
||||
|
||||
@@ -1071,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:
|
||||
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
||||
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
|
||||
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
|
||||
f"{ctx.item_names[hint.item]} is " \
|
||||
f"at {ctx.location_names[hint.location]} " \
|
||||
f"in {ctx.player_names[team, hint.finding_player]}'s World"
|
||||
|
||||
if hint.entrance:
|
||||
@@ -1101,6 +1071,28 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
||||
"item": net_item}
|
||||
|
||||
|
||||
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
|
||||
picks = Utils.get_fuzzy_results(input_text, possible_answers, limit=2)
|
||||
if len(picks) > 1:
|
||||
dif = picks[0][1] - picks[1][1]
|
||||
if picks[0][1] == 100:
|
||||
return picks[0][0], True, "Perfect Match"
|
||||
elif picks[0][1] < 75:
|
||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||
elif dif > 5:
|
||||
return picks[0][0], True, "Close Match"
|
||||
else:
|
||||
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
|
||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||
else:
|
||||
if picks[0][1] > 90:
|
||||
return picks[0][0], True, "Only Option Match"
|
||||
else:
|
||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||
|
||||
|
||||
class CommandMeta(type):
|
||||
def __new__(cls, name, bases, attrs):
|
||||
commands = attrs["commands"] = {}
|
||||
@@ -1352,7 +1344,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
if self.ctx.remaining_mode == "enabled":
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if remaining_item_ids:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
|
||||
for item_id in remaining_item_ids))
|
||||
else:
|
||||
self.output("No remaining items found.")
|
||||
@@ -1365,7 +1357,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if remaining_item_ids:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
|
||||
for item_id in remaining_item_ids))
|
||||
else:
|
||||
self.output("No remaining items found.")
|
||||
@@ -1383,8 +1375,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
|
||||
|
||||
if locations:
|
||||
game = self.ctx.slot_info[self.client.slot].game
|
||||
names = [self.ctx.location_names[game][location] for location in locations]
|
||||
names = [self.ctx.location_names[location] for location in locations]
|
||||
if filter_text:
|
||||
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
||||
if filter_text in location_groups: # location group name
|
||||
@@ -1409,8 +1400,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
|
||||
|
||||
if locations:
|
||||
game = self.ctx.slot_info[self.client.slot].game
|
||||
names = [self.ctx.location_names[game][location] for location in locations]
|
||||
names = [self.ctx.location_names[location] for location in locations]
|
||||
if filter_text:
|
||||
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
||||
if filter_text in location_groups: # location group name
|
||||
@@ -1491,10 +1481,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
elif input_text.isnumeric():
|
||||
game = self.ctx.games[self.client.slot]
|
||||
hint_id = int(input_text)
|
||||
hint_name = self.ctx.item_names[game][hint_id] \
|
||||
if not for_location and hint_id in self.ctx.item_names[game] \
|
||||
else self.ctx.location_names[game][hint_id] \
|
||||
if for_location and hint_id in self.ctx.location_names[game] \
|
||||
hint_name = self.ctx.item_names[hint_id] \
|
||||
if not for_location and hint_id in self.ctx.item_names \
|
||||
else self.ctx.location_names[hint_id] \
|
||||
if for_location and hint_id in self.ctx.location_names \
|
||||
else None
|
||||
if hint_name in self.ctx.non_hintable_names[game]:
|
||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||
@@ -1556,9 +1546,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
self.ctx.random.shuffle(not_found_hints)
|
||||
# By popular vote, make hints prefer non-local placements
|
||||
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
|
||||
while can_pay > 0:
|
||||
@@ -1568,10 +1555,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
hints.append(hint)
|
||||
can_pay -= 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)
|
||||
if not_found_hints:
|
||||
points_available = get_client_points(self.ctx, self.client)
|
||||
if hints and cost and int((points_available // cost) == 0):
|
||||
self.output(
|
||||
f"There may be more hintables, however, you cannot afford to pay for any more. "
|
||||
@@ -1638,7 +1625,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
try:
|
||||
cmd: str = args["cmd"]
|
||||
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,
|
||||
"text": f"Could not get command from {args} at `cmd`"}])
|
||||
raise
|
||||
@@ -1681,7 +1668,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
if ctx.compatibility == 0 and args['version'] != version_tuple:
|
||||
errors.add('IncompatibleVersion')
|
||||
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)}])
|
||||
else:
|
||||
team, slot = ctx.connect_names[args['name']]
|
||||
@@ -1932,6 +1919,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
def _cmd_exit(self) -> bool:
|
||||
"""Shutdown the server"""
|
||||
self.ctx.server.ws_server.close()
|
||||
if self.ctx.shutdown_task:
|
||||
self.ctx.shutdown_task.cancel()
|
||||
self.ctx.exit_event.set()
|
||||
return True
|
||||
|
||||
@@ -2289,8 +2278,7 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
|
||||
async def auto_shutdown(ctx, to_cancel=None):
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)
|
||||
await asyncio.sleep(ctx.auto_shutdown)
|
||||
|
||||
def inactivity_shutdown():
|
||||
ctx.server.ws_server.close()
|
||||
@@ -2298,7 +2286,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
||||
if to_cancel:
|
||||
for task in to_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():
|
||||
if not ctx.client_activity_timers.values():
|
||||
@@ -2310,8 +2298,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
||||
if seconds < 0:
|
||||
inactivity_shutdown()
|
||||
else:
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(ctx.exit_event.wait(), seconds)
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
|
||||
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":
|
||||
|
||||
@@ -198,8 +198,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
"slateblue": "6D8BE8",
|
||||
"plum": "AF99EF",
|
||||
"salmon": "FA8072",
|
||||
"white": "FFFFFF",
|
||||
"orange": "FF7700",
|
||||
"white": "FFFFFF"
|
||||
}
|
||||
|
||||
def __init__(self, ctx):
|
||||
@@ -248,7 +247,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
|
||||
def _handle_item_id(self, node: JSONMessagePart):
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.item_names.lookup_in_slot(item_id, node["player"])
|
||||
node["text"] = self.ctx.item_names[item_id]
|
||||
return self._handle_item_name(node)
|
||||
|
||||
def _handle_location_name(self, node: JSONMessagePart):
|
||||
@@ -256,8 +255,8 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_location_id(self, node: JSONMessagePart):
|
||||
location_id = int(node["text"])
|
||||
node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"])
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.location_names[item_id]
|
||||
return self._handle_location_name(node)
|
||||
|
||||
def _handle_entrance_name(self, node: JSONMessagePart):
|
||||
|
||||
326
Options.py
326
Options.py
@@ -12,7 +12,6 @@ from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from schema import And, Optional, Or, Schema
|
||||
from typing_extensions import Self
|
||||
|
||||
from Utils import get_fuzzy_results, is_iterable_except_str
|
||||
|
||||
@@ -25,7 +24,7 @@ if typing.TYPE_CHECKING:
|
||||
class OptionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class Visibility(enum.IntFlag):
|
||||
none = 0b0000
|
||||
template = 0b0001
|
||||
@@ -141,6 +140,12 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
def current_key(self) -> str:
|
||||
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
|
||||
def current_option_name(self) -> str:
|
||||
"""For display purposes. Worlds should be using current_key."""
|
||||
@@ -745,9 +750,39 @@ class NamedRange(Range):
|
||||
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):
|
||||
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:
|
||||
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
|
||||
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
|
||||
@@ -897,228 +932,6 @@ class ItemSet(OptionSet):
|
||||
convert_name_groups = True
|
||||
|
||||
|
||||
class PlandoText(typing.NamedTuple):
|
||||
at: str
|
||||
text: typing.List[str]
|
||||
percentage: int = 100
|
||||
|
||||
|
||||
PlandoTextsFromAnyType = typing.Union[
|
||||
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoText, typing.Any]], typing.Any
|
||||
]
|
||||
|
||||
|
||||
class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
default = ()
|
||||
supports_weighting = False
|
||||
display_name = "Plando Texts"
|
||||
|
||||
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
|
||||
self.value = list(deepcopy(value))
|
||||
super().__init__()
|
||||
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||
from BaseClasses import PlandoOptions
|
||||
if self.value and not (PlandoOptions.texts & plando_options):
|
||||
# plando is disabled but plando options were given so overwrite the options
|
||||
self.value = []
|
||||
logging.warning(f"The plando texts module is turned off, "
|
||||
f"so text for {player_name} will be ignored.")
|
||||
|
||||
@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:
|
||||
given_text = text.get("text", [])
|
||||
if isinstance(given_text, str):
|
||||
given_text = [given_text]
|
||||
texts.append(PlandoText(
|
||||
at,
|
||||
given_text,
|
||||
text.get("percentage", 100)
|
||||
))
|
||||
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)}")
|
||||
cls.verify_keys([text.at for text in texts])
|
||||
return cls(texts)
|
||||
else:
|
||||
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: typing.List[PlandoText]) -> str:
|
||||
return str({text.at: " ".join(text.text) for text in value})
|
||||
|
||||
def __iter__(self) -> typing.Iterator[PlandoText]:
|
||||
yield from self.value
|
||||
|
||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
||||
return self.value.__getitem__(index)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.value.__len__()
|
||||
|
||||
|
||||
class ConnectionsMeta(AssembleOptions):
|
||||
def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any]):
|
||||
if name != "PlandoConnections":
|
||||
assert "entrances" in attrs, f"Please define valid entrances for {name}"
|
||||
attrs["entrances"] = frozenset((connection.lower() for connection in attrs["entrances"]))
|
||||
assert "exits" in attrs, f"Please define valid exits for {name}"
|
||||
attrs["exits"] = frozenset((connection.lower() for connection in attrs["exits"]))
|
||||
if "__doc__" not in attrs:
|
||||
attrs["__doc__"] = PlandoConnections.__doc__
|
||||
cls = super().__new__(mcs, name, bases, attrs)
|
||||
return cls
|
||||
|
||||
|
||||
class PlandoConnection(typing.NamedTuple):
|
||||
class Direction:
|
||||
entrance = "entrance"
|
||||
exit = "exit"
|
||||
both = "both"
|
||||
|
||||
entrance: str
|
||||
exit: str
|
||||
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
|
||||
percentage: int = 100
|
||||
|
||||
|
||||
PlandoConFromAnyType = typing.Union[
|
||||
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoConnection, typing.Any]], typing.Any
|
||||
]
|
||||
|
||||
|
||||
class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=ConnectionsMeta):
|
||||
"""Generic connections plando. Format is:
|
||||
- entrance: "Entrance Name"
|
||||
exit: "Exit Name"
|
||||
direction: "Direction"
|
||||
percentage: 100
|
||||
Direction must be one of 'entrance', 'exit', or 'both', and defaults to 'both' if omitted.
|
||||
Percentage is an integer from 1 to 100, and defaults to 100 when omitted."""
|
||||
|
||||
display_name = "Plando Connections"
|
||||
|
||||
default = ()
|
||||
supports_weighting = False
|
||||
|
||||
entrances: typing.ClassVar[typing.AbstractSet[str]]
|
||||
exits: typing.ClassVar[typing.AbstractSet[str]]
|
||||
|
||||
duplicate_exits: bool = False
|
||||
"""Whether or not exits should be allowed to be duplicate."""
|
||||
|
||||
def __init__(self, value: typing.Iterable[PlandoConnection]):
|
||||
self.value = list(deepcopy(value))
|
||||
super(PlandoConnections, self).__init__()
|
||||
|
||||
@classmethod
|
||||
def validate_entrance_name(cls, entrance: str) -> bool:
|
||||
return entrance.lower() in cls.entrances
|
||||
|
||||
@classmethod
|
||||
def validate_exit_name(cls, exit: str) -> bool:
|
||||
return exit.lower() in cls.exits
|
||||
|
||||
@classmethod
|
||||
def can_connect(cls, entrance: str, exit: str) -> bool:
|
||||
"""Checks that a given entrance can connect to a given exit.
|
||||
By default, this will always return true unless overridden."""
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def validate_plando_connections(cls, connections: typing.Iterable[PlandoConnection]) -> None:
|
||||
used_entrances: typing.List[str] = []
|
||||
used_exits: typing.List[str] = []
|
||||
for connection in connections:
|
||||
entrance = connection.entrance
|
||||
exit = connection.exit
|
||||
direction = connection.direction
|
||||
if direction not in (PlandoConnection.Direction.entrance,
|
||||
PlandoConnection.Direction.exit,
|
||||
PlandoConnection.Direction.both):
|
||||
raise ValueError(f"Unknown direction: {direction}")
|
||||
if entrance in used_entrances:
|
||||
raise ValueError(f"Duplicate Entrance {entrance} not allowed.")
|
||||
if not cls.duplicate_exits and exit in used_exits:
|
||||
raise ValueError(f"Duplicate Exit {exit} not allowed.")
|
||||
used_entrances.append(entrance)
|
||||
used_exits.append(exit)
|
||||
if not cls.validate_entrance_name(entrance):
|
||||
raise ValueError(f"{entrance.title()} is not a valid entrance.")
|
||||
if not cls.validate_exit_name(exit):
|
||||
raise ValueError(f"{exit.title()} is not a valid exit.")
|
||||
if not cls.can_connect(entrance, exit):
|
||||
raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: PlandoConFromAnyType) -> Self:
|
||||
if not isinstance(data, typing.Iterable):
|
||||
raise Exception(f"Cannot create plando connections from non-List value, got {type(data)}.")
|
||||
|
||||
value: typing.List[PlandoConnection] = []
|
||||
for connection in data:
|
||||
if isinstance(connection, typing.Mapping):
|
||||
percentage = connection.get("percentage", 100)
|
||||
if random.random() < float(percentage / 100):
|
||||
entrance = connection.get("entrance", None)
|
||||
if is_iterable_except_str(entrance):
|
||||
entrance = random.choice(sorted(entrance))
|
||||
exit = connection.get("exit", None)
|
||||
if is_iterable_except_str(exit):
|
||||
exit = random.choice(sorted(exit))
|
||||
direction = connection.get("direction", "both")
|
||||
|
||||
if not entrance or not exit:
|
||||
raise Exception("Plando connection must have an entrance and an exit.")
|
||||
value.append(PlandoConnection(
|
||||
entrance,
|
||||
exit,
|
||||
direction,
|
||||
percentage
|
||||
))
|
||||
elif isinstance(connection, PlandoConnection):
|
||||
if random.random() < float(connection.percentage / 100):
|
||||
value.append(connection)
|
||||
else:
|
||||
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
||||
cls.validate_plando_connections(value)
|
||||
return cls(value)
|
||||
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||
from BaseClasses import PlandoOptions
|
||||
if self.value and not (PlandoOptions.connections & plando_options):
|
||||
# plando is disabled but plando options were given so overwrite the options
|
||||
self.value = []
|
||||
logging.warning(f"The plando connections module is turned off, "
|
||||
f"so connections for {player_name} will be ignored.")
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: typing.List[PlandoConnection]) -> str:
|
||||
return ", ".join(["%s %s %s" % (connection.entrance,
|
||||
"<=>" if connection.direction == PlandoConnection.Direction.both else
|
||||
"<=" if connection.direction == PlandoConnection.Direction.exit else
|
||||
"=>",
|
||||
connection.exit) for connection in value])
|
||||
|
||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
||||
return self.value.__getitem__(index)
|
||||
|
||||
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
||||
yield from self.value
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.value)
|
||||
|
||||
|
||||
class Accessibility(Choice):
|
||||
"""Set rules for reachability of your items/locations.
|
||||
Locations: ensure everything can be reached and acquired.
|
||||
@@ -1133,10 +946,8 @@ class Accessibility(Choice):
|
||||
|
||||
|
||||
class ProgressionBalancing(NamedRange):
|
||||
"""
|
||||
A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||
A lower setting means more getting stuck. A higher setting means less getting stuck.
|
||||
"""
|
||||
"""A 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."""
|
||||
default = 50
|
||||
range_start = 0
|
||||
range_end = 99
|
||||
@@ -1173,7 +984,7 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
||||
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Returns a dictionary of [str, Option.value]
|
||||
|
||||
|
||||
:param option_names: names of the options to return
|
||||
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
||||
"""
|
||||
@@ -1209,7 +1020,7 @@ class LocalItems(ItemSet):
|
||||
|
||||
class NonLocalItems(ItemSet):
|
||||
"""Forces these items to be outside their native world."""
|
||||
display_name = "Non-local Items"
|
||||
display_name = "Not Local Items"
|
||||
|
||||
|
||||
class StartInventory(ItemDict):
|
||||
@@ -1272,8 +1083,7 @@ class ItemLinks(OptionList):
|
||||
])
|
||||
|
||||
@staticmethod
|
||||
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world,
|
||||
allow_item_groups: bool = True) -> typing.Set:
|
||||
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
|
||||
pool = set()
|
||||
for item_name in items:
|
||||
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
|
||||
@@ -1350,47 +1160,7 @@ class DeathLinkMixin:
|
||||
death_link: DeathLink
|
||||
|
||||
|
||||
class OptionGroup(typing.NamedTuple):
|
||||
"""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:
|
||||
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
|
||||
import os
|
||||
|
||||
import yaml
|
||||
@@ -1428,11 +1198,15 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden or generate_hidden:
|
||||
grouped_options = 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:
|
||||
file_data = f.read()
|
||||
res = Template(file_data).render(
|
||||
option_groups=grouped_options,
|
||||
options=all_options,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range,
|
||||
)
|
||||
|
||||
49
README.md
49
README.md
@@ -1,10 +1,8 @@
|
||||
# [Archipelago](https://archipelago.gg)  | [Install](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
|
||||
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases,
|
||||
presently, Archipelago is also the randomizer itself.
|
||||
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, presently, Archipelago is also the randomizer itself.
|
||||
|
||||
Currently, the following games are supported:
|
||||
|
||||
* The Legend of Zelda: A Link to the Past
|
||||
* Factorio
|
||||
* Minecraft
|
||||
@@ -68,10 +66,6 @@ Currently, the following games are supported:
|
||||
* A Short Hike
|
||||
* Yoshi's Island
|
||||
* Mario & Luigi: Superstar Saga
|
||||
* Bomb Rush Cyberfunk
|
||||
* Aquaria
|
||||
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
||||
* A Hat in Time
|
||||
|
||||
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
|
||||
@@ -79,57 +73,36 @@ windows binaries.
|
||||
|
||||
## History
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
* [bonta0's MultiWorld](https://github.com/Bonta0/ALttPEntranceRandomizer/tree/multiworld_31)
|
||||
* [AmazingAmpharos' Entrance Randomizer](https://github.com/AmazingAmpharos/ALttPEntranceRandomizer)
|
||||
* [VT Web Randomizer](https://github.com/sporchia/alttp_vt_randomizer)
|
||||
* [Dessyreqt's alttprandomizer](https://github.com/Dessyreqt/alttprandomizer)
|
||||
* [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 up the
|
||||
vast majority of Enemizer contributions.
|
||||
* [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.
|
||||
|
||||
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.
|
||||
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 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
|
||||
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
|
||||
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).
|
||||
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
|
||||
|
||||
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)
|
||||
* [Enemizer](https://github.com/Ijwu/Enemizer)
|
||||
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
|
||||
|
||||
## Contributing
|
||||
|
||||
To contribute to Archipelago, including the WebHost, core program, or by adding a new game, see our
|
||||
[Contributing guidelines](/docs/contributing.md).
|
||||
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
|
||||
|
||||
## 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
|
||||
|
||||
Please refer to our [code of conduct](/docs/code_of_conduct.md).
|
||||
Please refer to our [code of conduct.](/docs/code_of_conduct.md)
|
||||
|
||||
@@ -247,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
toDraw = ""
|
||||
for i in range(20):
|
||||
if i < len(str(ctx.item_names.lookup_in_slot(l.item))):
|
||||
toDraw += str(ctx.item_names.lookup_in_slot(l.item))[i]
|
||||
if i < len(str(ctx.item_names[l.item])):
|
||||
toDraw += str(ctx.item_names[l.item])[i]
|
||||
else:
|
||||
break
|
||||
f.write(toDraw)
|
||||
|
||||
103
Utils.py
103
Utils.py
@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
|
||||
return ".".join(str(item) for item in self)
|
||||
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.4.6"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
@@ -101,7 +101,8 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
|
||||
|
||||
@functools.wraps(function)
|
||||
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:
|
||||
res = function(self, arg)
|
||||
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:
|
||||
if is_windows:
|
||||
os.startfile(filename) # type: ignore
|
||||
os.startfile(filename)
|
||||
else:
|
||||
from shutil import which
|
||||
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])
|
||||
|
||||
|
||||
@@ -300,21 +300,21 @@ def get_options() -> 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")
|
||||
storage = persistent_load()
|
||||
category_dict = storage.setdefault(category, {})
|
||||
category_dict[key] = value
|
||||
storage: dict = persistent_load()
|
||||
category = storage.setdefault(category, {})
|
||||
category[key] = value
|
||||
with open(path, "wt") as f:
|
||||
f.write(dump(storage, Dumper=Dumper))
|
||||
|
||||
|
||||
def persistent_load() -> Dict[str, Dict[str, Any]]:
|
||||
storage: Union[Dict[str, Dict[str, Any]], None] = getattr(persistent_load, "storage", None)
|
||||
def persistent_load() -> typing.Dict[str, dict]:
|
||||
storage = getattr(persistent_load, "storage", None)
|
||||
if storage:
|
||||
return storage
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
storage = {}
|
||||
storage: dict = {}
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
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}")
|
||||
if storage is None:
|
||||
storage = {}
|
||||
setattr(persistent_load, "storage", storage)
|
||||
persistent_load.storage = 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:
|
||||
logging.debug(f"Could not store data package: {e}")
|
||||
|
||||
|
||||
def get_default_adjuster_settings(game_name: str) -> Namespace:
|
||||
import LttPAdjuster
|
||||
adjuster_settings = Namespace()
|
||||
@@ -384,9 +383,7 @@ def get_adjuster_settings(game_name: str) -> Namespace:
|
||||
default_settings = get_default_adjuster_settings(game_name)
|
||||
|
||||
# Fill in any arguments from the argparser that we haven't seen before
|
||||
return Namespace(**vars(adjuster_settings), **{
|
||||
k: v for k, v in vars(default_settings).items() if k not in 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)})
|
||||
|
||||
|
||||
@cache_argsless
|
||||
@@ -410,13 +407,13 @@ safe_builtins = frozenset((
|
||||
class RestrictedUnpickler(pickle.Unpickler):
|
||||
generic_properties_module: Optional[object]
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
|
||||
self.options_module = importlib.import_module("Options")
|
||||
self.net_utils_module = importlib.import_module("NetUtils")
|
||||
self.generic_properties_module = None
|
||||
|
||||
def find_class(self, module: str, name: str) -> type:
|
||||
def find_class(self, module, name):
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
return getattr(builtins, name)
|
||||
# used by MultiServer -> savegame/multidata
|
||||
@@ -440,7 +437,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
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()."""
|
||||
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"""
|
||||
default_factory: typing.Callable[[typing.Any], typing.Any]
|
||||
|
||||
def __init__(self,
|
||||
default_factory: typing.Callable[[Any], Any] = None,
|
||||
seq: typing.Union[typing.Mapping, typing.Iterable, None] = None,
|
||||
**kwargs):
|
||||
if seq is not None:
|
||||
super().__init__(default_factory, seq, **kwargs)
|
||||
else:
|
||||
super().__init__(default_factory, **kwargs)
|
||||
|
||||
def __missing__(self, key):
|
||||
self[key] = value = self.default_factory(key)
|
||||
return value
|
||||
@@ -505,7 +493,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
file_handler.setFormatter(logging.Formatter(log_format))
|
||||
|
||||
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)
|
||||
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" on {platform.platform()}"
|
||||
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():
|
||||
while 1:
|
||||
try:
|
||||
@@ -585,7 +572,7 @@ class VersionException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def chaining_prefix(index: int, labels: typing.Sequence[str]) -> str:
|
||||
def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
|
||||
text = ""
|
||||
max_label = len(labels) - 1
|
||||
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)}"
|
||||
|
||||
|
||||
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]]:
|
||||
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())
|
||||
/ max(len(word1), len(word2)))
|
||||
|
||||
limit = limit if limit else len(word_list)
|
||||
limit: int = limit if limit else len(wordlist)
|
||||
return list(
|
||||
map(
|
||||
lambda container: (container[0], int(container[1]*100)), # convert up to limit to int %
|
||||
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],
|
||||
reverse=True
|
||||
)[0:limit]
|
||||
reverse=True)[0:limit]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
|
||||
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
||||
if len(picks) > 1:
|
||||
dif = picks[0][1] - picks[1][1]
|
||||
if picks[0][1] == 100:
|
||||
return picks[0][0], True, "Perfect Match"
|
||||
elif picks[0][1] < 75:
|
||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||
elif dif > 5:
|
||||
return picks[0][0], True, "Close Match"
|
||||
else:
|
||||
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
|
||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||
else:
|
||||
if picks[0][1] > 90:
|
||||
return picks[0][0], True, "Only Option Match"
|
||||
else:
|
||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||
|
||||
|
||||
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
|
||||
if "did you mean " in text:
|
||||
for question in ("Didn't find something that closely matches",
|
||||
"Too many close matches"):
|
||||
if text.startswith(question):
|
||||
name = get_text_between(text, "did you mean '",
|
||||
"'? (")
|
||||
return f"!{command} {name}"
|
||||
elif text.startswith("Missing: "):
|
||||
return text.replace("Missing: ", "!hint_location ")
|
||||
return None
|
||||
|
||||
|
||||
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
||||
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \
|
||||
-> typing.Optional[str]:
|
||||
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()
|
||||
|
||||
|
||||
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."""
|
||||
def sorter(element: Union[str, Dict[str, Any]]) -> str:
|
||||
if (not isinstance(element, str)):
|
||||
@@ -835,7 +788,7 @@ class DeprecateDict(dict):
|
||||
log_message: str
|
||||
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.should_error = error
|
||||
super().__init__()
|
||||
|
||||
@@ -176,7 +176,7 @@ class WargrooveContext(CommonContext):
|
||||
if not os.path.isfile(path):
|
||||
open(path, 'w').close()
|
||||
# Announcing commander unlocks
|
||||
item_name = self.item_names.lookup_in_slot(network_item.item)
|
||||
item_name = self.item_names[network_item.item]
|
||||
if item_name in faction_table.keys():
|
||||
for commander in faction_table[item_name]:
|
||||
logger.info(f"{commander.name} has been unlocked!")
|
||||
@@ -197,7 +197,7 @@ class WargrooveContext(CommonContext):
|
||||
open(print_path, 'w').close()
|
||||
with open(print_path, 'w') as f:
|
||||
f.write("Received " +
|
||||
self.item_names.lookup_in_slot(network_item.item) +
|
||||
self.item_names[network_item.item] +
|
||||
" from " +
|
||||
self.player_names[network_item.player])
|
||||
f.close()
|
||||
@@ -342,7 +342,7 @@ class WargrooveContext(CommonContext):
|
||||
faction_items = 0
|
||||
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
|
||||
for network_item in self.items_received:
|
||||
if self.item_names.lookup_in_slot(network_item.item) in faction_item_names:
|
||||
if self.item_names[network_item.item] in faction_item_names:
|
||||
faction_items += 1
|
||||
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
|
||||
# Must be an integer larger than 0
|
||||
|
||||
16
WebHost.py
16
WebHost.py
@@ -12,9 +12,6 @@ ModuleUpdate.update()
|
||||
import Utils
|
||||
import settings
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from flask import Flask
|
||||
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||
settings.no_gui = True
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
@@ -22,7 +19,7 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||
|
||||
|
||||
def get_app() -> "Flask":
|
||||
def get_app():
|
||||
from WebHostLib import register, cache, app as raw_app
|
||||
from WebHostLib.models import db
|
||||
|
||||
@@ -58,7 +55,6 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
||||
worlds[game] = world
|
||||
|
||||
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
|
||||
shutil.rmtree(base_target_path, ignore_errors=True)
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
target_path = os.path.join(base_target_path, game)
|
||||
@@ -121,7 +117,7 @@ if __name__ == "__main__":
|
||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
@@ -142,11 +138,3 @@ if __name__ == "__main__":
|
||||
else:
|
||||
from waitress import serve
|
||||
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
|
||||
|
||||
@@ -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["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["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
|
||||
@@ -84,6 +83,6 @@ def register():
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
# to trigger app routing picking up on it
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots
|
||||
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -5,6 +5,7 @@ from uuid import UUID
|
||||
from flask import Blueprint, abort, url_for
|
||||
|
||||
import worlds.Files
|
||||
from .. import cache
|
||||
from ..models import Room, Seed
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
@@ -48,4 +49,30 @@ def room_info(room: UUID):
|
||||
}
|
||||
|
||||
|
||||
from . import generate, user, datapackage # trigger registration
|
||||
@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
|
||||
|
||||
@@ -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
|
||||
@@ -3,25 +3,26 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
from datetime import timedelta, datetime
|
||||
from threading import Event, Thread
|
||||
from uuid import UUID
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from pony.orm import db_session, select, commit
|
||||
|
||||
from Utils import restricted_loads
|
||||
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():
|
||||
"""Stops previously launched threads"""
|
||||
global _stop_event
|
||||
stop_event = _stop_event
|
||||
_stop_event = Event() # new event for new threads
|
||||
stop_event.set()
|
||||
multiworld.start()
|
||||
|
||||
|
||||
def handle_generation_success(seed_id):
|
||||
@@ -58,50 +59,39 @@ def init_db(pony_config: dict):
|
||||
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 keep_running():
|
||||
stop_event = _stop_event
|
||||
try:
|
||||
with Locker("autohost"):
|
||||
cleanup()
|
||||
hosters = []
|
||||
for x in range(config["HOSTERS"]):
|
||||
hoster = MultiworldInstance(config, x)
|
||||
hosters.append(hoster)
|
||||
hoster.start()
|
||||
|
||||
while not stop_event.wait(0.1):
|
||||
# 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.")
|
||||
run_guardian()
|
||||
while 1:
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
rooms = select(
|
||||
room for room in Room if
|
||||
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||
for room in rooms:
|
||||
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
|
||||
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
|
||||
hosters[room.id.int % len(hosters)].start_room(room.id)
|
||||
launch_room(room, config)
|
||||
|
||||
except AlreadyRunningException:
|
||||
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 keep_running():
|
||||
stop_event = _stop_event
|
||||
try:
|
||||
with Locker("autogen"):
|
||||
|
||||
@@ -122,7 +112,8 @@ def autogen(config: dict):
|
||||
commit()
|
||||
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:
|
||||
# for update locks the database row(s) during transaction, preventing writes from elsewhere
|
||||
to_start = select(
|
||||
@@ -133,45 +124,37 @@ def autogen(config: dict):
|
||||
except AlreadyRunningException:
|
||||
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] = {}
|
||||
|
||||
|
||||
class MultiworldInstance():
|
||||
def __init__(self, config: dict, id: int):
|
||||
self.room_ids = set()
|
||||
def __init__(self, room: Room, config: dict):
|
||||
self.room_id = room.id
|
||||
self.process: typing.Optional[multiprocessing.Process] = None
|
||||
with guardian_lock:
|
||||
multiworlds[self.room_id] = self
|
||||
self.ponyconfig = config["PONY"]
|
||||
self.cert = config["SELFLAUNCHCERT"]
|
||||
self.key = config["SELFLAUNCHKEY"]
|
||||
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):
|
||||
if self.process and self.process.is_alive():
|
||||
return False
|
||||
|
||||
logging.info(f"Spinning up {self.room_id}")
|
||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.name, self.ponyconfig, get_static_server_data(),
|
||||
self.cert, self.key, self.host,
|
||||
self.rooms_to_start, self.rooms_shutting_down),
|
||||
name=self.name)
|
||||
args=(self.room_id, self.ponyconfig, get_static_server_data(),
|
||||
self.cert, self.key, self.host),
|
||||
name="MultiHost")
|
||||
process.start()
|
||||
# bind after start to prevent thread sync issues with guardian.
|
||||
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):
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
@@ -185,6 +168,40 @@ class MultiworldInstance():
|
||||
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 .customserver import run_server_process, get_static_server_data
|
||||
from .generate import gen_game
|
||||
|
||||
@@ -5,7 +5,6 @@ import collections
|
||||
import datetime
|
||||
import functools
|
||||
import logging
|
||||
import multiprocessing
|
||||
import pickle
|
||||
import random
|
||||
import socket
|
||||
@@ -54,19 +53,17 @@ del MultiServer
|
||||
|
||||
class DBCommandProcessor(ServerCommandProcessor):
|
||||
def output(self, text: str):
|
||||
self.ctx.logger.info(text)
|
||||
logging.info(text)
|
||||
|
||||
|
||||
class WebHostContext(Context):
|
||||
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,
|
||||
# without needing to import worlds system, which takes quite a bit of memory
|
||||
self.static_server_data = static_server_data
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1,
|
||||
40, True, "enabled", "enabled",
|
||||
"enabled", 0, 2, logger=logger)
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
|
||||
del self.static_server_data
|
||||
self.main_loop = asyncio.get_running_loop()
|
||||
self.video = {}
|
||||
@@ -74,7 +71,6 @@ class WebHostContext(Context):
|
||||
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
||||
setattr(self, key, value)
|
||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||
|
||||
@@ -102,37 +98,18 @@ class WebHostContext(Context):
|
||||
|
||||
multidata = self.decompress(room.seed.multidata)
|
||||
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", {})):
|
||||
game_data = multidata["datapackage"][game]
|
||||
if "checksum" in game_data:
|
||||
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||
# non-custom. remove from multidata and use static data
|
||||
if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||
# non-custom. remove from multidata
|
||||
# games package could be dropped from static data once all rooms embed data package
|
||||
del multidata["datapackage"][game]
|
||||
else:
|
||||
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
|
||||
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)
|
||||
|
||||
@db_session
|
||||
@@ -142,7 +119,7 @@ class WebHostContext(Context):
|
||||
savegame_data = Room.get(id=self.room_id).multisave
|
||||
if savegame_data:
|
||||
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()
|
||||
|
||||
@db_session
|
||||
@@ -168,176 +145,86 @@ def get_random_port():
|
||||
def get_static_server_data() -> dict:
|
||||
import worlds
|
||||
data = {
|
||||
"non_hintable_names": {
|
||||
world_name: world.hint_blacklist
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"gamespackage": {
|
||||
world_name: {
|
||||
key: value
|
||||
for key, value in game_package.items()
|
||||
if key not in ("item_name_groups", "location_name_groups")
|
||||
}
|
||||
for world_name, game_package in worlds.network_data_package["games"].items()
|
||||
},
|
||||
"item_name_groups": {
|
||||
world_name: world.item_name_groups
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"location_name_groups": {
|
||||
world_name: world.location_name_groups
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"non_hintable_names": {},
|
||||
"gamespackage": worlds.network_data_package["games"],
|
||||
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
}
|
||||
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def set_up_logging(room_id) -> logging.Logger:
|
||||
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,
|
||||
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
|
||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||
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
|
||||
|
||||
host: str):
|
||||
# establish DB connection for multidata and multisave
|
||||
db.bind(**ponyconfig)
|
||||
db.generate_mapping(check_tables=False)
|
||||
|
||||
if "worlds" in sys.modules:
|
||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||
async def main():
|
||||
if "worlds" in sys.modules:
|
||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||
|
||||
import gc
|
||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||
del cert_file, cert_key_file, ponyconfig
|
||||
gc.collect() # free intermediate objects used during setup
|
||||
import gc
|
||||
Utils.init_logging(str(room_id), write_mode="a")
|
||||
ctx = WebHostContext(static_server_data)
|
||||
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):
|
||||
with Locker(f"RoomLocker {room_id}"):
|
||||
try:
|
||||
logger = set_up_logging(room_id)
|
||||
ctx = WebHostContext(static_server_data, logger)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||
await ctx.server
|
||||
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:
|
||||
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
|
||||
except OSError: # likely port in use
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||
# ensure auto launch is on the same page in regard to room activity.
|
||||
with db_session:
|
||||
room: Room = Room.get(id=ctx.room_id)
|
||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)
|
||||
|
||||
await ctx.server
|
||||
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))
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
await ctx.shutdown_task
|
||||
logging.info("Shutting down")
|
||||
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
if ctx.saving:
|
||||
ctx._save()
|
||||
setattr(asyncio.current_task(), "save", None)
|
||||
except Exception as e:
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
room.last_port = -1
|
||||
logger.exception(e)
|
||||
raise
|
||||
else:
|
||||
if ctx.saving:
|
||||
ctx._save()
|
||||
setattr(asyncio.current_task(), "save", None)
|
||||
finally:
|
||||
try:
|
||||
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
|
||||
ctx.exit_event.set() # make sure the saving thread stops at some point
|
||||
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
|
||||
with (db_session):
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room = Room.get(id=room_id)
|
||||
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)
|
||||
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}.")
|
||||
|
||||
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()
|
||||
with Locker(room_id):
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
except Exception:
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
room.last_port = -1
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
raise
|
||||
|
||||
@@ -6,7 +6,7 @@ import random
|
||||
import tempfile
|
||||
import zipfile
|
||||
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 pony.orm import commit, db_session
|
||||
@@ -16,7 +16,6 @@ from Generate import PlandoOptions, handle_name
|
||||
from Main import main as ERmain
|
||||
from Utils import __version__
|
||||
from WebHostLib import app
|
||||
from settings import ServerOptions, GeneratorOptions
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from .check import get_yaml_data, roll_options
|
||||
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]]]:
|
||||
plando_options: Set[str] = set()
|
||||
for substr in ("bosses", "items", "connections", "texts"):
|
||||
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
|
||||
plando_options.add(substr)
|
||||
plando_options = {
|
||||
options_source.get("plando_bosses", ""),
|
||||
options_source.get("plando_items", ""),
|
||||
options_source.get("plando_connections", ""),
|
||||
options_source.get("plando_texts", "")
|
||||
}
|
||||
plando_options -= {""}
|
||||
|
||||
server_options = {
|
||||
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
|
||||
"release_mode": options_source.get("release_mode", ServerOptions.release_mode),
|
||||
"remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode),
|
||||
"collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||
"release_mode": options_source.get("release_mode", "goal"),
|
||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||
"server_password": options_source.get("server_password", None),
|
||||
}
|
||||
generator_options = {
|
||||
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
|
||||
"race": race,
|
||||
"spoiler": int(options_source.get("spoiler", 0)),
|
||||
"race": race
|
||||
}
|
||||
|
||||
if race:
|
||||
@@ -68,41 +70,37 @@ def generate(race=False):
|
||||
flash(options)
|
||||
else:
|
||||
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__)
|
||||
|
||||
|
||||
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.")
|
||||
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):
|
||||
if not meta:
|
||||
meta: Dict[str, Any] = {}
|
||||
|
||||
@@ -37,6 +37,25 @@ def start_playing():
|
||||
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
|
||||
@app.route('/games/<string:game>/info/<string:lang>')
|
||||
@cache.cached()
|
||||
|
||||
@@ -1,257 +1,205 @@
|
||||
import collections.abc
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from textwrap import dedent
|
||||
from typing import Dict, Union
|
||||
|
||||
import yaml
|
||||
from flask import redirect, render_template, request, Response
|
||||
import typing
|
||||
|
||||
import Options
|
||||
from Utils import local_path
|
||||
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")
|
||||
yaml_folder = os.path.join(target_folder, "configs")
|
||||
|
||||
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:
|
||||
if game_name in AutoWorldRegister.world_types:
|
||||
return AutoWorldRegister.world_types[game_name].web.theme
|
||||
return 'grass'
|
||||
weighted_options = {
|
||||
"baseOptions": {
|
||||
"description": "Generated by https://archipelago.gg/",
|
||||
"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]:
|
||||
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
|
||||
all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints
|
||||
|
||||
start_collapsed = {"Game Options": False}
|
||||
for group in world.web.option_groups:
|
||||
start_collapsed[group.name] = group.start_collapsed
|
||||
|
||||
return render_template(
|
||||
template,
|
||||
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_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,
|
||||
# Generate JSON files for player-options pages
|
||||
player_options = {
|
||||
"baseOptions": {
|
||||
"description": f"Generated by https://archipelago.gg/ for {game_name}",
|
||||
"game": game_name,
|
||||
"name": "",
|
||||
},
|
||||
}
|
||||
|
||||
if intent_generate:
|
||||
return generate_game({player_name: formatted_options})
|
||||
game_options = {}
|
||||
visible: typing.Set[str] = set()
|
||||
visible_weighted: typing.Set[str] = set()
|
||||
|
||||
else:
|
||||
return send_yaml(player_name, formatted_options)
|
||||
for option_name, option in all_options.items():
|
||||
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
|
||||
@app.route("/games/<string:game>/player-options")
|
||||
@cache.cached()
|
||||
def player_options(game: str):
|
||||
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
||||
elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
|
||||
game_options[option_name] = this_option = {
|
||||
"type": "select",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"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:
|
||||
options[key] = val
|
||||
logging.debug(f"{option} not exported to Web Options.")
|
||||
|
||||
for key, val in options.copy().items():
|
||||
key_parts = key.rsplit("||", 2)
|
||||
# Detect and build ItemDict options from their name pattern
|
||||
if key_parts[-1] == "qty":
|
||||
if key_parts[0] not in options:
|
||||
options[key_parts[0]] = {}
|
||||
if val != "0":
|
||||
options[key_parts[0]][key_parts[1]] = int(val)
|
||||
del options[key]
|
||||
player_options["presetOptions"] = {}
|
||||
for preset_name, preset in world.web.options_presets.items():
|
||||
player_options["presetOptions"][preset_name] = {}
|
||||
for option_name, option_value in preset.items():
|
||||
# Random range type settings are not valid.
|
||||
assert (not str(option_value).startswith("random-")), \
|
||||
f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. Special random " \
|
||||
f"values are not supported for presets."
|
||||
|
||||
# Detect keys which end with -custom, indicating a TextChoice with a possible custom value
|
||||
elif key_parts[-1].endswith("-custom"):
|
||||
if val:
|
||||
options[key_parts[-1][:-7]] = val
|
||||
# Normal random is supported, but needs to be handled explicitly.
|
||||
if option_value == "random":
|
||||
player_options["presetOptions"][preset_name][option_name] = option_value
|
||||
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 random-* keys and set their options accordingly
|
||||
for key, val in options.copy().items():
|
||||
if key.startswith("random-"):
|
||||
options[key.removeprefix("random-")] = "random"
|
||||
del options[key]
|
||||
# Still use the true value for the option, not the name.
|
||||
player_options["presetOptions"][preset_name][option_name] = option.value
|
||||
elif isinstance(option, Options.Range):
|
||||
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
|
||||
|
||||
# Error checking
|
||||
if not options["name"]:
|
||||
return "Player name is required."
|
||||
os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True)
|
||||
|
||||
# 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,
|
||||
filtered_player_options = player_options
|
||||
filtered_player_options["gameOptions"] = {
|
||||
option_name: option_data for option_name, option_data in game_options.items()
|
||||
if option_name in visible
|
||||
}
|
||||
|
||||
if intent_generate:
|
||||
return generate_game({player_name: formatted_options})
|
||||
with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f:
|
||||
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)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
flask>=3.0.3
|
||||
werkzeug>=3.0.3
|
||||
flask>=3.0.0
|
||||
pony>=0.7.17
|
||||
waitress>=3.0.0
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress>=1.15
|
||||
Flask-Limiter>=3.7.0
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.1.0
|
||||
Flask-Compress>=1.14
|
||||
Flask-Limiter>=3.5.0
|
||||
bokeh>=3.1.1; python_version <= '3.8'
|
||||
bokeh>=3.4.1; python_version >= '3.9'
|
||||
markupsafe>=2.1.5
|
||||
bokeh>=3.3.2; python_version >= '3.9'
|
||||
markupsafe>=2.1.3
|
||||
|
||||
523
WebHostLib/static/assets/player-options.js
Normal file
523
WebHostLib/static/assets/player-options.js
Normal 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);
|
||||
};
|
||||
@@ -1,335 +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;
|
||||
}
|
||||
|
||||
// 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';
|
||||
};
|
||||
@@ -1,16 +1,18 @@
|
||||
window.addEventListener('load', () => {
|
||||
// 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
|
||||
const gameSearch = document.getElementById('game-search');
|
||||
gameSearch.value = '';
|
||||
gameSearch.addEventListener('input', (evt) => {
|
||||
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) => {
|
||||
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 (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
|
||||
header.style.display = null;
|
||||
header.setAttribute('open', '1');
|
||||
header.firstElementChild.innerText = '▼';
|
||||
header.nextElementSibling.classList.remove('collapsed');
|
||||
} else {
|
||||
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);
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
document.querySelectorAll('details').forEach((detail) => {
|
||||
detail.setAttribute('open', '1');
|
||||
document.querySelectorAll('.collapse-toggle').forEach((header) => {
|
||||
if (header.style.display === 'none') { return; }
|
||||
header.firstElementChild.innerText = '▼';
|
||||
header.nextElementSibling.classList.remove('collapsed');
|
||||
});
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
document.querySelectorAll('details').forEach((detail) => {
|
||||
detail.removeAttribute('open');
|
||||
document.querySelectorAll('.collapse-toggle').forEach((header) => {
|
||||
if (header.style.display === 'none') { return; }
|
||||
header.firstElementChild.innerText = '▶';
|
||||
header.nextElementSibling.classList.add('collapsed');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ const adjustTableHeight = () => {
|
||||
* @returns {string}
|
||||
*/
|
||||
const secondsToHours = (seconds) => {
|
||||
let hours = Math.floor(seconds / 3600);
|
||||
let hours = Math.floor(seconds / 3600);
|
||||
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
@@ -38,18 +38,18 @@ window.addEventListener('load', () => {
|
||||
info: false,
|
||||
dom: "t",
|
||||
stateSave: true,
|
||||
stateSaveCallback: function (settings, data) {
|
||||
stateSaveCallback: function(settings, data) {
|
||||
delete data.search;
|
||||
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
|
||||
},
|
||||
stateLoadCallback: function (settings) {
|
||||
stateLoadCallback: function(settings) {
|
||||
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
||||
},
|
||||
footerCallback: function (tfoot, data, start, end, display) {
|
||||
footerCallback: function(tfoot, data, start, end, display) {
|
||||
if (tfoot) {
|
||||
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
|
||||
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
|
||||
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
|
||||
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
|
||||
}
|
||||
},
|
||||
columnDefs: [
|
||||
@@ -123,64 +123,49 @@ window.addEventListener('load', () => {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
const target_second = parseInt(document.getElementById('tracker-wrapper').getAttribute('data-second')) + 3;
|
||||
console.log("Target second of refresh: " + target_second);
|
||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
|
||||
|
||||
function getSleepTimeSeconds() {
|
||||
function getSleepTimeSeconds(){
|
||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
|
||||
return sleepSeconds || 60;
|
||||
}
|
||||
|
||||
let update_on_view = false;
|
||||
const update = () => {
|
||||
if (document.hidden) {
|
||||
console.log("Document reporting as not visible, not updating Tracker...");
|
||||
update_on_view = true;
|
||||
} else {
|
||||
update_on_view = false;
|
||||
const target = $("<div></div>");
|
||||
console.log("Updating Tracker...");
|
||||
target.load(location.href, function (response, status) {
|
||||
if (status === "success") {
|
||||
target.find(".table").each(function (i, new_table) {
|
||||
const new_trs = $(new_table).find("tbody>tr");
|
||||
const footer_tr = $(new_table).find("tfoot>tr");
|
||||
const old_table = tables.eq(i);
|
||||
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
||||
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
||||
old_table.clear();
|
||||
if (footer_tr.length) {
|
||||
$(old_table.table).find("tfoot").html(footer_tr);
|
||||
}
|
||||
old_table.rows.add(new_trs);
|
||||
old_table.draw();
|
||||
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
||||
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
||||
});
|
||||
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
|
||||
} else {
|
||||
console.log("Failed to connect to Server, in order to update Table Data.");
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
}
|
||||
updater = setTimeout(update, getSleepTimeSeconds() * 1000);
|
||||
const target = $("<div></div>");
|
||||
console.log("Updating Tracker...");
|
||||
target.load(location.href, function (response, status) {
|
||||
if (status === "success") {
|
||||
target.find(".table").each(function (i, new_table) {
|
||||
const new_trs = $(new_table).find("tbody>tr");
|
||||
const footer_tr = $(new_table).find("tfoot>tr");
|
||||
const old_table = tables.eq(i);
|
||||
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
||||
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
||||
old_table.clear();
|
||||
if (footer_tr.length) {
|
||||
$(old_table.table).find("tfoot").html(footer_tr);
|
||||
}
|
||||
old_table.rows.add(new_trs);
|
||||
old_table.draw();
|
||||
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
||||
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
||||
});
|
||||
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
|
||||
} else {
|
||||
console.log("Failed to connect to Server, in order to update Table Data.");
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||
}
|
||||
let updater = setTimeout(update, getSleepTimeSeconds() * 1000);
|
||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
adjustTableHeight();
|
||||
tables.draw();
|
||||
});
|
||||
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && update_on_view) {
|
||||
console.log("Page became visible, tracker should be refreshed.");
|
||||
clearTimeout(updater);
|
||||
update();
|
||||
}
|
||||
});
|
||||
|
||||
adjustTableHeight();
|
||||
});
|
||||
|
||||
1190
WebHostLib/static/assets/weighted-options.js
Normal file
1190
WebHostLib/static/assets/weighted-options.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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];
|
||||
}
|
||||
};
|
||||
@@ -44,7 +44,7 @@ a{
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
}
|
||||
|
||||
button, input[type=submit]{
|
||||
button{
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
padding: 10px 17px 11px 16px; /* top right bottom left */
|
||||
@@ -57,7 +57,7 @@ button, input[type=submit]{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:active, input[type=submit]:active{
|
||||
button:active{
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.5);
|
||||
padding-right: 16px;
|
||||
@@ -66,11 +66,11 @@ button:active, input[type=submit]:active{
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
button.button-grass, input[type=submit].button-grass{
|
||||
button.button-grass{
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
button.button-dirt, input[type=submit].button-dirt{
|
||||
button.button-dirt{
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
@@ -111,4 +111,4 @@ h5, h6{
|
||||
|
||||
.interactive{
|
||||
color: #ffef00;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
.markdown a{}
|
||||
|
||||
.markdown h1, .markdown details summary.h1{
|
||||
.markdown h1{
|
||||
font-size: 52px;
|
||||
font-weight: normal;
|
||||
font-family: LondrinaSolid-Regular, sans-serif;
|
||||
@@ -33,7 +33,7 @@
|
||||
text-shadow: 1px 1px 4px #000000;
|
||||
}
|
||||
|
||||
.markdown h2, .markdown details summary.h2{
|
||||
.markdown h2{
|
||||
font-size: 38px;
|
||||
font-weight: normal;
|
||||
font-family: LondrinaSolid-Light, sans-serif;
|
||||
@@ -45,7 +45,7 @@
|
||||
text-shadow: 1px 1px 2px #000000;
|
||||
}
|
||||
|
||||
.markdown h3, .markdown details summary.h3{
|
||||
.markdown h3{
|
||||
font-size: 26px;
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
text-transform: none;
|
||||
@@ -55,7 +55,7 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown h4, .markdown details summary.h4{
|
||||
.markdown h4{
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
text-transform: none;
|
||||
font-size: 24px;
|
||||
@@ -63,21 +63,21 @@
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.markdown h5, .markdown details summary.h5{
|
||||
.markdown h5{
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
text-transform: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markdown h6, .markdown details summary.h6{
|
||||
.markdown h6{
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
text-transform: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;;
|
||||
}
|
||||
|
||||
.markdown h4, .markdown h5, .markdown h6{
|
||||
.markdown h4, .markdown h5,.markdown h6{
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
244
WebHostLib/static/styles/player-options.css
Normal file
244
WebHostLib/static/styles/player-options.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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"}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,30 @@
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
#games h1, #games details summary.h1{
|
||||
#games h1{
|
||||
font-size: 60px;
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
#games h2, #games details summary.h2{
|
||||
#games h2{
|
||||
color: #93dcff;
|
||||
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{
|
||||
|
||||
@@ -42,7 +42,6 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/** Directional arrow styles */
|
||||
|
||||
315
WebHostLib/static/styles/weighted-options.css
Normal file
315
WebHostLib/static/styles/weighted-options.css
Normal file
@@ -0,0 +1,315 @@
|
||||
html{
|
||||
background-image: url('../static/backgrounds/grass.png');
|
||||
background-repeat: repeat;
|
||||
background-size: 650px 650px;
|
||||
scroll-padding-top: 90px;
|
||||
}
|
||||
|
||||
#weighted-settings{
|
||||
max-width: 1000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #eeffeb;
|
||||
}
|
||||
|
||||
#weighted-settings #games-wrapper{
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#weighted-settings .setting-wrapper{
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
#weighted-settings .setting-wrapper .add-option-div{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#weighted-settings .setting-wrapper .add-option-div button{
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0 0 0 0.15rem;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#weighted-settings .setting-wrapper .add-option-div button:active{
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
#weighted-settings p.setting-description{
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
#weighted-settings p.hint-text{
|
||||
margin: 0 0 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#weighted-settings .jump-link{
|
||||
color: #ffef00;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#weighted-settings table{
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#weighted-settings table th, #weighted-settings table td{
|
||||
border: none;
|
||||
}
|
||||
|
||||
#weighted-settings table td{
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#weighted-settings table .td-left{
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
padding-right: 1rem;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
#weighted-settings table .td-middle{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
#weighted-settings table .td-right{
|
||||
width: 4rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#weighted-settings table .td-delete{
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#weighted-settings table .range-option-delete{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#weighted-settings .items-wrapper{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#weighted-settings .items-div h3{
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#weighted-settings .items-wrapper .item-set-wrapper{
|
||||
width: 24%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#weighted-settings .item-container{
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-top: 0.125rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#weighted-settings .item-container .item-div{
|
||||
padding: 0.125rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#weighted-settings .item-container .item-div:hover{
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#weighted-settings .item-container .item-qty-div{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 0.125rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#weighted-settings .item-container .item-qty-div .item-qty-input-wrapper{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
#weighted-settings .item-container .item-qty-div input{
|
||||
min-width: unset;
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#weighted-settings .item-container .item-qty-div:hover{
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#weighted-settings .hints-div, #weighted-settings .locations-div{
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
#weighted-settings .hints-div h3, #weighted-settings .locations-div h3{
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#weighted-settings .hints-container, #weighted-settings .locations-container{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#weighted-settings .hints-wrapper, #weighted-settings .locations-wrapper{
|
||||
width: calc(50% - 0.5rem);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#weighted-settings .hints-wrapper .simple-list, #weighted-settings .locations-wrapper .simple-list{
|
||||
margin-top: 0.25rem;
|
||||
height: 300px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#weighted-settings #weighted-settings-button-row{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
#weighted-settings code{
|
||||
background-color: #d9cd8e;
|
||||
border-radius: 4px;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
#weighted-settings #user-message{
|
||||
display: none;
|
||||
width: calc(100% - 8px);
|
||||
background-color: #ffe86b;
|
||||
border-radius: 4px;
|
||||
color: #000000;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#weighted-settings #user-message.visible{
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#weighted-settings h1{
|
||||
font-size: 2.5rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 4px #000000;
|
||||
}
|
||||
|
||||
#weighted-settings h2{
|
||||
font-size: 2rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffe993;
|
||||
text-transform: none;
|
||||
text-shadow: 1px 1px 2px #000000;
|
||||
}
|
||||
|
||||
#weighted-settings h3, #weighted-settings h4, #weighted-settings h5, #weighted-settings h6{
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
#weighted-settings a{
|
||||
color: #ffef00;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#weighted-settings input:not([type]){
|
||||
border: 1px solid #000000;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
#weighted-settings input:not([type]):focus{
|
||||
border: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
#weighted-settings select{
|
||||
border: 1px solid #000000;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
min-width: 150px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
#weighted-settings .game-options, #weighted-settings .rom-options{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#weighted-settings .simple-list{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#weighted-settings .simple-list .list-row label{
|
||||
display: block;
|
||||
width: calc(100% - 0.5rem);
|
||||
padding: 0.0625rem 0.25rem;
|
||||
}
|
||||
|
||||
#weighted-settings .simple-list .list-row label:hover{
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#weighted-settings .simple-list .list-row label input[type=checkbox]{
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
#weighted-settings .simple-list hr{
|
||||
width: calc(100% - 2px);
|
||||
margin: 2px auto;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 0.6);
|
||||
}
|
||||
|
||||
#weighted-settings .invisible{
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px), all and (orientation: portrait){
|
||||
#weighted-settings .game-options{
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#game-options table label{
|
||||
display: block;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
html {
|
||||
background-image: url("../../static/backgrounds/grass.png");
|
||||
background-repeat: repeat;
|
||||
background-size: 650px 650px;
|
||||
scroll-padding-top: 90px;
|
||||
}
|
||||
|
||||
#weighted-options {
|
||||
max-width: 1000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #eeffeb;
|
||||
}
|
||||
#weighted-options #weighted-options-header h1 {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
#weighted-options #weighted-options-header h1:nth-child(2) {
|
||||
font-size: 1.4rem;
|
||||
margin-top: -8px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
#weighted-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;
|
||||
}
|
||||
#weighted-options .option-wrapper {
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
#weighted-options .option-wrapper .add-option-div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
#weighted-options .option-wrapper .add-option-div button {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0 0 0 0.15rem;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
#weighted-options .option-wrapper .add-option-div button:active {
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
#weighted-options p.option-description {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
#weighted-options p.hint-text {
|
||||
margin: 0 0 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
#weighted-options table {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
#weighted-options table th, #weighted-options table td {
|
||||
border: none;
|
||||
}
|
||||
#weighted-options table td {
|
||||
padding: 5px;
|
||||
}
|
||||
#weighted-options table .td-left {
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
padding-right: 1rem;
|
||||
width: 200px;
|
||||
}
|
||||
#weighted-options table .td-middle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
#weighted-options table .td-right {
|
||||
width: 4rem;
|
||||
text-align: right;
|
||||
}
|
||||
#weighted-options table .td-delete {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
#weighted-options table .range-option-delete {
|
||||
cursor: pointer;
|
||||
}
|
||||
#weighted-options #weighted-options-button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 15px;
|
||||
}
|
||||
#weighted-options #user-message {
|
||||
display: none;
|
||||
width: calc(100% - 8px);
|
||||
background-color: #ffe86b;
|
||||
border-radius: 4px;
|
||||
color: #000000;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
#weighted-options #user-message.visible {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
#weighted-options h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: normal;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 4px #000000;
|
||||
}
|
||||
#weighted-options h2, #weighted-options details summary.h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffe993;
|
||||
text-transform: none;
|
||||
text-shadow: 1px 1px 2px #000000;
|
||||
}
|
||||
#weighted-options h3, #weighted-options h4, #weighted-options h5, #weighted-options h6 {
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
text-transform: none;
|
||||
cursor: unset;
|
||||
}
|
||||
#weighted-options h3.option-group-header {
|
||||
margin-top: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
#weighted-options a {
|
||||
color: #ffef00;
|
||||
cursor: pointer;
|
||||
}
|
||||
#weighted-options input:not([type]) {
|
||||
border: 1px solid #000000;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
min-width: 150px;
|
||||
}
|
||||
#weighted-options input:not([type]):focus {
|
||||
border: 1px solid #ffffff;
|
||||
}
|
||||
#weighted-options .invisible {
|
||||
display: none;
|
||||
}
|
||||
#weighted-options .unsupported-option {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
#weighted-options .set-container, #weighted-options .dict-container, #weighted-options .list-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: 15rem;
|
||||
min-width: 14.5rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.25rem;
|
||||
padding-left: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
#weighted-options .set-container .divider, #weighted-options .dict-container .divider, #weighted-options .list-container .divider {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: rgba(20, 20, 20, 0.25);
|
||||
margin-top: 0.125rem;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
#weighted-options .set-container .set-entry, #weighted-options .set-container .dict-entry, #weighted-options .set-container .list-entry, #weighted-options .dict-container .set-entry, #weighted-options .dict-container .dict-entry, #weighted-options .dict-container .list-entry, #weighted-options .list-container .set-entry, #weighted-options .list-container .dict-entry, #weighted-options .list-container .list-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
user-select: none;
|
||||
line-height: 1rem;
|
||||
}
|
||||
#weighted-options .set-container .set-entry:hover, #weighted-options .set-container .dict-entry:hover, #weighted-options .set-container .list-entry:hover, #weighted-options .dict-container .set-entry:hover, #weighted-options .dict-container .dict-entry:hover, #weighted-options .dict-container .list-entry:hover, #weighted-options .list-container .set-entry:hover, #weighted-options .list-container .dict-entry:hover, #weighted-options .list-container .list-entry:hover {
|
||||
background-color: rgba(20, 20, 20, 0.25);
|
||||
}
|
||||
#weighted-options .set-container .set-entry input[type=checkbox], #weighted-options .set-container .dict-entry input[type=checkbox], #weighted-options .set-container .list-entry input[type=checkbox], #weighted-options .dict-container .set-entry input[type=checkbox], #weighted-options .dict-container .dict-entry input[type=checkbox], #weighted-options .dict-container .list-entry input[type=checkbox], #weighted-options .list-container .set-entry input[type=checkbox], #weighted-options .list-container .dict-entry input[type=checkbox], #weighted-options .list-container .list-entry input[type=checkbox] {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
#weighted-options .set-container .set-entry input[type=number], #weighted-options .set-container .dict-entry input[type=number], #weighted-options .set-container .list-entry input[type=number], #weighted-options .dict-container .set-entry input[type=number], #weighted-options .dict-container .dict-entry input[type=number], #weighted-options .dict-container .list-entry input[type=number], #weighted-options .list-container .set-entry input[type=number], #weighted-options .list-container .dict-entry input[type=number], #weighted-options .list-container .list-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;
|
||||
}
|
||||
#weighted-options .set-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .set-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .set-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .list-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .list-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .list-entry input[type=number]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
#weighted-options .set-container .set-entry label, #weighted-options .set-container .dict-entry label, #weighted-options .set-container .list-entry label, #weighted-options .dict-container .set-entry label, #weighted-options .dict-container .dict-entry label, #weighted-options .dict-container .list-entry label, #weighted-options .list-container .set-entry label, #weighted-options .list-container .dict-entry label, #weighted-options .list-container .list-entry label {
|
||||
flex-grow: 1;
|
||||
margin-right: 0;
|
||||
min-width: unset;
|
||||
display: unset;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px), all and (orientation: portrait) {
|
||||
#weighted-options .game-options {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#game-options table label {
|
||||
display: block;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=weightedOptions.css.map */
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"sourceRoot":"","sources":["weightedOptions.scss"],"names":[],"mappings":"AAAA;EACI;EACA;EACA;EACA;;;AAGJ;EACI;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;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAOZ;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;;AAIR;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAIA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;AAEA;EACA;;AACA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;;AAMhB;EACI;;;AAGJ;EACI;IACI;IACA;;EAGJ;IACI;IACA","file":"weightedOptions.css"}
|
||||
@@ -1,274 +0,0 @@
|
||||
html{
|
||||
background-image: url('../../static/backgrounds/grass.png');
|
||||
background-repeat: repeat;
|
||||
background-size: 650px 650px;
|
||||
scroll-padding-top: 90px;
|
||||
}
|
||||
|
||||
#weighted-options{
|
||||
max-width: 1000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #eeffeb;
|
||||
|
||||
#weighted-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;
|
||||
}
|
||||
|
||||
.option-wrapper{
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.add-option-div{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
button{
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0 0 0 0.15rem;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
|
||||
&:active{
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p{
|
||||
&.option-description{
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
&.hint-text{
|
||||
margin: 0 0 1rem;
|
||||
font-style: italic;
|
||||
};
|
||||
}
|
||||
|
||||
table{
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
th, td{
|
||||
border: none;
|
||||
}
|
||||
|
||||
td{
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.td-left{
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
padding-right: 1rem;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.td-middle{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.td-right{
|
||||
width: 4rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.td-delete{
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.range-option-delete{
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
#weighted-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;
|
||||
|
||||
&.visible{
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
h1{
|
||||
font-size: 2.5rem;
|
||||
font-weight: normal;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 4px #000000;
|
||||
}
|
||||
|
||||
h2, details summary.h2{
|
||||
font-size: 2rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffe993;
|
||||
text-transform: none;
|
||||
text-shadow: 1px 1px 2px #000000;
|
||||
}
|
||||
|
||||
h3, h4, h5, h6{
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
text-transform: none;
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
h3{
|
||||
&.option-group-header{
|
||||
margin-top: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
a{
|
||||
color: #ffef00;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:not([type]){
|
||||
border: 1px solid #000000;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
min-width: 150px;
|
||||
|
||||
&:focus{
|
||||
border: 1px solid #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.invisible{
|
||||
display: none;
|
||||
}
|
||||
|
||||
.unsupported-option{
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.set-container, .dict-container, .list-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: 15rem;
|
||||
min-width: 14.5rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.25rem;
|
||||
padding-left: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.divider{
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: rgba(20, 20, 20, 0.25);
|
||||
margin-top: 0.125rem;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.set-entry, .dict-entry, .list-entry{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
user-select: none;
|
||||
line-height: 1rem;
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hidden{
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px), all and (orientation: portrait){
|
||||
#weighted-options .game-options{
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#game-options table label{
|
||||
display: block;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,7 @@
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if room.tracker %}
|
||||
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
|
||||
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
|
||||
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
|
||||
<br />
|
||||
{% endif %}
|
||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
{% extends "tablepage.html" %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>Multiworld Sphere Tracker</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="styles/tracker.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/trackerCommon.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include "header/dirtHeader.html" %}
|
||||
|
||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}">
|
||||
<div id="tracker-header-bar">
|
||||
<input placeholder="Search" id="search" />
|
||||
|
||||
<div class="info">
|
||||
{% if tracker_data.get_spheres() %}
|
||||
This tracker lists already found locations by their logical access sphere.
|
||||
It ignores items that cannot be sent
|
||||
and will therefore differ from the sphere numbers in the spoiler playthrough.
|
||||
This tracker will automatically update itself periodically.
|
||||
{% else %}
|
||||
This Multiworld has no Sphere data, likely due to being too old, cannot display data.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tables-container">
|
||||
{%- for team, players in tracker_data.get_all_players().items() %}
|
||||
<div class="table-wrapper">
|
||||
<table id="checks-table" class="table non-unique-item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sphere</th>
|
||||
{#- Mimicking hint table header for familiarity. #}
|
||||
<th>Finder</th>
|
||||
<th>Receiver</th>
|
||||
<th>Item</th>
|
||||
<th>Location</th>
|
||||
<th>Game</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{%- for sphere in tracker_data.get_spheres() %}
|
||||
{%- set current_sphere = loop.index %}
|
||||
{%- for player, sphere_location_ids in sphere.items() %}
|
||||
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
|
||||
{%- set finder_game = tracker_data.get_player_game(team, player) %}
|
||||
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
|
||||
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
|
||||
<tr>
|
||||
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
|
||||
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
|
||||
<td>{{ current_sphere }}</td>
|
||||
<td>{{ tracker_data.get_player_name(team, player) }}</td>
|
||||
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
|
||||
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
|
||||
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
|
||||
<td>{{ finder_game }}</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
|
||||
{%- endfor %}
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -10,7 +10,7 @@
|
||||
{% include "header/dirtHeader.html" %}
|
||||
{% include "multitrackerNavigation.html" %}
|
||||
|
||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}" data-second="{{ saving_second }}">
|
||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}">
|
||||
<div id="tracker-header-bar">
|
||||
<input placeholder="Search" id="search" />
|
||||
|
||||
|
||||
180
WebHostLib/templates/ootTracker.html
Normal file
180
WebHostLib/templates/ootTracker.html
Normal file
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ player_name }}'s Tracker</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/ootTracker.css') }}"/>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/ootTracker.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||
<table id="inventory-table">
|
||||
<tr>
|
||||
<td><img src="{{ ocarina_url }}" class="{{ 'acquired' if 'Ocarina' in acquired_items }}" title="Ocarina" /></td>
|
||||
<td><img src="{{ icons['Bombs'] }}" class="{{ 'acquired' if 'Bomb Bag' in acquired_items }}" title="Bombs" /></td>
|
||||
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Bow' in acquired_items }}" title="Fairy Bow" /></td>
|
||||
<td><img src="{{ icons['Fire Arrows'] }}" class="{{ 'acquired' if 'Fire Arrows' in acquired_items }}" title="Fire Arrows" /></td>
|
||||
<td><img src="{{ icons['Kokiri Sword'] }}" class="{{ 'acquired' if 'Kokiri Sword' in acquired_items }}" title="Kokiri Sword" /></td>
|
||||
<td><img src="{{ icons['Biggoron Sword'] }}" class="{{ 'acquired' if 'Biggoron Sword' in acquired_items }}" title="Biggoron's Sword" /></td>
|
||||
<td><img src="{{ icons['Mirror Shield'] }}" class="{{ 'acquired' if 'Mirror Shield' in acquired_items }}" title="Mirror Shield" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Slingshot'] }}" class="{{ 'acquired' if 'Slingshot' in acquired_items }}" title="Slingshot" /></td>
|
||||
<td><img src="{{ icons['Bombchus'] }}" class="{{ 'acquired' if has_bombchus }}" title="Bombchus" /></td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ hookshot_url }}" class="{{ 'acquired' if 'Progressive Hookshot' in acquired_items }}" title="Progressive Hookshot" />
|
||||
<div class="item-count">{{ hookshot_length }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><img src="{{ icons['Ice Arrows'] }}" class="{{ 'acquired' if 'Ice Arrows' in acquired_items }}" title="Ice Arrows" /></td>
|
||||
<td><img src="{{ strength_upgrade_url }}" class="{{ 'acquired' if 'Progressive Strength Upgrade' in acquired_items }}" title="Progressive Strength Upgrade" /></td>
|
||||
<td><img src="{{ icons['Goron Tunic'] }}" class="{{ 'acquired' if 'Goron Tunic' in acquired_items }}" title="Goron Tunic" /></td>
|
||||
<td><img src="{{ icons['Zora Tunic'] }}" class="{{ 'acquired' if 'Zora Tunic' in acquired_items }}" title="Zora Tunic" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Boomerang'] }}" class="{{ 'acquired' if 'Boomerang' in acquired_items }}" title="Boomerang" /></td>
|
||||
<td><img src="{{ icons['Lens of Truth'] }}" class="{{ 'acquired' if 'Lens of Truth' in acquired_items }}" title="Lens of Truth" /></td>
|
||||
<td><img src="{{ icons['Megaton Hammer'] }}" class="{{ 'acquired' if 'Megaton Hammer' in acquired_items }}" title="Megaton Hammer" /></td>
|
||||
<td><img src="{{ icons['Light Arrows'] }}" class="{{ 'acquired' if 'Light Arrows' in acquired_items }}" title="Light Arrows" /></td>
|
||||
<td><img src="{{ scale_url }}" class="{{ 'acquired' if 'Progressive Scale' in acquired_items }}" title="Progressive Scale" /></td>
|
||||
<td><img src="{{ icons['Iron Boots'] }}" class="{{ 'acquired' if 'Iron Boots' in acquired_items }}" title="Iron Boots" /></td>
|
||||
<td><img src="{{ icons['Hover Boots'] }}" class="{{ 'acquired' if 'Hover Boots' in acquired_items }}" title="Hover Boots" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ bottle_url }}" class="{{ 'acquired' if bottle_count > 0 }}" title="Bottles" />
|
||||
<div class="item-count">{{ bottle_count if bottle_count > 0 else '' }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><img src="{{ icons['Dins Fire'] }}" class="{{ 'acquired' if 'Dins Fire' in acquired_items }}" title="Din's Fire" /></td>
|
||||
<td><img src="{{ icons['Farores Wind'] }}" class="{{ 'acquired' if 'Farores Wind' in acquired_items }}" title="Farore's Wind" /></td>
|
||||
<td><img src="{{ icons['Nayrus Love'] }}" class="{{ 'acquired' if 'Nayrus Love' in acquired_items }}" title="Nayru's Love" /></td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ wallet_url }}" class="{{ 'acquired' if 'Progressive Wallet' in acquired_items }}" title="Progressive Wallet" />
|
||||
<div class="item-count">{{ wallet_size }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><img src="{{ magic_meter_url }}" class="{{ 'acquired' if 'Magic Meter' in acquired_items }}" title="Magic Meter" /></td>
|
||||
<td><img src="{{ icons['Gerudo Membership Card'] }}" class="{{ 'acquired' if 'Gerudo Membership Card' in acquired_items }}" title="Gerudo Membership Card" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Zeldas Lullaby'] }}" class="{{ 'acquired' if 'Zeldas Lullaby' in acquired_items }}" title="Zelda's Lullaby" id="lullaby"/>
|
||||
<div class="item-count">Zelda</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Eponas Song'] }}" class="{{ 'acquired' if 'Eponas Song' in acquired_items }}" title="Epona's Song" id="epona" />
|
||||
<div class="item-count">Epona</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Sarias Song'] }}" class="{{ 'acquired' if 'Sarias Song' in acquired_items }}" title="Saria's Song" id="saria"/>
|
||||
<div class="item-count">Saria</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Suns Song'] }}" class="{{ 'acquired' if 'Suns Song' in acquired_items }}" title="Sun's Song" id="sun"/>
|
||||
<div class="item-count">Sun</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Song of Time'] }}" class="{{ 'acquired' if 'Song of Time' in acquired_items }}" title="Song of Time" id="time"/>
|
||||
<div class="item-count">Time</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Song of Storms'] }}" class="{{ 'acquired' if 'Song of Storms' in acquired_items }}" title="Song of Storms" />
|
||||
<div class="item-count">Storms</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Gold Skulltula Token'] }}" class="{{ 'acquired' if token_count > 0 }}" title="Gold Skulltula Tokens" />
|
||||
<div class="item-count">{{ token_count }}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Minuet of Forest'] }}" class="{{ 'acquired' if 'Minuet of Forest' in acquired_items }}" title="Minuet of Forest" />
|
||||
<div class="item-count">Min</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Bolero of Fire'] }}" class="{{ 'acquired' if 'Bolero of Fire' in acquired_items }}" title="Bolero of Fire" />
|
||||
<div class="item-count">Bol</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Serenade of Water'] }}" class="{{ 'acquired' if 'Serenade of Water' in acquired_items }}" title="Serenade of Water" />
|
||||
<div class="item-count">Ser</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Requiem of Spirit'] }}" class="{{ 'acquired' if 'Requiem of Spirit' in acquired_items }}" title="Requiem of Spirit" />
|
||||
<div class="item-count">Req</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Nocturne of Shadow'] }}" class="{{ 'acquired' if 'Nocturne of Shadow' in acquired_items }}" title="Nocturne of Shadow" />
|
||||
<div class="item-count">Noc</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Prelude of Light'] }}" class="{{ 'acquired' if 'Prelude of Light' in acquired_items }}" title="Prelude of Light" />
|
||||
<div class="item-count">Pre</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Triforce'] if game_finished else icons['Triforce Piece'] }}" class="{{ 'acquired' if game_finished or piece_count > 0 }}" title="{{ 'Triforce' if game_finished else 'Triforce Pieces' }}" id=triforce />
|
||||
<div class="item-count">{{ piece_count if piece_count > 0 else '' }}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table id="location-table">
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><img src="{{ icons['Small Key'] }}" title="Small Keys" /></td>
|
||||
<td><img src="{{ icons['Boss Key'] }}" title="Boss Key" /></td>
|
||||
<td class="right-align">Items</td>
|
||||
</tr>
|
||||
{% for area in checks_done %}
|
||||
<tr class="location-category" id="{{area}}-header">
|
||||
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
|
||||
<td class="smallkeys">{{ small_key_counts.get(area, '-') }}</td>
|
||||
<td class="bosskeys">{{ boss_key_counts.get(area, '-') }}</td>
|
||||
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
|
||||
</tr>
|
||||
<tbody class="locations hide" id="{{area}}">
|
||||
{% for location in location_info[area] %}
|
||||
<tr>
|
||||
<td class="location-name">{{ location }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
62
WebHostLib/templates/player-options.html
Normal file
62
WebHostLib/templates/player-options.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
<title>{{ game }} Options</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/player-options.css") }}" />
|
||||
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/md5.min.js") }}"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/player-options.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/'+theme+'Header.html' %}
|
||||
<div id="player-options" class="markdown" data-game="{{ game }}">
|
||||
<div id="user-message"></div>
|
||||
<h1><span id="game-name">Player</span> Options</h1>
|
||||
<p>Choose the options you would like to play with! You may generate a single-player game from this page,
|
||||
or download an options file you can use to participate in a MultiWorld.</p>
|
||||
|
||||
<p>
|
||||
A more advanced options configuration for all games can be found on the
|
||||
<a href="/weighted-options">Weighted options</a> page.
|
||||
<br />
|
||||
A list of all games you have generated can be found on the <a href="/user-content">User Content Page</a>.
|
||||
<br />
|
||||
You may also download the
|
||||
<a href="/static/generated/configs/{{ game }}.yaml">template file for this game</a>.
|
||||
</p>
|
||||
|
||||
<div id="meta-options">
|
||||
<div>
|
||||
<label for="player-name">
|
||||
Player Name: <span class="interactive" data-tooltip="This is the name you use to connect with your game. This is also known as your 'slot name'.">(?)</span>
|
||||
</label>
|
||||
<input id="player-name" placeholder="Player" data-key="name" maxlength="16" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="game-options-preset">
|
||||
Options Preset: <span class="interactive" data-tooltip="Select from a list of developer-curated presets (if any) or reset all options to their defaults.">(?)</span>
|
||||
</label>
|
||||
<select id="game-options-preset">
|
||||
<option value="__default">Defaults</option>
|
||||
<option value="__custom" hidden>Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<h2>Game Options</h2>
|
||||
<div id="game-options">
|
||||
<div id="game-options-left" class="left"></div>
|
||||
<div id="game-options-right" class="right"></div>
|
||||
</div>
|
||||
|
||||
<div id="player-options-button-row">
|
||||
<button id="export-options">Export Options</button>
|
||||
<button id="generate-game">Generate Game</button>
|
||||
<button id="generate-race">Generate Race</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,210 +0,0 @@
|
||||
{% macro Toggle(option_name, option) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="select-container">
|
||||
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
{% if option.default == 1 %}
|
||||
<option value="false">No</option>
|
||||
<option value="true" selected>Yes</option>
|
||||
{% else %}
|
||||
<option value="false" selected>No</option>
|
||||
<option value="true">Yes</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
{{ RandomizeButton(option_name, option) }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro Choice(option_name, option) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="select-container">
|
||||
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
{% for id, name in option.name_lookup.items() %}
|
||||
{% if name != "random" %}
|
||||
{% if option.default == id %}
|
||||
<option value="{{ name }}" selected>{{ option.get_option_name(id) }}</option>
|
||||
{% else %}
|
||||
<option value="{{ name }}">{{ option.get_option_name(id) }}</option>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ RandomizeButton(option_name, option) }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro Range(option_name, option) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="range-container">
|
||||
<input
|
||||
type="range"
|
||||
id="{{ option_name }}"
|
||||
name="{{ option_name }}"
|
||||
min="{{ option.range_start }}"
|
||||
max="{{ option.range_end }}"
|
||||
value="{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}"
|
||||
{{ "disabled" if option.default == "random" }}
|
||||
/>
|
||||
<span id="{{ option_name }}-value" class="range-value js-required">
|
||||
{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}
|
||||
</span>
|
||||
{{ RandomizeButton(option_name, option) }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro NamedRange(option_name, option) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="named-range-container">
|
||||
<select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
{% for key, val in option.special_range_names.items() %}
|
||||
{% if option.default == val %}
|
||||
<option value="{{ val }}" selected>{{ key }} ({{ val }})</option>
|
||||
{% else %}
|
||||
<option value="{{ val }}">{{ key }} ({{ val }})</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<option value="custom" hidden>Custom</option>
|
||||
</select>
|
||||
<div class="named-range-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
id="{{ option_name }}"
|
||||
name="{{ option_name }}"
|
||||
min="{{ option.range_start }}"
|
||||
max="{{ option.range_end }}"
|
||||
value="{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}"
|
||||
{{ "disabled" if option.default == "random" }}
|
||||
/>
|
||||
<span id="{{ option_name }}-value" class="range-value js-required">
|
||||
{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}
|
||||
</span>
|
||||
{{ RandomizeButton(option_name, option) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro FreeText(option_name, option) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="free-text-container">
|
||||
<input type="text" id="{{ option_name }}" name="{{ option_name }}" value="{{ option.default }}" />
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro TextChoice(option_name, option) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="text-choice-container">
|
||||
<div class="text-choice-wrapper">
|
||||
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
{% for id, name in option.name_lookup.items()|sort %}
|
||||
{% if name != "random" %}
|
||||
{% if option.default == id %}
|
||||
<option value="{{ name }}" selected>{{ option.get_option_name(id) }}</option>
|
||||
{% else %}
|
||||
<option value="{{ name }}">{{ option.get_option_name(id) }}</option>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<option value="custom" hidden>Custom</option>
|
||||
</select>
|
||||
{{ RandomizeButton(option_name, option) }}
|
||||
</div>
|
||||
<input type="text" id="{{ option_name }}-custom" name="{{ option_name }}-custom" data-option-name="{{ option_name }}" placeholder="Custom value..." />
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro ItemDict(option_name, option, world) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="option-container">
|
||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
||||
<div class="option-entry">
|
||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro OptionList(option_name, option) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="option-container">
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||
<div class="option-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}" value="{{ key }}" {{ "checked" if key in option.default }} />
|
||||
<label for="{{ option_name }}-{{ key }}">{{ key }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro LocationSet(option_name, option, world) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="option-container">
|
||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||
{% if group_name != "Everywhere" %}
|
||||
<div class="option-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}" value="{{ group_name }}" {{ "checked" if group_name in option.default }} />
|
||||
<label for="{{ option_name }}-{{ group_name }}">{{ group_name }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if world.location_name_groups.keys()|length > 1 %}
|
||||
<div class="option-divider"> </div>
|
||||
{% endif %}
|
||||
{% for location_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.location_names|sort) %}
|
||||
<div class="option-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ location_name }}" name="{{ option_name }}" value="{{ location_name }}" {{ "checked" if location_name in option.default }} />
|
||||
<label for="{{ option_name }}-{{ location_name }}">{{ location_name }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro ItemSet(option_name, option, world) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="option-container">
|
||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||
{% if group_name != "Everything" %}
|
||||
<div class="option-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}" value="{{ group_name }}" {{ "checked" if group_name in option.default }} />
|
||||
<label for="{{ option_name }}-{{ group_name }}">{{ group_name }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if world.item_name_groups.keys()|length > 1 %}
|
||||
<div class="option-divider"> </div>
|
||||
{% endif %}
|
||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
||||
<div class="option-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ item_name }}" name="{{ option_name }}" value="{{ item_name }}" {{ "checked" if item_name in option.default }} />
|
||||
<label for="{{ option_name }}-{{ item_name }}">{{ item_name }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro OptionSet(option_name, option) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="option-container">
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||
<div class="option-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}" value="{{ key }}" {{ "checked" if key in option.default }} />
|
||||
<label for="{{ option_name }}-{{ key }}">{{ key }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro OptionTitle(option_name, option) %}
|
||||
<label for="{{ option_name }}">
|
||||
{{ option.display_name|default(option_name) }}:
|
||||
<span class="interactive" data-tooltip="{% filter dedent %}{{(option.__doc__ | default("Please document me!"))|escape }}{% endfilter %}">(?)</span>
|
||||
</label>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro RandomizeButton(option_name, option) %}
|
||||
<div class="randomize-button" data-tooltip="Toggle randomization for this option!">
|
||||
<label for="random-{{ option_name }}">
|
||||
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
|
||||
🎲
|
||||
</label>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -1,166 +0,0 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
{% import 'playerOptions/macros.html' as inputs %}
|
||||
|
||||
{% block head %}
|
||||
<title>{{ world_name }} Options</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/playerOptions/playerOptions.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/md5.min.js") }}"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/playerOptions.js") }}"></script>
|
||||
|
||||
<noscript>
|
||||
<style>
|
||||
.js-required{
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/'+theme+'Header.html' %}
|
||||
<div id="player-options" class="markdown" data-game="{{ world_name }}" data-presets="{{ presets }}">
|
||||
<noscript>
|
||||
<div class="js-warning-banner">
|
||||
This page has reduced functionality without JavaScript.
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<div id="user-message">{{ message }}</div>
|
||||
|
||||
<div id="player-options-header">
|
||||
<h1>{{ world_name }}</h1>
|
||||
<h1>Player Options</h1>
|
||||
</div>
|
||||
<p>Choose the options you would like to play with! You may generate a single-player game from this page,
|
||||
or download an options file you can use to participate in a MultiWorld.</p>
|
||||
|
||||
<p>
|
||||
A more advanced options configuration for all games can be found on the
|
||||
<a href="weighted-options">Weighted options</a> page.
|
||||
<br />
|
||||
A list of all games you have generated can be found on the <a href="/user-content">User Content Page</a>.
|
||||
<br />
|
||||
You may also download the
|
||||
<a href="/static/generated/configs/{{ world_name }}.yaml">template file for this game</a>.
|
||||
</p>
|
||||
|
||||
<form id="options-form" method="post" enctype="application/x-www-form-urlencoded" action="generate-yaml">
|
||||
<div id="meta-options">
|
||||
<div>
|
||||
<label for="player-name">
|
||||
Player Name: <span class="interactive" data-tooltip="This is the name you use to connect with your game. This is also known as your 'slot name'.">(?)</span>
|
||||
</label>
|
||||
<input id="player-name" placeholder="Player" name="name" maxlength="16" />
|
||||
</div>
|
||||
<div class="js-required">
|
||||
<label for="game-options-preset">
|
||||
Options Preset: <span class="interactive" data-tooltip="Select from a list of developer-curated presets (if any) or reset all options to their defaults.">(?)</span>
|
||||
</label>
|
||||
<select id="game-options-preset" name="game-options-preset" disabled>
|
||||
<option value="default">Default</option>
|
||||
{% for preset_name in world.web.options_presets %}
|
||||
<option value="{{ preset_name }}">{{ preset_name }}</option>
|
||||
{% endfor %}
|
||||
<option value="custom" hidden>Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="option-groups">
|
||||
{% for group_name, group_options in option_groups.items() %}
|
||||
<details class="group-container" {% if not start_collapsed[group_name] %}open{% endif %}>
|
||||
<summary class="h2">{{ group_name }}</summary>
|
||||
<div class="game-options">
|
||||
<div class="left">
|
||||
{% for option_name, option in group_options.items() %}
|
||||
{% if loop.index <= (loop.length / 2)|round(0,"ceil") %}
|
||||
{% if issubclass(option, Options.Toggle) %}
|
||||
{{ inputs.Toggle(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.TextChoice) %}
|
||||
{{ inputs.TextChoice(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.Choice) %}
|
||||
{{ inputs.Choice(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.NamedRange) %}
|
||||
{{ inputs.NamedRange(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.Range) %}
|
||||
{{ inputs.Range(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.FreeText) %}
|
||||
{{ inputs.FreeText(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||
{{ inputs.ItemDict(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||
{{ inputs.OptionList(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
|
||||
{{ inputs.LocationSet(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
|
||||
{{ inputs.ItemSet(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
|
||||
{{ inputs.OptionSet(option_name, option) }}
|
||||
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="right">
|
||||
{% for option_name, option in group_options.items() %}
|
||||
{% if loop.index > (loop.length / 2)|round(0,"ceil") %}
|
||||
{% if issubclass(option, Options.Toggle) %}
|
||||
{{ inputs.Toggle(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.TextChoice) %}
|
||||
{{ inputs.TextChoice(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.Choice) %}
|
||||
{{ inputs.Choice(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.NamedRange) %}
|
||||
{{ inputs.NamedRange(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.Range) %}
|
||||
{{ inputs.Range(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.FreeText) %}
|
||||
{{ inputs.FreeText(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||
{{ inputs.ItemDict(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||
{{ inputs.OptionList(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
|
||||
{{ inputs.LocationSet(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
|
||||
{{ inputs.ItemSet(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
|
||||
{{ inputs.OptionSet(option_name, option) }}
|
||||
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="player-options-button-row">
|
||||
<input type="submit" name="intent-export" value="Export Options" />
|
||||
<input type="submit" name="intent-generate" value="Generate Single-Player Game">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -24,6 +24,7 @@
|
||||
<li><a href="/games">Supported Games Page</a></li>
|
||||
<li><a href="/tutorial">Tutorials Page</a></li>
|
||||
<li><a href="/user-content">User Content</a></li>
|
||||
<li><a href="/weighted-options">Weighted Options Page</a></li>
|
||||
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
|
||||
<li><a href="/glossary/en">Glossary</a></li>
|
||||
</ul>
|
||||
@@ -49,12 +50,8 @@
|
||||
<ul>
|
||||
{% for game in games | title_sorted %}
|
||||
{% if game['has_settings'] %}
|
||||
<li>{{ game['title'] }}</li>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('player_options', game=game['title']) }}">Player Options</a></li>
|
||||
<li><a href="{{ url_for('weighted_options', game=game['title']) }}">Weighted Options</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
<li><a href="{{ url_for('player_options', game=game['title']) }}">{{ game['title'] }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -41,8 +41,10 @@
|
||||
</div>
|
||||
{% for game_name in worlds | title_sorted %}
|
||||
{% set world = worlds[game_name] %}
|
||||
<details data-game="{{ game_name }}">
|
||||
<summary class="h2">{{ game_name }}</summary>
|
||||
<h2 class="collapse-toggle" data-game="{{ game_name }}">
|
||||
<span class="collapse-arrow">▶</span>{{ game_name }}
|
||||
</h2>
|
||||
<p class="collapsed">
|
||||
{{ world.__doc__ | default("No description provided.", true) }}<br />
|
||||
<a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a>
|
||||
{% if world.web.tutorials %}
|
||||
@@ -51,18 +53,16 @@
|
||||
{% endif %}
|
||||
{% if world.web.options_page is string %}
|
||||
<span class="link-spacer">|</span>
|
||||
<a href="{{ world.web.options_page }}">Options Page (External Link)</a>
|
||||
<a href="{{ world.web.options_page }}">Options Page</a>
|
||||
{% elif world.web.options_page %}
|
||||
<span class="link-spacer">|</span>
|
||||
<a href="{{ url_for("player_options", game=game_name) }}">Options Page</a>
|
||||
<span class="link-spacer">|</span>
|
||||
<a href="{{ url_for("weighted_options", game=game_name) }}">Advanced Options</a>
|
||||
{% endif %}
|
||||
{% if world.web.bug_report_page %}
|
||||
<span class="link-spacer">|</span>
|
||||
<a href="{{ world.web.bug_report_page }}">Report a Bug</a>
|
||||
{% endif %}
|
||||
</details>
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
48
WebHostLib/templates/weighted-options.html
Normal file
48
WebHostLib/templates/weighted-options.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
<title>{{ game }} Options</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/weighted-options.css") }}" />
|
||||
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/md5.min.js") }}"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/weighted-options.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<div id="weighted-settings" class="markdown" data-game="{{ game }}">
|
||||
<div id="user-message"></div>
|
||||
<h1>Weighted Options</h1>
|
||||
<p>Weighted options allow you to choose how likely a particular option is to be used in game generation.
|
||||
The higher an option is weighted, the more likely the option will be chosen. Think of them like
|
||||
entries in a raffle.</p>
|
||||
|
||||
<p>Choose the games and options you would like to play with! You may generate a single-player game from
|
||||
this page, or download an options file you can use to participate in a MultiWorld.</p>
|
||||
|
||||
<p>A list of all games you have generated can be found on the <a href="/user-content">User Content</a>
|
||||
page.</p>
|
||||
|
||||
<p><label for="player-name">Please enter your player name. This will appear in-game as you send and receive
|
||||
items if you are playing in a MultiWorld.</label><br />
|
||||
<input id="player-name" placeholder="Player Name" data-key="name" maxlength="16" />
|
||||
</p>
|
||||
|
||||
<div id="game-choice">
|
||||
<!-- User chooses games by weight -->
|
||||
</div>
|
||||
|
||||
<!-- To be generated and populated per-game with weight > 0 -->
|
||||
<div id="games-wrapper">
|
||||
|
||||
</div>
|
||||
|
||||
<div id="weighted-settings-button-row">
|
||||
<button id="export-options">Export Options</button>
|
||||
<button id="generate-game">Generate Game</button>
|
||||
<button id="generate-race">Generate Race</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,264 +0,0 @@
|
||||
{% macro Toggle(option_name, option) %}
|
||||
<table>
|
||||
<tbody>
|
||||
{{ RangeRow(option_name, option, "No", "false", False, "true" if option.default else "false") }}
|
||||
{{ RangeRow(option_name, option, "Yes", "true", False, "true" if option.default else "false") }}
|
||||
{{ RandomRow(option_name, option) }}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro DefaultOnToggle(option_name, option) %}
|
||||
<!-- Toggle handles defaults properly, so we just reuse that -->
|
||||
{{ Toggle(option_name, option) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro Choice(option_name, option) %}
|
||||
<table>
|
||||
<tbody>
|
||||
{% for id, name in option.name_lookup.items() %}
|
||||
{% if name != 'random' %}
|
||||
{% if option.default != 'random' %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }}
|
||||
{% else %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ RandomRow(option_name, option) }}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro Range(option_name, option) %}
|
||||
<div class="hint-text js-required">
|
||||
This is a range option.
|
||||
<br /><br />
|
||||
Accepted values:<br />
|
||||
Normal range: {{ option.range_start }} - {{ option.range_end }}
|
||||
{% if option.special_range_names %}
|
||||
<br /><br />
|
||||
The following values have special meanings, and may fall outside the normal range.
|
||||
<ul>
|
||||
{% for name, value in option.special_range_names.items() %}
|
||||
<li>{{ value }}: {{ name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<div class="add-option-div">
|
||||
<input type="number" class="range-option-value" data-option="{{ option_name }}" />
|
||||
<button type="button" class="add-range-option-button" data-option="{{ option_name }}">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="range-rows" data-option="{{ option_name }}">
|
||||
<tbody>
|
||||
{{ RangeRow(option_name, option, option.range_start, option.range_start, True) }}
|
||||
{% if option.range_start < option.default < option.range_end %}
|
||||
{{ RangeRow(option_name, option, option.default, option.default, True) }}
|
||||
{% endif %}
|
||||
{{ RangeRow(option_name, option, option.range_end, option.range_end, True) }}
|
||||
{{ RandomRows(option_name, option) }}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro NamedRange(option_name, option) %}
|
||||
<!-- Range is able to properly handle NamedDRange options -->
|
||||
{{ Range(option_name, option) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro FreeText(option_name, option) %}
|
||||
<div class="hint-text">
|
||||
This option allows custom values only. Please enter your desired values below.
|
||||
<div class="custom-value-wrapper">
|
||||
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
||||
<button type="button" data-option="{{ option_name }}">Add</button>
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
{% if option.default %}
|
||||
{{ RangeRow(option_name, option, option.default, option.default) }}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro TextChoice(option_name, option) %}
|
||||
<div class="hint-text">
|
||||
Custom values are also allowed for this option. To create one, enter it into the input box below.
|
||||
<div class="custom-value-wrapper">
|
||||
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
||||
<button type="button" data-option="{{ option_name }}">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
{% for id, name in option.name_lookup.items() %}
|
||||
{% if name != 'random' %}
|
||||
{% if option.default != 'random' %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }}
|
||||
{% else %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ RandomRow(option_name, option) }}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro PlandoBosses(option_name, option) %}
|
||||
<!-- PlandoBosses is handled by its parent, TextChoice -->
|
||||
{{ TextChoice(option_name, option) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro ItemDict(option_name, option, world) %}
|
||||
<div class="dict-container">
|
||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
||||
<div class="dict-entry">
|
||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||
<input
|
||||
type="number"
|
||||
id="{{ option_name }}-{{ item_name }}-qty"
|
||||
name="{{ option_name }}||{{ item_name }}"
|
||||
value="{{ option.default[item_name] if item_name in option.default else "0" }}"
|
||||
/>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro OptionList(option_name, option) %}
|
||||
<div class="list-container">
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||
<div class="list-entry">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="{{ option_name }}-{{ key }}"
|
||||
name="{{ option_name }}||{{ key }}"
|
||||
value="1"
|
||||
checked="{{ "checked" if key in option.default else "" }}"
|
||||
/>
|
||||
<label for="{{ option_name }}-{{ key }}">
|
||||
{{ key }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro LocationSet(option_name, option, world) %}
|
||||
<div class="set-container">
|
||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||
{% if group_name != "Everywhere" %}
|
||||
<div class="set-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}||{{ group_name }}" value="1" {{ "checked" if group_name in option.default }} />
|
||||
<label for="{{ option_name }}-{{ group_name }}">{{ group_name }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if world.location_name_groups.keys()|length > 1 %}
|
||||
<div class="divider"> </div>
|
||||
{% endif %}
|
||||
{% for location_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.location_names|sort) %}
|
||||
<div class="set-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ location_name }}" name="{{ option_name }}||{{ location_name }}" value="1" {{ "checked" if location_name in option.default }} />
|
||||
<label for="{{ option_name }}-{{ location_name }}">{{ location_name }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro ItemSet(option_name, option, world) %}
|
||||
<div class="set-container">
|
||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||
{% if group_name != "Everything" %}
|
||||
<div class="set-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}||{{ group_name }}" value="1" {{ "checked" if group_name in option.default }} />
|
||||
<label for="{{ option_name }}-{{ group_name }}">{{ group_name }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if world.item_name_groups.keys()|length > 1 %}
|
||||
<div class="set-divider"> </div>
|
||||
{% endif %}
|
||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
||||
<div class="set-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ item_name }}" name="{{ option_name }}||{{ item_name }}" value="1" {{ "checked" if item_name in option.default }} />
|
||||
<label for="{{ option_name }}-{{ item_name }}">{{ item_name }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro OptionSet(option_name, option) %}
|
||||
<div class="set-container">
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||
<div class="set-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}||{{ key }}" value="1" {{ "checked" if key in option.default }} />
|
||||
<label for="{{ option_name }}-{{ key }}">{{ key }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro OptionTitleTd(option_name, value) %}
|
||||
<td class="td-left">
|
||||
<label for="{{ option_name }}||{{ value }}">
|
||||
{{ option.display_name|default(option_name) }}
|
||||
</label>
|
||||
</td>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro RandomRow(option_name, option, extra_column=False) %}
|
||||
{{ RangeRow(option_name, option, "Random", "random") }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro RandomRows(option_name, option, extra_column=False) %}
|
||||
{% for key, value in {"Random": "random", "Random (Low)": "random-low", "Random (Middle)": "random-middle", "Random (High)": "random-high"}.items() %}
|
||||
{{ RangeRow(option_name, option, key, value) }}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro RangeRow(option_name, option, display_value, value, can_delete=False, default_override=None) %}
|
||||
<tr data-row="{{ option_name }}-{{ value }}-row" data-option-name="{{ option_name }}" data-value="{{ value }}">
|
||||
<td class="td-left">
|
||||
<label for="{{ option_name }}||{{ value }}">
|
||||
{{ display_value }}
|
||||
</label>
|
||||
</td>
|
||||
<td class="td-middle">
|
||||
<input
|
||||
type="range"
|
||||
id="{{ option_name }}||{{ value }}"
|
||||
name="{{ option_name }}||{{ value }}"
|
||||
min="0"
|
||||
max="50"
|
||||
{% if option.default == value or default_override == value %}
|
||||
value="25"
|
||||
{% else %}
|
||||
value="0"
|
||||
{% endif %}
|
||||
/>
|
||||
</td>
|
||||
<td class="td-right">
|
||||
<span id="{{ option_name }}||{{ value }}-value">
|
||||
{% if option.default == value or default_override == value %}
|
||||
25
|
||||
{% else %}
|
||||
0
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
{% if can_delete %}
|
||||
<td>
|
||||
<span class="range-option-delete js-required" data-target="{{ option_name }}-{{ value }}-row">
|
||||
❌
|
||||
</span>
|
||||
</td>
|
||||
{% else %}
|
||||
<td><!-- This td empty on purpose --></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
@@ -1,119 +0,0 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
{% import 'weightedOptions/macros.html' as inputs %}
|
||||
|
||||
{% block head %}
|
||||
<title>{{ world_name }} Weighted Options</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/weightedOptions/weightedOptions.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/weightedOptions.js") }}"></script>
|
||||
<noscript>
|
||||
<style>
|
||||
.js-required{
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/'+theme+'Header.html' %}
|
||||
<div id="weighted-options" class="markdown" data-game="{{ world_name }}">
|
||||
<noscript>
|
||||
<div class="js-warning-banner">
|
||||
This page has reduced functionality without JavaScript.
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<div id="user-message"></div>
|
||||
|
||||
<div id="weighted-options-header">
|
||||
<h1>{{ world_name }}</h1>
|
||||
<h1>Weighted Options</h1>
|
||||
</div>
|
||||
|
||||
<form id="weighted-options-form" method="post" enctype="application/x-www-form-urlencoded" action="generate-weighted-yaml">
|
||||
|
||||
<p>Weighted options allow you to choose how likely a particular option's value is to be used in game
|
||||
generation. The higher a value is weighted, the more likely the option will be chosen. Think of them like
|
||||
entries in a raffle.</p>
|
||||
|
||||
<p>Choose the options you would like to play with! You may generate a single-player game from
|
||||
this page, or download an options file you can use to participate in a MultiWorld.</p>
|
||||
|
||||
<p>A list of all games you have generated can be found on the <a href="/user-content">User Content</a>
|
||||
page.</p>
|
||||
|
||||
|
||||
<p><label for="player-name">Please enter your player name. This will appear in-game as you send and receive
|
||||
items if you are playing in a MultiWorld.</label><br />
|
||||
<input id="player-name" placeholder="Player Name" name="name" maxlength="16" />
|
||||
</p>
|
||||
|
||||
<div id="{{ world_name }}-container">
|
||||
{% for group_name, group_options in option_groups.items() %}
|
||||
<details {% if not start_collapsed[group_name] %}open{% endif %}>
|
||||
<summary class="h2">{{ group_name }}</summary>
|
||||
{% for option_name, option in group_options.items() %}
|
||||
<div class="option-wrapper">
|
||||
<h4>{{ option.display_name|default(option_name) }}</h4>
|
||||
<div class="option-description">
|
||||
{{ option.__doc__ }}
|
||||
</div>
|
||||
{% if issubclass(option, Options.Toggle) %}
|
||||
{{ inputs.Toggle(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.DefaultOnToggle) %}
|
||||
{{ inputs.DefaultOnToggle(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.PlandoBosses) %}
|
||||
{{ inputs.PlandoBosses(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.TextChoice) %}
|
||||
{{ inputs.TextChoice(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.Choice) %}
|
||||
{{ inputs.Choice(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.NamedRange) %}
|
||||
{{ inputs.NamedRange(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.Range) %}
|
||||
{{ inputs.Range(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.FreeText) %}
|
||||
{{ inputs.FreeText(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||
{{ inputs.ItemDict(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||
{{ inputs.OptionList(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
|
||||
{{ inputs.LocationSet(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
|
||||
{{ inputs.ItemSet(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
|
||||
{{ inputs.OptionSet(option_name, option) }}
|
||||
|
||||
{% else %}
|
||||
<div class="unsupported-option">
|
||||
This option is not supported. Please edit your .yaml file manually.
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="weighted-options-button-row">
|
||||
<input type="submit" name="intent-export" value="Export Options" />
|
||||
<input type="submit" name="intent-generate" value="Generate Single-Player Game">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,9 +3,8 @@ import collections
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, NamedTuple, Counter
|
||||
from uuid import UUID
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
from flask import render_template, make_response, Response, request
|
||||
from flask import render_template
|
||||
from werkzeug.exceptions import abort
|
||||
|
||||
from MultiServer import Context, get_saving_second
|
||||
@@ -292,47 +291,47 @@ class TrackerData:
|
||||
|
||||
return video_feeds
|
||||
|
||||
@_cache_results
|
||||
def get_spheres(self) -> List[List[int]]:
|
||||
""" each sphere is { player: { location_id, ... } } """
|
||||
return self._multidata.get("spheres", [])
|
||||
|
||||
@app.route("/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
|
||||
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool = False) -> str:
|
||||
key = f"{tracker}_{tracked_team}_{tracked_player}_{generic}"
|
||||
tracker_page = cache.get(key)
|
||||
if tracker_page:
|
||||
return tracker_page
|
||||
|
||||
timeout, tracker_page = get_timeout_and_tracker(tracker, tracked_team, tracked_player, generic)
|
||||
cache.set(key, tracker_page, timeout)
|
||||
return tracker_page
|
||||
|
||||
|
||||
def _process_if_request_valid(incoming_request, room: Optional[Room]) -> Optional[Response]:
|
||||
@app.route("/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
|
||||
def get_generic_game_tracker(tracker: UUID, tracked_team: int, tracked_player: int) -> str:
|
||||
return get_player_tracker(tracker, tracked_team, tracked_player, True)
|
||||
|
||||
|
||||
@app.route("/tracker/<suuid:tracker>", defaults={"game": "Generic"})
|
||||
@app.route("/tracker/<suuid:tracker>/<game>")
|
||||
@cache.memoize(timeout=TRACKER_CACHE_TIMEOUT_IN_SECONDS)
|
||||
def get_multiworld_tracker(tracker: UUID, game: str):
|
||||
# Room must exist.
|
||||
room = Room.get(tracker=tracker)
|
||||
if not room:
|
||||
abort(404)
|
||||
|
||||
if_modified = incoming_request.headers.get("If-Modified-Since", None)
|
||||
if if_modified:
|
||||
if_modified = parsedate_to_datetime(if_modified)
|
||||
# if_modified has less precision than last_activity, so we bring them to same precision
|
||||
if if_modified >= room.last_activity.replace(microsecond=0):
|
||||
return make_response("", 304)
|
||||
tracker_data = TrackerData(room)
|
||||
enabled_trackers = list(get_enabled_multiworld_trackers(room).keys())
|
||||
if game not in _multiworld_trackers:
|
||||
return render_generic_multiworld_tracker(tracker_data, enabled_trackers)
|
||||
|
||||
return _multiworld_trackers[game](tracker_data, enabled_trackers)
|
||||
|
||||
|
||||
@app.route("/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
|
||||
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool = False) -> Response:
|
||||
key = f"{tracker}_{tracked_team}_{tracked_player}_{generic}"
|
||||
response: Optional[Response] = cache.get(key)
|
||||
if response:
|
||||
return response
|
||||
|
||||
def get_timeout_and_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool) -> Tuple[int, str]:
|
||||
# Room must exist.
|
||||
room = Room.get(tracker=tracker)
|
||||
if not room:
|
||||
abort(404)
|
||||
|
||||
response = _process_if_request_valid(request, room)
|
||||
if response:
|
||||
return response
|
||||
|
||||
timeout, last_modified, tracker_page = get_timeout_and_player_tracker(room, tracked_team, tracked_player, generic)
|
||||
response = make_response(tracker_page)
|
||||
response.last_modified = last_modified
|
||||
cache.set(key, response, timeout)
|
||||
return response
|
||||
|
||||
|
||||
def get_timeout_and_player_tracker(room: Room, tracked_team: int, tracked_player: int, generic: bool)\
|
||||
-> Tuple[int, datetime.datetime, str]:
|
||||
tracker_data = TrackerData(room)
|
||||
|
||||
# Load and render the game-specific player tracker, or fallback to generic tracker if none exists.
|
||||
@@ -342,48 +341,7 @@ def get_timeout_and_player_tracker(room: Room, tracked_team: int, tracked_player
|
||||
else:
|
||||
tracker = render_generic_tracker(tracker_data, tracked_team, tracked_player)
|
||||
|
||||
return ((tracker_data.get_room_saving_second() - datetime.datetime.now().second)
|
||||
% TRACKER_CACHE_TIMEOUT_IN_SECONDS or TRACKER_CACHE_TIMEOUT_IN_SECONDS, room.last_activity, tracker)
|
||||
|
||||
|
||||
@app.route("/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
|
||||
def get_generic_game_tracker(tracker: UUID, tracked_team: int, tracked_player: int) -> Response:
|
||||
return get_player_tracker(tracker, tracked_team, tracked_player, True)
|
||||
|
||||
|
||||
@app.route("/tracker/<suuid:tracker>", defaults={"game": "Generic"})
|
||||
@app.route("/tracker/<suuid:tracker>/<game>")
|
||||
def get_multiworld_tracker(tracker: UUID, game: str) -> Response:
|
||||
key = f"{tracker}_{game}"
|
||||
response: Optional[Response] = cache.get(key)
|
||||
if response:
|
||||
return response
|
||||
|
||||
# Room must exist.
|
||||
room = Room.get(tracker=tracker)
|
||||
|
||||
response = _process_if_request_valid(request, room)
|
||||
if response:
|
||||
return response
|
||||
|
||||
timeout, last_modified, tracker_page = get_timeout_and_multiworld_tracker(room, game)
|
||||
response = make_response(tracker_page)
|
||||
response.last_modified = last_modified
|
||||
cache.set(key, response, timeout)
|
||||
return response
|
||||
|
||||
|
||||
def get_timeout_and_multiworld_tracker(room: Room, game: str)\
|
||||
-> Tuple[int, datetime.datetime, str]:
|
||||
tracker_data = TrackerData(room)
|
||||
enabled_trackers = list(get_enabled_multiworld_trackers(room).keys())
|
||||
if game in _multiworld_trackers:
|
||||
tracker = _multiworld_trackers[game](tracker_data, enabled_trackers)
|
||||
else:
|
||||
tracker = render_generic_multiworld_tracker(tracker_data, enabled_trackers)
|
||||
|
||||
return ((tracker_data.get_room_saving_second() - datetime.datetime.now().second)
|
||||
% TRACKER_CACHE_TIMEOUT_IN_SECONDS or TRACKER_CACHE_TIMEOUT_IN_SECONDS, room.last_activity, tracker)
|
||||
return (tracker_data.get_room_saving_second() - datetime.datetime.now().second) % 60 or 60, tracker
|
||||
|
||||
|
||||
def get_enabled_multiworld_trackers(room: Room) -> Dict[str, Callable]:
|
||||
@@ -453,30 +411,9 @@ def render_generic_multiworld_tracker(tracker_data: TrackerData, enabled_tracker
|
||||
videos=tracker_data.get_room_videos(),
|
||||
item_id_to_name=tracker_data.item_id_to_name,
|
||||
location_id_to_name=tracker_data.location_id_to_name,
|
||||
saving_second=tracker_data.get_room_saving_second(),
|
||||
)
|
||||
|
||||
|
||||
def render_generic_multiworld_sphere_tracker(tracker_data: TrackerData) -> str:
|
||||
return render_template(
|
||||
"multispheretracker.html",
|
||||
room=tracker_data.room,
|
||||
tracker_data=tracker_data,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/sphere_tracker/<suuid:tracker>")
|
||||
@cache.memoize(timeout=TRACKER_CACHE_TIMEOUT_IN_SECONDS)
|
||||
def get_multiworld_sphere_tracker(tracker: UUID):
|
||||
# Room must exist.
|
||||
room = Room.get(tracker=tracker)
|
||||
if not room:
|
||||
abort(404)
|
||||
|
||||
tracker_data = TrackerData(room)
|
||||
return render_generic_multiworld_sphere_tracker(tracker_data)
|
||||
|
||||
|
||||
# TODO: This is a temporary solution until a proper Tracker API can be implemented for tracker templates and data to
|
||||
# live in their respective world folders.
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ def get_payload(ctx: ZeldaContext):
|
||||
|
||||
|
||||
def reconcile_shops(ctx: ZeldaContext):
|
||||
checked_location_names = [ctx.location_names.lookup_in_slot(location) for location in ctx.checked_locations]
|
||||
checked_location_names = [ctx.location_names[location] for location in ctx.checked_locations]
|
||||
shops = [location for location in checked_location_names if "Shop" in location]
|
||||
left_slots = [shop for shop in shops if "Left" in shop]
|
||||
middle_slots = [shop for shop in shops if "Middle" in shop]
|
||||
@@ -190,7 +190,7 @@ async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone=
|
||||
locations_checked = []
|
||||
location = None
|
||||
for location in ctx.missing_locations:
|
||||
location_name = ctx.location_names.lookup_in_slot(location)
|
||||
location_name = ctx.location_names[location]
|
||||
|
||||
if location_name in Locations.overworld_locations and zone == "overworld":
|
||||
status = locations_array[Locations.major_location_offsets[location_name]]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#cython: language_level=3
|
||||
#distutils: language = c
|
||||
#distutils: depends = intset.h
|
||||
#distutils: language = c++
|
||||
|
||||
"""
|
||||
Provides faster implementation of some core parts.
|
||||
@@ -14,6 +13,7 @@ from cpython cimport PyObject
|
||||
from typing import Any, Dict, Iterable, Iterator, Generator, Sequence, Tuple, TypeVar, Union, Set, List, TYPE_CHECKING
|
||||
from cymem.cymem cimport Pool
|
||||
from libc.stdint cimport int64_t, uint32_t
|
||||
from libcpp.set cimport set as std_set
|
||||
from collections import defaultdict
|
||||
|
||||
cdef extern from *:
|
||||
@@ -31,27 +31,6 @@ ctypedef int64_t ap_id_t
|
||||
cdef ap_player_t MAX_PLAYER_ID = 1000000 # limit the size of indexing array
|
||||
cdef size_t INVALID_SIZE = <size_t>(-1) # this is all 0xff... adding 1 results in 0, but it's not negative
|
||||
|
||||
# configure INTSET for player
|
||||
cdef extern from *:
|
||||
"""
|
||||
#define INTSET_NAME ap_player_set
|
||||
#define INTSET_TYPE uint32_t // has to match ap_player_t
|
||||
"""
|
||||
|
||||
# create INTSET for player
|
||||
cdef extern from "intset.h":
|
||||
"""
|
||||
#undef INTSET_NAME
|
||||
#undef INTSET_TYPE
|
||||
"""
|
||||
ctypedef struct ap_player_set:
|
||||
pass
|
||||
|
||||
ap_player_set* ap_player_set_new(size_t bucket_count) nogil
|
||||
void ap_player_set_free(ap_player_set* set) nogil
|
||||
bint ap_player_set_add(ap_player_set* set, ap_player_t val) nogil
|
||||
bint ap_player_set_contains(ap_player_set* set, ap_player_t val) nogil
|
||||
|
||||
|
||||
cdef struct LocationEntry:
|
||||
# layout is so that
|
||||
@@ -206,7 +185,7 @@ cdef class LocationStore:
|
||||
def find_item(self, slots: Set[int], seeked_item_id: int) -> Generator[Tuple[int, int, int, int, int], None, None]:
|
||||
cdef ap_id_t item = seeked_item_id
|
||||
cdef ap_player_t receiver
|
||||
cdef ap_player_set* receivers
|
||||
cdef std_set[ap_player_t] receivers
|
||||
cdef size_t slot_count = len(slots)
|
||||
if slot_count == 1:
|
||||
# specialized implementation for single slot
|
||||
@@ -218,20 +197,13 @@ cdef class LocationStore:
|
||||
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
|
||||
elif slot_count:
|
||||
# generic implementation with lookup in set
|
||||
receivers = ap_player_set_new(min(1023, slot_count)) # limit top level struct to 16KB
|
||||
if not receivers:
|
||||
raise MemoryError()
|
||||
try:
|
||||
for receiver in slots:
|
||||
if not ap_player_set_add(receivers, receiver):
|
||||
raise MemoryError()
|
||||
with nogil:
|
||||
for entry in self.entries[:self.entry_count]:
|
||||
if entry.item == item and ap_player_set_contains(receivers, entry.receiver):
|
||||
with gil:
|
||||
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
|
||||
finally:
|
||||
ap_player_set_free(receivers)
|
||||
for receiver in slots:
|
||||
receivers.insert(receiver)
|
||||
with nogil:
|
||||
for entry in self.entries[:self.entry_count]:
|
||||
if entry.item == item and receivers.count(entry.receiver):
|
||||
with gil:
|
||||
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
|
||||
|
||||
def get_for_player(self, slot: int) -> Dict[int, Set[int]]:
|
||||
cdef ap_player_t receiver = slot
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# This file is used when doing pyximport
|
||||
import os
|
||||
# This file is required to get pyximport to work with C++.
|
||||
# Switching from std::set to a pure C implementation is still on the table to simplify everything.
|
||||
|
||||
def make_ext(modname, pyxfilename):
|
||||
from distutils.extension import Extension
|
||||
return Extension(name=modname,
|
||||
sources=[pyxfilename],
|
||||
depends=["intset.h"],
|
||||
include_dirs=[os.getcwd()],
|
||||
language="c")
|
||||
language='c++')
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
plum: "AF99EF" # typically progression item
|
||||
salmon: "FA8072" # typically trap item
|
||||
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
|
||||
orange: "FF7700" # Used for command echo
|
||||
<Label>:
|
||||
color: "FFFFFF"
|
||||
<TabbedPanel>:
|
||||
|
||||
@@ -45,10 +45,7 @@ requires:
|
||||
{% endmacro %}
|
||||
|
||||
{{ game }}:
|
||||
{%- for group_name, group_options in option_groups.items() %}
|
||||
# {{ group_name }}
|
||||
|
||||
{%- for option_key, option in group_options.items() %}
|
||||
{%- for option_key, option in options.items() %}
|
||||
{{ option_key }}:
|
||||
{%- if option.__doc__ %}
|
||||
# {{ option.__doc__
|
||||
@@ -86,4 +83,3 @@ requires:
|
||||
{%- endif -%}
|
||||
{{ "\n" }}
|
||||
{%- endfor %}
|
||||
{%- endfor %}
|
||||
|
||||
BIN
data/yatta.ico
BIN
data/yatta.ico
Binary file not shown.
|
Before Width: | Height: | Size: 149 KiB |
BIN
data/yatta.png
BIN
data/yatta.png
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@@ -6,30 +6,25 @@
|
||||
#
|
||||
# All usernames must be GitHub usernames (and are case sensitive).
|
||||
|
||||
###################
|
||||
## Active Worlds ##
|
||||
###################
|
||||
|
||||
# Adventure
|
||||
/worlds/adventure/ @JusticePS
|
||||
|
||||
# A Hat in Time
|
||||
/worlds/ahit/ @CookieCat45
|
||||
|
||||
# A Link to the Past
|
||||
/worlds/alttp/ @Berserker66
|
||||
|
||||
# Sudoku (APSudoku)
|
||||
/worlds/apsudoku/ @EmilyV99
|
||||
|
||||
# Aquaria
|
||||
/worlds/aquaria/ @tioui
|
||||
|
||||
# ArchipIDLE
|
||||
/worlds/archipidle/ @LegendaryLinux
|
||||
|
||||
# Sudoku (BK Sudoku)
|
||||
/worlds/bk_sudoku/ @Jarno458
|
||||
|
||||
# Blasphemous
|
||||
/worlds/blasphemous/ @TRPG0
|
||||
|
||||
# Bomb Rush Cyberfunk
|
||||
/worlds/bomb_rush_cyberfunk/ @TRPG0
|
||||
|
||||
# Bumper Stickers
|
||||
/worlds/bumpstik/ @FelicitusNeko
|
||||
|
||||
@@ -63,6 +58,9 @@
|
||||
# Factorio
|
||||
/worlds/factorio/ @Berserker66
|
||||
|
||||
# Final Fantasy
|
||||
/worlds/ff1/ @jtoyoda
|
||||
|
||||
# Final Fantasy Mystic Quest
|
||||
/worlds/ffmq/ @Alchav @wildham0
|
||||
|
||||
@@ -70,7 +68,7 @@
|
||||
/worlds/heretic/ @Daivuk
|
||||
|
||||
# Hollow Knight
|
||||
/worlds/hk/ @BadMagic100 @qwint
|
||||
/worlds/hk/ @BadMagic100 @ThePhar
|
||||
|
||||
# Hylics 2
|
||||
/worlds/hylics2/ @TRPG0
|
||||
@@ -199,31 +197,15 @@
|
||||
# Yoshi's Island
|
||||
/worlds/yoshisisland/ @PinkSwitch
|
||||
|
||||
#Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
||||
/worlds/yugioh06/ @Rensen3
|
||||
|
||||
# Zillion
|
||||
/worlds/zillion/ @beauxq
|
||||
|
||||
# Zork Grand Inquisitor
|
||||
/worlds/zork_grand_inquisitor/ @nbrochu
|
||||
|
||||
|
||||
## Active Unmaintained Worlds
|
||||
|
||||
# The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks
|
||||
# compatibility, these worlds may be moved to `worlds_disabled`. If you are interested in stepping up as maintainer for
|
||||
# any of these worlds, please review `/docs/world maintainer.md` documentation.
|
||||
|
||||
# Final Fantasy (1)
|
||||
# /worlds/ff1/
|
||||
|
||||
|
||||
## Disabled Unmaintained Worlds
|
||||
|
||||
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
|
||||
# interested in stepping up as maintainer for any of these worlds, please review `/docs/world maintainer.md`
|
||||
# documentation.
|
||||
##################################
|
||||
## Disabled Unmaintained Worlds ##
|
||||
##################################
|
||||
|
||||
# Ori and the Blind Forest
|
||||
# /worlds_disabled/oribf/
|
||||
# /worlds_disabled/oribf/ <Unmaintained>
|
||||
|
||||
@@ -1,49 +1,43 @@
|
||||
# Contributing
|
||||
|
||||
All contributions are welcome, though we have a few requests of contributors, whether they be for core, webhost, or new
|
||||
game contributions:
|
||||
Contributions are welcome. We have a few requests for new contributors:
|
||||
|
||||
* **Follow styling guidelines.**
|
||||
Please take a look at the [code style documentation](/docs/style.md)
|
||||
to ensure ease of communication and uniformity.
|
||||
|
||||
* **Ensure that critical changes are covered by tests.**
|
||||
It is strongly recommended that unit tests are used to avoid regression and to ensure everything is still working.
|
||||
If you wish to contribute by adding a new game, please take a look at
|
||||
the [logic unit test documentation](/docs/tests.md).
|
||||
If you wish to contribute to the website, please take a look at [these tests](/test/webhost).
|
||||
* **Ensure that critical changes are covered by tests.**
|
||||
It is strongly recommended that unit tests are used to avoid regression and to ensure everything is still working.
|
||||
If you wish to contribute by adding a new game, please take a look at the [logic unit test documentation](/docs/tests.md).
|
||||
If you wish to contribute to the website, please take a look at [these tests](/test/webhost).
|
||||
|
||||
* **Do not introduce unit test failures/regressions.**
|
||||
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
|
||||
your changes. Currently, the oldest supported version
|
||||
is [Python 3.8](https://www.python.org/downloads/release/python-380/).
|
||||
It is recommended that automated github actions are turned on in your fork to have github run unit tests after
|
||||
pushing.
|
||||
You can turn them on here:
|
||||

|
||||
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
|
||||
your changes. Currently, the oldest supported version is [Python 3.8](https://www.python.org/downloads/release/python-380/).
|
||||
It is recommended that automated github actions are turned on in your fork to have github run all of the unit tests after pushing.
|
||||
You can turn them on here:
|
||||

|
||||
|
||||
* **When reviewing PRs, please leave a message about what was done.**
|
||||
We don't have full test coverage, so manual testing can help.
|
||||
For code changes that could affect multiple worlds or that could have changes in unexpected code paths, manual testing
|
||||
or checking if all code paths are covered by automated tests is desired. The original author may not have been able
|
||||
to test all possibly affected worlds, or didn't know it would affect another world. In such cases, it is helpful to
|
||||
state which games or settings were rolled, if any.
|
||||
Please also tell us if you looked at code, just did functional testing, did both, or did neither.
|
||||
If testing the PR depends on other PRs, please state what you merged into what for testing.
|
||||
We cannot determine what "LGTM" means without additional context, so that should not be the norm.
|
||||
We don't have full test coverage, so manual testing can help.
|
||||
For code changes that could affect multiple worlds or that could have changes in unexpected code paths, manual testing
|
||||
or checking if all code paths are covered by automated tests is desired. The original author may not have been able
|
||||
to test all possibly affected worlds, or didn't know it would affect another world. In such cases, it is helpful to
|
||||
state which games or settings were rolled, if any.
|
||||
Please also tell us if you looked at code, just did functional testing, did both, or did neither.
|
||||
If testing the PR depends on other PRs, please state what you merged into what for testing.
|
||||
We cannot determine what "LGTM" means without additional context, so that should not be the norm.
|
||||
|
||||
Other than these requests, we tend to judge code on a case-by-case basis.
|
||||
Other than these requests, we tend to judge code on a case-by-case basis.
|
||||
|
||||
For contribution to the website, please refer to the [WebHost README](/WebHostLib/README.md).
|
||||
|
||||
If you want to contribute to the core, you will be subject to stricter review on your pull requests. It is recommended
|
||||
that you get in touch with other core maintainers via the [Discord](https://archipelago.gg/discord).
|
||||
|
||||
If you want to add Archipelago support for a new game, please take a look at
|
||||
the [adding games documentation](/docs/adding%20games.md)
|
||||
which details what is required to implement support for a game, and has tips on to get started.
|
||||
If you want to merge a new game into the main Archipelago repo, please make sure to read the responsibilities as a
|
||||
[world maintainer](/docs/world%20maintainer.md).
|
||||
If you want to add Archipelago support for a new game, please take a look at the [adding games documentation](/docs/adding%20games.md), which details what is required
|
||||
to implement support for a game, as well as tips for how to get started.
|
||||
If you want to merge a new game into the main Archipelago repo, please make sure to read the responsibilities as a
|
||||
[world maintainer](/docs/world%20maintainer.md).
|
||||
|
||||
For other questions, feel free to explore the [main documentation folder](/docs), and ask us questions in the
|
||||
#ap-world-dev channel of the [Discord](https://archipelago.gg/discord).
|
||||
For other questions, feel free to explore the [main documentation folder](/docs/) and ask us questions in the #archipelago-dev channel
|
||||
of the [Discord](https://archipelago.gg/discord).
|
||||
|
||||
@@ -53,7 +53,7 @@ Example:
|
||||
```
|
||||
|
||||
## (Server -> Client)
|
||||
These packets are sent from the multiworld server to the client. They are not messages which the server accepts.
|
||||
These packets are are sent from the multiworld server to the client. They are not messages which the server accepts.
|
||||
* [RoomInfo](#RoomInfo)
|
||||
* [ConnectionRefused](#ConnectionRefused)
|
||||
* [Connected](#Connected)
|
||||
@@ -80,6 +80,7 @@ Sent to clients when they connect to an Archipelago server.
|
||||
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
|
||||
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
|
||||
| games | list\[str\] | List of games present in this multiworld. |
|
||||
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). **Deprecated. Use `datapackage_checksums` instead.** |
|
||||
| datapackage_checksums | dict[str, str] | Checksum hash of the individual games' data packages the server will send. Used by newer clients to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents) for more information. |
|
||||
| seed_name | str | Uniquely identifying name of this generation |
|
||||
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
|
||||
@@ -499,9 +500,9 @@ In JSON this may look like:
|
||||
{"item": 3, "location": 3, "player": 3, "flags": 0}
|
||||
]
|
||||
```
|
||||
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||
`item` is the item id of the item. Item ids are in the range of ± 2<sup>53</sup>-1.
|
||||
|
||||
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||
`location` is the location id of the item inside the world. Location ids are in the range of ± 2<sup>53</sup>-1.
|
||||
|
||||
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
|
||||
|
||||
@@ -645,47 +646,15 @@ class Hint(typing.NamedTuple):
|
||||
```
|
||||
|
||||
### Data Package Contents
|
||||
A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago
|
||||
server most easily and not maintain their own mappings. Some contents include:
|
||||
A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago server most easily. Currently, this package is used to send ID to name mappings so that clients need not maintain their own mappings.
|
||||
|
||||
- Name to ID mappings for items and locations.
|
||||
- A checksum of each game's data package for clients to tell if a cached package is invalid.
|
||||
We encourage clients to cache the data package they receive on disk, or otherwise not tied to a session. You will know when your cache is outdated if the [RoomInfo](#RoomInfo) packet or the datapackage itself denote a different version. A special case is datapackage version 0, where it is expected the package is custom and should not be cached.
|
||||
|
||||
We encourage clients to cache the data package they receive on disk, or otherwise not tied to a session. You will know
|
||||
when your cache is outdated if the [RoomInfo](#RoomInfo) packet or the datapackage itself denote a different checksum
|
||||
than any locally cached ones.
|
||||
|
||||
**Important Notes about IDs and Names**:
|
||||
|
||||
* IDs ≤ 0 are reserved for "Archipelago" and should not be used by other world implementations.
|
||||
* The IDs from the game "Archipelago" (in `worlds/generic`) may be used in any world.
|
||||
* Especially Location ID `-1`: `Cheat Console` and `-2`: `Server` (typically Remote Start Inventory)
|
||||
* Any names and IDs are only unique in its own world data package, but different games may reuse these names or IDs.
|
||||
* At runtime, you will need to look up the game of the player to know which item or location ID/Name to lookup in the
|
||||
data package. This can be easily achieved by reviewing the `slot_info` for a particular player ID prior to lookup.
|
||||
* For example, a data package like this is valid (Some properties such as `checksum` were omitted):
|
||||
```json
|
||||
{
|
||||
"games": {
|
||||
"Game A": {
|
||||
"location_name_to_id": {
|
||||
"Boss Chest": 40
|
||||
},
|
||||
"item_name_to_id": {
|
||||
"Item X": 12
|
||||
}
|
||||
},
|
||||
"Game B": {
|
||||
"location_name_to_id": {
|
||||
"Minigame Prize": 40
|
||||
},
|
||||
"item_name_to_id": {
|
||||
"Item X": 40
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Note:
|
||||
* Any ID is unique to its type across AP: Item 56 only exists once and Location 56 only exists once.
|
||||
* Any Name is unique to its type across its own Game only: Single Arrow can exist in two games.
|
||||
* The IDs from the game "Archipelago" may be used in any other game.
|
||||
Especially Location ID -1: Cheat Console and -2: Server (typically Remote Start Inventory)
|
||||
|
||||
#### Contents
|
||||
| Name | Type | Notes |
|
||||
@@ -699,6 +668,7 @@ GameData is a **dict** but contains these keys and values. It's broken out into
|
||||
|---------------------|----------------|-------------------------------------------------------------------------------------------------------------------------------|
|
||||
| item_name_to_id | dict[str, int] | Mapping of all item names to their respective ID. |
|
||||
| location_name_to_id | dict[str, int] | Mapping of all location names to their respective ID. |
|
||||
| version | int | Version number of this game's data. Deprecated. Used by older clients to request an updated datapackage if cache is outdated. |
|
||||
| checksum | str | A checksum hash of this game's data. |
|
||||
|
||||
### Tags
|
||||
|
||||
@@ -85,37 +85,6 @@ class ExampleWorld(World):
|
||||
options: ExampleGameOptions
|
||||
```
|
||||
|
||||
### Option Groups
|
||||
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
|
||||
by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment
|
||||
with the group name at the beginning of each group of options. The `start_collapsed` Boolean only affects how the groups
|
||||
appear on the WebHost, with the grouping being collapsed when this is `True`.
|
||||
|
||||
Options without a group name are categorized into a generic "Game Options" group, which is always the first group. If
|
||||
every option for your world is in a group, this group will be removed. There is also an "Items & Location Options"
|
||||
group, which is automatically created using certain specified `item_and_loc_options`. These specified options cannot be
|
||||
removed from this group.
|
||||
|
||||
Both the "Game Options" and "Item & Location Options" groups can be overridden by creating your own groups with
|
||||
those names, letting you add options to them and change whether they start collapsed. The "Item &
|
||||
Location Options" group can also be moved to a different position in the group ordering, but "Game Options" will always
|
||||
be first, regardless of where it is in your list.
|
||||
|
||||
```python
|
||||
from worlds.AutoWorld import WebWorld
|
||||
from Options import OptionGroup
|
||||
from . import Options
|
||||
|
||||
class MyWorldWeb(WebWorld):
|
||||
option_groups = [
|
||||
OptionGroup("Color Options", [
|
||||
Options.ColorblindMode,
|
||||
Options.FlashReduction,
|
||||
Options.UIColors,
|
||||
]),
|
||||
]
|
||||
```
|
||||
|
||||
### Option Checking
|
||||
Options are parsed by `Generate.py` before the worlds are created, and then the option classes are created shortly after
|
||||
world instantiation. These are created as attributes on the MultiWorld and can be accessed with
|
||||
@@ -132,8 +101,7 @@ or if I need a boolean object, such as in my slot_data I can access it as:
|
||||
start_with_sword = bool(self.options.starting_sword.value)
|
||||
```
|
||||
All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes,
|
||||
strings that match the option attributes after "option_" is stripped, and the attributes themselves. The option can
|
||||
also be checked to see if it exists within a collection, but this will fail for a set of strings due to hashing.
|
||||
strings that match the option attributes after "option_" is stripped, and the attributes themselves.
|
||||
```python
|
||||
# options.py
|
||||
class Logic(Choice):
|
||||
@@ -145,12 +113,6 @@ class Logic(Choice):
|
||||
alias_extra_hard = 2
|
||||
crazy = 4 # won't be listed as an option and only exists as an attribute on the class
|
||||
|
||||
class Weapon(Choice):
|
||||
option_none = 0
|
||||
option_sword = 1
|
||||
option_bow = 2
|
||||
option_hammer = 3
|
||||
|
||||
# __init__.py
|
||||
from .options import Logic
|
||||
|
||||
@@ -164,16 +126,6 @@ elif self.options.logic == Logic.option_extreme:
|
||||
do_extreme_things()
|
||||
elif self.options.logic == "crazy":
|
||||
do_insane_things()
|
||||
|
||||
# check if the current option is in a collection of integers using the class attributes
|
||||
if self.options.weapon in {Weapon.option_bow, Weapon.option_sword}:
|
||||
do_stuff()
|
||||
# in order to make a set of strings work, we have to compare against current_key
|
||||
elif self.options.weapon.current_key in {"none", "hammer"}:
|
||||
do_something_else()
|
||||
# though it's usually better to just use a tuple instead
|
||||
elif self.options.weapon in ("none", "hammer"):
|
||||
do_something_else()
|
||||
```
|
||||
## Generic Option Classes
|
||||
These options are generically available to every game automatically, but can be overridden for slightly different
|
||||
@@ -203,12 +155,10 @@ Gives the player starting hints for where the items defined here are.
|
||||
Gives the player starting hints for the items on locations defined here.
|
||||
|
||||
### ExcludeLocations
|
||||
Marks locations given here as `LocationProgressType.Excluded` so that neither progression nor useful items can be
|
||||
placed on them.
|
||||
Marks locations given here as `LocationProgressType.Excluded` so that progression items can't be placed on them.
|
||||
|
||||
### PriorityLocations
|
||||
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them if any are available in
|
||||
the pool.
|
||||
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them.
|
||||
|
||||
### ItemLinks
|
||||
Allows users to share their item pool with other players. Currently item links are per game. A link of one game between
|
||||
|
||||
@@ -17,14 +17,13 @@ Then run any of the starting point scripts, like Generate.py, and the included M
|
||||
required modules and after pressing enter proceed to install everything automatically.
|
||||
After this, you should be able to run the programs.
|
||||
|
||||
* `Launcher.py` gives access to many components, including clients registered in `worlds/LauncherComponents.py`.
|
||||
* The Launcher button "Generate Template Options" will generate default yamls for all worlds.
|
||||
* With yaml(s) in the `Players` folder, `Generate.py` will generate the multiworld archive.
|
||||
* `MultiServer.py`, with the filename of the generated archive as a command line parameter, will host the multiworld locally.
|
||||
* `--log_network` is a command line parameter useful for debugging.
|
||||
* `WebHost.py` will host the website on your computer.
|
||||
* You can copy `docs/webhost configuration sample.yaml` to `config.yaml`
|
||||
to change WebHost options (like the web hosting port number).
|
||||
* As a side effect, `WebHost.py` creates the template yamls for all the games in `WebHostLib/static/generated`.
|
||||
|
||||
|
||||
## Windows
|
||||
|
||||
@@ -121,53 +121,6 @@ class RLWeb(WebWorld):
|
||||
# ...
|
||||
```
|
||||
|
||||
* `location_descriptions` (optional) WebWorlds can provide a map that contains human-friendly descriptions of locations
|
||||
or location groups.
|
||||
|
||||
```python
|
||||
# locations.py
|
||||
location_descriptions = {
|
||||
"Red Potion #6": "In a secret destructible block under the second stairway",
|
||||
"L2 Spaceship": """
|
||||
The group of all items in the spaceship in Level 2.
|
||||
|
||||
This doesn't include the item on the spaceship door, since it can be
|
||||
accessed without the Spaceship Key.
|
||||
"""
|
||||
}
|
||||
|
||||
# __init__.py
|
||||
from worlds.AutoWorld import WebWorld
|
||||
from .locations import location_descriptions
|
||||
|
||||
|
||||
class MyGameWeb(WebWorld):
|
||||
location_descriptions = location_descriptions
|
||||
```
|
||||
|
||||
* `item_descriptions` (optional) WebWorlds can provide a map that contains human-friendly descriptions of items or item
|
||||
groups.
|
||||
|
||||
```python
|
||||
# items.py
|
||||
item_descriptions = {
|
||||
"Red Potion": "A standard health potion",
|
||||
"Spaceship Key": """
|
||||
The key to the spaceship in Level 2.
|
||||
|
||||
This is necessary to get to the Star Realm.
|
||||
""",
|
||||
}
|
||||
|
||||
# __init__.py
|
||||
from worlds.AutoWorld import WebWorld
|
||||
from .items import item_descriptions
|
||||
|
||||
|
||||
class MyGameWeb(WebWorld):
|
||||
item_descriptions = item_descriptions
|
||||
```
|
||||
|
||||
### MultiWorld Object
|
||||
|
||||
The `MultiWorld` object references the whole multiworld (all items and locations for all players) and is accessible
|
||||
@@ -225,6 +178,37 @@ Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED
|
||||
The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being
|
||||
required, and will prevent progression and useful items from being placed at excluded locations.
|
||||
|
||||
#### Documenting Locations
|
||||
|
||||
Worlds can optionally provide a `location_descriptions` map which contains human-friendly descriptions of locations and
|
||||
location groups. These descriptions will show up in location-selection options on the Weighted Options page. Extra
|
||||
indentation and single newlines will be collapsed into spaces.
|
||||
|
||||
```python
|
||||
# locations.py
|
||||
|
||||
location_descriptions = {
|
||||
"Red Potion #6": "In a secret destructible block under the second stairway",
|
||||
"L2 Spaceship":
|
||||
"""
|
||||
The group of all items in the spaceship in Level 2.
|
||||
|
||||
This doesn't include the item on the spaceship door, since it can be accessed without the Spaceship Key.
|
||||
"""
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# __init__.py
|
||||
|
||||
from worlds.AutoWorld import World
|
||||
from .locations import location_descriptions
|
||||
|
||||
|
||||
class MyGameWorld(World):
|
||||
location_descriptions = location_descriptions
|
||||
```
|
||||
|
||||
### Items
|
||||
|
||||
Items are all things that can "drop" for your game. This may be RPG items like weapons, or technologies you normally
|
||||
@@ -249,6 +233,37 @@ Other classifications include:
|
||||
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
|
||||
will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres
|
||||
|
||||
#### Documenting Items
|
||||
|
||||
Worlds can optionally provide an `item_descriptions` map which contains human-friendly descriptions of items and item
|
||||
groups. These descriptions will show up in item-selection options on the Weighted Options page. Extra indentation and
|
||||
single newlines will be collapsed into spaces.
|
||||
|
||||
```python
|
||||
# items.py
|
||||
|
||||
item_descriptions = {
|
||||
"Red Potion": "A standard health potion",
|
||||
"Spaceship Key":
|
||||
"""
|
||||
The key to the spaceship in Level 2.
|
||||
|
||||
This is necessary to get to the Star Realm.
|
||||
"""
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# __init__.py
|
||||
|
||||
from worlds.AutoWorld import World
|
||||
from .items import item_descriptions
|
||||
|
||||
|
||||
class MyGameWorld(World):
|
||||
item_descriptions = item_descriptions
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
An Event is a special combination of a Location and an Item, with both having an `id` of `None`. These can be used to
|
||||
|
||||
@@ -75,7 +75,7 @@ Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLaunc
|
||||
[Run]
|
||||
|
||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
|
||||
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: lttp_sprites
|
||||
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Flags: nowait; Components: lttp_sprites
|
||||
Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden
|
||||
Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
@@ -87,14 +87,7 @@ Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
|
||||
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
|
||||
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
|
||||
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
|
||||
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy"
|
||||
Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy"
|
||||
Type: files; Name: "{app}\lib\worlds\sc2wol.apworld"
|
||||
Type: filesandordirs; Name: "{app}\lib\worlds\sc2wol"
|
||||
Type: dirifempty; Name: "{app}\lib\worlds\sc2wol"
|
||||
Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku"
|
||||
Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku"
|
||||
Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe"
|
||||
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
|
||||
Type: filesandordirs; Name: "{app}\SNI\lua*"
|
||||
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
|
||||
#include "installdelete.iss"
|
||||
@@ -176,11 +169,6 @@ Root: HKCR; Subkey: "{#MyAppName}pkmnepatch"; ValueData: "Ar
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnepatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apmlss"; ValueData: "{#MyAppName}mlsspatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mlsspatch"; ValueData: "Archipelago Mario & Luigi Superstar Saga Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mlsspatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mlsspatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apcv64"; ValueData: "{#MyAppName}cv64patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Archipelago Castlevania 64 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
@@ -206,21 +194,11 @@ Root: HKCR; Subkey: "{#MyAppName}yipatch"; ValueData: "Archi
|
||||
Root: HKCR; Subkey: "{#MyAppName}yipatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}yipatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apygo06"; ValueData: "{#MyAppName}ygo06patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ygo06patch"; ValueData: "Archipelago Yu-Gi-Oh 2006 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apworld"; ValueData: "{#MyAppName}worlddata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}worlddata"; ValueData: "Archipelago World Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}worlddata\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1""";
|
||||
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
|
||||
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
|
||||
|
||||
135
intset.h
135
intset.h
@@ -1,135 +0,0 @@
|
||||
/* A specialized unordered_set implementation for literals, where bucket_count
|
||||
* is defined at initialization rather than increased automatically.
|
||||
*/
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifndef INTSET_NAME
|
||||
#error "Please #define INTSET_NAME ... before including intset.h"
|
||||
#endif
|
||||
|
||||
#ifndef INTSET_TYPE
|
||||
#error "Please #define INTSET_TYPE ... before including intset.h"
|
||||
#endif
|
||||
|
||||
/* macros to generate unique names from INTSET_NAME */
|
||||
#ifndef INTSET_CONCAT
|
||||
#define INTSET_CONCAT_(a, b) a ## b
|
||||
#define INTSET_CONCAT(a, b) INTSET_CONCAT_(a, b)
|
||||
#define INTSET_FUNC_(a, b) INTSET_CONCAT(a, _ ## b)
|
||||
#endif
|
||||
|
||||
#define INTSET_FUNC(name) INTSET_FUNC_(INTSET_NAME, name)
|
||||
#define INTSET_BUCKET INTSET_CONCAT(INTSET_NAME, Bucket)
|
||||
#define INTSET_UNION INTSET_CONCAT(INTSET_NAME, Union)
|
||||
|
||||
#if defined(_MSC_VER)
|
||||
#pragma warning(push)
|
||||
#pragma warning(disable : 4200)
|
||||
#endif
|
||||
|
||||
|
||||
typedef struct {
|
||||
size_t count;
|
||||
union INTSET_UNION {
|
||||
INTSET_TYPE val;
|
||||
INTSET_TYPE *data;
|
||||
} v;
|
||||
} INTSET_BUCKET;
|
||||
|
||||
typedef struct {
|
||||
size_t bucket_count;
|
||||
INTSET_BUCKET buckets[];
|
||||
} INTSET_NAME;
|
||||
|
||||
static INTSET_NAME *INTSET_FUNC(new)(size_t buckets)
|
||||
{
|
||||
size_t i, size;
|
||||
INTSET_NAME *set;
|
||||
|
||||
if (buckets < 1)
|
||||
buckets = 1;
|
||||
if ((SIZE_MAX - sizeof(INTSET_NAME)) / sizeof(INTSET_BUCKET) < buckets)
|
||||
return NULL;
|
||||
size = sizeof(INTSET_NAME) + buckets * sizeof(INTSET_BUCKET);
|
||||
set = (INTSET_NAME*)malloc(size);
|
||||
if (!set)
|
||||
return NULL;
|
||||
memset(set, 0, size); /* gcc -fanalyzer does not understand this sets all buckets' count to 0 */
|
||||
for (i = 0; i < buckets; i++) {
|
||||
set->buckets[i].count = 0;
|
||||
}
|
||||
set->bucket_count = buckets;
|
||||
return set;
|
||||
}
|
||||
|
||||
static void INTSET_FUNC(free)(INTSET_NAME *set)
|
||||
{
|
||||
size_t i;
|
||||
if (!set)
|
||||
return;
|
||||
for (i = 0; i < set->bucket_count; i++) {
|
||||
if (set->buckets[i].count > 1)
|
||||
free(set->buckets[i].v.data);
|
||||
}
|
||||
free(set);
|
||||
}
|
||||
|
||||
static bool INTSET_FUNC(contains)(INTSET_NAME *set, INTSET_TYPE val)
|
||||
{
|
||||
size_t i;
|
||||
INTSET_BUCKET* bucket = &set->buckets[(size_t)val % set->bucket_count];
|
||||
if (bucket->count == 1)
|
||||
return bucket->v.val == val;
|
||||
for (i = 0; i < bucket->count; ++i) {
|
||||
if (bucket->v.data[i] == val)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool INTSET_FUNC(add)(INTSET_NAME *set, INTSET_TYPE val)
|
||||
{
|
||||
INTSET_BUCKET* bucket;
|
||||
|
||||
if (INTSET_FUNC(contains)(set, val))
|
||||
return true; /* ok */
|
||||
|
||||
bucket = &set->buckets[(size_t)val % set->bucket_count];
|
||||
if (bucket->count == 0) {
|
||||
bucket->v.val = val;
|
||||
bucket->count = 1;
|
||||
} else if (bucket->count == 1) {
|
||||
INTSET_TYPE old = bucket->v.val;
|
||||
bucket->v.data = (INTSET_TYPE*)malloc(2 * sizeof(INTSET_TYPE));
|
||||
if (!bucket->v.data) {
|
||||
bucket->v.val = old;
|
||||
return false; /* error */
|
||||
}
|
||||
bucket->v.data[0] = old;
|
||||
bucket->v.data[1] = val;
|
||||
bucket->count = 2;
|
||||
} else {
|
||||
size_t new_bucket_size;
|
||||
INTSET_TYPE* new_bucket_data;
|
||||
|
||||
new_bucket_size = (bucket->count + 1) * sizeof(INTSET_TYPE);
|
||||
new_bucket_data = (INTSET_TYPE*)realloc(bucket->v.data, new_bucket_size);
|
||||
if (!new_bucket_data)
|
||||
return false; /* error */
|
||||
bucket->v.data = new_bucket_data;
|
||||
bucket->v.data[bucket->count++] = val;
|
||||
}
|
||||
return true; /* success */
|
||||
}
|
||||
|
||||
|
||||
#if defined(_MSC_VER)
|
||||
#pragma warning(pop)
|
||||
#endif
|
||||
|
||||
#undef INTSET_FUNC
|
||||
#undef INTSET_BUCKET
|
||||
#undef INTSET_UNION
|
||||
92
kvui.py
92
kvui.py
@@ -3,7 +3,6 @@ import logging
|
||||
import sys
|
||||
import typing
|
||||
import re
|
||||
from collections import deque
|
||||
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
@@ -65,7 +64,7 @@ from kivy.uix.popup import Popup
|
||||
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
|
||||
|
||||
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
|
||||
from Utils import async_start, get_input_text_from_response
|
||||
from Utils import async_start
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import CommonClient
|
||||
@@ -286,10 +285,16 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
|
||||
temp = MarkupLabel(text=self.text).markup
|
||||
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
|
||||
cmdinput = App.get_running_app().textinput
|
||||
if not cmdinput.text:
|
||||
input_text = get_input_text_from_response(text, App.get_running_app().last_autofillable_command)
|
||||
if input_text is not None:
|
||||
cmdinput.text = input_text
|
||||
if not cmdinput.text and " did you mean " in text:
|
||||
for question in ("Didn't find something that closely matches, did you mean ",
|
||||
"Too many close matches, did you mean "):
|
||||
if text.startswith(question):
|
||||
name = Utils.get_text_between(text, question,
|
||||
"? (")
|
||||
cmdinput.text = f"!{App.get_running_app().last_autofillable_command} {name}"
|
||||
break
|
||||
elif not cmdinput.text and text.startswith("Missing: "):
|
||||
cmdinput.text = text.replace("Missing: ", "!hint_location ")
|
||||
|
||||
Clipboard.copy(text.replace("&", "&").replace("&bl;", "[").replace("&br;", "]"))
|
||||
return self.parent.select_with_touch(self.index, touch)
|
||||
@@ -381,57 +386,6 @@ class ConnectBarTextInput(TextInput):
|
||||
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
|
||||
|
||||
|
||||
def is_command_input(string: str) -> bool:
|
||||
return len(string) > 0 and string[0] in "/!"
|
||||
|
||||
|
||||
class CommandPromptTextInput(TextInput):
|
||||
MAXIMUM_HISTORY_MESSAGES = 50
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._command_history_index = -1
|
||||
self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES)
|
||||
|
||||
def update_history(self, new_entry: str) -> None:
|
||||
self._command_history_index = -1
|
||||
if is_command_input(new_entry):
|
||||
self._command_history.appendleft(new_entry)
|
||||
|
||||
def keyboard_on_key_down(
|
||||
self,
|
||||
window,
|
||||
keycode: typing.Tuple[int, str],
|
||||
text: typing.Optional[str],
|
||||
modifiers: typing.List[str]
|
||||
) -> bool:
|
||||
"""
|
||||
:param window: The kivy window object
|
||||
:param keycode: A tuple of (keycode, keyname). Keynames are always lowercase
|
||||
:param text: The text printed by this key, not accounting for modifiers, or `None` if no text.
|
||||
Seems to pretty naively interpret the keycode as unicode, so numlock can return odd characters.
|
||||
:param modifiers: A list of string modifiers, like `ctrl` or `numlock`
|
||||
"""
|
||||
if keycode[1] == 'up':
|
||||
self._change_to_history_text_if_available(self._command_history_index + 1)
|
||||
return True
|
||||
if keycode[1] == 'down':
|
||||
self._change_to_history_text_if_available(self._command_history_index - 1)
|
||||
return True
|
||||
return super().keyboard_on_key_down(window, keycode, text, modifiers)
|
||||
|
||||
def _change_to_history_text_if_available(self, new_index: int) -> None:
|
||||
if new_index < -1:
|
||||
return
|
||||
if new_index >= len(self._command_history):
|
||||
return
|
||||
self._command_history_index = new_index
|
||||
if new_index == -1:
|
||||
self.text = ""
|
||||
return
|
||||
self.text = self._command_history[self._command_history_index]
|
||||
|
||||
|
||||
class MessageBox(Popup):
|
||||
class MessageBoxLabel(Label):
|
||||
def __init__(self, **kwargs):
|
||||
@@ -467,7 +421,7 @@ class GameManager(App):
|
||||
self.commandprocessor = ctx.command_processor(ctx)
|
||||
self.icon = r"data/icon.png"
|
||||
self.json_to_kivy_parser = KivyJSONtoTextParser(ctx)
|
||||
self.log_panels: typing.Dict[str, Widget] = {}
|
||||
self.log_panels = {}
|
||||
|
||||
# keep track of last used command to autofill on click
|
||||
self.last_autofillable_command = "hint"
|
||||
@@ -551,7 +505,7 @@ class GameManager(App):
|
||||
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None)
|
||||
info_button.bind(on_release=self.command_button_action)
|
||||
bottom_layout.add_widget(info_button)
|
||||
self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
|
||||
self.textinput = TextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
|
||||
self.textinput.bind(on_text_validate=self.on_message)
|
||||
self.textinput.text_validate_unfocus = False
|
||||
bottom_layout.add_widget(self.textinput)
|
||||
@@ -609,18 +563,14 @@ class GameManager(App):
|
||||
|
||||
self.ctx.exit_event.set()
|
||||
|
||||
def on_message(self, textinput: CommandPromptTextInput):
|
||||
def on_message(self, textinput: TextInput):
|
||||
try:
|
||||
input_text = textinput.text.strip()
|
||||
textinput.text = ""
|
||||
textinput.update_history(input_text)
|
||||
|
||||
if self.ctx.input_requests > 0:
|
||||
self.ctx.input_requests -= 1
|
||||
self.ctx.input_queue.put_nowait(input_text)
|
||||
elif is_command_input(input_text):
|
||||
self.ctx.on_ui_command(input_text)
|
||||
self.commandprocessor(input_text)
|
||||
elif input_text:
|
||||
self.commandprocessor(input_text)
|
||||
|
||||
@@ -733,18 +683,10 @@ class HintLog(RecycleView):
|
||||
for hint in hints:
|
||||
data.append({
|
||||
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
|
||||
"item": {"text": self.parser.handle_node({
|
||||
"type": "item_id",
|
||||
"text": hint["item"],
|
||||
"flags": hint["item_flags"],
|
||||
"player": hint["receiving_player"],
|
||||
})},
|
||||
"item": {"text": self.parser.handle_node(
|
||||
{"type": "item_id", "text": hint["item"], "flags": hint["item_flags"]})},
|
||||
"finding": {"text": self.parser.handle_node({"type": "player_id", "text": hint["finding_player"]})},
|
||||
"location": {"text": self.parser.handle_node({
|
||||
"type": "location_id",
|
||||
"text": hint["location"],
|
||||
"player": hint["finding_player"],
|
||||
})},
|
||||
"location": {"text": self.parser.handle_node({"type": "location_id", "text": hint["location"]})},
|
||||
"entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text",
|
||||
"color": "blue", "text": hint["entrance"]
|
||||
if hint["entrance"] else "Vanilla"})},
|
||||
|
||||
@@ -2,13 +2,13 @@ colorama>=0.4.6
|
||||
websockets>=12.0
|
||||
PyYAML>=6.0.1
|
||||
jellyfish>=1.0.3
|
||||
jinja2>=3.1.4
|
||||
schema>=0.7.7
|
||||
jinja2>=3.1.3
|
||||
schema>=0.7.5
|
||||
kivy>=2.3.0
|
||||
bsdiff4>=1.2.4
|
||||
platformdirs>=4.2.2
|
||||
certifi>=2024.6.2
|
||||
cython>=3.0.10
|
||||
platformdirs>=4.1.0
|
||||
certifi>=2023.11.17
|
||||
cython>=3.0.8
|
||||
cymem>=2.0.8
|
||||
orjson>=3.10.3
|
||||
typing_extensions>=4.12.1
|
||||
orjson>=3.9.10
|
||||
typing_extensions>=4.7.0
|
||||
|
||||
20
settings.py
20
settings.py
@@ -643,6 +643,17 @@ class GeneratorOptions(Group):
|
||||
PLAYTHROUGH = 2
|
||||
FULL = 3
|
||||
|
||||
class GlitchTriforceRoom(IntEnum):
|
||||
"""
|
||||
Glitch to Triforce room from Ganon
|
||||
When disabled, you have to have a weapon that can hurt ganon (master sword or swordless/easy item functionality
|
||||
+ hammer) and have completed the goal required for killing ganon to be able to access the triforce room.
|
||||
1 -> Enabled.
|
||||
0 -> Disabled (except in no-logic)
|
||||
"""
|
||||
OFF = 0
|
||||
ON = 1
|
||||
|
||||
class PlandoOptions(str):
|
||||
"""
|
||||
List of options that can be plando'd. Can be combined, for example "bosses, items"
|
||||
@@ -654,14 +665,6 @@ class GeneratorOptions(Group):
|
||||
OFF = 0
|
||||
ON = 1
|
||||
|
||||
class PanicMethod(str):
|
||||
"""
|
||||
What to do if the current item placements appear unsolvable.
|
||||
raise -> Raise an exception and abort.
|
||||
swap -> Attempt to fix it by swapping prior placements around. (Default)
|
||||
start_inventory -> Move remaining items to start_inventory, generate additional filler items to fill locations.
|
||||
"""
|
||||
|
||||
enemizer_path: EnemizerPath = EnemizerPath("EnemizerCLI/EnemizerCLI.Core") # + ".exe" is implied on Windows
|
||||
player_files_path: PlayerFilesPath = PlayerFilesPath("Players")
|
||||
players: Players = Players(0)
|
||||
@@ -670,7 +673,6 @@ class GeneratorOptions(Group):
|
||||
spoiler: Spoiler = Spoiler(3)
|
||||
race: Race = Race(0)
|
||||
plando_options: PlandoOptions = PlandoOptions("bosses, connections, texts")
|
||||
panic_method: PanicMethod = PanicMethod("swap")
|
||||
|
||||
|
||||
class SNIOptions(Group):
|
||||
|
||||
4
setup.py
4
setup.py
@@ -21,7 +21,7 @@ from pathlib import Path
|
||||
|
||||
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||
try:
|
||||
requirement = 'cx-Freeze==7.0.0'
|
||||
requirement = 'cx-Freeze>=7.0.0'
|
||||
import pkg_resources
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
@@ -190,7 +190,7 @@ if is_windows:
|
||||
c = next(component for component in components if component.script_name == "Launcher")
|
||||
exes.append(cx_Freeze.Executable(
|
||||
script=f"{c.script_name}.py",
|
||||
target_name=f"{c.frozen_name}Debug.exe",
|
||||
target_name=f"{c.frozen_name}(DEBUG).exe",
|
||||
icon=resolve_icon(c.icon),
|
||||
))
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
project(ap-cpp-tests)
|
||||
|
||||
enable_testing()
|
||||
|
||||
find_package(GTest REQUIRED)
|
||||
|
||||
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
|
||||
add_definitions("/source-charset:utf-8")
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
|
||||
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
|
||||
# enable static analysis for gcc
|
||||
add_compile_options(-fanalyzer -Werror)
|
||||
# disable stuff that gets triggered by googletest
|
||||
add_compile_options(-Wno-analyzer-malloc-leak)
|
||||
# enable asan for gcc
|
||||
add_compile_options(-fsanitize=address)
|
||||
add_link_options(-fsanitize=address)
|
||||
endif ()
|
||||
|
||||
add_executable(test_default)
|
||||
|
||||
target_include_directories(test_default
|
||||
PRIVATE
|
||||
${GTEST_INCLUDE_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(test_default
|
||||
${GTEST_BOTH_LIBRARIES}
|
||||
)
|
||||
|
||||
add_test(
|
||||
NAME test_default
|
||||
COMMAND test_default
|
||||
)
|
||||
|
||||
set_property(
|
||||
TEST test_default
|
||||
PROPERTY ENVIRONMENT "ASAN_OPTIONS=allocator_may_return_null=1"
|
||||
)
|
||||
|
||||
file(GLOB ITEMS *)
|
||||
foreach(item ${ITEMS})
|
||||
if(IS_DIRECTORY ${item} AND EXISTS ${item}/CMakeLists.txt)
|
||||
message(${item})
|
||||
add_subdirectory(${item})
|
||||
endif()
|
||||
endforeach()
|
||||
@@ -1,32 +0,0 @@
|
||||
# C++ tests
|
||||
|
||||
Test framework for C and C++ code in AP.
|
||||
|
||||
## Adding a Test
|
||||
|
||||
### GoogleTest
|
||||
|
||||
Adding GoogleTests is as simple as creating a directory with
|
||||
* one or more `test_*.cpp` files that define tests using
|
||||
[GoogleTest API](https://google.github.io/googletest/)
|
||||
* a `CMakeLists.txt` that adds the .cpp files to `test_default` target using
|
||||
[target_sources](https://cmake.org/cmake/help/latest/command/target_sources.html)
|
||||
|
||||
### CTest
|
||||
|
||||
If either GoogleTest is not suitable for the test or the build flags / sources / libraries are incompatible,
|
||||
you can add another CTest to the project using add_target and add_test, similar to how it's done for `test_default`.
|
||||
|
||||
## Running Tests
|
||||
|
||||
* Install [CMake](https://cmake.org/).
|
||||
* Build and/or install GoogleTest and make sure
|
||||
[CMake can find it](https://cmake.org/cmake/help/latest/module/FindGTest.html), or
|
||||
[create a parent `CMakeLists.txt` that fetches GoogleTest](https://google.github.io/googletest/quickstart-cmake.html).
|
||||
* Enter the directory with the top-most `CMakeLists.txt` and run
|
||||
```sh
|
||||
mkdir build
|
||||
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build/ --config Release && \
|
||||
ctest --test-dir build/ -C Release --output-on-failure
|
||||
```
|
||||
@@ -1,4 +0,0 @@
|
||||
target_sources(test_default
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/test_intset.cpp
|
||||
)
|
||||
@@ -1,105 +0,0 @@
|
||||
#include <limits>
|
||||
#include <cstdint>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
// uint32Set
|
||||
#define INTSET_NAME uint32Set
|
||||
#define INTSET_TYPE uint32_t
|
||||
#include "../../../intset.h"
|
||||
#undef INTSET_NAME
|
||||
#undef INTSET_TYPE
|
||||
|
||||
// int64Set
|
||||
#define INTSET_NAME int64Set
|
||||
#define INTSET_TYPE int64_t
|
||||
#include "../../../intset.h"
|
||||
|
||||
|
||||
TEST(IntsetTest, ZeroBuckets)
|
||||
{
|
||||
// trying to allocate with zero buckets has to either fail or be functioning
|
||||
uint32Set *set = uint32Set_new(0);
|
||||
if (!set)
|
||||
return; // failed -> OK
|
||||
|
||||
EXPECT_FALSE(uint32Set_contains(set, 1));
|
||||
EXPECT_TRUE(uint32Set_add(set, 1));
|
||||
EXPECT_TRUE(uint32Set_contains(set, 1));
|
||||
uint32Set_free(set);
|
||||
}
|
||||
|
||||
TEST(IntsetTest, Duplicate)
|
||||
{
|
||||
// adding the same number again can't fail
|
||||
uint32Set *set = uint32Set_new(2);
|
||||
ASSERT_TRUE(set);
|
||||
EXPECT_TRUE(uint32Set_add(set, 0));
|
||||
EXPECT_TRUE(uint32Set_add(set, 0));
|
||||
EXPECT_TRUE(uint32Set_contains(set, 0));
|
||||
uint32Set_free(set);
|
||||
}
|
||||
|
||||
TEST(IntsetTest, SetAllocFailure)
|
||||
{
|
||||
// try to allocate 100TB of RAM, should fail and return NULL
|
||||
if (sizeof(size_t) < 8)
|
||||
GTEST_SKIP() << "Alloc error not testable on 32bit";
|
||||
int64Set *set = int64Set_new(6250000000000ULL);
|
||||
EXPECT_FALSE(set);
|
||||
int64Set_free(set);
|
||||
}
|
||||
|
||||
TEST(IntsetTest, SetAllocOverflow)
|
||||
{
|
||||
// try to overflow argument passed to malloc
|
||||
int64Set *set = int64Set_new(std::numeric_limits<size_t>::max());
|
||||
EXPECT_FALSE(set);
|
||||
int64Set_free(set);
|
||||
}
|
||||
|
||||
TEST(IntsetTest, NullFree)
|
||||
{
|
||||
// free(NULL) should not try to free buckets
|
||||
uint32Set_free(NULL);
|
||||
int64Set_free(NULL);
|
||||
}
|
||||
|
||||
TEST(IntsetTest, BucketRealloc)
|
||||
{
|
||||
// add a couple of values to the same bucket to test growing the bucket
|
||||
uint32Set* set = uint32Set_new(1);
|
||||
ASSERT_TRUE(set);
|
||||
EXPECT_FALSE(uint32Set_contains(set, 0));
|
||||
EXPECT_TRUE(uint32Set_add(set, 0));
|
||||
EXPECT_TRUE(uint32Set_contains(set, 0));
|
||||
for (uint32_t i = 1; i < 32; ++i) {
|
||||
EXPECT_TRUE(uint32Set_add(set, i));
|
||||
EXPECT_TRUE(uint32Set_contains(set, i - 1));
|
||||
EXPECT_TRUE(uint32Set_contains(set, i));
|
||||
EXPECT_FALSE(uint32Set_contains(set, i + 1));
|
||||
}
|
||||
uint32Set_free(set);
|
||||
}
|
||||
|
||||
TEST(IntSet, Max)
|
||||
{
|
||||
constexpr auto n = std::numeric_limits<uint32_t>::max();
|
||||
uint32Set *set = uint32Set_new(1);
|
||||
ASSERT_TRUE(set);
|
||||
EXPECT_FALSE(uint32Set_contains(set, n));
|
||||
EXPECT_TRUE(uint32Set_add(set, n));
|
||||
EXPECT_TRUE(uint32Set_contains(set, n));
|
||||
uint32Set_free(set);
|
||||
}
|
||||
|
||||
TEST(InsetTest, Negative)
|
||||
{
|
||||
constexpr auto n = std::numeric_limits<int64_t>::min();
|
||||
static_assert(n < 0, "n not negative");
|
||||
int64Set *set = int64Set_new(3);
|
||||
ASSERT_TRUE(set);
|
||||
EXPECT_FALSE(int64Set_contains(set, n));
|
||||
EXPECT_TRUE(int64Set_add(set, n));
|
||||
EXPECT_TRUE(int64Set_contains(set, n));
|
||||
int64Set_free(set);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ from argparse import Namespace
|
||||
from typing import List, Optional, Tuple, Type, Union
|
||||
|
||||
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
|
||||
from worlds import network_data_package
|
||||
from worlds.AutoWorld import World, call_all
|
||||
|
||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
|
||||
@@ -61,10 +60,6 @@ class TestWorld(World):
|
||||
hidden = True
|
||||
|
||||
|
||||
# add our test world to the data package, so we can test it later
|
||||
network_data_package["games"][TestWorld.game] = TestWorld.get_data_package_data()
|
||||
|
||||
|
||||
def generate_test_multiworld(players: int = 1) -> MultiWorld:
|
||||
"""
|
||||
Generates a multiworld using a special Test Case World class, and seed of 0.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from Utils import get_intended_text, get_input_text_from_response
|
||||
|
||||
|
||||
class TestClient(unittest.TestCase):
|
||||
def test_autofill_hint_from_fuzzy_hint(self) -> None:
|
||||
tests = (
|
||||
("item", ["item1", "item2"]), # Multiple close matches
|
||||
("itm", ["item1", "item21"]), # No close match, multiple option
|
||||
("item", ["item1"]), # No close match, single option
|
||||
("item", ["\"item\" 'item' (item)"]), # Testing different special characters
|
||||
)
|
||||
|
||||
for input_text, possible_answers in tests:
|
||||
item_name, usable, response = get_intended_text(input_text, possible_answers)
|
||||
self.assertFalse(usable, "This test must be updated, it seems get_fuzzy_results behavior changed")
|
||||
|
||||
hint_command = get_input_text_from_response(response, "hint")
|
||||
self.assertIsNotNone(hint_command,
|
||||
"The response to fuzzy hints is no longer recognized by the hint autofill")
|
||||
self.assertEqual(hint_command, f"!hint {item_name}",
|
||||
"The hint command autofilled by the response is not correct")
|
||||
@@ -1,12 +1,27 @@
|
||||
import unittest
|
||||
|
||||
from Fill import distribute_items_restrictive
|
||||
from worlds import network_data_package
|
||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestIDs(unittest.TestCase):
|
||||
def test_unique_items(self):
|
||||
"""Tests that every game has a unique ID per item in the datapackage"""
|
||||
known_item_ids = set()
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
current = len(known_item_ids)
|
||||
known_item_ids |= set(world_type.item_id_to_name)
|
||||
self.assertEqual(len(known_item_ids) - len(world_type.item_id_to_name), current)
|
||||
|
||||
def test_unique_locations(self):
|
||||
"""Tests that every game has a unique ID per location in the datapackage"""
|
||||
known_location_ids = set()
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
current = len(known_location_ids)
|
||||
known_location_ids |= set(world_type.location_id_to_name)
|
||||
self.assertEqual(len(known_location_ids) - len(world_type.location_id_to_name), current)
|
||||
|
||||
def test_range_items(self):
|
||||
"""There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision."""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
@@ -85,4 +100,3 @@ class TestIDs(unittest.TestCase):
|
||||
f"{loc_name} is not a valid item name for location_name_to_id")
|
||||
self.assertIsInstance(loc_id, int,
|
||||
f"{loc_id} for {loc_name} should be an int")
|
||||
self.assertEqual(datapackage["checksum"], network_data_package["games"][gamename]["checksum"])
|
||||
|
||||
@@ -3,7 +3,6 @@ import unittest
|
||||
from Fill import distribute_items_restrictive
|
||||
from NetUtils import encode
|
||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||
from worlds import failed_world_loads
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
|
||||
@@ -48,7 +47,3 @@ class TestImplemented(unittest.TestCase):
|
||||
for key, data in multiworld.worlds[1].fill_slot_data().items():
|
||||
self.assertIsInstance(key, str, "keys in slot data must be a string")
|
||||
self.assertIsInstance(encode(data), str, f"object {type(data).__name__} not serializable.")
|
||||
|
||||
def test_no_failed_world_loads(self):
|
||||
if failed_world_loads:
|
||||
self.fail(f"The following worlds failed to load: {failed_world_loads}")
|
||||
|
||||
@@ -25,8 +25,6 @@ class TestBase(unittest.TestCase):
|
||||
{"medallions", "stones", "rewards", "logic_bottles"},
|
||||
"Starcraft 2":
|
||||
{"Missions", "WoL Missions"},
|
||||
"Yu-Gi-Oh! 2006":
|
||||
{"Campaign Boss Beaten"}
|
||||
}
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game_name, game_name=game_name):
|
||||
@@ -64,6 +62,15 @@ class TestBase(unittest.TestCase):
|
||||
for item in multiworld.itempool:
|
||||
self.assertIn(item.name, world_type.item_name_to_id)
|
||||
|
||||
def test_item_descriptions_have_valid_names(self):
|
||||
"""Ensure all item descriptions match an item name or item group name"""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
valid_names = world_type.item_names.union(world_type.item_name_groups)
|
||||
for name in world_type.item_descriptions:
|
||||
with self.subTest("Name should be valid", game=game_name, item=name):
|
||||
self.assertIn(name, valid_names,
|
||||
"All item descriptions must match defined item names")
|
||||
|
||||
def test_itempool_not_modified(self):
|
||||
"""Test that worlds don't modify the itempool after `create_items`"""
|
||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||
|
||||
@@ -66,3 +66,12 @@ class TestBase(unittest.TestCase):
|
||||
for location in locations:
|
||||
self.assertIn(location, world_type.location_name_to_id)
|
||||
self.assertNotIn(group_name, world_type.location_name_to_id)
|
||||
|
||||
def test_location_descriptions_have_valid_names(self):
|
||||
"""Ensure all location descriptions match a location name or location group name"""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
valid_names = world_type.location_names.union(world_type.location_name_groups)
|
||||
for name in world_type.location_descriptions:
|
||||
with self.subTest("Name should be valid", game=game_name, location=name):
|
||||
self.assertIn(name, valid_names,
|
||||
"All location descriptions must match defined location names")
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestPlayerOptions(unittest.TestCase):
|
||||
self.assertEqual(new_weights["list_2"], ["string_3"])
|
||||
self.assertEqual(new_weights["list_1"], ["string", "string_2"])
|
||||
self.assertEqual(new_weights["dict_1"]["option_a"], 50)
|
||||
self.assertEqual(new_weights["dict_1"]["option_b"], 50)
|
||||
self.assertEqual(new_weights["dict_1"]["option_b"], 0)
|
||||
self.assertEqual(new_weights["dict_1"]["option_c"], 50)
|
||||
self.assertNotIn("option_f", new_weights["dict_2"])
|
||||
self.assertEqual(new_weights["dict_2"]["option_g"], 50)
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
# A bunch of tests to verify MultiServer and custom webhost server work as expected.
|
||||
# This spawns processes and may modify your local AP, so this is not run as part of unit testing.
|
||||
# Run with `python test/hosting` instead,
|
||||
import logging
|
||||
import traceback
|
||||
from tempfile import TemporaryDirectory
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
|
||||
from test.hosting.client import Client
|
||||
from test.hosting.generate import generate_local
|
||||
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
|
||||
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
|
||||
stop_autohost, upload_multidata)
|
||||
from test.hosting.world import copy as copy_world, delete as delete_world
|
||||
|
||||
failure = False
|
||||
fail_fast = True
|
||||
|
||||
|
||||
def assert_true(condition: Any, msg: str = "") -> None:
|
||||
global failure
|
||||
if not condition:
|
||||
failure = True
|
||||
msg = f": {msg}" if msg else ""
|
||||
raise AssertionError(f"Assertion failed{msg}")
|
||||
|
||||
|
||||
def assert_equal(first: Any, second: Any, msg: str = "") -> None:
|
||||
global failure
|
||||
if first != second:
|
||||
failure = True
|
||||
msg = f": {msg}" if msg else ""
|
||||
raise AssertionError(f"Assertion failed: {first} == {second}{msg}")
|
||||
|
||||
|
||||
if fail_fast:
|
||||
expect_true = assert_true
|
||||
expect_equal = assert_equal
|
||||
else:
|
||||
def expect_true(condition: Any, msg: str = "") -> None:
|
||||
global failure
|
||||
if not condition:
|
||||
failure = True
|
||||
tb = "".join(traceback.format_stack()[:-1])
|
||||
msg = f": {msg}" if msg else ""
|
||||
logging.error(f"Expectation failed{msg}\n{tb}")
|
||||
|
||||
def expect_equal(first: Any, second: Any, msg: str = "") -> None:
|
||||
global failure
|
||||
if first != second:
|
||||
failure = True
|
||||
tb = "".join(traceback.format_stack()[:-1])
|
||||
msg = f": {msg}" if msg else ""
|
||||
logging.error(f"Expectation failed {first} == {second}{msg}\n{tb}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import warnings
|
||||
warnings.simplefilter("ignore", ResourceWarning)
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
|
||||
spacer = '=' * 80
|
||||
|
||||
with TemporaryDirectory() as tempdir:
|
||||
multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]]
|
||||
p1_games = []
|
||||
data_paths = []
|
||||
rooms = []
|
||||
|
||||
copy_world("Clique", "Temp World")
|
||||
try:
|
||||
for n, games in enumerate(multis, 1):
|
||||
print(f"Generating [{n}] {', '.join(games)}")
|
||||
multidata = generate_local(games, tempdir)
|
||||
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
|
||||
p1_games.append(games[0])
|
||||
data_paths.append(multidata)
|
||||
finally:
|
||||
delete_world("Temp World")
|
||||
|
||||
webapp = get_app(tempdir)
|
||||
webhost_client = webapp.test_client()
|
||||
for n, multidata in enumerate(data_paths, 1):
|
||||
seed = upload_multidata(webhost_client, multidata)
|
||||
room = create_room(webhost_client, seed)
|
||||
print(f"Uploaded [{n}] {multidata} as {room}\n")
|
||||
rooms.append(room)
|
||||
|
||||
print("Starting autohost")
|
||||
from WebHostLib.autolauncher import autohost
|
||||
try:
|
||||
autohost(webapp.config)
|
||||
|
||||
host: ServeGame
|
||||
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
|
||||
involved_games = {"Archipelago"} | set(multi_games)
|
||||
for collected_items in range(3):
|
||||
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
|
||||
with LocalServeGame(multidata) as host:
|
||||
with Client(host.address, game, "Player1") as client:
|
||||
local_data_packages = client.games_packages
|
||||
local_collected_items = len(client.checked_locations)
|
||||
if collected_items < 2: # Clique only has 2 Locations
|
||||
client.collect_any()
|
||||
# TODO: Ctrl+C test here as well
|
||||
|
||||
for game_name in sorted(involved_games):
|
||||
expect_true(game_name in local_data_packages,
|
||||
f"{game_name} missing from MultiServer datap ackage")
|
||||
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
|
||||
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
|
||||
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||
for game_name in local_data_packages:
|
||||
expect_true(game_name in involved_games,
|
||||
f"Received unexpected extra data package for {game_name} from MultiServer")
|
||||
assert_equal(local_collected_items, collected_items,
|
||||
"MultiServer did not load or save correctly")
|
||||
|
||||
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
|
||||
prev_host_adr: str
|
||||
with WebHostServeGame(webhost_client, room) as host:
|
||||
prev_host_adr = host.address
|
||||
with Client(host.address, game, "Player1") as client:
|
||||
web_data_packages = client.games_packages
|
||||
web_collected_items = len(client.checked_locations)
|
||||
if collected_items < 2: # Clique only has 2 Locations
|
||||
client.collect_any()
|
||||
if collected_items == 1:
|
||||
sleep(1) # wait for the server to collect the item
|
||||
stop_autohost(True) # simulate Ctrl+C
|
||||
sleep(3)
|
||||
autohost(webapp.config) # this will spin the room right up again
|
||||
sleep(1) # make log less annoying
|
||||
# if saving failed, the next iteration will fail below
|
||||
|
||||
# verify server shut down
|
||||
try:
|
||||
with Client(prev_host_adr, game, "Player1") as client:
|
||||
assert_true(False, "Server did not shut down")
|
||||
except ConnectionError:
|
||||
pass
|
||||
|
||||
for game_name in sorted(involved_games):
|
||||
expect_true(game_name in web_data_packages,
|
||||
f"{game_name} missing from customserver data package")
|
||||
expect_true("item_name_groups" not in web_data_packages.get(game_name, {}),
|
||||
f"item_name_groups are not supposed to be in customserver data for {game_name}")
|
||||
expect_true("location_name_groups" not in web_data_packages.get(game_name, {}),
|
||||
f"location_name_groups are not supposed to be in customserver data for {game_name}")
|
||||
for game_name in web_data_packages:
|
||||
expect_true(game_name in involved_games,
|
||||
f"Received unexpected extra data package for {game_name} from customserver")
|
||||
assert_equal(web_collected_items, collected_items,
|
||||
"customserver did not load or save correctly during/after "
|
||||
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
|
||||
|
||||
# compare customserver to MultiServer
|
||||
expect_equal(local_data_packages, web_data_packages,
|
||||
"customserver datapackage differs from MultiServer")
|
||||
|
||||
sleep(5.5) # make sure all tasks actually stopped
|
||||
|
||||
# raise an exception in customserver and verify the save doesn't get destroyed
|
||||
# local variables room is the last room's id here
|
||||
old_data = get_multidata_for_room(webhost_client, room)
|
||||
print(f"Destroying multidata for {room}")
|
||||
set_multidata_for_room(webhost_client, room, bytes([0]))
|
||||
try:
|
||||
start_room(webhost_client, room, timeout=7)
|
||||
except TimeoutError:
|
||||
pass
|
||||
else:
|
||||
assert_true(False, "Room started with destroyed multidata")
|
||||
print(f"Restoring multidata for {room}")
|
||||
set_multidata_for_room(webhost_client, room, old_data)
|
||||
with WebHostServeGame(webhost_client, room) as host:
|
||||
with Client(host.address, game, "Player1") as client:
|
||||
assert_equal(len(client.checked_locations), 2,
|
||||
"Save was destroyed during exception in customserver")
|
||||
print("Save file is not busted 🥳")
|
||||
|
||||
finally:
|
||||
print("Stopping autohost")
|
||||
stop_autohost(False)
|
||||
|
||||
if failure:
|
||||
print("Some tests failed")
|
||||
exit(1)
|
||||
exit(0)
|
||||
@@ -1,110 +0,0 @@
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Collection, Dict, Iterable, Optional
|
||||
from websockets import ConnectionClosed
|
||||
from websockets.sync.client import connect, ClientConnection
|
||||
from threading import Thread
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Client"
|
||||
]
|
||||
|
||||
|
||||
class Client:
|
||||
"""Incomplete, minimalistic sync test client for AP network protocol"""
|
||||
|
||||
recv_timeout = 1.0
|
||||
|
||||
host: str
|
||||
game: str
|
||||
slot: str
|
||||
password: Optional[str]
|
||||
|
||||
_ws: Optional[ClientConnection]
|
||||
|
||||
games: Iterable[str]
|
||||
data_package_checksums: Dict[str, Any]
|
||||
games_packages: Dict[str, Any]
|
||||
missing_locations: Collection[int]
|
||||
checked_locations: Collection[int]
|
||||
|
||||
def __init__(self, host: str, game: str, slot: str, password: Optional[str] = None) -> None:
|
||||
self.host = host
|
||||
self.game = game
|
||||
self.slot = slot
|
||||
self.password = password
|
||||
self._ws = None
|
||||
self.games = []
|
||||
self.data_package_checksums = {}
|
||||
self.games_packages = {}
|
||||
self.missing_locations = []
|
||||
self.checked_locations = []
|
||||
|
||||
def __enter__(self) -> "Client":
|
||||
try:
|
||||
self.connect()
|
||||
except BaseException:
|
||||
self.__exit__(*sys.exc_info())
|
||||
raise
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
|
||||
self.close()
|
||||
|
||||
def _poll(self) -> None:
|
||||
assert self._ws
|
||||
try:
|
||||
while True:
|
||||
self._ws.recv()
|
||||
except (TimeoutError, ConnectionClosed, KeyboardInterrupt, SystemExit):
|
||||
pass
|
||||
|
||||
def connect(self) -> None:
|
||||
self._ws = connect(f"ws://{self.host}")
|
||||
room_info = json.loads(self._ws.recv(self.recv_timeout))[0]
|
||||
self.games = sorted(room_info["games"])
|
||||
self.data_package_checksums = room_info["datapackage_checksums"]
|
||||
self._ws.send(json.dumps([{
|
||||
"cmd": "GetDataPackage",
|
||||
"games": list(self.games),
|
||||
}]))
|
||||
data_package_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
|
||||
self.games_packages = data_package_msg["data"]["games"]
|
||||
self._ws.send(json.dumps([{
|
||||
"cmd": "Connect",
|
||||
"game": self.game,
|
||||
"name": self.slot,
|
||||
"password": self.password,
|
||||
"uuid": "",
|
||||
"version": {
|
||||
"class": "Version",
|
||||
"major": 0,
|
||||
"minor": 4,
|
||||
"build": 6,
|
||||
},
|
||||
"items_handling": 0,
|
||||
"tags": [],
|
||||
"slot_data": False,
|
||||
}]))
|
||||
connect_result_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
|
||||
if connect_result_msg["cmd"] != "Connected":
|
||||
raise ConnectionError(", ".join(connect_result_msg.get("errors", [connect_result_msg["cmd"]])))
|
||||
self.missing_locations = connect_result_msg["missing_locations"]
|
||||
self.checked_locations = connect_result_msg["checked_locations"]
|
||||
|
||||
def close(self) -> None:
|
||||
if self._ws:
|
||||
Thread(target=self._poll).start()
|
||||
self._ws.close()
|
||||
|
||||
def collect(self, locations: Iterable[int]) -> None:
|
||||
if not self._ws:
|
||||
raise ValueError("Not connected")
|
||||
self._ws.send(json.dumps([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": locations,
|
||||
}]))
|
||||
|
||||
def collect_any(self) -> None:
|
||||
self.collect([next(iter(self.missing_locations))])
|
||||
@@ -1,75 +0,0 @@
|
||||
import json
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Union, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from multiprocessing.managers import ListProxy # noqa
|
||||
|
||||
__all__ = [
|
||||
"generate_local",
|
||||
]
|
||||
|
||||
|
||||
def _generate_local_inner(games: Iterable[str],
|
||||
dest: Union[Path, str],
|
||||
results: "ListProxy[Union[Path, BaseException]]") -> None:
|
||||
original_argv = sys.argv
|
||||
warnings.simplefilter("ignore")
|
||||
try:
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
if not isinstance(dest, Path):
|
||||
dest = Path(dest)
|
||||
|
||||
with TemporaryDirectory() as players_dir:
|
||||
with TemporaryDirectory() as output_dir:
|
||||
import Generate
|
||||
|
||||
for n, game in enumerate(games, 1):
|
||||
player_path = Path(players_dir) / f"{n}.yaml"
|
||||
with open(player_path, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({
|
||||
"name": f"Player{n}",
|
||||
"game": game,
|
||||
game: {"hard_mode": "true"},
|
||||
"description": f"generate_local slot {n} ('Player{n}'): {game}",
|
||||
}))
|
||||
|
||||
# this is basically copied from test/programs/test_generate.py
|
||||
# uses a reproducible seed that is different for each set of games
|
||||
sys.argv = [sys.argv[0], "--seed", str(hash(tuple(games))),
|
||||
"--player_files_path", players_dir,
|
||||
"--outputpath", output_dir]
|
||||
Generate.main()
|
||||
output_files = list(Path(output_dir).glob('*.zip'))
|
||||
assert len(output_files) == 1
|
||||
final_file = dest / output_files[0].name
|
||||
output_files[0].rename(final_file)
|
||||
results.append(final_file)
|
||||
except BaseException as e:
|
||||
results.append(e)
|
||||
raise e
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
def generate_local(games: Iterable[str], dest: Union[Path, str]) -> Path:
|
||||
from multiprocessing import Manager, Process, set_start_method
|
||||
|
||||
try:
|
||||
set_start_method("spawn")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
manager = Manager()
|
||||
results: "ListProxy[Union[Path, Exception]]" = manager.list()
|
||||
|
||||
p = Process(target=_generate_local_inner, args=(games, dest, results))
|
||||
p.start()
|
||||
p.join()
|
||||
result = results[0]
|
||||
if isinstance(result, BaseException):
|
||||
raise Exception("Could not generate multiworld") from result
|
||||
return result
|
||||
@@ -1,115 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from threading import Event
|
||||
from werkzeug.test import Client as FlaskClient
|
||||
|
||||
__all__ = [
|
||||
"ServeGame",
|
||||
"LocalServeGame",
|
||||
"WebHostServeGame",
|
||||
]
|
||||
|
||||
|
||||
class ServeGame:
|
||||
address: str
|
||||
|
||||
|
||||
def _launch_multiserver(multidata: Path, ready: "Event", stop: "Event") -> None:
|
||||
import os
|
||||
import warnings
|
||||
|
||||
original_argv = sys.argv
|
||||
original_stdin = sys.stdin
|
||||
warnings.simplefilter("ignore")
|
||||
try:
|
||||
import asyncio
|
||||
from MultiServer import main, parse_args
|
||||
|
||||
sys.argv = [sys.argv[0], str(multidata), "--host", "127.0.0.1"]
|
||||
r, w = os.pipe()
|
||||
sys.stdin = os.fdopen(r, "r")
|
||||
|
||||
async def set_ready() -> None:
|
||||
await asyncio.sleep(.01) # switch back to other task once more
|
||||
ready.set() # server should be up, set ready state
|
||||
|
||||
async def wait_stop() -> None:
|
||||
await asyncio.get_event_loop().run_in_executor(None, stop.wait)
|
||||
os.fdopen(w, "w").write("/exit")
|
||||
|
||||
async def run() -> None:
|
||||
# this will run main() until first await, then switch to set_ready()
|
||||
await asyncio.gather(
|
||||
main(parse_args()),
|
||||
set_ready(),
|
||||
wait_stop(),
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
sys.stdin = original_stdin
|
||||
|
||||
|
||||
class LocalServeGame(ServeGame):
|
||||
from multiprocessing import Process
|
||||
|
||||
_multidata: Path
|
||||
_proc: Process
|
||||
_stop: "Event"
|
||||
|
||||
def __init__(self, multidata: Path) -> None:
|
||||
self.address = ""
|
||||
self._multidata = multidata
|
||||
|
||||
def __enter__(self) -> "LocalServeGame":
|
||||
from multiprocessing import Manager, Process, set_start_method
|
||||
|
||||
try:
|
||||
set_start_method("spawn")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
manager = Manager()
|
||||
ready: "Event" = manager.Event()
|
||||
self._stop = manager.Event()
|
||||
|
||||
self._proc = Process(target=_launch_multiserver, args=(self._multidata, ready, self._stop))
|
||||
try:
|
||||
self._proc.start()
|
||||
ready.wait(30)
|
||||
self.address = "localhost:38281"
|
||||
return self
|
||||
except BaseException:
|
||||
self.__exit__(*sys.exc_info())
|
||||
raise
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
|
||||
try:
|
||||
self._stop.set()
|
||||
self._proc.join(30)
|
||||
except TimeoutError:
|
||||
self._proc.terminate()
|
||||
self._proc.join()
|
||||
|
||||
|
||||
class WebHostServeGame(ServeGame):
|
||||
_client: "FlaskClient"
|
||||
_room: str
|
||||
|
||||
def __init__(self, app_client: "FlaskClient", room: str) -> None:
|
||||
self.address = ""
|
||||
self._client = app_client
|
||||
self._room = room
|
||||
|
||||
def __enter__(self) -> "WebHostServeGame":
|
||||
from .webhost import start_room
|
||||
self.address = start_room(self._client, self._room)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
|
||||
from .webhost import stop_room
|
||||
stop_room(self._client, self._room, timeout=30)
|
||||
@@ -1,208 +0,0 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from flask import Flask
|
||||
from werkzeug.test import Client as FlaskClient
|
||||
|
||||
__all__ = [
|
||||
"get_app",
|
||||
"upload_multidata",
|
||||
"create_room",
|
||||
"start_room",
|
||||
"stop_room",
|
||||
"set_room_timeout",
|
||||
"get_multidata_for_room",
|
||||
"set_multidata_for_room",
|
||||
"stop_autohost",
|
||||
]
|
||||
|
||||
|
||||
def get_app(tempdir: str) -> "Flask":
|
||||
from WebHostLib import app as raw_app
|
||||
from WebHost import get_app
|
||||
raw_app.config["PONY"] = {
|
||||
"provider": "sqlite",
|
||||
"filename": str(Path(tempdir) / "host.db"),
|
||||
"create_db": True,
|
||||
}
|
||||
raw_app.config.update({
|
||||
"TESTING": True,
|
||||
"HOST_ADDRESS": "localhost",
|
||||
"HOSTERS": 1,
|
||||
})
|
||||
return get_app()
|
||||
|
||||
|
||||
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
|
||||
response = app_client.post("/uploads", data={
|
||||
"file": multidata.open("rb"),
|
||||
})
|
||||
assert response.status_code < 400, f"Upload of {multidata} failed: status {response.status_code}"
|
||||
assert "Location" in response.headers, f"Upload of {multidata} failed: no redirect"
|
||||
location = response.headers["Location"]
|
||||
assert isinstance(location, str)
|
||||
assert location.startswith("/seed/"), f"Upload of {multidata} failed: unexpected redirect"
|
||||
return location[6:]
|
||||
|
||||
|
||||
def create_room(app_client: "FlaskClient", seed: str, auto_start: bool = False) -> str:
|
||||
response = app_client.get(f"/new_room/{seed}")
|
||||
assert response.status_code < 400, f"Creating room for {seed} failed: status {response.status_code}"
|
||||
assert "Location" in response.headers, f"Creating room for {seed} failed: no redirect"
|
||||
location = response.headers["Location"]
|
||||
assert isinstance(location, str)
|
||||
assert location.startswith("/room/"), f"Creating room for {seed} failed: unexpected redirect"
|
||||
room_id = location[6:]
|
||||
|
||||
if not auto_start:
|
||||
# by default, creating a room will auto-start it, so we update last activity here
|
||||
stop_room(app_client, room_id, simulate_idle=False)
|
||||
|
||||
return room_id
|
||||
|
||||
|
||||
def start_room(app_client: "FlaskClient", room_id: str, timeout: float = 30) -> str:
|
||||
from time import sleep
|
||||
|
||||
import pony.orm
|
||||
|
||||
poll_interval = .2
|
||||
|
||||
print(f"Starting room {room_id}")
|
||||
no_timeout = timeout <= 0
|
||||
while no_timeout or timeout > 0:
|
||||
try:
|
||||
response = app_client.get(f"/room/{room_id}")
|
||||
except pony.orm.core.OptimisticCheckError:
|
||||
# hoster wrote to room during our transaction
|
||||
continue
|
||||
|
||||
assert response.status_code == 200, f"Starting room for {room_id} failed: status {response.status_code}"
|
||||
match = re.search(r"/connect ([\w:.\-]+)", response.text)
|
||||
if match:
|
||||
return match[1]
|
||||
timeout -= poll_interval
|
||||
sleep(poll_interval)
|
||||
raise TimeoutError("Room did not start")
|
||||
|
||||
|
||||
def stop_room(app_client: "FlaskClient",
|
||||
room_id: str,
|
||||
timeout: Optional[float] = None,
|
||||
simulate_idle: bool = True) -> None:
|
||||
from datetime import datetime, timedelta
|
||||
from time import sleep
|
||||
|
||||
from pony.orm import db_session
|
||||
|
||||
from WebHostLib.models import Command, Room
|
||||
from WebHostLib import app
|
||||
|
||||
poll_interval = 2
|
||||
|
||||
print(f"Stopping room {room_id}")
|
||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
||||
|
||||
if timeout is not None:
|
||||
sleep(.1) # should not be required, but other things might use threading
|
||||
|
||||
with db_session:
|
||||
room: Room = Room.get(id=room_uuid)
|
||||
if simulate_idle:
|
||||
new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5)
|
||||
else:
|
||||
new_last_activity = datetime.utcnow() - timedelta(days=3)
|
||||
room.last_activity = new_last_activity
|
||||
address = f"localhost:{room.last_port}" if room.last_port > 0 else None
|
||||
if address:
|
||||
original_timeout = room.timeout
|
||||
room.timeout = 1 # avoid spinning it up again
|
||||
Command(room=room, commandtext="/exit")
|
||||
|
||||
try:
|
||||
if address and timeout is not None:
|
||||
print("waiting for shutdown")
|
||||
import socket
|
||||
host_str, port_str = tuple(address.split(":"))
|
||||
address_tuple = host_str, int(port_str)
|
||||
|
||||
no_timeout = timeout <= 0
|
||||
while no_timeout or timeout > 0:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.connect(address_tuple)
|
||||
s.close()
|
||||
except ConnectionRefusedError:
|
||||
return
|
||||
sleep(poll_interval)
|
||||
timeout -= poll_interval
|
||||
|
||||
raise TimeoutError("Room did not stop")
|
||||
finally:
|
||||
with db_session:
|
||||
room = Room.get(id=room_uuid)
|
||||
room.last_port = 0 # easier to detect when the host is up this way
|
||||
if address:
|
||||
room.timeout = original_timeout
|
||||
room.last_activity = new_last_activity
|
||||
print("timeout restored")
|
||||
|
||||
|
||||
def set_room_timeout(room_id: str, timeout: float) -> None:
|
||||
from pony.orm import db_session
|
||||
|
||||
from WebHostLib.models import Room
|
||||
from WebHostLib import app
|
||||
|
||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
||||
with db_session:
|
||||
room: Room = Room.get(id=room_uuid)
|
||||
room.timeout = timeout
|
||||
|
||||
|
||||
def get_multidata_for_room(webhost_client: "FlaskClient", room_id: str) -> bytes:
|
||||
from pony.orm import db_session
|
||||
|
||||
from WebHostLib.models import Room
|
||||
from WebHostLib import app
|
||||
|
||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
||||
with db_session:
|
||||
room: Room = Room.get(id=room_uuid)
|
||||
return cast(bytes, room.seed.multidata)
|
||||
|
||||
|
||||
def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: bytes) -> None:
|
||||
from pony.orm import db_session
|
||||
|
||||
from WebHostLib.models import Room
|
||||
from WebHostLib import app
|
||||
|
||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
||||
with db_session:
|
||||
room: Room = Room.get(id=room_uuid)
|
||||
room.seed.multidata = data
|
||||
|
||||
|
||||
def stop_autohost(graceful: bool = True) -> None:
|
||||
import os
|
||||
import signal
|
||||
|
||||
import multiprocessing
|
||||
|
||||
from WebHostLib.autolauncher import stop
|
||||
|
||||
stop()
|
||||
proc: multiprocessing.process.BaseProcess
|
||||
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()):
|
||||
if graceful and proc.pid:
|
||||
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
|
||||
else:
|
||||
proc.kill()
|
||||
try:
|
||||
proc.join(30)
|
||||
except TimeoutError:
|
||||
proc.kill()
|
||||
proc.join()
|
||||
@@ -1,42 +0,0 @@
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
|
||||
__all__ = ["copy", "delete"]
|
||||
|
||||
|
||||
_new_worlds: Dict[str, str] = {}
|
||||
|
||||
|
||||
def copy(src: str, dst: str) -> None:
|
||||
from Utils import get_file_safe_name
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
assert dst not in _new_worlds, "World already created"
|
||||
if '"' in dst or "\\" in dst: # easier to reject than to escape
|
||||
raise ValueError(f"Unsupported symbols in {dst}")
|
||||
dst_folder_name = get_file_safe_name(dst.lower())
|
||||
src_cls = AutoWorldRegister.world_types[src]
|
||||
src_folder = Path(src_cls.__file__).parent
|
||||
worlds_folder = src_folder.parent
|
||||
if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir()
|
||||
or not (worlds_folder / "generic").is_dir()):
|
||||
raise ValueError(f"Unsupported layout for copy_world from {src}")
|
||||
dst_folder = worlds_folder / dst_folder_name
|
||||
if dst_folder.is_dir():
|
||||
raise ValueError(f"Destination {dst_folder} already exists")
|
||||
shutil.copytree(src_folder, dst_folder)
|
||||
_new_worlds[dst] = str(dst_folder)
|
||||
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
|
||||
contents = f.read()
|
||||
contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
|
||||
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
|
||||
f.write(contents)
|
||||
|
||||
|
||||
def delete(name: str) -> None:
|
||||
assert name in _new_worlds, "World not created by this script"
|
||||
shutil.rmtree(_new_worlds[name])
|
||||
del _new_worlds[name]
|
||||
@@ -1,5 +1,4 @@
|
||||
# Tests for _speedups.LocationStore and NetUtils._LocationStore
|
||||
import os
|
||||
import typing
|
||||
import unittest
|
||||
import warnings
|
||||
@@ -8,8 +7,6 @@ from NetUtils import LocationStore, _LocationStore
|
||||
State = typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||
RawLocations = typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||
|
||||
ci = bool(os.environ.get("CI")) # always set in GitHub actions
|
||||
|
||||
sample_data: RawLocations = {
|
||||
1: {
|
||||
11: (21, 2, 7),
|
||||
@@ -27,9 +24,6 @@ sample_data: RawLocations = {
|
||||
3: {
|
||||
9: (99, 4, 0),
|
||||
},
|
||||
5: {
|
||||
9: (99, 5, 0),
|
||||
}
|
||||
}
|
||||
|
||||
empty_state: State = {
|
||||
@@ -51,14 +45,14 @@ class Base:
|
||||
store: typing.Union[LocationStore, _LocationStore]
|
||||
|
||||
def test_len(self) -> None:
|
||||
self.assertEqual(len(self.store), 5)
|
||||
self.assertEqual(len(self.store), 4)
|
||||
self.assertEqual(len(self.store[1]), 3)
|
||||
|
||||
def test_key_error(self) -> None:
|
||||
with self.assertRaises(KeyError):
|
||||
_ = self.store[0]
|
||||
with self.assertRaises(KeyError):
|
||||
_ = self.store[6]
|
||||
_ = self.store[5]
|
||||
locations = self.store[1] # no Exception
|
||||
with self.assertRaises(KeyError):
|
||||
_ = locations[7]
|
||||
@@ -77,7 +71,7 @@ class Base:
|
||||
self.assertEqual(self.store[1].get(10, (None, None, None)), (None, None, None))
|
||||
|
||||
def test_iter(self) -> None:
|
||||
self.assertEqual(sorted(self.store), [1, 2, 3, 4, 5])
|
||||
self.assertEqual(sorted(self.store), [1, 2, 3, 4])
|
||||
self.assertEqual(len(self.store), len(sample_data))
|
||||
self.assertEqual(list(self.store[1]), [11, 12, 13])
|
||||
self.assertEqual(len(self.store[1]), len(sample_data[1]))
|
||||
@@ -91,26 +85,13 @@ class Base:
|
||||
self.assertEqual(sorted(self.store[1].items())[0][1], self.store[1][11])
|
||||
|
||||
def test_find_item(self) -> None:
|
||||
# empty player set
|
||||
self.assertEqual(sorted(self.store.find_item(set(), 99)), [])
|
||||
# no such player, single
|
||||
self.assertEqual(sorted(self.store.find_item({6}, 99)), [])
|
||||
# no such player, set
|
||||
self.assertEqual(sorted(self.store.find_item({7, 8, 9}, 99)), [])
|
||||
# no such item
|
||||
self.assertEqual(sorted(self.store.find_item({3}, 1)), [])
|
||||
# valid matches
|
||||
self.assertEqual(sorted(self.store.find_item({5}, 99)), [])
|
||||
self.assertEqual(sorted(self.store.find_item({3}, 99)),
|
||||
[(4, 9, 99, 3, 0)])
|
||||
self.assertEqual(sorted(self.store.find_item({3, 4}, 99)),
|
||||
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
|
||||
self.assertEqual(sorted(self.store.find_item({2, 3, 4}, 99)),
|
||||
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
|
||||
# test hash collision in set
|
||||
self.assertEqual(sorted(self.store.find_item({3, 5}, 99)),
|
||||
[(4, 9, 99, 3, 0), (5, 9, 99, 5, 0)])
|
||||
self.assertEqual(sorted(self.store.find_item(set(range(2048)), 13)),
|
||||
[(1, 13, 13, 1, 0)])
|
||||
|
||||
def test_get_for_player(self) -> None:
|
||||
self.assertEqual(self.store.get_for_player(3), {4: {9}})
|
||||
@@ -215,20 +196,18 @@ class TestPurePythonLocationStoreConstructor(Base.TestLocationStoreConstructor):
|
||||
super().setUp()
|
||||
|
||||
|
||||
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
|
||||
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
|
||||
class TestSpeedupsLocationStore(Base.TestLocationStore):
|
||||
"""Run base method tests for cython implementation."""
|
||||
def setUp(self) -> None:
|
||||
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
|
||||
self.store = LocationStore(sample_data)
|
||||
super().setUp()
|
||||
|
||||
|
||||
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
|
||||
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
|
||||
class TestSpeedupsLocationStoreConstructor(Base.TestLocationStoreConstructor):
|
||||
"""Run base constructor tests and tests the additional constraints for cython implementation."""
|
||||
def setUp(self) -> None:
|
||||
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
|
||||
self.type = LocationStore
|
||||
super().setUp()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user