Compare commits

..

1 Commits

Author SHA1 Message Date
NewSoupVi
83d8bd584b Revert "DS3: Make your own region cache (#4040)"
This reverts commit 2751ccdaab.
2024-10-11 03:03:32 +02:00
951 changed files with 16549 additions and 68985 deletions

1
.gitattributes vendored
View File

@@ -1,2 +1 @@
worlds/blasphemous/region_data.py linguist-generated=true worlds/blasphemous/region_data.py linguist-generated=true
worlds/yachtdice/YachtWeights.py linguist-generated=true

View File

@@ -1,21 +1,8 @@
{ {
"include": [ "include": [
"../BizHawkClient.py", "type_check.py",
"../Patch.py",
"../test/param.py",
"../test/general/test_groups.py",
"../test/general/test_helpers.py",
"../test/general/test_memory.py",
"../test/general/test_names.py",
"../test/multiworld/__init__.py",
"../test/multiworld/test_multiworlds.py",
"../test/netutils/__init__.py",
"../test/programs/__init__.py",
"../test/programs/test_multi_server.py",
"../test/utils/__init__.py",
"../test/webhost/test_descriptions.py",
"../worlds/AutoSNIClient.py", "../worlds/AutoSNIClient.py",
"type_check.py" "../Patch.py"
], ],
"exclude": [ "exclude": [
@@ -29,7 +16,7 @@
"reportMissingImports": true, "reportMissingImports": true,
"reportMissingTypeStubs": true, "reportMissingTypeStubs": true,
"pythonVersion": "3.10", "pythonVersion": "3.8",
"pythonPlatform": "Windows", "pythonPlatform": "Windows",
"executionEnvironments": [ "executionEnvironments": [

View File

@@ -53,7 +53,7 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
if: env.diff != '' if: env.diff != ''
with: with:
python-version: '3.10' python-version: 3.8
- name: "Install dependencies" - name: "Install dependencies"
if: env.diff != '' if: env.diff != ''

View File

@@ -24,15 +24,14 @@ env:
jobs: jobs:
# build-release-macos: # LF volunteer # build-release-macos: # LF volunteer
build-win: # RCs will still be built and signed by hand build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install python - name: Install python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '~3.12.7' python-version: '3.8'
check-latest: true
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
@@ -112,11 +111,10 @@ jobs:
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '~3.12.7' python-version: '3.11'
check-latest: true
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV echo "PYTHON=python3.11" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
@@ -132,7 +130,7 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner # charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`" echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"

View File

@@ -47,7 +47,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -58,7 +58,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v3 uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -72,4 +72,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v2

View File

@@ -11,7 +11,7 @@ on:
- '**.hh?' - '**.hh?'
- '**.hpp' - '**.hpp'
- '**.hxx' - '**.hxx'
- '**/CMakeLists.txt' - '**.CMakeLists'
- '.github/workflows/ctest.yml' - '.github/workflows/ctest.yml'
pull_request: pull_request:
paths: paths:
@@ -21,7 +21,7 @@ on:
- '**.hh?' - '**.hh?'
- '**.hpp' - '**.hpp'
- '**.hxx' - '**.hxx'
- '**/CMakeLists.txt' - '**.CMakeLists'
- '.github/workflows/ctest.yml' - '.github/workflows/ctest.yml'
jobs: jobs:
@@ -36,9 +36,9 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 - uses: ilammy/msvc-dev-cmd@v1
if: startsWith(matrix.os,'windows') if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73 - uses: Bacondish2023/setup-googletest@v1
with: with:
build-type: 'Release' build-type: 'Release'
- name: Build tests - name: Build tests

View File

@@ -44,11 +44,10 @@ jobs:
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '~3.12.7' python-version: '3.11'
check-latest: true
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV echo "PYTHON=python3.11" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
@@ -64,7 +63,7 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner # charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`" echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"

View File

@@ -40,10 +40,10 @@ jobs:
run: | run: |
wget https://apt.llvm.org/llvm.sh wget https://apt.llvm.org/llvm.sh
chmod +x ./llvm.sh chmod +x ./llvm.sh
sudo ./llvm.sh 19 sudo ./llvm.sh 17
- name: Install scan-build command - name: Install scan-build command
run: | run: |
sudo apt install clang-tools-19 sudo apt install clang-tools-17
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
@@ -56,7 +56,7 @@ jobs:
- name: scan-build - name: scan-build
run: | run: |
source venv/bin/activate source venv/bin/activate
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
- name: Store report - name: Store report
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -26,7 +26,7 @@ jobs:
- name: "Install dependencies" - name: "Install dependencies"
run: | run: |
python -m pip install --upgrade pip pyright==1.1.392.post0 python -m pip install --upgrade pip pyright==1.1.358
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
- name: "pyright: strict check on specific files" - name: "pyright: strict check on specific files"

View File

@@ -33,11 +33,13 @@ jobs:
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
python: python:
- {version: '3.8'}
- {version: '3.9'}
- {version: '3.10'} - {version: '3.10'}
- {version: '3.11'} - {version: '3.11'}
- {version: '3.12'} - {version: '3.12'}
include: include:
- python: {version: '3.10'} # old compat - python: {version: '3.8'} # win7 compat
os: windows-latest os: windows-latest
- python: {version: '3.12'} # current - python: {version: '3.12'} # current
os: windows-latest os: windows-latest
@@ -87,4 +89,4 @@ jobs:
run: | run: |
source venv/bin/activate source venv/bin/activate
export PYTHONPATH=$(pwd) export PYTHONPATH=$(pwd)
timeout 600 python test/hosting/__main__.py python test/hosting/__main__.py

2
.gitignore vendored
View File

@@ -4,13 +4,11 @@
*_Spoiler.txt *_Spoiler.txt
*.bmbp *.bmbp
*.apbp *.apbp
*.apcivvi
*.apl2ac *.apl2ac
*.apm3 *.apm3
*.apmc *.apmc
*.apz5 *.apz5
*.aptloz *.aptloz
*.aptww
*.apemerald *.apemerald
*.pyc *.pyc
*.pyd *.pyd

View File

@@ -511,7 +511,7 @@ if __name__ == '__main__':
import colorama import colorama
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -1,16 +1,18 @@
from __future__ import annotations from __future__ import annotations
import collections import collections
import itertools
import functools import functools
import logging import logging
import random import random
import secrets import secrets
import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace from argparse import Namespace
from collections import Counter, deque from collections import Counter, deque
from collections.abc import Collection, MutableSequence from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple, from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING) Optional, Protocol, Set, Tuple, Union, Type)
from typing_extensions import NotRequired, TypedDict from typing_extensions import NotRequired, TypedDict
@@ -18,8 +20,7 @@ import NetUtils
import Options import Options
import Utils import Utils
if TYPE_CHECKING: if typing.TYPE_CHECKING:
from entrance_rando import ERPlacementState
from worlds import AutoWorld from worlds import AutoWorld
@@ -230,7 +231,7 @@ class MultiWorld():
for player in self.player_ids: for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player) self.worlds[player] = world_type(self, player)
options_dataclass: type[Options.PerGameCommonOptions] = world_type.options_dataclass options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player] self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
for option_key in options_dataclass.type_hints}) for option_key in options_dataclass.type_hints})
@@ -340,7 +341,7 @@ class MultiWorld():
new_item.classification |= classifications[item_name] new_item.classification |= classifications[item_name]
new_itempool.append(new_item) new_itempool.append(new_item)
region = Region(group["world"].origin_region_name, group_id, self, "ItemLink") region = Region("Menu", group_id, self, "ItemLink")
self.regions.append(region) self.regions.append(region)
locations = region.locations locations = region.locations
# ensure that progression items are linked first, then non-progression # ensure that progression items are linked first, then non-progression
@@ -427,12 +428,12 @@ class MultiWorld():
def get_location(self, location_name: str, player: int) -> Location: def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name] return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState: def get_all_state(self, use_cache: bool) -> CollectionState:
cached = getattr(self, "_all_state", None) cached = getattr(self, "_all_state", None)
if use_cache and cached: if use_cache and cached:
return cached.copy() return cached.copy()
ret = CollectionState(self, allow_partial_entrances) ret = CollectionState(self)
for item in self.itempool: for item in self.itempool:
self.worlds[item.player].collect(ret, item) self.worlds[item.player].collect(ret, item)
@@ -605,49 +606,6 @@ class MultiWorld():
state.collect(location.item, True, location) state.collect(location.item, True, location)
locations -= sphere locations -= sphere
def get_sendable_spheres(self) -> Iterator[Set[Location]]:
"""
yields a set of multiserver sendable locations (location.item.code: int) for each logical sphere
If there are unreachable locations, the last sphere of reachable locations is followed by an empty set,
and then a set of all of the unreachable locations.
"""
state = CollectionState(self)
locations: Set[Location] = set()
events: Set[Location] = set()
for location in self.get_filled_locations():
if type(location.item.code) is int:
locations.add(location)
else:
events.add(location)
while locations:
sphere: Set[Location] = set()
# cull events out
done_events: Set[Union[Location, None]] = {None}
while done_events:
done_events = set()
for event in events:
if event.can_reach(state):
state.collect(event.item, True, event)
done_events.add(event)
events -= done_events
for location in locations:
if location.can_reach(state):
sphere.add(location)
yield sphere
if not sphere:
if locations:
yield locations # unreachable locations
break
for location in sphere:
state.collect(location.item, True, location)
locations -= sphere
def fulfills_accessibility(self, state: Optional[CollectionState] = None): def fulfills_accessibility(self, state: Optional[CollectionState] = None):
"""Check if accessibility rules are fulfilled with current or supplied state.""" """Check if accessibility rules are fulfilled with current or supplied state."""
if not state: if not state:
@@ -718,11 +676,10 @@ class CollectionState():
path: Dict[Union[Region, Entrance], PathValue] path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location] locations_checked: Set[Location]
stale: Dict[int, bool] stale: Dict[int, bool]
allow_partial_entrances: bool
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = [] additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): def __init__(self, parent: MultiWorld):
self.prog_items = {player: Counter() for player in parent.get_all_ids()} self.prog_items = {player: Counter() for player in parent.get_all_ids()}
self.multiworld = parent self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.reachable_regions = {player: set() for player in parent.get_all_ids()}
@@ -731,7 +688,6 @@ class CollectionState():
self.path = {} self.path = {}
self.locations_checked = set() self.locations_checked = set()
self.stale = {player: True for player in parent.get_all_ids()} self.stale = {player: True for player in parent.get_all_ids()}
self.allow_partial_entrances = allow_partial_entrances
for function in self.additional_init_functions: for function in self.additional_init_functions:
function(self, parent) function(self, parent)
for items in parent.precollected_items.values(): for items in parent.precollected_items.values():
@@ -766,8 +722,6 @@ class CollectionState():
if new_region in reachable_regions: if new_region in reachable_regions:
blocked_connections.remove(connection) blocked_connections.remove(connection)
elif connection.can_reach(self): elif connection.can_reach(self):
if self.allow_partial_entrances and not new_region:
continue
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region" assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
reachable_regions.add(new_region) reachable_regions.add(new_region)
blocked_connections.remove(connection) blocked_connections.remove(connection)
@@ -793,9 +747,7 @@ class CollectionState():
if new_region in reachable_regions: if new_region in reachable_regions:
blocked_connections.remove(connection) blocked_connections.remove(connection)
elif connection.can_reach(self): elif connection.can_reach(self):
if self.allow_partial_entrances and not new_region: assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
continue
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
reachable_regions.add(new_region) reachable_regions.add(new_region)
blocked_connections.remove(connection) blocked_connections.remove(connection)
blocked_connections.update(new_region.exits) blocked_connections.update(new_region.exits)
@@ -815,7 +767,6 @@ class CollectionState():
ret.advancements = self.advancements.copy() ret.advancements = self.advancements.copy()
ret.path = self.path.copy() ret.path = self.path.copy()
ret.locations_checked = self.locations_checked.copy() ret.locations_checked = self.locations_checked.copy()
ret.allow_partial_entrances = self.allow_partial_entrances
for function in self.additional_copy_functions: for function in self.additional_copy_functions:
ret = function(self, ret) ret = function(self, ret)
return ret return ret
@@ -869,40 +820,21 @@ class CollectionState():
def has(self, item: str, player: int, count: int = 1) -> bool: def has(self, item: str, player: int, count: int = 1) -> bool:
return self.prog_items[player][item] >= count return self.prog_items[player][item] >= count
# for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of
# creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the
# argument to all() would be a new generator instance, for example.
def has_all(self, items: Iterable[str], player: int) -> bool: def has_all(self, items: Iterable[str], player: int) -> bool:
"""Returns True if each item name of items is in state at least once.""" """Returns True if each item name of items is in state at least once."""
player_prog_items = self.prog_items[player] return all(self.prog_items[player][item] for item in items)
for item in items:
if not player_prog_items[item]:
return False
return True
def has_any(self, items: Iterable[str], player: int) -> bool: def has_any(self, items: Iterable[str], player: int) -> bool:
"""Returns True if at least one item name of items is in state at least once.""" """Returns True if at least one item name of items is in state at least once."""
player_prog_items = self.prog_items[player] return any(self.prog_items[player][item] for item in items)
for item in items:
if player_prog_items[item]:
return True
return False
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool: def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
"""Returns True if each item name is in the state at least as many times as specified.""" """Returns True if each item name is in the state at least as many times as specified."""
player_prog_items = self.prog_items[player] return all(self.prog_items[player][item] >= count for item, count in item_counts.items())
for item, count in item_counts.items():
if player_prog_items[item] < count:
return False
return True
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool: def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
"""Returns True if at least one item name is in the state at least as many times as specified.""" """Returns True if at least one item name is in the state at least as many times as specified."""
player_prog_items = self.prog_items[player] return any(self.prog_items[player][item] >= count for item, count in item_counts.items())
for item, count in item_counts.items():
if player_prog_items[item] >= count:
return True
return False
def count(self, item: str, player: int) -> int: def count(self, item: str, player: int) -> int:
return self.prog_items[player][item] return self.prog_items[player][item]
@@ -930,20 +862,11 @@ class CollectionState():
def count_from_list(self, items: Iterable[str], player: int) -> int: def count_from_list(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state.""" """Returns the cumulative count of items from a list present in state."""
player_prog_items = self.prog_items[player] return sum(self.prog_items[player][item_name] for item_name in items)
total = 0
for item_name in items:
total += player_prog_items[item_name]
return total
def count_from_list_unique(self, items: Iterable[str], player: int) -> int: 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.""" """Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
player_prog_items = self.prog_items[player] return sum(self.prog_items[player][item_name] > 0 for item_name in items)
total = 0
for item_name in items:
if player_prog_items[item_name] > 0:
total += 1
return total
# item name group related # item name group related
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
@@ -1008,11 +931,6 @@ class CollectionState():
self.stale[item.player] = True self.stale[item.player] = True
class EntranceType(IntEnum):
ONE_WAY = 1
TWO_WAY = 2
class Entrance: class Entrance:
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
hide_path: bool = False hide_path: bool = False
@@ -1020,24 +938,19 @@ class Entrance:
name: str name: str
parent_region: Optional[Region] parent_region: Optional[Region]
connected_region: Optional[Region] = None connected_region: Optional[Region] = None
randomization_group: int
randomization_type: EntranceType
# LttP specific, TODO: should make a LttPEntrance # LttP specific, TODO: should make a LttPEntrance
addresses = None addresses = None
target = None target = None
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None, def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None:
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
self.name = name self.name = name
self.parent_region = parent self.parent_region = parent
self.player = player self.player = player
self.randomization_group = randomization_group
self.randomization_type = randomization_type
def can_reach(self, state: CollectionState) -> bool: def can_reach(self, state: CollectionState) -> bool:
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region" assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
if self.parent_region.can_reach(state) and self.access_rule(state): if self.parent_region.can_reach(state) and self.access_rule(state):
if not self.hide_path and self not in state.path: if not self.hide_path and not self in state.path:
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
return True return True
@@ -1049,32 +962,6 @@ class Entrance:
self.addresses = addresses self.addresses = addresses
region.entrances.append(self) region.entrances.append(self)
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
"""
Determines whether this is a valid source transition, that is, whether the entrance
randomizer is allowed to pair it to place any other regions. By default, this is the
same as a reachability check, but can be modified by Entrance implementations to add
other restrictions based on the placement state.
:param er_state: The current (partial) state of the ongoing entrance randomization
"""
return self.can_reach(er_state.collection_state)
def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool:
"""
Determines whether a given Entrance is a valid target transition, that is, whether
the entrance randomizer is allowed to pair this Entrance to that Entrance. By default,
only allows connection between entrances of the same type (one ways only go to one ways,
two ways always go to two ways) and prevents connecting an exit to itself in coupled mode.
:param other: The proposed Entrance to connect to
:param dead_end: Whether the other entrance considered a dead end by Entrance randomization
:param er_state: The current (partial) state of the ongoing entrance randomization
"""
# the implementation of coupled causes issues for self-loops since the reverse entrance will be the
# same as the forward entrance. In uncoupled they are ok.
return self.randomization_type == other.randomization_type and (not er_state.coupled or self.name != other.name)
def __repr__(self): def __repr__(self):
multiworld = self.parent_region.multiworld if self.parent_region else None multiworld = self.parent_region.multiworld if self.parent_region else None
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
@@ -1088,7 +975,7 @@ class Region:
entrances: List[Entrance] entrances: List[Entrance]
exits: List[Entrance] exits: List[Entrance]
locations: List[Location] locations: List[Location]
entrance_type: ClassVar[type[Entrance]] = Entrance entrance_type: ClassVar[Type[Entrance]] = Entrance
class Register(MutableSequence): class Register(MutableSequence):
region_manager: MultiWorld.RegionManager region_manager: MultiWorld.RegionManager
@@ -1188,7 +1075,7 @@ class Region:
return entrance.parent_region.get_connecting_entrance(is_main_entrance) return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def add_locations(self, locations: Dict[str, Optional[int]], def add_locations(self, locations: Dict[str, Optional[int]],
location_type: Optional[type[Location]] = None) -> None: location_type: Optional[Type[Location]] = None) -> None:
""" """
Adds locations to the Region object, where location_type is your Location class and locations is a dict of Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address. location names to address.
@@ -1224,18 +1111,8 @@ class Region:
self.exits.append(exit_) self.exits.append(exit_)
return exit_ return exit_
def create_er_target(self, name: str) -> Entrance:
"""
Creates and returns an Entrance object as an entrance to this region
:param name: name of the Entrance being created
"""
entrance = self.entrance_type(self.player, name)
entrance.connect(self)
return entrance
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]: rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
""" """
Connects current region to regions in exit dictionary. Passed region names must exist first. Connects current region to regions in exit dictionary. Passed region names must exist first.
@@ -1245,14 +1122,10 @@ class Region:
""" """
if not isinstance(exits, Dict): if not isinstance(exits, Dict):
exits = dict.fromkeys(exits) exits = dict.fromkeys(exits)
return [ for connecting_region, name in exits.items():
self.connect( self.connect(self.multiworld.get_region(connecting_region, self.player),
self.multiworld.get_region(connecting_region, self.player), name,
name, rules[connecting_region] if rules and connecting_region in rules else None)
rules[connecting_region] if rules and connecting_region in rules else None,
)
for connecting_region, name in exits.items()
]
def __repr__(self): def __repr__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})' return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
@@ -1336,26 +1209,13 @@ class Location:
class ItemClassification(IntFlag): class ItemClassification(IntFlag):
filler = 0b0000 filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
""" aka trash, as in filler items like ammo, currency etc """ progression = 0b0001 # Item that is logically relevant
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
progression = 0b0001 trap = 0b0100 # detrimental item
""" Item that is logically relevant. skip_balancing = 0b1000 # should technically never occur on its own
Protects this item from being placed on excluded or unreachable locations. """ # Item that is logically relevant, but progression balancing should not touch.
# Typically currency or other counted items.
useful = 0b0010
""" Item that is especially useful.
Protects this item from being placed on excluded or unreachable locations.
When combined with another flag like "progression", it means "an especially useful progression item". """
trap = 0b0100
""" Item that is detrimental in some way. """
skip_balancing = 0b1000
""" should technically never occur on its own
Item that is logically relevant, but progression balancing should not touch.
Typically currency or other counted items. """
progression_skip_balancing = 0b1001 # only progression gets balanced progression_skip_balancing = 0b1001 # only progression gets balanced
def as_flag(self) -> int: def as_flag(self) -> int:
@@ -1404,14 +1264,6 @@ class Item:
def trap(self) -> bool: def trap(self) -> bool:
return ItemClassification.trap in self.classification return ItemClassification.trap in self.classification
@property
def filler(self) -> bool:
return not (self.advancement or self.useful or self.trap)
@property
def excludable(self) -> bool:
return not (self.advancement or self.useful)
@property @property
def flags(self) -> int: def flags(self) -> int:
return self.classification.as_flag() return self.classification.as_flag()
@@ -1530,21 +1382,14 @@ class Spoiler:
# second phase, sphere 0 # second phase, sphere 0
removed_precollected: List[Item] = [] removed_precollected: List[Item] = []
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
for precollected_items in multiworld.precollected_items.values(): logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
# The list of items is mutated by removing one item at a time to determine if each item is required to beat multiworld.precollected_items[item.player].remove(item)
# the game, and re-adding that item if it was required, so a copy needs to be made before iterating. multiworld.state.remove(item)
for item in precollected_items.copy(): if not multiworld.can_beat_game():
if not item.advancement: multiworld.push_precollected(item)
continue else:
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player) removed_precollected.append(item)
precollected_items.remove(item)
multiworld.state.remove(item)
if not multiworld.can_beat_game():
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
multiworld.push_precollected(item)
else:
removed_precollected.append(item)
# we are now down to just the required progress items in collection_spheres. Unfortunately # we are now down to just the required progress items in collection_spheres. Unfortunately
# the previous pruning stage could potentially have made certain items dependant on others # the previous pruning stage could potentially have made certain items dependant on others
@@ -1683,7 +1528,7 @@ class Spoiler:
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else [f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()])) [f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
if self.unreachables: if self.unreachables:
outfile.write('\n\nUnreachable Progression Items:\n\n') outfile.write('\n\nUnreachable Items:\n\n')
outfile.write( outfile.write(
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables])) '\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))

View File

@@ -23,7 +23,7 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor from MultiServer import CommandProcessor
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType) RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType)
from Utils import Version, stream_input, async_start from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
@@ -31,7 +31,6 @@ import ssl
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import kvui import kvui
import argparse
logger = logging.getLogger("Client") logger = logging.getLogger("Client")
@@ -356,8 +355,6 @@ class CommonContext:
self.item_names = self.NameLookupDict(self, "item") self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location") self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self) self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self) self.rawjsontotextparser = RawJSONtoTextParser(self)
@@ -413,7 +410,6 @@ class CommonContext:
await self.server.socket.close() await self.server.socket.close()
if self.server_task is not None: if self.server_task is not None:
await self.server_task await self.server_task
self.ui.update_hints()
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None: async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """ """ `msgs` JSON serializable """
@@ -460,13 +456,6 @@ class CommonContext:
await self.send_msgs([payload]) await self.send_msgs([payload])
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}]) await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
async def check_locations(self, locations: typing.Collection[int]) -> set[int]:
"""Send new location checks to the server. Returns the set of actually new locations that were sent."""
locations = set(locations) & self.missing_locations
if locations:
await self.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(locations)}])
return locations
async def console_input(self) -> str: async def console_input(self) -> str:
if self.ui: if self.ui:
self.ui.focus_textinput() self.ui.focus_textinput()
@@ -560,14 +549,7 @@ class CommonContext:
await self.ui_task await self.ui_task
if self.input_task: if self.input_task:
self.input_task.cancel() self.input_task.cancel()
# Hints
def update_hint(self, location: int, finding_player: int, status: typing.Optional[HintStatus]) -> None:
msg = {"cmd": "UpdateHint", "location": location, "player": finding_player}
if status is not None:
msg["status"] = status
async_start(self.send_msgs([msg]), name="update_hint")
# DataPackage # DataPackage
async def prepare_data_package(self, relevant_games: typing.Set[str], async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int], remote_date_package_versions: typing.Dict[str, int],
@@ -589,34 +571,26 @@ class CommonContext:
needed_updates.add(game) needed_updates.add(game)
continue continue
cached_version: int = self.versions.get(game, 0) local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
cached_checksum: typing.Optional[str] = self.checksums.get(game) local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
# no action required if cached version is new enough # no action required if local version is new enough
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \ if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
or remote_checksum != cached_checksum: or remote_checksum != local_checksum:
local_version: int = network_data_package["games"].get(game, {}).get("version", 0) cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum") cache_version: int = cached_game.get("version", 0)
if ((remote_checksum or remote_version <= local_version and remote_version != 0) cache_checksum: typing.Optional[str] = cached_game.get("checksum")
and remote_checksum == local_checksum): # download remote version if cache is not new enough
self.update_game(network_data_package["games"][game], game) if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else: else:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum) self.update_game(cached_game, game)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cached_game, game)
if needed_updates: if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates]) await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
def update_game(self, game_package: dict, game: str): def update_game(self, game_package: dict, game: str):
self.item_names.update_game(game, game_package["item_name_to_id"]) self.item_names.update_game(game, game_package["item_name_to_id"])
self.location_names.update_game(game, game_package["location_name_to_id"]) self.location_names.update_game(game, game_package["location_name_to_id"])
self.versions[game] = game_package.get("version", 0)
self.checksums[game] = game_package.get("checksum")
def update_data_package(self, data_package: dict): def update_data_package(self, data_package: dict):
for game, game_data in data_package["games"].items(): for game, game_data in data_package["games"].items():
@@ -709,16 +683,8 @@ class CommonContext:
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True}) logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1]) self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def make_gui(self) -> "type[kvui.GameManager]": def make_gui(self) -> typing.Type["kvui.GameManager"]:
""" """To return the Kivy App class needed for run_gui so it can be overridden before being built"""
To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built
Common changes are changing `base_title` to update the window title of the client and
updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger.
ex. `logging_pairs.append(("Foo", "Bar"))`
will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")`
"""
from kvui import GameManager from kvui import GameManager
class TextManager(GameManager): class TextManager(GameManager):
@@ -734,11 +700,6 @@ class CommonContext:
def run_cli(self): def run_cli(self):
if sys.stdin: if sys.stdin:
if sys.stdin.fileno() != 0:
from multiprocessing import parent_process
if parent_process():
return # ignore MultiProcessing pipe
# steam overlay breaks when starting console_loop # steam overlay breaks when starting console_loop
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''): if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.") logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
@@ -907,7 +868,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.disconnected_intentionally = True ctx.disconnected_intentionally = True
ctx.event_invalid_game() ctx.event_invalid_game()
elif 'IncompatibleVersion' in errors: elif 'IncompatibleVersion' in errors:
ctx.disconnected_intentionally = True
raise Exception('Server reported your client version as incompatible. ' raise Exception('Server reported your client version as incompatible. '
'This probably means you have to update.') 'This probably means you have to update.')
elif 'InvalidItemsHandling' in errors: elif 'InvalidItemsHandling' in errors:
@@ -1058,32 +1018,6 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser return parser
def handle_url_arg(args: "argparse.Namespace",
parser: "typing.Optional[argparse.ArgumentParser]" = None) -> "argparse.Namespace":
"""
Parse the url arg "archipelago://name:pass@host:port" from launcher into correct launch args for CommonClient
If alternate data is required the urlparse response is saved back to args.url if valid
"""
if not args.url:
return args
url = urllib.parse.urlparse(args.url)
if url.scheme != "archipelago":
if not parser:
parser = get_base_parser()
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
return args
args.url = url
args.connect = url.netloc
if url.username:
args.name = urllib.parse.unquote(url.username)
if url.password:
args.password = urllib.parse.unquote(url.password)
return args
def run_as_textclient(*args): def run_as_textclient(*args):
class TextContext(CommonContext): class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry # Text Mode to use !hint and such with games that have no text entry
@@ -1096,7 +1030,7 @@ def run_as_textclient(*args):
if password_requested and not self.password: if password_requested and not self.password:
await super(TextContext, self).server_auth(password_requested) await super(TextContext, self).server_auth(password_requested)
await self.get_username() await self.get_username()
await self.send_connect(game="") await self.send_connect()
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == "Connected": if cmd == "Connected":
@@ -1125,10 +1059,20 @@ def run_as_textclient(*args):
parser.add_argument("url", nargs="?", help="Archipelago connection url") parser.add_argument("url", nargs="?", help="Archipelago connection url")
args = parser.parse_args(args) args = parser.parse_args(args)
args = handle_url_arg(args, parser=parser) # handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
if args.url:
url = urllib.parse.urlparse(args.url)
if url.scheme == "archipelago":
args.connect = url.netloc
if url.username:
args.name = urllib.parse.unquote(url.username)
if url.password:
args.password = urllib.parse.unquote(url.password)
else:
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
# use colorama to display colored text highlighting on windows # use colorama to display colored text highlighting on windows
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main(args)) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

View File

@@ -261,7 +261,7 @@ if __name__ == '__main__':
parser = get_base_parser() parser = get_base_parser()
args = parser.parse_args() args = parser.parse_args()
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main(args)) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

108
Fill.py
View File

@@ -36,8 +36,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location], def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False, item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None, swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True, allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
name: str = "Unknown") -> None:
""" """
:param multiworld: Multiworld to be filled. :param multiworld: Multiworld to be filled.
:param base_state: State assumed before fill. :param base_state: State assumed before fill.
@@ -64,22 +63,14 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
placed = 0 placed = 0
while any(reachable_items.values()) and locations: while any(reachable_items.values()) and locations:
if one_item_per_player: # grab one item per player
# grab one item per player items_to_place = [items.pop()
items_to_place = [items.pop() for items in reachable_items.values() if items]
for items in reachable_items.values() if items]
else:
next_player = multiworld.random.choice([player for player, items in reachable_items.items() if items])
items_to_place = []
if item_pool:
items_to_place.append(reachable_items[next_player].pop())
for item in items_to_place: for item in items_to_place:
for p, pool_item in enumerate(item_pool): for p, pool_item in enumerate(item_pool):
if pool_item is item: if pool_item is item:
item_pool.pop(p) item_pool.pop(p)
break break
maximum_exploration_state = sweep_from_pool( maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player) base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
if single_player_placement else None) if single_player_placement else None)
@@ -235,30 +226,18 @@ def remaining_fill(multiworld: MultiWorld,
locations: typing.List[Location], locations: typing.List[Location],
itempool: typing.List[Item], itempool: typing.List[Item],
name: str = "Remaining", name: str = "Remaining",
move_unplaceable_to_start_inventory: bool = False, move_unplaceable_to_start_inventory: bool = False) -> None:
check_location_can_fill: bool = False) -> None:
unplaced_items: typing.List[Item] = [] unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = [] placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations)) total = min(len(itempool), len(locations))
placed = 0 placed = 0
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
if check_location_can_fill:
state = CollectionState(multiworld)
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
return location_to_fill.can_fill(state, item_to_fill, check_access=False)
else:
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
return location_to_fill.item_rule(item_to_fill)
while locations and itempool: while locations and itempool:
item_to_place = itempool.pop() item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None spot_to_fill: typing.Optional[Location] = None
for i, location in enumerate(locations): for i, location in enumerate(locations):
if location_can_fill_item(location, item_to_place): if location.item_rule(item_to_place):
# popping by index is faster than removing by content, # popping by index is faster than removing by content,
spot_to_fill = locations.pop(i) spot_to_fill = locations.pop(i)
# skipping a scan for the element # skipping a scan for the element
@@ -279,7 +258,7 @@ def remaining_fill(multiworld: MultiWorld,
location.item = None location.item = None
placed_item.location = None placed_item.location = None
if location_can_fill_item(location, item_to_place): if location.item_rule(item_to_place):
# Add this item to the existing placement, and # Add this item to the existing placement, and
# add the old item to the back of the queue # add the old item to the back of the queue
spot_to_fill = placements.pop(i) spot_to_fill = placements.pop(i)
@@ -501,14 +480,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if prioritylocations: if prioritylocations:
# "priority fill" # "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking, single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
name="Priority", one_item_per_player=True, allow_partial=True)
if prioritylocations:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False)
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
@@ -537,8 +509,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if progitempool: if progitempool:
raise FillError( raise FillError(
f"Not enough locations for progression items. " f"Not enough locations for progression items. "
f"There are {len(progitempool)} more progression items than there are available locations.\n" f"There are {len(progitempool)} more progression items than there are available locations.",
f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
multiworld=multiworld, multiworld=multiworld,
) )
accessibility_corrections(multiworld, multiworld.state, defaultlocations) accessibility_corrections(multiworld, multiworld.state, defaultlocations)
@@ -556,7 +527,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if excludedlocations: if excludedlocations:
raise FillError( raise FillError(
f"Not enough filler items for excluded locations. " f"Not enough filler items for excluded locations. "
f"There are {len(excludedlocations)} more excluded locations than excludable items.", f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
multiworld=multiworld, multiworld=multiworld,
) )
@@ -577,26 +548,6 @@ def distribute_items_restrictive(multiworld: MultiWorld,
print_data = {"items": items_counter, "locations": locations_counter} print_data = {"items": items_counter, "locations": locations_counter}
logging.info(f"Per-Player counts: {print_data})") logging.info(f"Per-Player counts: {print_data})")
more_locations = locations_counter - items_counter
more_items = items_counter - locations_counter
for player in multiworld.player_ids:
if more_locations[player]:
logging.error(
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
elif more_items[player]:
logging.warning(
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
if unfilled:
raise FillError(
f"Unable to fill all locations.\n" +
f"Unfilled locations({len(unfilled)}): {unfilled}"
)
else:
logging.warning(
f"Unable to place all items.\n" +
f"Unplaced items({len(unplaced)}): {unplaced}"
)
def flood_items(multiworld: MultiWorld) -> None: def flood_items(multiworld: MultiWorld) -> None:
# get items to distribute # get items to distribute
@@ -1027,32 +978,15 @@ def distribute_planned(multiworld: MultiWorld) -> None:
multiworld.random.shuffle(items) multiworld.random.shuffle(items)
count = 0 count = 0
err: typing.List[str] = [] err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = [] successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
claimed_indices: typing.Set[typing.Optional[int]] = set()
for item_name in items: for item_name in items:
index_to_delete: typing.Optional[int] = None item = multiworld.worlds[player].create_item(item_name)
if from_pool:
try:
# If from_pool, try to find an existing item with this name & player in the itempool and use it
index_to_delete, item = next(
(i, item) for i, item in enumerate(multiworld.itempool)
if item.player == player and item.name == item_name and i not in claimed_indices
)
except StopIteration:
warn(
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
item = multiworld.worlds[player].create_item(item_name)
else:
item = multiworld.worlds[player].create_item(item_name)
for location in reversed(candidates): for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item: if not location.item:
if location.item_rule(item): if location.item_rule(item):
if location.can_fill(multiworld.state, item, False): if location.can_fill(multiworld.state, item, False):
successful_pairs.append((index_to_delete, item, location)) successful_pairs.append((item, location))
claimed_indices.add(index_to_delete)
candidates.remove(location) candidates.remove(location)
count = count + 1 count = count + 1
break break
@@ -1064,7 +998,6 @@ def distribute_planned(multiworld: MultiWorld) -> None:
err.append(f"Cannot place {item_name} into already filled location {location}.") err.append(f"Cannot place {item_name} into already filled location {location}.")
else: else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.") err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
if count == maxcount: if count == maxcount:
break break
if count < placement['count']['min']: if count < placement['count']['min']:
@@ -1072,16 +1005,17 @@ def distribute_planned(multiworld: MultiWorld) -> None:
failed( failed(
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}", f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
placement['force']) placement['force'])
for (item, location) in successful_pairs:
# Sort indices in reverse so we can remove them one by one
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
for (index, item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False) multiworld.push_item(location, item, collect=False)
location.locked = True location.locked = True
logging.debug(f"Plando placed {item} at {location}") logging.debug(f"Plando placed {item} at {location}")
if index is not None: # If this item is from_pool and was found in the pool, remove it. if from_pool:
multiworld.itempool.pop(index) try:
multiworld.itempool.remove(item)
except ValueError:
warn(
f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
except Exception as e: except Exception as e:
raise Exception( raise Exception(

View File

@@ -42,9 +42,7 @@ def mystery_argparse():
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--race', action='store_true', default=defaults.race) parser.add_argument('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path) parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level') parser.add_argument('--log_level', default='info', help='Sets log level')
parser.add_argument('--log_time', help="Add timestamps to STDOUT",
default=defaults.logtime, action='store_true')
parser.add_argument("--csv_output", action="store_true", parser.add_argument("--csv_output", action="store_true",
help="Output rolled player options to csv (made for async multiworld).") help="Output rolled player options to csv (made for async multiworld).")
parser.add_argument("--plando", default=defaults.plando_options, parser.add_argument("--plando", default=defaults.plando_options,
@@ -77,7 +75,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
seed = get_seed(args.seed) seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time) Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
random.seed(seed) random.seed(seed)
seed_name = get_seed_name(random) seed_name = get_seed_name(random)
@@ -112,18 +110,11 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
player_files = {} player_files = {}
for file in os.scandir(args.player_files_path): for file in os.scandir(args.player_files_path):
fname = file.name fname = file.name
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \ if file.is_file() and not fname.startswith(".") and \
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}: os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
path = os.path.join(args.player_files_path, fname) path = os.path.join(args.player_files_path, fname)
try: try:
weights_for_file = [] weights_cache[fname] = read_weights_yamls(path)
for doc_idx, yaml in enumerate(read_weights_yamls(path)):
if yaml is None:
logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}")
else:
weights_for_file.append(yaml)
weights_cache[fname] = tuple(weights_for_file)
except Exception as e: except Exception as e:
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
@@ -440,7 +431,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if "linked_options" in weights: if "linked_options" in weights:
weights = roll_linked_options(weights) weights = roll_linked_options(weights)
valid_keys = {"triggers"} valid_keys = set()
if "triggers" in weights: if "triggers" in weights:
weights = roll_triggers(weights, weights["triggers"], valid_keys) weights = roll_triggers(weights, weights["triggers"], valid_keys)
@@ -462,10 +453,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.") raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.game = get_choice("game", weights) ret.game = get_choice("game", weights)
if not isinstance(ret.game, str):
if ret.game is None:
raise Exception('"game" not specified')
raise Exception(f"Invalid game: {ret.game}")
if ret.game not in AutoWorldRegister.world_types: if ret.game not in AutoWorldRegister.world_types:
from worlds import failed_world_loads from worlds import failed_world_loads
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0] picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
@@ -499,23 +486,15 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
for option_key, option in world_type.options_dataclass.type_hints.items(): for option_key, option in world_type.options_dataclass.type_hints.items():
handle_option(ret, game_weights, option_key, option, plando_options) handle_option(ret, game_weights, option_key, option, plando_options)
valid_keys.add(option_key) valid_keys.add(option_key)
for option_key in game_weights:
# TODO remove plando_items after moving it to the options system if option_key in {"triggers", *valid_keys}:
valid_keys.add("plando_items") continue
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
if PlandoOptions.items in plando_options: if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", [])) ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
if ret.game == "A Link to the Past": if ret.game == "A Link to the Past":
# TODO there are still more LTTP options not on the options system
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
roll_alttp_settings(ret, game_weights) roll_alttp_settings(ret, game_weights)
# log a warning for options within a game section that aren't determined as valid
for option_key in game_weights:
if option_key in valid_keys:
continue
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
f"for player {ret.name}.")
return ret return ret

View File

@@ -1,7 +1,7 @@
MIT License MIT License
Copyright (c) 2017 LLCoolDave Copyright (c) 2017 LLCoolDave
Copyright (c) 2025 Berserker66 Copyright (c) 2022 Berserker66
Copyright (c) 2022 CaitSith2 Copyright (c) 2022 CaitSith2
Copyright (c) 2021 LegendaryLinux Copyright (c) 2021 LegendaryLinux

View File

@@ -22,21 +22,20 @@ from os.path import isfile
from shutil import which from shutil import which
from typing import Callable, Optional, Sequence, Tuple, Union from typing import Callable, Optional, Sequence, Tuple, Union
import Utils
import settings
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
if __name__ == "__main__": if __name__ == "__main__":
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
import settings from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
import Utils is_windows, is_macos, is_linux
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
user_path)
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
def open_host_yaml(): def open_host_yaml():
s = settings.get_settings() file = settings.get_settings().filename
file = s.filename
s.save()
assert file, "host.yaml missing" assert file, "host.yaml missing"
if is_linux: if is_linux:
exe = which('sensible-editor') or which('gedit') or \ exe = which('sensible-editor') or which('gedit') or \
@@ -103,7 +102,6 @@ components.extend([
Component("Open host.yaml", func=open_host_yaml), Component("Open host.yaml", func=open_host_yaml),
Component("Open Patch", func=open_patch), Component("Open Patch", func=open_patch),
Component("Generate Template Options", func=generate_yamls), Component("Generate Template Options", func=generate_yamls),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")), Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Browse Files", func=browse_files), Component("Browse Files", func=browse_files),
@@ -126,13 +124,12 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
elif component.display_name == "Text Client": elif component.display_name == "Text Client":
text_client_component = component text_client_component = component
if client_component is None: from kvui import App, Button, BoxLayout, Label, Clock, Window
run_component(text_client_component, *launch_args)
return
from kvui import App, Button, BoxLayout, Label, Window
class Popup(App): class Popup(App):
timer_label: Label
remaining_time: Optional[int]
def __init__(self): def __init__(self):
self.title = "Connect to Multiworld" self.title = "Connect to Multiworld"
self.icon = r"data/icon.png" self.icon = r"data/icon.png"
@@ -140,29 +137,47 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
def build(self): def build(self):
layout = BoxLayout(orientation="vertical") layout = BoxLayout(orientation="vertical")
layout.add_widget(Label(text="Select client to open and connect with."))
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
text_client_button = Button( if client_component is None:
text=text_client_component.display_name, self.remaining_time = 7
on_release=lambda *args: run_component(text_client_component, *launch_args) label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
) f"Launching Text Client in 7 seconds...")
button_row.add_widget(text_client_button) self.timer_label = Label(text=label_text)
layout.add_widget(self.timer_label)
Clock.schedule_interval(self.update_label, 1)
else:
layout.add_widget(Label(text="Select client to open and connect with."))
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
game_client_button = Button( text_client_button = Button(
text=client_component.display_name, text=text_client_component.display_name,
on_release=lambda *args: run_component(client_component, *launch_args) on_release=lambda *args: run_component(text_client_component, *launch_args)
) )
button_row.add_widget(game_client_button) button_row.add_widget(text_client_button)
layout.add_widget(button_row) game_client_button = Button(
text=client_component.display_name,
on_release=lambda *args: run_component(client_component, *launch_args)
)
button_row.add_widget(game_client_button)
layout.add_widget(button_row)
return layout return layout
def _stop(self, *largs): def update_label(self, dt):
# see run_gui Launcher _stop comment for details if self.remaining_time > 1:
self.root_window.close() # countdown the timer and string replace the number
super()._stop(*largs) self.remaining_time -= 1
self.timer_label.text = self.timer_label.text.replace(
str(self.remaining_time + 1), str(self.remaining_time)
)
else:
# our timer is finished so launch text client and close down
run_component(text_client_component, *launch_args)
Clock.unschedule(self.update_label)
App.get_running_app().stop()
Window.close()
Popup().run() Popup().run()
@@ -224,8 +239,9 @@ refresh_components: Optional[Callable[[], None]] = None
def run_gui(): def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
from kivy.core.window import Window from kivy.core.window import Window
from kivy.uix.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout from kivy.uix.relativelayout import RelativeLayout
class Launcher(App): class Launcher(App):
@@ -236,7 +252,7 @@ def run_gui():
_client_layout: Optional[ScrollBox] = None _client_layout: Optional[ScrollBox] = None
def __init__(self, ctx=None): def __init__(self, ctx=None):
self.title = self.base_title + " " + Utils.__version__ self.title = self.base_title
self.ctx = ctx self.ctx = ctx
self.icon = r"data/icon.png" self.icon = r"data/icon.png"
super().__init__() super().__init__()
@@ -258,8 +274,8 @@ def run_gui():
button.component = component button.component = component
button.bind(on_release=self.component_action) button.bind(on_release=self.component_action)
if component.icon != "icon": if component.icon != "icon":
image = ApAsyncImage(source=icon_paths[component.icon], image = AsyncImage(source=icon_paths[component.icon],
size=(38, 38), size_hint=(None, 1), pos=(5, 0)) size=(38, 38), size_hint=(None, 1), pos=(5, 0))
box_layout = RelativeLayout(size_hint_y=None, height=40) box_layout = RelativeLayout(size_hint_y=None, height=40)
box_layout.add_widget(button) box_layout.add_widget(button)
box_layout.add_widget(image) box_layout.add_widget(image)

View File

@@ -28,7 +28,6 @@ from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
from NetUtils import ClientStatus from NetUtils import ClientStatus
from worlds.ladx.Common import BASE_ID as LABaseID from worlds.ladx.Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker from worlds.ladx.GpsTracker import GpsTracker
from worlds.ladx.TrackerConsts import storage_key
from worlds.ladx.ItemTracker import ItemTracker from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name from worlds.ladx.Locations import get_locations_to_id, meta_to_name
@@ -101,23 +100,19 @@ class LAClientConstants:
WRamCheckSize = 0x4 WRamCheckSize = 0x4
WRamSafetyValue = bytearray([0]*WRamCheckSize) WRamSafetyValue = bytearray([0]*WRamCheckSize)
wRamStart = 0xC000
hRamStart = 0xFF80
hRamSize = 0x80
MinGameplayValue = 0x06 MinGameplayValue = 0x06
MaxGameplayValue = 0x1A MaxGameplayValue = 0x1A
VictoryGameplayAndSub = 0x0102 VictoryGameplayAndSub = 0x0102
class RAGameboy(): class RAGameboy():
cache = [] cache = []
cache_start = 0
cache_size = 0
last_cache_read = None last_cache_read = None
socket = None socket = None
def __init__(self, address, port) -> None: def __init__(self, address, port) -> None:
self.cache_start = LAClientConstants.wRamStart
self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart
self.address = address self.address = address
self.port = port self.port = port
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -136,14 +131,9 @@ class RAGameboy():
async def get_retroarch_status(self): async def get_retroarch_status(self):
return await self.send_command("GET_STATUS") return await self.send_command("GET_STATUS")
def set_checks_range(self, checks_start, checks_size): def set_cache_limits(self, cache_start, cache_size):
self.checks_start = checks_start self.cache_start = cache_start
self.checks_size = checks_size self.cache_size = cache_size
def set_location_range(self, location_start, location_size, critical_addresses):
self.location_start = location_start
self.location_size = location_size
self.critical_location_addresses = critical_addresses
def send(self, b): def send(self, b):
if type(b) is str: if type(b) is str:
@@ -198,57 +188,21 @@ class RAGameboy():
if not await self.check_safe_gameplay(): if not await self.check_safe_gameplay():
return return
attempts = 0 cache = []
while True: remaining_size = self.cache_size
# RA doesn't let us do an atomic read of a large enough block of RAM while remaining_size:
# Some bytes can't change in between reading location_block and hram_block block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
location_block = await self.read_memory_block(self.location_start, self.location_size) remaining_size -= len(block)
hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize) cache += block
verification_block = await self.read_memory_block(self.location_start, self.location_size)
valid = True
for address in self.critical_location_addresses:
if location_block[address - self.location_start] != verification_block[address - self.location_start]:
valid = False
if valid:
break
attempts += 1
# Shouldn't really happen, but keep it from choking
if attempts > 5:
return
checks_block = await self.read_memory_block(self.checks_start, self.checks_size)
if not await self.check_safe_gameplay(): if not await self.check_safe_gameplay():
return return
self.cache = bytearray(self.cache_size) self.cache = cache
start = self.checks_start - self.cache_start
self.cache[start:start + len(checks_block)] = checks_block
start = self.location_start - self.cache_start
self.cache[start:start + len(location_block)] = location_block
start = LAClientConstants.hRamStart - self.cache_start
self.cache[start:start + len(hram_block)] = hram_block
self.last_cache_read = time.time() self.last_cache_read = time.time()
async def read_memory_block(self, address: int, size: int):
block = bytearray()
remaining_size = size
while remaining_size:
chunk = await self.async_read_memory(address + len(block), remaining_size)
remaining_size -= len(chunk)
block += chunk
return block
async def read_memory_cache(self, addresses): async def read_memory_cache(self, addresses):
# TODO: can we just update once per frame?
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time(): if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
await self.update_cache() await self.update_cache()
if not self.cache: if not self.cache:
@@ -281,7 +235,7 @@ class RAGameboy():
def check_command_response(self, command: str, response: bytes): def check_command_response(self, command: str, response: bytes):
if command == "VERSION": if command == "VERSION":
ok = re.match(r"\d+\.\d+\.\d+", response.decode('ascii')) is not None ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None
else: else:
ok = response.startswith(command.encode()) ok = response.startswith(command.encode())
if not ok: if not ok:
@@ -405,12 +359,11 @@ class LinksAwakeningClient():
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode() auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
self.auth = auth self.auth = auth
async def wait_and_init_tracker(self, magpie: MagpieBridge): async def wait_and_init_tracker(self):
await self.wait_for_game_ready() await self.wait_for_game_ready()
self.tracker = LocationTracker(self.gameboy) self.tracker = LocationTracker(self.gameboy)
self.item_tracker = ItemTracker(self.gameboy) self.item_tracker = ItemTracker(self.gameboy)
self.gps_tracker = GpsTracker(self.gameboy) self.gps_tracker = GpsTracker(self.gameboy)
magpie.gps_tracker = self.gps_tracker
async def recved_item_from_ap(self, item_id, from_player, next_index): async def recved_item_from_ap(self, item_id, from_player, next_index):
# Don't allow getting an item until you've got your first check # Don't allow getting an item until you've got your first check
@@ -452,11 +405,9 @@ class LinksAwakeningClient():
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1 return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
async def main_tick(self, item_get_cb, win_cb, deathlink_cb): async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
await self.gameboy.update_cache()
await self.tracker.readChecks(item_get_cb) await self.tracker.readChecks(item_get_cb)
await self.item_tracker.readItems() await self.item_tracker.readItems()
await self.gps_tracker.read_location() await self.gps_tracker.read_location()
await self.gps_tracker.read_entrances()
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth] current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
if self.deathlink_debounce and current_health != 0: if self.deathlink_debounce and current_health != 0:
@@ -506,7 +457,7 @@ class LinksAwakeningContext(CommonContext):
la_task = None la_task = None
client = None client = None
# TODO: does this need to re-read on reset? # TODO: does this need to re-read on reset?
found_checks = set() found_checks = []
last_resend = time.time() last_resend = time.time()
magpie_enabled = False magpie_enabled = False
@@ -514,10 +465,6 @@ class LinksAwakeningContext(CommonContext):
magpie_task = None magpie_task = None
won = False won = False
@property
def slot_storage_key(self):
return f"{self.slot_info[self.slot].name}_{storage_key}"
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None: def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient() self.client = LinksAwakeningClient()
self.slot_data = {} self.slot_data = {}
@@ -558,17 +505,9 @@ class LinksAwakeningContext(CommonContext):
self.ui = LADXManager(self) self.ui = LADXManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
# Store the entrances we find on the server for future sessions
message = [{
"cmd": "Set",
"key": self.slot_storage_key,
"default": {},
"want_reply": False,
"operations": [{"operation": "update", "value": entrances}],
}]
async def send_checks(self):
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
await self.send_msgs(message) await self.send_msgs(message)
had_invalid_slot_data = None had_invalid_slot_data = None
@@ -597,20 +536,14 @@ class LinksAwakeningContext(CommonContext):
logger.info("victory!") logger.info("victory!")
await self.send_msgs(message) await self.send_msgs(message)
self.won = True self.won = True
async def request_found_entrances(self):
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
# Ask for updates so that players can co-op entrances in a seed
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
if self.ENABLE_DEATHLINK: if self.ENABLE_DEATHLINK:
self.client.pending_deathlink = True self.client.pending_deathlink = True
def new_checks(self, item_ids, ladxr_ids): def new_checks(self, item_ids, ladxr_ids):
self.found_checks.update(item_ids) self.found_checks += item_ids
create_task_log_exception(self.check_locations(self.found_checks)) create_task_log_exception(self.send_checks())
if self.magpie_enabled: if self.magpie_enabled:
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids)) create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
@@ -627,10 +560,6 @@ class LinksAwakeningContext(CommonContext):
while self.client.auth == None: while self.client.auth == None:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# Just return if we're closing
if self.exit_event.is_set():
return
self.auth = self.client.auth self.auth = self.client.auth
await self.send_connect() await self.send_connect()
@@ -643,12 +572,6 @@ class LinksAwakeningContext(CommonContext):
if cmd == "ReceivedItems": if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]): for index, item in enumerate(args["items"], start=args["index"]):
self.client.recvd_checks[index] = item self.client.recvd_checks[index] = item
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key:
self.client.gps_tracker.receive_found_entrances(args["value"])
async def sync(self): async def sync(self):
sync_msg = [{'cmd': 'Sync'}] sync_msg = [{'cmd': 'Sync'}]
@@ -662,12 +585,6 @@ class LinksAwakeningContext(CommonContext):
checkMetadataTable[check.id])] for check in ladxr_checks] checkMetadataTable[check.id])] for check in ladxr_checks]
self.new_checks(checks, [check.id for check in ladxr_checks]) self.new_checks(checks, [check.id for check in ladxr_checks])
for check in ladxr_checks:
if check.value and check.linkedItem:
linkedItem = check.linkedItem
if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data):
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
async def victory(): async def victory():
await self.send_victory() await self.send_victory()
@@ -701,36 +618,21 @@ class LinksAwakeningContext(CommonContext):
if not self.client.recvd_checks: if not self.client.recvd_checks:
await self.sync() await self.sync()
await self.client.wait_and_init_tracker(self.magpie) await self.client.wait_and_init_tracker()
min_tick_duration = 0.1
last_tick = time.time()
while True: while True:
await self.client.main_tick(on_item_get, victory, deathlink) await self.client.main_tick(on_item_get, victory, deathlink)
await asyncio.sleep(0.1)
now = time.time() now = time.time()
tick_duration = now - last_tick
sleep_duration = max(min_tick_duration - tick_duration, 0)
await asyncio.sleep(sleep_duration)
last_tick = now
if self.last_resend + 5.0 < now: if self.last_resend + 5.0 < now:
self.last_resend = now self.last_resend = now
await self.check_locations(self.found_checks) await self.send_checks()
if self.magpie_enabled: if self.magpie_enabled:
try: try:
self.magpie.set_checks(self.client.tracker.all_checks) self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker) await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker)
self.magpie.slot_data = self.slot_data self.magpie.slot_data = self.slot_data
if self.client.gps_tracker.needs_found_entrances:
await self.request_found_entrances()
self.client.gps_tracker.needs_found_entrances = False
new_entrances = await self.magpie.send_gps(self.client.gps_tracker)
if new_entrances:
await self.send_new_entrances(new_entrances)
except Exception: except Exception:
# Don't let magpie errors take out the client # Don't let magpie errors take out the client
pass pass
@@ -799,6 +701,6 @@ async def main():
await ctx.shutdown() await ctx.shutdown()
if __name__ == '__main__': if __name__ == '__main__':
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -33,15 +33,10 @@ WINDOW_MIN_HEIGHT = 525
WINDOW_MIN_WIDTH = 425 WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object): class AdjusterWorld(object):
class AdjusterSubWorld(object):
def __init__(self, random):
self.random = random
def __init__(self, sprite_pool): def __init__(self, sprite_pool):
import random import random
self.sprite_pool = {1: sprite_pool} self.sprite_pool = {1: sprite_pool}
self.per_slot_randoms = {1: random} self.per_slot_randoms = {1: random}
self.worlds = {1: self.AdjusterSubWorld(random)}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):

View File

@@ -370,7 +370,7 @@ if __name__ == "__main__":
import colorama import colorama
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

87
Main.py
View File

@@ -148,44 +148,50 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
else: else:
multiworld.worlds[1].options.non_local_items.value = set() multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set() multiworld.worlds[1].options.local_items.value = set()
AutoWorld.call_all(multiworld, "connect_entrances")
AutoWorld.call_all(multiworld, "generate_basic") AutoWorld.call_all(multiworld, "generate_basic")
# remove starting inventory from pool items. # remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible. # Because some worlds don't actually create items during create_items this has to be as late as possible.
fallback_inventory = StartInventoryPool({}) if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
depletion_pool: Dict[int, Dict[str, int]] = { new_items: List[Item] = []
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy() old_items: List[Item] = []
for player in multiworld.player_ids depletion_pool: Dict[int, Dict[str, int]] = {
} player: getattr(multiworld.worlds[player].options,
target_per_player = { "start_inventory_from_pool",
player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items StartInventoryPool({})).value.copy()
} for player in multiworld.player_ids
}
if target_per_player: for player, items in depletion_pool.items():
new_itempool: List[Item] = [] player_world: AutoWorld.World = multiworld.worlds[player]
for count in items.values():
# Make new itempool with start_inventory_from_pool items removed for _ in range(count):
for item in multiworld.itempool: new_items.append(player_world.create_filler())
target: int = sum(sum(items.values()) for items in depletion_pool.values())
for i, item in enumerate(multiworld.itempool):
if depletion_pool[item.player].get(item.name, 0): if depletion_pool[item.player].get(item.name, 0):
target -= 1
depletion_pool[item.player][item.name] -= 1 depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items
if not target:
old_items.extend(multiworld.itempool[i+1:])
break
else: else:
new_itempool.append(item) old_items.append(item)
# Create filler in place of the removed items, warn if any items couldn't be found in the multiworld itempool # leftovers?
for player, target in target_per_player.items(): if target:
unfound_items = {item: count for item, count in depletion_pool[player].items() if count} for player, remaining_items in depletion_pool.items():
remaining_items = {name: count for name, count in remaining_items.items() if count}
if unfound_items: if remaining_items:
player_name = multiworld.get_player_name(player) logger.warning(f"{multiworld.get_player_name(player)}"
logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}") f" is trying to remove items from their pool that don't exist: {remaining_items}")
# find all filler we generated for the current player and remove until it matches
needed_items = target_per_player[player] - sum(unfound_items.values()) removables = [item for item in new_items if item.player == player]
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)] for _ in range(sum(remaining_items.values())):
new_items.remove(removables.pop())
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change." assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change."
multiworld.itempool[:] = new_itempool multiworld.itempool[:] = new_items + old_items
multiworld.link_items() multiworld.link_items()
@@ -243,7 +249,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
def write_multidata(): def write_multidata():
import NetUtils import NetUtils
from NetUtils import HintStatus
slot_data = {} slot_data = {}
client_versions = {} client_versions = {}
games = {} games = {}
@@ -268,10 +273,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for slot in multiworld.player_ids: for slot in multiworld.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data() slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
def precollect_hint(location: Location, auto_status: HintStatus): def precollect_hint(location):
entrance = er_hint_data.get(location.player, {}).get(location.address, "") entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address, hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False, entrance, location.item.flags, auto_status) location.item.code, False, entrance, location.item.flags)
precollected_hints[location.player].add(hint) precollected_hints[location.player].add(hint)
if location.item.player not in multiworld.groups: if location.item.player not in multiworld.groups:
precollected_hints[location.item.player].add(hint) precollected_hints[location.item.player].add(hint)
@@ -284,22 +289,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if type(location.address) == int: if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \ assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \ "location.address should then also be None. Location: " \
f" {location}, Item: {location.item}" f" {location}"
assert location.address not in locations_data[location.player], ( assert location.address not in locations_data[location.player], (
f"Locations with duplicate address. {location} and " f"Locations with duplicate address. {location} and "
f"{locations_data[location.player][location.address]}") f"{locations_data[location.player][location.address]}")
locations_data[location.player][location.address] = \ locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags location.item.code, location.item.player, location.item.flags
auto_status = HintStatus.HINT_AVOID if location.item.trap else HintStatus.HINT_PRIORITY
if location.name in multiworld.worlds[location.player].options.start_location_hints: if location.name in multiworld.worlds[location.player].options.start_location_hints:
if not location.item.trap: # Unspecified status for location hints, except traps precollect_hint(location)
auto_status = HintStatus.HINT_UNSPECIFIED
precollect_hint(location, auto_status)
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints: elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
precollect_hint(location, auto_status) precollect_hint(location)
elif any([location.item.name in multiworld.worlds[player].options.start_hints elif any([location.item.name in multiworld.worlds[player].options.start_hints
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]): for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location, auto_status) precollect_hint(location)
# embedded data package # embedded data package
data_package = { data_package = {
@@ -311,10 +313,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# get spheres -> filter address==None -> skip empty # get spheres -> filter address==None -> skip empty
spheres: List[Dict[int, Set[int]]] = [] spheres: List[Dict[int, Set[int]]] = []
for sphere in multiworld.get_sendable_spheres(): for sphere in multiworld.get_spheres():
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set) current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
for sphere_location in sphere: for sphere_location in sphere:
current_sphere[sphere_location.player].add(sphere_location.address) if type(sphere_location.address) is int:
current_sphere[sphere_location.player].add(sphere_location.address)
if current_sphere: if current_sphere:
spheres.append(dict(current_sphere)) spheres.append(dict(current_sphere))

View File

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

View File

@@ -28,11 +28,9 @@ ModuleUpdate.update()
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import ssl import ssl
from NetUtils import ServerConnection
import colorama
import websockets import websockets
from websockets.extensions.permessage_deflate import PerMessageDeflate import colorama
try: try:
# ponyorm is a requirement for webhost, not default server, so may not be importable # ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError from pony.orm.dbapiprovider import OperationalError
@@ -43,11 +41,10 @@ import NetUtils
import Utils import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore, Hint, HintStatus SlotType, LocationStore
from BaseClasses import ItemClassification
min_client_version = Version(0, 1, 6) min_client_version = Version(0, 1, 6)
colorama.just_fix_windows_console() colorama.init()
def remove_from_list(container, value): def remove_from_list(container, value):
@@ -121,14 +118,13 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint): class Client(Endpoint):
version = Version(0, 0, 0) version = Version(0, 0, 0)
tags: typing.List[str] tags: typing.List[str] = []
remote_items: bool remote_items: bool
remote_start_inventory: bool remote_start_inventory: bool
no_items: bool no_items: bool
no_locations: bool no_locations: bool
no_text: bool
def __init__(self, socket: "ServerConnection", ctx: Context) -> None: def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
super().__init__(socket) super().__init__(socket)
self.auth = False self.auth = False
self.team = None self.team = None
@@ -178,7 +174,6 @@ class Context:
"compatibility": int} "compatibility": int}
# team -> slot id -> list of clients authenticated to slot. # team -> slot id -> list of clients authenticated to slot.
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]] clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
endpoints: list[Client]
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]] locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]] location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
hints_used: typing.Dict[typing.Tuple[int, int], int] hints_used: typing.Dict[typing.Tuple[int, int], int]
@@ -190,9 +185,11 @@ class Context:
slot_info: typing.Dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
generator_version = Version(0, 0, 0) generator_version = Version(0, 0, 0)
checksums: typing.Dict[str, str] checksums: typing.Dict[str, str]
item_names: typing.Dict[str, typing.Dict[int, str]] item_names: typing.Dict[str, typing.Dict[int, str]] = (
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')))
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[str, typing.Dict[int, str]] location_names: typing.Dict[str, typing.Dict[int, str]] = (
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')))
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]] all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]] all_location_and_group_names: typing.Dict[str, typing.Set[str]]
@@ -201,6 +198,7 @@ class Context:
""" each sphere is { player: { location_id, ... } } """ """ each sphere is { player: { location_id, ... } } """
logger: logging.Logger logger: logging.Logger
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled", hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
@@ -233,7 +231,7 @@ class Context:
self.hint_cost = hint_cost self.hint_cost = hint_cost
self.location_check_points = location_check_points self.location_check_points = location_check_points
self.hints_used = collections.defaultdict(int) self.hints_used = collections.defaultdict(int)
self.hints: typing.Dict[team_slot, typing.Set[Hint]] = collections.defaultdict(set) self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
self.release_mode: str = release_mode self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode self.collect_mode: str = collect_mode
@@ -271,10 +269,6 @@ class Context:
self.location_name_groups = {} self.location_name_groups = {}
self.all_item_and_group_names = {} self.all_item_and_group_names = {}
self.all_location_and_group_names = {} self.all_location_and_group_names = {}
self.item_names = collections.defaultdict(
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})'))
self.location_names = collections.defaultdict(
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
self.non_hintable_names = collections.defaultdict(frozenset) self.non_hintable_names = collections.defaultdict(frozenset)
self._load_game_data() self._load_game_data()
@@ -368,28 +362,18 @@ class Context:
return True return True
def broadcast_all(self, msgs: typing.List[dict]): def broadcast_all(self, msgs: typing.List[dict]):
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs) msgs = self.dumper(msgs)
data = self.dumper(msgs) endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
endpoints = ( async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
endpoint
for endpoint in self.endpoints
if endpoint.auth and not (msg_is_text and endpoint.no_text)
)
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
def broadcast_text_all(self, text: str, additional_arguments: dict = {}): def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
self.logger.info("Notice (all): %s" % text) self.logger.info("Notice (all): %s" % text)
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}]) self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_team(self, team: int, msgs: typing.List[dict]): def broadcast_team(self, team: int, msgs: typing.List[dict]):
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs) msgs = self.dumper(msgs)
data = self.dumper(msgs) endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
endpoints = ( async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
endpoint
for endpoint in itertools.chain.from_iterable(self.clients[team].values())
if not (msg_is_text and endpoint.no_text)
)
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]): def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
@@ -403,13 +387,13 @@ class Context:
await on_client_disconnected(self, endpoint) await on_client_disconnected(self, endpoint)
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}): def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
if not client.auth or client.no_text: if not client.auth:
return return
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text)) self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}])) async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}): def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
if not client.auth or client.no_text: if not client.auth:
return return
async_start(self.send_msgs(client, async_start(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments} [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
@@ -458,7 +442,7 @@ class Context:
self.slot_info = decoded_obj["slot_info"] self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()} self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items() self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
if slot_info.type == SlotType.group} if slot_info.type == SlotType.group}
self.clients = {0: {}} self.clients = {0: {}}
@@ -671,29 +655,13 @@ class Context:
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot]))) return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0 return 0
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None, def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
changed: typing.Optional[typing.Set[team_slot]] = None) -> None:
"""Refreshes the hints for the specified team/slot. Providing 'None' for either team or slot
will refresh all teams or all slots respectively. If a set is passed for 'changed', each (team,slot)
pair that has at least one hint modified will be added to the set.
"""
for hint_team, hint_slot in self.hints: for hint_team, hint_slot in self.hints:
if team != hint_team and team is not None: if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
continue # Check specified team only, all if team is None self.hints[hint_team, hint_slot] = {
if slot != hint_slot and slot is not None: hint.re_check(self, hint_team) for hint in
continue # Check specified slot only, all if slot is None self.hints[hint_team, hint_slot]
new_hints: typing.Set[Hint] = set() }
for hint in self.hints[hint_team, hint_slot]:
new_hint = hint.re_check(self, hint_team)
new_hints.add(new_hint)
if hint == new_hint:
continue
for player in self.slot_set(hint.receiving_player) | {hint.finding_player}:
if changed is not None:
changed.add((hint_team,player))
if slot is not None and slot != player:
self.replace_hint(hint_team, player, hint, new_hint)
self.hints[hint_team, hint_slot] = new_hints
def get_rechecked_hints(self, team: int, slot: int): def get_rechecked_hints(self, team: int, slot: int):
self.recheck_hints(team, slot) self.recheck_hints(team, slot)
@@ -742,7 +710,7 @@ class Context:
else: else:
return self.player_names[team, slot] return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False, def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
recipients: typing.Sequence[int] = None): recipients: typing.Sequence[int] = None):
"""Send and remember hints.""" """Send and remember hints."""
if only_new: if only_new:
@@ -757,8 +725,7 @@ class Context:
concerns[player].append(data) concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]: if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data) concerns[hint.finding_player].append(data)
# remember hints in all cases
# only remember hints that were not already found at the time of creation
if not hint.found: if not hint.found:
# since hints are bidirectional, finding player and receiving player, # since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists # we can check once if hint already exists
@@ -774,24 +741,13 @@ class Context:
self.on_new_hint(team, slot) self.on_new_hint(team, slot)
for slot, hint_data in concerns.items(): for slot, hint_data in concerns.items():
if recipients is None or slot in recipients: if recipients is None or slot in recipients:
clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, [])) clients = self.clients[team].get(slot)
if not clients: if not clients:
continue continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)] client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
for client in clients: for client in clients:
async_start(self.send_msgs(client, client_hints)) async_start(self.send_msgs(client, client_hints))
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
for hint in self.hints[team, finding_player]:
if hint.location == seeked_location and hint.finding_player == finding_player:
return hint
return None
def replace_hint(self, team: int, slot: int, old_hint: Hint, new_hint: Hint) -> None:
if old_hint in self.hints[team, slot]:
self.hints[team, slot].remove(old_hint)
self.hints[team, slot].add(new_hint)
# "events" # "events"
def on_goal_achieved(self, client: Client): def on_goal_achieved(self, client: Client):
@@ -833,7 +789,7 @@ def update_aliases(ctx: Context, team: int):
async_start(ctx.send_encoded_msgs(client, cmd)) async_start(ctx.send_encoded_msgs(client, cmd))
async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None: async def server(websocket, path: str = "/", ctx: Context = None):
client = Client(websocket, ctx) client = Client(websocket, ctx)
ctx.endpoints.append(client) ctx.endpoints.append(client)
@@ -924,10 +880,6 @@ async def on_client_joined(ctx: Context, client: Client):
"If your client supports it, " "If your client supports it, "
"you may have additional local commands you can list with /help.", "you may have additional local commands you can list with /help.",
{"type": "Tutorial"}) {"type": "Tutorial"})
if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions):
ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! "
"It may stop working in the future. If you are a player, please report this to the "
"client's developer.")
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
@@ -994,13 +946,9 @@ def get_status_string(ctx: Context, team: int, tag: str):
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags]) tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})" completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else "" tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
status_text = ( goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
" and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else
" and is ready." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_READY else
"."
)
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \ text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
f"{tag_text}{status_text} {completion_text}" f"{tag_text}{goal_text} {completion_text}"
return text return text
@@ -1078,37 +1026,21 @@ def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int], def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
count_activity: bool = True): count_activity: bool = True):
slot_locations = ctx.locations[slot]
new_locations = set(locations) - ctx.location_checks[team, slot] new_locations = set(locations) - ctx.location_checks[team, slot]
new_locations.intersection_update(slot_locations) # ignore location IDs unknown to this multidata new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
if new_locations: if new_locations:
if count_activity: if count_activity:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc) ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
sortable: list[tuple[int, int, int, int]] = []
for location in new_locations: for location in new_locations:
# extract all fields to avoid runtime overhead in LocationStore item_id, target_player, flags = ctx.locations[slot][location]
item_id, target_player, flags = slot_locations[location]
# sort/group by receiver and item
sortable.append((target_player, item_id, location, flags))
info_texts: list[dict[str, typing.Any]] = []
for target_player, item_id, location, flags in sorted(sortable):
new_item = NetworkItem(item_id, location, slot, flags) new_item = NetworkItem(item_id, location, slot, flags)
send_items_to(ctx, team, target_player, new_item) send_items_to(ctx, team, target_player, new_item)
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % ( 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], 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])) ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
if len(info_texts) >= 140: info_text = json_format_send_event(new_item, target_player)
# split into chunks that are close to compression window of 64K but not too big on the wire ctx.broadcast_team(team, [info_text])
# (roughly 1300-2600 bytes after compression depending on repetitiveness)
ctx.broadcast_team(team, info_texts)
info_texts.clear()
info_texts.append(json_format_send_event(new_item, target_player))
ctx.broadcast_team(team, info_texts)
del info_texts
del sortable
ctx.location_checks[team, slot] |= new_locations ctx.location_checks[team, slot] |= new_locations
send_new_items(ctx) send_new_items(ctx)
@@ -1117,15 +1049,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
"hint_points": get_slot_points(ctx, team, slot), "hint_points": get_slot_points(ctx, team, slot),
"checked_locations": new_locations, # send back new checks only "checked_locations": new_locations, # send back new checks only
}]) }])
updated_slots: typing.Set[tuple[int, int]] = set() old_hints = ctx.hints[team, slot].copy()
ctx.recheck_hints(team, slot, updated_slots) ctx.recheck_hints(team, slot)
for hint_team, hint_slot in updated_slots: if old_hints != ctx.hints[team, slot]:
ctx.on_changed_hints(hint_team, hint_slot) ctx.on_changed_hints(team, slot)
ctx.save() ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
-> typing.List[Hint]:
hints = [] hints = []
slots: typing.Set[int] = {slot} slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items(): for group_id, group in ctx.groups.items():
@@ -1135,58 +1066,31 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item] seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
for finding_player, location_id, item_id, receiving_player, item_flags \ for finding_player, location_id, item_id, receiving_player, item_flags \
in ctx.locations.find_item(slots, seeked_item_id): in ctx.locations.find_item(slots, seeked_item_id):
prev_hint = ctx.get_hint(team, finding_player, location_id) found = location_id in ctx.location_checks[team, finding_player]
if prev_hint: entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(prev_hint) hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
else: item_flags))
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
new_status = auto_status
if found:
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags, new_status))
return hints return hints
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \ def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
-> typing.List[Hint]:
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location] seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status) return collect_hint_location_id(ctx, team, slot, seeked_location)
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]:
-> typing.List[Hint]:
prev_hint = ctx.get_hint(team, slot, seeked_location)
if prev_hint:
return [prev_hint]
result = ctx.locations[slot].get(seeked_location, (None, None, None)) result = ctx.locations[slot].get(seeked_location, (None, None, None))
if any(result): if any(result):
item_id, receiving_player, item_flags = result item_id, receiving_player, item_flags = result
found = seeked_location in ctx.location_checks[team, slot] found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "") entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
new_status = auto_status return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags)]
if found:
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
new_status)]
return [] return []
status_names: typing.Dict[HintStatus, str] = { def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
HintStatus.HINT_FOUND: "(found)",
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
HintStatus.HINT_NO_PRIORITY: "(no priority)",
HintStatus.HINT_AVOID: "(avoid)",
HintStatus.HINT_PRIORITY: "(priority)",
}
def format_hint(ctx: Context, team: int, hint: Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \ text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \ f"{ctx.item_names[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"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
@@ -1194,8 +1098,7 @@ def format_hint(ctx: Context, team: int, hint: Hint) -> str:
if hint.entrance: if hint.entrance:
text += f" at {hint.entrance}" text += f" at {hint.entrance}"
return text + (". (found)" if hint.found else ".")
return text + ". " + status_names.get(hint.status, "(unknown)")
def json_format_send_event(net_item: NetworkItem, receiving_player: int): def json_format_send_event(net_item: NetworkItem, receiving_player: int):
@@ -1250,10 +1153,7 @@ class CommandProcessor(metaclass=CommandMeta):
if not raw: if not raw:
return return
try: try:
try: command = shlex.split(raw, comments=False)
command = shlex.split(raw, comments=False)
except ValueError: # most likely: "ValueError: No closing quotation"
command = raw.split()
basecommand = command[0] basecommand = command[0]
if basecommand[0] == self.marker: if basecommand[0] == self.marker:
method = self.commands.get(basecommand[1:].lower(), None) method = self.commands.get(basecommand[1:].lower(), None)
@@ -1599,7 +1499,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool: def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client) points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot) cost = self.ctx.get_hint_cost(self.client.slot)
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
if not input_text: if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]} self.ctx.hints[self.client.team, self.client.slot]}
@@ -1625,9 +1525,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = [] hints = []
elif not for_location: elif not for_location:
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status) hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
else: else:
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status) hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
else: else:
game = self.ctx.games[self.client.slot] game = self.ctx.games[self.client.slot]
@@ -1647,16 +1547,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = [] hints = []
for item_name in self.ctx.item_name_groups[game][hint_name]: for item_name in self.ctx.item_name_groups[game][hint_name]:
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status)) hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status) hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
elif hint_name in self.ctx.location_name_groups[game]: # location group name elif hint_name in self.ctx.location_name_groups[game]: # location group name
hints = [] hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]: for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game): if loc_name in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status)) hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name))
else: # location name else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status) hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
else: else:
self.output(response) self.output(response)
@@ -1821,9 +1721,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.clients[team][slot].append(client) ctx.clients[team][slot].append(client)
client.version = args['version'] client.version = args['version']
client.tags = args['tags'] client.tags = args['tags']
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
# set NoText for old PopTracker clients that predate the tag to save traffic
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
connected_packet = { connected_packet = {
"cmd": "Connected", "cmd": "Connected",
"team": client.team, "slot": client.slot, "team": client.team, "slot": client.slot,
@@ -1896,9 +1794,6 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.tags = args["tags"] client.tags = args["tags"]
if set(old_tags) != set(client.tags): if set(old_tags) != set(client.tags):
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
client.no_text = "NoText" in client.tags or (
"PopTracker" in client.tags and client.version < (0, 5, 1)
)
ctx.broadcast_text_all( ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags " f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
f"from {old_tags} to {client.tags}.", f"from {old_tags} to {client.tags}.",
@@ -1927,63 +1822,19 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
for location in args["locations"]: for location in args["locations"]:
if type(location) is not int: if type(location) is not int:
await ctx.send_msgs(client, await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
"text": 'Locations has to be a list of integers',
"original_cmd": cmd}]) "original_cmd": cmd}])
return return
target_item, target_player, flags = ctx.locations[client.slot][location] target_item, target_player, flags = ctx.locations[client.slot][location]
if create_as_hint: if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location, hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
HintStatus.HINT_UNSPECIFIED))
locs.append(NetworkItem(target_item, location, target_player, flags)) locs.append(NetworkItem(target_item, location, target_player, flags))
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2) ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
if locs and create_as_hint: if locs and create_as_hint:
ctx.save() ctx.save()
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}]) await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'UpdateHint':
location = args["location"]
player = args["player"]
status = args["status"]
if not isinstance(player, int) or not isinstance(location, int) \
or (status is not None and not isinstance(status, int)):
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint',
"original_cmd": cmd}])
return
hint = ctx.get_hint(client.team, player, location)
if not hint:
return # Ignored safely
if client.slot not in ctx.slot_set(hint.receiving_player):
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission',
"original_cmd": cmd}])
return
new_hint = hint
if status is None:
return
try:
status = HintStatus(status)
except ValueError:
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'UpdateHint: Invalid Status', "original_cmd": cmd}])
return
if status == HintStatus.HINT_FOUND:
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'UpdateHint: Cannot manually update status to "HINT_FOUND"', "original_cmd": cmd}])
return
new_hint = new_hint.re_prioritize(ctx, status)
if hint == new_hint:
return
ctx.replace_hint(client.team, hint.finding_player, hint, new_hint)
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
ctx.save()
ctx.on_changed_hints(client.team, hint.finding_player)
ctx.on_changed_hints(client.team, hint.receiving_player)
elif cmd == 'StatusUpdate': elif cmd == 'StatusUpdate':
update_client_status(ctx, client, args["status"]) update_client_status(ctx, client, args["status"])
@@ -2031,7 +1882,6 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
args["cmd"] = "SetReply" args["cmd"] = "SetReply"
value = ctx.stored_data.get(args["key"], args.get("default", 0)) value = ctx.stored_data.get(args["key"], args.get("default", 0))
args["original_value"] = copy.copy(value) args["original_value"] = copy.copy(value)
args["slot"] = client.slot
for operation in args["operations"]: for operation in args["operations"]:
func = modify_functions[operation["operation"]] func = modify_functions[operation["operation"]]
value = func(value, operation["value"]) value = func(value, operation["value"])
@@ -2106,10 +1956,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
"""Shutdown the server""" """Shutdown the server"""
try: self.ctx.server.ws_server.close()
self.ctx.server.ws_server.close() self.ctx.exit_event.set()
finally:
self.ctx.exit_event.set()
return True return True
@mark_raw @mark_raw
@@ -2289,9 +2137,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
hints = [] hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item]: for item_name_from_group in self.ctx.item_name_groups[game][item]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY)) hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
else: # item name or id else: # item name or id
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY) hints = collect_hints(self.ctx, team, slot, item)
if hints: if hints:
self.ctx.notify_hints(team, hints) self.ctx.notify_hints(team, hints)
@@ -2325,17 +2173,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable: if usable:
if isinstance(location, int): if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location, hints = collect_hint_location_id(self.ctx, team, slot, location)
HintStatus.HINT_UNSPECIFIED)
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]: elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
hints = [] hints = []
for loc_name_from_group in self.ctx.location_name_groups[game][location]: for loc_name_from_group in self.ctx.location_name_groups[game][location]:
if loc_name_from_group in self.ctx.location_names_for_game(game): if loc_name_from_group in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group, hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
HintStatus.HINT_UNSPECIFIED))
else: else:
hints = collect_hint_location_name(self.ctx, team, slot, location, hints = collect_hint_location_name(self.ctx, team, slot, location)
HintStatus.HINT_UNSPECIFIED)
if hints: if hints:
self.ctx.notify_hints(team, hints) self.ctx.notify_hints(team, hints)
else: else:
@@ -2425,8 +2270,6 @@ def parse_args() -> argparse.Namespace:
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file") parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
parser.add_argument('--loglevel', default=defaults["loglevel"], parser.add_argument('--loglevel', default=defaults["loglevel"],
choices=['debug', 'info', 'warning', 'error', 'critical']) choices=['debug', 'info', 'warning', 'error', 'critical'])
parser.add_argument('--logtime', help="Add timestamps to STDOUT",
default=defaults["logtime"], action='store_true')
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int) parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int) parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true') parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
@@ -2507,9 +2350,7 @@ def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLConte
async def main(args: argparse.Namespace): async def main(args: argparse.Namespace):
Utils.init_logging(name="Server", Utils.init_logging("Server", loglevel=args.loglevel.lower())
loglevel=args.loglevel.lower(),
add_timestamp=args.logtime)
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points, ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode, args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,

View File

@@ -5,20 +5,11 @@ import enum
import warnings import warnings
from json import JSONEncoder, JSONDecoder from json import JSONEncoder, JSONDecoder
if typing.TYPE_CHECKING: import websockets
from websockets import WebSocketServerProtocol as ServerConnection
from Utils import ByValue, Version from Utils import ByValue, Version
class HintStatus(ByValue, enum.IntEnum):
HINT_UNSPECIFIED = 0
HINT_NO_PRIORITY = 10
HINT_AVOID = 20
HINT_PRIORITY = 30
HINT_FOUND = 40
class JSONMessagePart(typing.TypedDict, total=False): class JSONMessagePart(typing.TypedDict, total=False):
text: str text: str
# optional # optional
@@ -28,8 +19,6 @@ class JSONMessagePart(typing.TypedDict, total=False):
player: int player: int
# if type == item indicates item flags # if type == item indicates item flags
flags: int flags: int
# if type == hint_status
hint_status: HintStatus
class ClientStatus(ByValue, enum.IntEnum): class ClientStatus(ByValue, enum.IntEnum):
@@ -152,7 +141,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
class Endpoint: class Endpoint:
socket: "ServerConnection" socket: websockets.WebSocketServerProtocol
def __init__(self, socket): def __init__(self, socket):
self.socket = socket self.socket = socket
@@ -195,7 +184,6 @@ class JSONTypes(str, enum.Enum):
location_name = "location_name" location_name = "location_name"
location_id = "location_id" location_id = "location_id"
entrance_name = "entrance_name" entrance_name = "entrance_name"
hint_status = "hint_status"
class JSONtoTextParser(metaclass=HandlerMeta): class JSONtoTextParser(metaclass=HandlerMeta):
@@ -236,7 +224,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_player_id(self, node: JSONMessagePart): def _handle_player_id(self, node: JSONMessagePart):
player = int(node["text"]) player = int(node["text"])
node["color"] = 'magenta' if self.ctx.slot_concerns_self(player) else 'yellow' node["color"] = 'magenta' if player == self.ctx.slot else 'yellow'
node["text"] = self.ctx.player_names[player] node["text"] = self.ctx.player_names[player]
return self._handle_color(node) return self._handle_color(node)
@@ -277,10 +265,6 @@ class JSONtoTextParser(metaclass=HandlerMeta):
node["color"] = 'blue' node["color"] = 'blue'
return self._handle_color(node) return self._handle_color(node)
def _handle_hint_status(self, node: JSONMessagePart):
node["color"] = status_colors.get(node["hint_status"], "red")
return self._handle_color(node)
class RawJSONtoTextParser(JSONtoTextParser): class RawJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart): def _handle_color(self, node: JSONMessagePart):
@@ -313,27 +297,6 @@ def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs)
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs}) parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
status_names: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "(found)",
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
HintStatus.HINT_NO_PRIORITY: "(no priority)",
HintStatus.HINT_AVOID: "(avoid)",
HintStatus.HINT_PRIORITY: "(priority)",
}
status_colors: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "green",
HintStatus.HINT_UNSPECIFIED: "white",
HintStatus.HINT_NO_PRIORITY: "slateblue",
HintStatus.HINT_AVOID: "salmon",
HintStatus.HINT_PRIORITY: "plum",
}
def add_json_hint_status(parts: list, hint_status: HintStatus, text: typing.Optional[str] = None, **kwargs):
parts.append({"text": text if text != None else status_names.get(hint_status, "(unknown)"),
"hint_status": hint_status, "type": JSONTypes.hint_status, **kwargs})
class Hint(typing.NamedTuple): class Hint(typing.NamedTuple):
receiving_player: int receiving_player: int
finding_player: int finding_player: int
@@ -342,21 +305,14 @@ class Hint(typing.NamedTuple):
found: bool found: bool
entrance: str = "" entrance: str = ""
item_flags: int = 0 item_flags: int = 0
status: HintStatus = HintStatus.HINT_UNSPECIFIED
def re_check(self, ctx, team) -> Hint: def re_check(self, ctx, team) -> Hint:
if self.found and self.status == HintStatus.HINT_FOUND: if self.found:
return self return self
found = self.location in ctx.location_checks[team, self.finding_player] found = self.location in ctx.location_checks[team, self.finding_player]
if found: if found:
return self._replace(found=found, status=HintStatus.HINT_FOUND) return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
return self self.item_flags)
def re_prioritize(self, ctx, status: HintStatus) -> Hint:
if self.found and status != HintStatus.HINT_FOUND:
status = HintStatus.HINT_FOUND
if status != self.status:
return self._replace(status=status)
return self return self
def __hash__(self): def __hash__(self):
@@ -378,7 +334,10 @@ class Hint(typing.NamedTuple):
else: else:
add_json_text(parts, "'s World") add_json_text(parts, "'s World")
add_json_text(parts, ". ") add_json_text(parts, ". ")
add_json_hint_status(parts, self.status) if self.found:
add_json_text(parts, "(found)", type="color", color="green")
else:
add_json_text(parts, "(not found)", type="color", color="red")
return {"cmd": "PrintJSON", "data": parts, "type": "Hint", return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
"receiving": self.receiving_player, "receiving": self.receiving_player,
@@ -424,8 +383,6 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
checked = state[team, slot] checked = state[team, slot]
if not checked: if not checked:
# This optimizes the case where everyone connects to a fresh game at the same time. # This optimizes the case where everyone connects to a fresh game at the same time.
if slot not in self:
raise KeyError(slot)
return [] return []
return [location_id for return [location_id for
location_id in self[slot] if location_id in self[slot] if

View File

@@ -1,6 +1,7 @@
import tkinter as tk import tkinter as tk
import argparse import argparse
import logging import logging
import random
import os import os
import zipfile import zipfile
from itertools import chain from itertools import chain
@@ -196,6 +197,7 @@ def set_icon(window):
def adjust(args): def adjust(args):
# Create a fake multiworld and OOTWorld to use as a base # Create a fake multiworld and OOTWorld to use as a base
multiworld = MultiWorld(1) multiworld = MultiWorld(1)
multiworld.per_slot_randoms = {1: random}
ootworld = OOTWorld(multiworld, 1) ootworld = OOTWorld(multiworld, 1)
# Set options in the fake OOTWorld # Set options in the fake OOTWorld
for name, option in chain(cosmetic_options.items(), sfx_options.items()): for name, option in chain(cosmetic_options.items(), sfx_options.items()):

View File

@@ -346,7 +346,7 @@ if __name__ == '__main__':
import colorama import colorama
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -15,7 +15,7 @@ from dataclasses import dataclass
from schema import And, Optional, Or, Schema from schema import And, Optional, Or, Schema
from typing_extensions import Self from typing_extensions import Self
from Utils import get_file_safe_name, get_fuzzy_results, is_iterable_except_str, output_path from Utils import get_fuzzy_results, is_iterable_except_str, output_path
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from BaseClasses import MultiWorld, PlandoOptions from BaseClasses import MultiWorld, PlandoOptions
@@ -137,7 +137,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
If this is False, the docstring is instead interpreted as plain text, and If this is False, the docstring is instead interpreted as plain text, and
displayed as-is on the WebHost with whitespace preserved. displayed as-is on the WebHost with whitespace preserved.
If this is None, it inherits the value of `WebWorld.rich_text_options_doc`. For If this is None, it inherits the value of `World.rich_text_options_doc`. For
backwards compatibility, this defaults to False, but worlds are encouraged to backwards compatibility, this defaults to False, but worlds are encouraged to
set it to True and use reStructuredText for their Option documentation. set it to True and use reStructuredText for their Option documentation.
@@ -496,7 +496,7 @@ class TextChoice(Choice):
def __init__(self, value: typing.Union[str, int]): def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, int), \ assert isinstance(value, str) or isinstance(value, int), \
f"'{value}' is not a valid option for '{self.__class__.__name__}'" f"{value} is not a valid option for {self.__class__.__name__}"
self.value = value self.value = value
@property @property
@@ -617,17 +617,17 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
used_locations.append(location) used_locations.append(location)
used_bosses.append(boss) used_bosses.append(boss)
if not cls.valid_boss_name(boss): if not cls.valid_boss_name(boss):
raise ValueError(f"'{boss.title()}' is not a valid boss name.") raise ValueError(f"{boss.title()} is not a valid boss name.")
if not cls.valid_location_name(location): if not cls.valid_location_name(location):
raise ValueError(f"'{location.title()}' is not a valid boss location name.") raise ValueError(f"{location.title()} is not a valid boss location name.")
if not cls.can_place_boss(boss, location): if not cls.can_place_boss(boss, location):
raise ValueError(f"'{location.title()}' is not a valid location for {boss.title()} to be placed.") raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
else: else:
if cls.duplicate_bosses: if cls.duplicate_bosses:
if not cls.valid_boss_name(option): if not cls.valid_boss_name(option):
raise ValueError(f"'{option}' is not a valid boss name.") raise ValueError(f"{option} is not a valid boss name.")
else: else:
raise ValueError(f"'{option.title()}' is not formatted correctly.") raise ValueError(f"{option.title()} is not formatted correctly.")
@classmethod @classmethod
def can_place_boss(cls, boss: str, location: str) -> bool: def can_place_boss(cls, boss: str, location: str) -> bool:
@@ -689,9 +689,9 @@ class Range(NumericOption):
@classmethod @classmethod
def weighted_range(cls, text) -> Range: def weighted_range(cls, text) -> Range:
if text == "random-low": if text == "random-low":
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0)) return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
elif text == "random-high": elif text == "random-high":
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0)) return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
elif text == "random-middle": elif text == "random-middle":
return cls(cls.triangular(cls.range_start, cls.range_end)) return cls(cls.triangular(cls.range_start, cls.range_end))
elif text.startswith("random-range-"): elif text.startswith("random-range-"):
@@ -717,11 +717,11 @@ class Range(NumericOption):
f"{random_range[0]}-{random_range[1]} is outside allowed range " f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}") f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"): if text.startswith("random-range-low"):
return cls(cls.triangular(random_range[0], random_range[1], 0.0)) return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
elif text.startswith("random-range-middle"): elif text.startswith("random-range-middle"):
return cls(cls.triangular(random_range[0], random_range[1])) return cls(cls.triangular(random_range[0], random_range[1]))
elif text.startswith("random-range-high"): elif text.startswith("random-range-high"):
return cls(cls.triangular(random_range[0], random_range[1], 1.0)) return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
else: else:
return cls(random.randint(random_range[0], random_range[1])) return cls(random.randint(random_range[0], random_range[1]))
@@ -739,16 +739,8 @@ class Range(NumericOption):
return str(self.value) return str(self.value)
@staticmethod @staticmethod
def triangular(lower: int, end: int, tri: float = 0.5) -> int: def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
""" return int(round(random.triangular(lower, end, tri), 0))
Integer triangular distribution for `lower` inclusive to `end` inclusive.
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
"""
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
# when a != b, so ensure the result is never more than `end`.
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
class NamedRange(Range): class NamedRange(Range):
@@ -762,7 +754,7 @@ class NamedRange(Range):
elif value > self.range_end and value not in self.special_range_names.values(): elif value > self.range_end and value not in self.special_range_names.values():
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " + raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}") f"and is also not one of the supported named special values: {self.special_range_names}")
# See docstring # See docstring
for key in self.special_range_names: for key in self.special_range_names:
if key != key.lower(): if key != key.lower():
@@ -825,21 +817,18 @@ class VerifyKeys(metaclass=FreezeValidKeys):
for item_name in self.value: for item_name in self.value:
if item_name not in world.item_names: if item_name not in world.item_names:
picks = get_fuzzy_results(item_name, world.item_names, limit=1) picks = get_fuzzy_results(item_name, world.item_names, limit=1)
raise Exception(f"Item '{item_name}' from option '{self}' " raise Exception(f"Item {item_name} from option {self} "
f"is not a valid item name from '{world.game}'. " f"is not a valid item name from {world.game}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
elif self.verify_location_name: elif self.verify_location_name:
for location_name in self.value: for location_name in self.value:
if location_name not in world.location_names: if location_name not in world.location_names:
picks = get_fuzzy_results(location_name, world.location_names, limit=1) picks = get_fuzzy_results(location_name, world.location_names, limit=1)
raise Exception(f"Location '{location_name}' from option '{self}' " raise Exception(f"Location {location_name} from option {self} "
f"is not a valid location name from '{world.game}'. " f"is not a valid location name from {world.game}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
def __iter__(self) -> typing.Iterator[typing.Any]:
return self.value.__iter__()
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]): class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default = {} default = {}
supports_weighting = False supports_weighting = False
@@ -871,8 +860,6 @@ class ItemDict(OptionDict):
verify_item_name = True verify_item_name = True
def __init__(self, value: typing.Dict[str, int]): def __init__(self, value: typing.Dict[str, int]):
if any(item_count is None for item_count in value.values()):
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
if any(item_count < 1 for item_count in value.values()): if any(item_count < 1 for item_count in value.values()):
raise Exception("Cannot have non-positive item counts.") raise Exception("Cannot have non-positive item counts.")
super(ItemDict, self).__init__(value) super(ItemDict, self).__init__(value)
@@ -1119,11 +1106,11 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
used_entrances.append(entrance) used_entrances.append(entrance)
used_exits.append(exit) used_exits.append(exit)
if not cls.validate_entrance_name(entrance): if not cls.validate_entrance_name(entrance):
raise ValueError(f"'{entrance.title()}' is not a valid entrance.") raise ValueError(f"{entrance.title()} is not a valid entrance.")
if not cls.validate_exit_name(exit): if not cls.validate_exit_name(exit):
raise ValueError(f"'{exit.title()}' is not a valid exit.") raise ValueError(f"{exit.title()} is not a valid exit.")
if not cls.can_connect(entrance, exit): if not cls.can_connect(entrance, exit):
raise ValueError(f"Connection between '{entrance.title()}' and '{exit.title()}' is invalid.") raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")
@classmethod @classmethod
def from_any(cls, data: PlandoConFromAnyType) -> Self: def from_any(cls, data: PlandoConFromAnyType) -> Self:
@@ -1188,7 +1175,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
class Accessibility(Choice): class Accessibility(Choice):
""" """
Set rules for reachability of your items/locations. Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired. **Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired. **Minimal:** ensure what is needed to reach your goal can be acquired.
@@ -1206,7 +1193,7 @@ class Accessibility(Choice):
class ItemsAccessibility(Accessibility): class ItemsAccessibility(Accessibility):
""" """
Set rules for reachability of your items/locations. Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired. **Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired. **Minimal:** ensure what is needed to reach your goal can be acquired.
@@ -1257,16 +1244,12 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing progression_balancing: ProgressionBalancing
accessibility: Accessibility accessibility: Accessibility
def as_dict(self, def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
""" """
Returns a dictionary of [str, Option.value] Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return :param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab` :param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
""" """
assert option_names, "options.as_dict() was used without any option names." assert option_names, "options.as_dict() was used without any option names."
option_results = {} option_results = {}
@@ -1288,8 +1271,6 @@ class CommonOptions(metaclass=OptionsMetaProperty):
value = getattr(self, option_name).value value = getattr(self, option_name).value
if isinstance(value, set): if isinstance(value, set):
value = sorted(value) value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value option_results[display_name] = value
else: else:
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}") raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
@@ -1387,8 +1368,8 @@ class ItemLinks(OptionList):
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1) picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else "" picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
raise Exception(f"Item '{item_name}' from item link '{item_link}' " raise Exception(f"Item {item_name} from item link {item_link} "
f"is not a valid item from '{world.game}' for '{pool_name}'. " f"is not a valid item from {world.game} for {pool_name}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}") f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
if allow_item_groups: if allow_item_groups:
pool |= world.item_name_groups.get(item_name, {item_name}) pool |= world.item_name_groups.get(item_name, {item_name})
@@ -1479,26 +1460,22 @@ it.
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[ def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]: str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
"""Generates and returns a dictionary for the option groups of a specified world.""" """Generates and returns a dictionary for the option groups of a specified world."""
option_to_name = {option: option_name for option_name, option in world.options_dataclass.type_hints.items()} option_groups = {option: option_group.name
for option_group in world.web.option_groups
ordered_groups = {group.name: group.options for group in world.web.option_groups} for option in option_group.options}
# add a default option group for uncategorized options to get thrown into # add a default option group for uncategorized options to get thrown into
if "Game Options" not in ordered_groups: ordered_groups = ["Game Options"]
grouped_options = set(option for group in ordered_groups.values() for option in group) [ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
ungrouped_options = [option for option in option_to_name if option not in grouped_options] grouped_options = {group: {} for group in ordered_groups}
# only add the game options group if we have ungrouped options for option_name, option in world.options_dataclass.type_hints.items():
if ungrouped_options: if visibility_level & option.visibility:
ordered_groups = {**{"Game Options": ungrouped_options}, **ordered_groups} grouped_options[option_groups.get(option, "Game Options")][option_name] = option
return { # if the world doesn't have any ungrouped options, this group will be empty so just remove it
group: { if not grouped_options["Game Options"]:
option_to_name[option]: option del grouped_options["Game Options"]
for option in group_options
if (visibility_level in option.visibility and option in option_to_name) return grouped_options
}
for group, group_options in ordered_groups.items()
}
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None: def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
@@ -1554,7 +1531,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
del file_data del file_data
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f: with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res) f.write(res)
@@ -1579,11 +1556,10 @@ def dump_player_options(multiworld: MultiWorld) -> None:
player_output = { player_output = {
"Game": multiworld.game[player], "Game": multiworld.game[player],
"Name": multiworld.get_player_name(player), "Name": multiworld.get_player_name(player),
"ID": player,
} }
output.append(player_output) output.append(player_output)
for option_key, option in world.options_dataclass.type_hints.items(): for option_key, option in world.options_dataclass.type_hints.items():
if option.visibility == Visibility.none: if issubclass(Removed, option):
continue continue
display_name = getattr(option, "display_name", option_key) display_name = getattr(option, "display_name", option_key)
player_output[display_name] = getattr(world.options, option_key).current_option_name player_output[display_name] = getattr(world.options, option_key).current_option_name
@@ -1592,7 +1568,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
game_option_names.append(display_name) game_option_names.append(display_name)
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file: with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
fields = ["ID", "Game", "Name", *all_option_names] fields = ["Game", "Name", *all_option_names]
writer = DictWriter(file, fields) writer = DictWriter(file, fields)
writer.writeheader() writer.writeheader()
writer.writerows(output) writer.writerows(output)

View File

@@ -76,12 +76,6 @@ Currently, the following games are supported:
* Kingdom Hearts 1 * Kingdom Hearts 1
* Mega Man 2 * Mega Man 2
* Yacht Dice * Yacht Dice
* Faxanadu
* Saving Princess
* Castlevania: Circle of the Moon
* Inscryption
* Civilization VI
* The Legend of Zelda: The Wind Waker
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -243,9 +243,6 @@ class SNIContext(CommonContext):
# Once the games handled by SNIClient gets made to be remote items, # Once the games handled by SNIClient gets made to be remote items,
# this will no longer be needed. # this will no longer be needed.
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}])) async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
if self.client_handler is not None:
self.client_handler.on_package(self, cmd, args)
def run_gui(self) -> None: def run_gui(self) -> None:
from kvui import GameManager from kvui import GameManager
@@ -636,13 +633,7 @@ async def game_watcher(ctx: SNIContext) -> None:
if not ctx.client_handler: if not ctx.client_handler:
continue continue
try: rom_validated = await ctx.client_handler.validate_rom(ctx)
rom_validated = await ctx.client_handler.validate_rom(ctx)
except Exception as e:
snes_logger.error(f"An error occurred, see logs for details: {e}")
text_file_logger = logging.getLogger()
text_file_logger.exception(e)
rom_validated = False
if not rom_validated or (ctx.auth and ctx.auth != ctx.rom): if not rom_validated or (ctx.auth and ctx.auth != ctx.rom):
snes_logger.warning("ROM change detected, please reconnect to the multiworld server") snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
@@ -658,13 +649,7 @@ async def game_watcher(ctx: SNIContext) -> None:
perf_counter = time.perf_counter() perf_counter = time.perf_counter()
try: await ctx.client_handler.game_watcher(ctx)
await ctx.client_handler.game_watcher(ctx)
except Exception as e:
snes_logger.error(f"An error occurred, see logs for details: {e}")
text_file_logger = logging.getLogger()
text_file_logger.exception(e)
await snes_disconnect(ctx)
async def run_game(romfile: str) -> None: async def run_game(romfile: str) -> None:
@@ -735,6 +720,6 @@ async def main() -> None:
if __name__ == '__main__': if __name__ == '__main__':
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -500,7 +500,7 @@ def main():
import colorama import colorama
colorama.just_fix_windows_console() colorama.init()
asyncio.run(_main()) asyncio.run(_main())
colorama.deinit() colorama.deinit()

108
Utils.py
View File

@@ -18,8 +18,8 @@ import warnings
from argparse import Namespace from argparse import Namespace
from settings import Settings, get_settings from settings import Settings, get_settings
from time import sleep from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard from typing_extensions import TypeGuard
from yaml import load, load_all, dump from yaml import load, load_all, dump
try: try:
@@ -31,7 +31,6 @@ if typing.TYPE_CHECKING:
import tkinter import tkinter
import pathlib import pathlib
from BaseClasses import Region from BaseClasses import Region
import multiprocessing
def tuplize_version(version: str) -> Version: def tuplize_version(version: str) -> Version:
@@ -47,7 +46,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.6.0" __version__ = "0.5.1"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -152,15 +151,8 @@ def home_path(*path: str) -> str:
if hasattr(home_path, 'cached_path'): if hasattr(home_path, 'cached_path'):
pass pass
elif sys.platform.startswith('linux'): elif sys.platform.startswith('linux'):
xdg_data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) home_path.cached_path = os.path.expanduser('~/Archipelago')
home_path.cached_path = xdg_data_home + '/Archipelago' os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
if not os.path.isdir(home_path.cached_path):
legacy_home_path = os.path.expanduser('~/Archipelago')
if os.path.isdir(legacy_home_path):
os.renames(legacy_home_path, home_path.cached_path)
os.symlink(home_path.cached_path, legacy_home_path)
else:
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else: else:
# not implemented # not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously home_path.cached_path = local_path() # this will generate the same exceptions we got previously
@@ -428,11 +420,10 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "builtins" and name in safe_builtins: if module == "builtins" and name in safe_builtins:
return getattr(builtins, name) return getattr(builtins, name)
# used by MultiServer -> savegame/multidata # used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
"SlotType", "NetworkSlot", "HintStatus"}:
return getattr(self.net_utils_module, name) return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate # Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name == "PlandoItem": if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
if not self.generic_properties_module: if not self.generic_properties_module:
self.generic_properties_module = importlib.import_module("worlds.generic") self.generic_properties_module = importlib.import_module("worlds.generic")
return getattr(self.generic_properties_module, name) return getattr(self.generic_properties_module, name)
@@ -443,8 +434,7 @@ class RestrictedUnpickler(pickle.Unpickler):
else: else:
mod = importlib.import_module(module) mod = importlib.import_module(module)
obj = getattr(mod, name) obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, if issubclass(obj, self.options_module.Option):
self.options_module.PlandoText)):
return obj return obj
# Forbid everything else. # Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
@@ -493,9 +483,9 @@ def get_text_after(text: str, start: str) -> str:
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG} loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
write_mode: str = "w", log_format: str = "[%(name)s at %(asctime)s]: %(message)s", log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
add_timestamp: bool = False, exception_logger: typing.Optional[str] = None): exception_logger: typing.Optional[str] = None):
import datetime import datetime
loglevel: int = loglevel_mapping.get(loglevel, loglevel) loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = user_path("logs") log_folder = user_path("logs")
@@ -522,15 +512,11 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
def filter(self, record: logging.LogRecord) -> bool: def filter(self, record: logging.LogRecord) -> bool:
return self.condition(record) return self.condition(record)
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False))) file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage()))
root_logger.addHandler(file_handler) root_logger.addHandler(file_handler)
if sys.stdout: if sys.stdout:
formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
stream_handler = logging.StreamHandler(sys.stdout) stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False))) stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
if add_timestamp:
stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler) root_logger.addHandler(stream_handler)
# Relay unhandled exceptions to logger. # Relay unhandled exceptions to logger.
@@ -542,8 +528,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
sys.__excepthook__(exc_type, exc_value, exc_traceback) sys.__excepthook__(exc_type, exc_value, exc_traceback)
return return
logging.getLogger(exception_logger).exception("Uncaught exception", logging.getLogger(exception_logger).exception("Uncaught exception",
exc_info=(exc_type, exc_value, exc_traceback), exc_info=(exc_type, exc_value, exc_traceback))
extra={"NoStream": exception_logger is None})
return orig_hook(exc_type, exc_value, exc_traceback) return orig_hook(exc_type, exc_value, exc_traceback)
handle_exception._wrapped = True handle_exception._wrapped = True
@@ -566,7 +551,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
import platform import platform
logging.info( logging.info(
f"Archipelago ({__version__}) logging initialized" f"Archipelago ({__version__}) logging initialized"
f" on {platform.platform()} process {os.getpid()}" f" on {platform.platform()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
f"{' (frozen)' if is_frozen() else ''}" f"{' (frozen)' if is_frozen() else ''}"
) )
@@ -582,8 +567,6 @@ def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"):
else: else:
if text: if text:
queue.put_nowait(text) queue.put_nowait(text)
else:
sleep(0.01) # non-blocking stream
from threading import Thread from threading import Thread
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True) thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
@@ -681,19 +664,6 @@ def get_input_text_from_response(text: str, command: str) -> typing.Optional[str
return None return None
def is_kivy_running() -> bool:
if "kivy" in sys.modules:
from kivy.app import App
return App.get_running_app() is not None
return False
def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
res.put(open_filename(*args))
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]: -> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.") logging.info(f"Opening file input dialog for {title}.")
@@ -723,13 +693,6 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
f'This attempt was made because open_filename was used for "{title}".') f'This attempt was made because open_filename was used for "{title}".')
raise e raise e
else: else:
if is_macos and is_kivy_running():
# on macOS, mixing kivy and tk does not work, so spawn a new process
# FIXME: performance of this is pretty bad, and we should (also) look into alternatives
from multiprocessing import Process, Queue
res: "Queue[typing.Optional[str]]" = Queue()
Process(target=_mp_open_filename, args=(res, title, filetypes, suggest)).start()
return res.get()
try: try:
root = tkinter.Tk() root = tkinter.Tk()
except tkinter.TclError: except tkinter.TclError:
@@ -739,12 +702,6 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
initialfile=suggest or None) initialfile=suggest or None)
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
res.put(open_directory(*args))
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def run(*args: str): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
@@ -768,16 +725,9 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
import tkinter.filedialog import tkinter.filedialog
except Exception as e: except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. ' logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because open_directory was used for "{title}".') f'This attempt was made because open_filename was used for "{title}".')
raise e raise e
else: else:
if is_macos and is_kivy_running():
# on macOS, mixing kivy and tk does not work, so spawn a new process
# FIXME: performance of this is pretty bad, and we should (also) look into alternatives
from multiprocessing import Process, Queue
res: "Queue[typing.Optional[str]]" = Queue()
Process(target=_mp_open_directory, args=(res, title, suggest)).start()
return res.get()
try: try:
root = tkinter.Tk() root = tkinter.Tk()
except tkinter.TclError: except tkinter.TclError:
@@ -790,6 +740,12 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
def is_kivy_running():
if "kivy" in sys.modules:
from kivy.app import App
return App.get_running_app() is not None
return False
if is_kivy_running(): if is_kivy_running():
from kvui import MessageBox from kvui import MessageBox
MessageBox(title, text, error).open() MessageBox(title, text, error).open()
@@ -868,10 +824,11 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
task.add_done_callback(_faf_tasks.discard) task.add_done_callback(_faf_tasks.discard)
def deprecate(message: str, add_stacklevels: int = 0): def deprecate(message: str):
if __debug__: if __debug__:
raise Exception(message) raise Exception(message)
warnings.warn(message, stacklevel=2 + add_stacklevels) import warnings
warnings.warn(message)
class DeprecateDict(dict): class DeprecateDict(dict):
@@ -885,9 +842,10 @@ class DeprecateDict(dict):
def __getitem__(self, item: Any) -> Any: def __getitem__(self, item: Any) -> Any:
if self.should_error: if self.should_error:
deprecate(self.log_message, add_stacklevels=1) deprecate(self.log_message)
elif __debug__: elif __debug__:
warnings.warn(self.log_message, stacklevel=2) import warnings
warnings.warn(self.log_message)
return super().__getitem__(item) return super().__getitem__(item)
@@ -941,7 +899,7 @@ def freeze_support() -> None:
def visualize_regions(root_region: Region, file_name: str, *, def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True, show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None: linetype_ortho: bool = True) -> None:
"""Visualize the layout of a world as a PlantUML diagram. """Visualize the layout of a world as a PlantUML diagram.
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.) :param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
@@ -957,22 +915,16 @@ def visualize_regions(root_region: Region, file_name: str, *,
Items without ID will be shown in italics. Items without ID will be shown in italics.
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown. :param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines. :param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
Example usage in World code: Example usage in World code:
from Utils import visualize_regions from Utils import visualize_regions
state = self.multiworld.get_all_state(False) visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
state.update_reachable_regions(self.player)
visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True,
regions_to_highlight=state.reachable_regions[self.player])
Example usage in Main code: Example usage in Main code:
from Utils import visualize_regions from Utils import visualize_regions
for player in multiworld.player_ids: for player in multiworld.player_ids:
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml") visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
""" """
if regions_to_highlight is None:
regions_to_highlight = set()
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled" assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
from collections import deque from collections import deque
@@ -1025,7 +977,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}") uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
def visualize_region(region: Region) -> None: def visualize_region(region: Region) -> None:
uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}") uml.append(f"class \"{fmt(region)}\"")
if show_locations: if show_locations:
visualize_locations(region) visualize_locations(region)
visualize_exits(region) visualize_exits(region)

View File

@@ -446,6 +446,6 @@ if __name__ == '__main__':
parser = get_base_parser(description="Wargroove Client, for text interfacing.") parser = get_base_parser(description="Wargroove Client, for text interfacing.")
args, rest = parser.parse_known_args() args, rest = parser.parse_known_args()
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main(args)) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

View File

@@ -12,12 +12,11 @@ ModuleUpdate.update()
# in case app gets imported by something like gunicorn # in case app gets imported by something like gunicorn
import Utils import Utils
import settings import settings
from Utils import get_file_safe_name
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from flask import Flask from flask import Flask
Utils.local_path.cached_path = os.path.dirname(__file__) Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
settings.no_gui = True settings.no_gui = True
configpath = os.path.abspath("config.yaml") configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home if not os.path.exists(configpath): # fall back to config.yaml in home
@@ -34,7 +33,7 @@ def get_app() -> "Flask":
app.config.from_file(configpath, yaml.safe_load) app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}") logging.info(f"Updated config from {configpath}")
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it. # inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
parser = argparse.ArgumentParser(allow_abbrev=False) parser = argparse.ArgumentParser()
parser.add_argument('--config_override', default=None, parser.add_argument('--config_override', default=None,
help="Path to yaml config file that overrules config.yaml.") help="Path to yaml config file that overrules config.yaml.")
args = parser.parse_known_args()[0] args = parser.parse_known_args()[0]
@@ -72,7 +71,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
shutil.rmtree(base_target_path, ignore_errors=True) shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items(): for game, world in worlds.items():
# copy files from world's docs folder to the generated folder # copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, get_file_safe_name(game)) target_path = os.path.join(base_target_path, game)
os.makedirs(target_path, exist_ok=True) os.makedirs(target_path, exist_ok=True)
if world.zip_path: if world.zip_path:

View File

@@ -9,7 +9,7 @@ from flask_compress import Compress
from pony.flask import Pony from pony.flask import Pony
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from Utils import title_sorted, get_file_safe_name from Utils import title_sorted
UPLOAD_FOLDER = os.path.relpath('uploads') UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs') LOGS_FOLDER = os.path.relpath('logs')
@@ -20,7 +20,6 @@ Pony(app)
app.jinja_env.filters['any'] = any app.jinja_env.filters['any'] = any
app.jinja_env.filters['all'] = all app.jinja_env.filters['all'] = all
app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
app.config["SELFHOST"] = True # application process is in charge of running the websites app.config["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens app.config["GENERATORS"] = 8 # maximum concurrent world gens
@@ -39,8 +38,6 @@ app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
app.config["JOB_THRESHOLD"] = 1 app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable. # after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600 app.config["JOB_TIME"] = 600
# memory limit for generator processes in bytes
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
app.config['SESSION_PERMANENT'] = True app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent # waitress uses one thread for I/O, these are for processing of views that then get sent
@@ -87,6 +84,6 @@ def register():
from WebHostLib.customserver import run_server_process from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it # to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options
app.register_blueprint(api.api_endpoints) app.register_blueprint(api.api_endpoints)

View File

@@ -3,13 +3,13 @@ from typing import List, Tuple
from flask import Blueprint from flask import Blueprint
from ..models import Seed, Slot from ..models import Seed
api_endpoints = Blueprint('api', __name__, url_prefix="/api") api_endpoints = Blueprint('api', __name__, url_prefix="/api")
def get_players(seed: Seed) -> List[Tuple[str, str]]: def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)] return [(slot.player_name, slot.game) for slot in seed.slots]
from . import datapackage, generate, room, user # trigger registration from . import datapackage, generate, room, user # trigger registration

View File

@@ -30,4 +30,4 @@ def get_seeds():
"creation_time": seed.creation_time, "creation_time": seed.creation_time,
"players": get_players(seed.slots), "players": get_players(seed.slots),
}) })
return jsonify(response) return jsonify(response)

View File

@@ -6,7 +6,6 @@ import multiprocessing
import typing import typing
from datetime import timedelta, datetime from datetime import timedelta, datetime
from threading import Event, Thread from threading import Event, Thread
from typing import Any
from uuid import UUID from uuid import UUID
from pony.orm import db_session, select, commit from pony.orm import db_session, select, commit
@@ -54,21 +53,7 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
generation.state = STATE_STARTED generation.state = STATE_STARTED
def init_generator(config: dict[str, Any]) -> None: def init_db(pony_config: dict):
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# set soft limit for memory to from config (default 4GiB)
soft_limit = config["GENERATOR_MEMORY_LIMIT"]
old_limit, hard_limit = resource.getrlimit(resource.RLIMIT_AS)
if soft_limit != old_limit:
resource.setrlimit(resource.RLIMIT_AS, (soft_limit, hard_limit))
logging.debug(f"Changed AS mem limit {old_limit} -> {soft_limit}")
del resource, soft_limit, hard_limit
pony_config = config["PONY"]
db.bind(**pony_config) db.bind(**pony_config)
db.generate_mapping() db.generate_mapping()
@@ -120,8 +105,8 @@ def autogen(config: dict):
try: try:
with Locker("autogen"): with Locker("autogen"):
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator, with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
initargs=(config,), maxtasksperchild=10) as generator_pool: initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
with db_session: with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)

View File

@@ -105,9 +105,8 @@ def roll_options(options: Dict[str, Union[dict, str]],
plando_options=plando_options) plando_options=plando_options)
else: else:
for i, yaml_data in enumerate(yaml_datas): for i, yaml_data in enumerate(yaml_datas):
if yaml_data is not None: rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data, plando_options=plando_options)
plando_options=plando_options)
except Exception as e: except Exception as e:
if e.__cause__: if e.__cause__:
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}" results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"

View File

@@ -117,7 +117,6 @@ class WebHostContext(Context):
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load 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.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})} self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
missing_checksum = False
for game in list(multidata.get("datapackage", {})): for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game] game_data = multidata["datapackage"][game]
@@ -133,13 +132,11 @@ class WebHostContext(Context):
continue continue
else: else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}") self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
else:
missing_checksum = True # Game rolled on old AP and will load data package from multidata
self.gamespackage[game] = static_gamespackage.get(game, {}) self.gamespackage[game] = static_gamespackage.get(game, {})
self.item_name_groups[game] = static_item_name_groups.get(game, {}) self.item_name_groups[game] = static_item_name_groups.get(game, {})
self.location_name_groups[game] = static_location_name_groups.get(game, {}) self.location_name_groups[game] = static_location_name_groups.get(game, {})
if not game_data_packages and not missing_checksum: if not game_data_packages:
# all static -> use the static dicts directly # all static -> use the static dicts directly
self.gamespackage = static_gamespackage self.gamespackage = static_gamespackage
self.item_name_groups = static_item_name_groups self.item_name_groups = static_item_name_groups

View File

@@ -31,11 +31,11 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[s
server_options = { server_options = {
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)), "hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)), "release_mode": options_source.get("release_mode", ServerOptions.release_mode),
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)), "remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode),
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_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))), "item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
"server_password": str(options_source.get("server_password", None)), "server_password": options_source.get("server_password", None),
} }
generator_options = { generator_options = {
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)), "spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),

View File

@@ -5,7 +5,6 @@ from typing import Any, IO, Dict, Iterator, List, Tuple, Union
import jinja2.exceptions import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from pony.orm import count, commit, db_session from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from . import app, cache from . import app, cache
@@ -18,6 +17,13 @@ def get_world_theme(game_name: str):
return 'grass' return 'grass'
@app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404) @app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound) @app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err): def page_not_found(err):
@@ -63,40 +69,14 @@ def tutorial_landing():
@app.route('/faq/<string:lang>/') @app.route('/faq/<string:lang>/')
@cache.cached() @cache.cached()
def faq(lang: str): def faq(lang):
import markdown return render_template("faq.html", lang=lang)
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template(
"markdown_document.html",
title="Frequently Asked Questions",
html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
)
@app.route('/glossary/<string:lang>/') @app.route('/glossary/<string:lang>/')
@cache.cached() @cache.cached()
def glossary(lang: str): def terms(lang):
import markdown return render_template("glossary.html", lang=lang)
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template(
"markdown_document.html",
title="Glossary",
html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
)
@app.route('/seed/<suuid:seed>') @app.route('/seed/<suuid:seed>')

View File

@@ -1,11 +1,11 @@
flask>=3.1.0 flask>=3.0.3
werkzeug>=3.1.3 werkzeug>=3.0.4
pony>=0.7.19 pony>=0.7.19
waitress>=3.0.2 waitress>=3.0.0
Flask-Caching>=2.3.0 Flask-Caching>=2.3.0
Flask-Compress>=1.17 Flask-Compress>=1.15
Flask-Limiter>=3.12 Flask-Limiter>=3.8.0
bokeh>=3.6.3 bokeh>=3.1.1; python_version <= '3.8'
markupsafe>=3.0.2 bokeh>=3.4.3; python_version == '3.9'
Markdown>=3.7 bokeh>=3.5.2; python_version >= '3.10'
mdx-breakless-lists>=1.0.1 markupsafe>=2.1.5

View File

@@ -1,31 +0,0 @@
from uuid import uuid4, UUID
from flask import session, render_template
from WebHostLib import app
@app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.route('/session')
def show_session():
return render_template(
"session.html",
)
@app.route('/session/<string:_id>')
def set_session(_id: str):
new_id: UUID = UUID(_id, version=4)
old_id: UUID = session["_id"]
if old_id != new_id:
session["_id"] = new_id
return render_template(
"session.html",
old_id=old_id,
)

View File

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

View File

@@ -22,7 +22,7 @@ players to rely upon each other to complete their game.
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
players to randomize any of the supported games, and send items between them. This allows players of different players to randomize any of the supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds. games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
Here is a list of our [Supported Games](https://archipelago.gg/games). Here is a list of our [Supported Games](https://archipelago.gg/games).
## Can I generate a single-player game with Archipelago? ## Can I generate a single-player game with Archipelago?

View File

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

View File

@@ -288,11 +288,6 @@ const applyPresets = (presetName) => {
} }
}); });
namedRangeSelect.value = trueValue; namedRangeSelect.value = trueValue;
// It is also possible for a preset to use an unnamed value. If this happens, set the dropdown to "Custom"
if (namedRangeSelect.selectedIndex == -1)
{
namedRangeSelect.value = "custom";
}
} }
// Handle options whose presets are "random" // Handle options whose presets are "random"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

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