Compare commits
282 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9e79854a8 | ||
|
|
144d612c52 | ||
|
|
3acbe9ece1 | ||
|
|
7d0b701a2d | ||
|
|
f91537fb48 | ||
|
|
3c5ec49dbe | ||
|
|
9a37a136a1 | ||
|
|
54a0a5ac00 | ||
|
|
704f14ffcd | ||
|
|
925fb967d3 | ||
|
|
5dd19fccd0 | ||
|
|
781100a571 | ||
|
|
3fb0b57d19 | ||
|
|
f79657b41a | ||
|
|
4a5ba756b6 | ||
|
|
0b3d34ab24 | ||
|
|
2e81774a1f | ||
|
|
aa22b62b41 | ||
|
|
51c4fe8f67 | ||
|
|
26f9720e69 | ||
|
|
1f712d9a87 | ||
|
|
5b4d7c7526 | ||
|
|
a948697f3a | ||
|
|
e3b5451672 | ||
|
|
6c69f590cf | ||
|
|
c9625e1b35 | ||
|
|
ced93022b6 | ||
|
|
f4b926ebbe | ||
|
|
203d89d1d3 | ||
|
|
4d42814f5d | ||
|
|
d80069385d | ||
|
|
85a0d59f73 | ||
|
|
58f2205304 | ||
|
|
769fbc55a9 | ||
|
|
f43fa612d5 | ||
|
|
5b0de6b6c7 | ||
|
|
ac8a206d46 | ||
|
|
6896d631db | ||
|
|
6f2e1c2a7e | ||
|
|
ffe0221deb | ||
|
|
18e8d50768 | ||
|
|
81b9a53a37 | ||
|
|
b6ab91fe4b | ||
|
|
f26cda07db | ||
|
|
409c915375 | ||
|
|
ecc3094c70 | ||
|
|
17b3ee6eaf | ||
|
|
284e7797c5 | ||
|
|
62ce42440b | ||
|
|
7b755408fa | ||
|
|
ed721dd0c1 | ||
|
|
1a5d22ca78 | ||
|
|
21dbfd2472 | ||
|
|
472d2d5406 | ||
|
|
3af2b1dc66 | ||
|
|
6cfc3a4667 | ||
|
|
992657750c | ||
|
|
a67688749f | ||
|
|
f735416bda | ||
|
|
e5374eb8b8 | ||
|
|
b83b48629d | ||
|
|
ca6792a8a7 | ||
|
|
7cbd50a2e6 | ||
|
|
d6da3bc899 | ||
|
|
9eaca95277 | ||
|
|
c1b27f79ac | ||
|
|
0705f6e6c0 | ||
|
|
a537d8eb65 | ||
|
|
845a604955 | ||
|
|
7adb673a80 | ||
|
|
72e88bb493 | ||
|
|
089b3f17a7 | ||
|
|
ad30e3264a | ||
|
|
e262c8be9c | ||
|
|
492e3a355e | ||
|
|
1487d323cd | ||
|
|
dd88b2c658 | ||
|
|
46dfc4d4fc | ||
|
|
b0a61be9df | ||
|
|
7c00c9a49d | ||
|
|
1365bd7a0a | ||
|
|
6e5adc7abd | ||
|
|
c97e4866dd | ||
|
|
8444ffa0c7 | ||
|
|
2fb59d39c9 | ||
|
|
b5343a36ff | ||
|
|
d7a0f4cb4c | ||
|
|
77d35b95e2 | ||
|
|
b605fb1032 | ||
|
|
a5231a27cc | ||
|
|
1454bacfdd | ||
|
|
ed4e44b994 | ||
|
|
d36c983461 | ||
|
|
05aa96a335 | ||
|
|
6f2464d4ad | ||
|
|
91185f4f7c | ||
|
|
1371c63a8d | ||
|
|
30b414429f | ||
|
|
ce210cd4ee | ||
|
|
8923b06a49 | ||
|
|
b783eab1e8 | ||
|
|
b972e8c071 | ||
|
|
faeb54224e | ||
|
|
1ba7700283 | ||
|
|
710cf4ebba | ||
|
|
82260d728f | ||
|
|
62e4285924 | ||
|
|
ce78c75999 | ||
|
|
c022c742b5 | ||
|
|
3cb5219e09 | ||
|
|
5d30d16e09 | ||
|
|
4780fd9974 | ||
|
|
3ba0576cf6 | ||
|
|
283d1ab7e8 | ||
|
|
78bc7b8156 | ||
|
|
a07ddb4371 | ||
|
|
4395c608e8 | ||
|
|
f4322242a1 | ||
|
|
a3711eb463 | ||
|
|
6656528d78 | ||
|
|
e1f16c6721 | ||
|
|
334781e976 | ||
|
|
6c939d2d59 | ||
|
|
e882c68277 | ||
|
|
dbf284d4b2 | ||
|
|
75624042f7 | ||
|
|
0dade05133 | ||
|
|
fcaba14b62 | ||
|
|
6073d5e37e | ||
|
|
41a7d7eeee | ||
|
|
d3a3c29bc9 | ||
|
|
0ad5b0ade8 | ||
|
|
e6e31a27e6 | ||
|
|
a650e90b57 | ||
|
|
36f17111bf | ||
|
|
03b90cf39b | ||
|
|
5729b78504 | ||
|
|
ba50c947ba | ||
|
|
2424b79626 | ||
|
|
d4b1351c99 | ||
|
|
859ae87ec9 | ||
|
|
124ce13da7 | ||
|
|
48ea274655 | ||
|
|
85a713771b | ||
|
|
3ae8992fb6 | ||
|
|
01c6037562 | ||
|
|
4b80b786e2 | ||
|
|
bd5c8ec172 | ||
|
|
baf291d7a2 | ||
|
|
9c102da901 | ||
|
|
75e18e3cc9 | ||
|
|
a3d6036939 | ||
|
|
7eb12174b7 | ||
|
|
73146ef30c | ||
|
|
66314de965 | ||
|
|
5141f36e95 | ||
|
|
9ba613277e | ||
|
|
f9c6ecc8b2 | ||
|
|
a734d25f66 | ||
|
|
2a850261b8 | ||
|
|
70b9b97841 | ||
|
|
6c9b7eca10 | ||
|
|
dd659de079 | ||
|
|
7916d1e67c | ||
|
|
c9e63a836a | ||
|
|
8f60a4a259 | ||
|
|
eac3e3c29e | ||
|
|
c295926ce1 | ||
|
|
85159a4f1f | ||
|
|
8b87e20a96 | ||
|
|
17f03bb5f8 | ||
|
|
74f922ea37 | ||
|
|
10bc05a172 | ||
|
|
432d8fa1c2 | ||
|
|
f3413e9cef | ||
|
|
b3e5ef876a | ||
|
|
9be996ba0e | ||
|
|
fa93bc5d1e | ||
|
|
6b4f6ebc1e | ||
|
|
930529e211 | ||
|
|
aae8b16073 | ||
|
|
f4072833f3 | ||
|
|
f52d65a141 | ||
|
|
2bdc1e0fc5 | ||
|
|
639b9598bd | ||
|
|
a29205b547 | ||
|
|
345d5154a9 | ||
|
|
a0207e0286 | ||
|
|
7449bf6b99 | ||
|
|
1cba694b78 | ||
|
|
9082ce74df | ||
|
|
5dfb2c514f | ||
|
|
e2e5c5102b | ||
|
|
08b99b8c33 | ||
|
|
72d2a33c0b | ||
|
|
6d0f0d2f4a | ||
|
|
a64548a4c6 | ||
|
|
d1dee226bf | ||
|
|
504eceaf4f | ||
|
|
96abc32f7d | ||
|
|
048658955b | ||
|
|
931e335155 | ||
|
|
1323474a52 | ||
|
|
f7b9ac990b | ||
|
|
085b655ad9 | ||
|
|
0b5c7fe8a9 | ||
|
|
aaf25f8c6f | ||
|
|
f00975c73d | ||
|
|
ad40acd392 | ||
|
|
4503ba75b6 | ||
|
|
14c7b22fea | ||
|
|
1541f46d44 | ||
|
|
b6c58c5c24 | ||
|
|
4dde3a2191 | ||
|
|
edacc07808 | ||
|
|
f3c59818b1 | ||
|
|
594a8321c4 | ||
|
|
f10eb850dc | ||
|
|
3f6754d7f2 | ||
|
|
382a5df1d8 | ||
|
|
9b5a2bedac | ||
|
|
d15fa57151 | ||
|
|
b27f667a15 | ||
|
|
579abb33c0 | ||
|
|
daad3d0350 | ||
|
|
d61a76fb02 | ||
|
|
5d4684f315 | ||
|
|
cd7b1df650 | ||
|
|
af77b76265 | ||
|
|
77ee6d73bc | ||
|
|
33daebef57 | ||
|
|
05ec14e23c | ||
|
|
049a8780b5 | ||
|
|
703e3393a6 | ||
|
|
f709d61d04 | ||
|
|
c6d2971d67 | ||
|
|
af14045c3a | ||
|
|
ede59ef5a1 | ||
|
|
63d471514f | ||
|
|
ff297f2951 | ||
|
|
a0f49dd7d9 | ||
|
|
79cec89e24 | ||
|
|
2b0cab82fa | ||
|
|
48822227b5 | ||
|
|
375b5796d9 | ||
|
|
c12ed316cf | ||
|
|
26577b16dc | ||
|
|
af0b5f8cf2 | ||
|
|
618564c60a | ||
|
|
f2ac937d1e | ||
|
|
d4d777b101 | ||
|
|
b772d42df5 | ||
|
|
e8f3aa96da | ||
|
|
2d0bdebaa9 | ||
|
|
ef4d1e77e3 | ||
|
|
f495bf7261 | ||
|
|
2751ccdaab | ||
|
|
6287bc27a6 | ||
|
|
97f2c25924 | ||
|
|
e5a0ef799f | ||
|
|
216e0603e1 | ||
|
|
05a67386c6 | ||
|
|
0ec9039ca6 | ||
|
|
f06f95d03d | ||
|
|
5a853dfccd | ||
|
|
23469fa5c3 | ||
|
|
dc1da4e88b | ||
|
|
67f6b458d7 | ||
|
|
8193fa12b2 | ||
|
|
de0c498470 | ||
|
|
7337309426 | ||
|
|
3205e9b3a0 | ||
|
|
05439012dc | ||
|
|
177c0fef52 | ||
|
|
5c4e81d046 | ||
|
|
a2d585ba5c | ||
|
|
5ea55d77b0 | ||
|
|
ab8caea8be | ||
|
|
a043ed50a6 | ||
|
|
e85a835b47 | ||
|
|
9a9fea0ca2 | ||
|
|
e910a37273 |
1
.gitattributes
vendored
@@ -1 +1,2 @@
|
||||
worlds/blasphemous/region_data.py linguist-generated=true
|
||||
worlds/yachtdice/YachtWeights.py linguist-generated=true
|
||||
|
||||
2
.github/pyright-config.json
vendored
@@ -16,7 +16,7 @@
|
||||
"reportMissingImports": true,
|
||||
"reportMissingTypeStubs": true,
|
||||
|
||||
"pythonVersion": "3.8",
|
||||
"pythonVersion": "3.10",
|
||||
"pythonPlatform": "Windows",
|
||||
|
||||
"executionEnvironments": [
|
||||
|
||||
2
.github/workflows/analyze-modified-files.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
if: env.diff != ''
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: '3.10'
|
||||
|
||||
- name: "Install dependencies"
|
||||
if: env.diff != ''
|
||||
|
||||
10
.github/workflows/build.yml
vendored
@@ -24,14 +24,15 @@ env:
|
||||
jobs:
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-win-py38: # RCs will still be built and signed by hand
|
||||
build-win: # RCs will still be built and signed by hand
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.8'
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||
@@ -111,10 +112,11 @@ jobs:
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# 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).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -72,4 +72,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
||||
5
.github/workflows/release.yml
vendored
@@ -44,10 +44,11 @@ jobs:
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
|
||||
6
.github/workflows/scan-build.yml
vendored
@@ -40,10 +40,10 @@ jobs:
|
||||
run: |
|
||||
wget https://apt.llvm.org/llvm.sh
|
||||
chmod +x ./llvm.sh
|
||||
sudo ./llvm.sh 17
|
||||
sudo ./llvm.sh 19
|
||||
- name: Install scan-build command
|
||||
run: |
|
||||
sudo apt install clang-tools-17
|
||||
sudo apt install clang-tools-19
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
- name: scan-build
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
|
||||
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
|
||||
- name: Store report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
6
.github/workflows/unittests.yml
vendored
@@ -33,13 +33,11 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python:
|
||||
- {version: '3.8'}
|
||||
- {version: '3.9'}
|
||||
- {version: '3.10'}
|
||||
- {version: '3.11'}
|
||||
- {version: '3.12'}
|
||||
include:
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
- python: {version: '3.10'} # old compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
os: windows-latest
|
||||
@@ -89,4 +87,4 @@ jobs:
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
export PYTHONPATH=$(pwd)
|
||||
python test/hosting/__main__.py
|
||||
timeout 600 python test/hosting/__main__.py
|
||||
|
||||
113
BaseClasses.py
@@ -1,18 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import itertools
|
||||
import functools
|
||||
import logging
|
||||
import random
|
||||
import secrets
|
||||
import typing # this can go away when Python 3.8 support is dropped
|
||||
from argparse import Namespace
|
||||
from collections import Counter, deque
|
||||
from collections.abc import Collection, MutableSequence
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
||||
Optional, Protocol, Set, Tuple, Union, Type)
|
||||
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
@@ -20,7 +18,7 @@ import NetUtils
|
||||
import Options
|
||||
import Utils
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
if TYPE_CHECKING:
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
@@ -194,7 +192,9 @@ class MultiWorld():
|
||||
self.player_types[new_id] = NetUtils.SlotType.group
|
||||
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
||||
self.worlds[new_id] = world_type.create_group(self, new_id, players)
|
||||
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
|
||||
self.worlds[new_id].collect_item = AutoWorld.World.collect_item.__get__(self.worlds[new_id])
|
||||
self.worlds[new_id].collect = AutoWorld.World.collect.__get__(self.worlds[new_id])
|
||||
self.worlds[new_id].remove = AutoWorld.World.remove.__get__(self.worlds[new_id])
|
||||
self.player_name[new_id] = name
|
||||
|
||||
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
|
||||
@@ -229,7 +229,7 @@ class MultiWorld():
|
||||
for player in self.player_ids:
|
||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||
self.worlds[player] = world_type(self, player)
|
||||
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
|
||||
options_dataclass: type[Options.PerGameCommonOptions] = world_type.options_dataclass
|
||||
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
|
||||
for option_key in options_dataclass.type_hints})
|
||||
|
||||
@@ -339,7 +339,7 @@ class MultiWorld():
|
||||
new_item.classification |= classifications[item_name]
|
||||
new_itempool.append(new_item)
|
||||
|
||||
region = Region("Menu", group_id, self, "ItemLink")
|
||||
region = Region(group["world"].origin_region_name, group_id, self, "ItemLink")
|
||||
self.regions.append(region)
|
||||
locations = region.locations
|
||||
# ensure that progression items are linked first, then non-progression
|
||||
@@ -604,6 +604,49 @@ class MultiWorld():
|
||||
state.collect(location.item, True, location)
|
||||
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):
|
||||
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
||||
if not state:
|
||||
@@ -720,7 +763,7 @@ class CollectionState():
|
||||
if new_region in reachable_regions:
|
||||
blocked_connections.remove(connection)
|
||||
elif connection.can_reach(self):
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
||||
reachable_regions.add(new_region)
|
||||
blocked_connections.remove(connection)
|
||||
blocked_connections.update(new_region.exits)
|
||||
@@ -946,6 +989,7 @@ class Entrance:
|
||||
self.player = player
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
|
||||
if self.parent_region.can_reach(state) and self.access_rule(state):
|
||||
if not self.hide_path and not self in state.path:
|
||||
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
||||
@@ -972,7 +1016,7 @@ class Region:
|
||||
entrances: List[Entrance]
|
||||
exits: List[Entrance]
|
||||
locations: List[Location]
|
||||
entrance_type: ClassVar[Type[Entrance]] = Entrance
|
||||
entrance_type: ClassVar[type[Entrance]] = Entrance
|
||||
|
||||
class Register(MutableSequence):
|
||||
region_manager: MultiWorld.RegionManager
|
||||
@@ -1072,7 +1116,7 @@ class Region:
|
||||
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||
|
||||
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
|
||||
location names to address.
|
||||
@@ -1109,7 +1153,7 @@ class Region:
|
||||
return exit_
|
||||
|
||||
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
||||
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
|
||||
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
|
||||
"""
|
||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||
|
||||
@@ -1119,10 +1163,14 @@ class Region:
|
||||
"""
|
||||
if not isinstance(exits, Dict):
|
||||
exits = dict.fromkeys(exits)
|
||||
for connecting_region, name in exits.items():
|
||||
self.connect(self.multiworld.get_region(connecting_region, self.player),
|
||||
name,
|
||||
rules[connecting_region] if rules and connecting_region in rules else None)
|
||||
return [
|
||||
self.connect(
|
||||
self.multiworld.get_region(connecting_region, self.player),
|
||||
name,
|
||||
rules[connecting_region] if rules and connecting_region in rules else None,
|
||||
)
|
||||
for connecting_region, name in exits.items()
|
||||
]
|
||||
|
||||
def __repr__(self):
|
||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||
@@ -1166,7 +1214,7 @@ class Location:
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average
|
||||
assert self.parent_region, "Can't reach location without region"
|
||||
assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region"
|
||||
return self.parent_region.can_reach(state) and self.access_rule(state)
|
||||
|
||||
def place_locked_item(self, item: Item):
|
||||
@@ -1261,6 +1309,14 @@ class Item:
|
||||
def trap(self) -> bool:
|
||||
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
|
||||
def flags(self) -> int:
|
||||
return self.classification.as_flag()
|
||||
@@ -1379,14 +1435,21 @@ class Spoiler:
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected: List[Item] = []
|
||||
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
multiworld.precollected_items[item.player].remove(item)
|
||||
multiworld.state.remove(item)
|
||||
if not multiworld.can_beat_game():
|
||||
multiworld.push_precollected(item)
|
||||
else:
|
||||
removed_precollected.append(item)
|
||||
|
||||
for precollected_items in multiworld.precollected_items.values():
|
||||
# The list of items is mutated by removing one item at a time to determine if each item is required to beat
|
||||
# the game, and re-adding that item if it was required, so a copy needs to be made before iterating.
|
||||
for item in precollected_items.copy():
|
||||
if not item.advancement:
|
||||
continue
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
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
|
||||
# the previous pruning stage could potentially have made certain items dependant on others
|
||||
@@ -1525,7 +1588,7 @@ class Spoiler:
|
||||
[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()]))
|
||||
if self.unreachables:
|
||||
outfile.write('\n\nUnreachable Items:\n\n')
|
||||
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
||||
outfile.write(
|
||||
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ if __name__ == "__main__":
|
||||
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType)
|
||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
||||
from Utils import Version, stream_input, async_start
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
@@ -45,10 +45,21 @@ def get_ssl_context():
|
||||
|
||||
|
||||
class ClientCommandProcessor(CommandProcessor):
|
||||
"""
|
||||
The Command Processor will parse every method of the class that starts with "_cmd_" as a command to be called
|
||||
when parsing user input, i.e. _cmd_exit will be called when the user sends the command "/exit".
|
||||
|
||||
The decorator @mark_raw can be imported from MultiServer and tells the parser to only split on the first
|
||||
space after the command i.e. "/exit one two three" will be passed in as method("one two three") with mark_raw
|
||||
and method("one", "two", "three") without.
|
||||
|
||||
In addition all docstrings for command methods will be displayed to the user on launch and when using "/help"
|
||||
"""
|
||||
def __init__(self, ctx: CommonContext):
|
||||
self.ctx = ctx
|
||||
|
||||
def output(self, text: str):
|
||||
"""Helper function to abstract logging to the CommonClient UI"""
|
||||
logger.info(text)
|
||||
|
||||
def _cmd_exit(self) -> bool:
|
||||
@@ -164,13 +175,14 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
|
||||
def default(self, raw: str):
|
||||
"""The default message parser to be used when parsing any messages that do not match a command"""
|
||||
raw = self.ctx.on_user_say(raw)
|
||||
if raw:
|
||||
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||
|
||||
|
||||
class CommonContext:
|
||||
# Should be adjusted as needed in subclasses
|
||||
# The following attributes are used to Connect and should be adjusted as needed in subclasses
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
game: typing.Optional[str] = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
@@ -343,6 +355,8 @@ class CommonContext:
|
||||
|
||||
self.item_names = self.NameLookupDict(self, "item")
|
||||
self.location_names = self.NameLookupDict(self, "location")
|
||||
self.versions = {}
|
||||
self.checksums = {}
|
||||
|
||||
self.jsontotextparser = JSONtoTextParser(self)
|
||||
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
||||
@@ -398,6 +412,7 @@ class CommonContext:
|
||||
await self.server.socket.close()
|
||||
if self.server_task is not None:
|
||||
await self.server_task
|
||||
self.ui.update_hints()
|
||||
|
||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
||||
""" `msgs` JSON serializable """
|
||||
@@ -429,7 +444,10 @@ class CommonContext:
|
||||
self.auth = await self.console_input()
|
||||
|
||||
async def send_connect(self, **kwargs: typing.Any) -> None:
|
||||
""" send `Connect` packet to log in to server """
|
||||
"""
|
||||
Send a `Connect` packet to log in to the server,
|
||||
additional keyword args can override any value in the connection packet
|
||||
"""
|
||||
payload = {
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
@@ -439,6 +457,7 @@ class CommonContext:
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
await self.send_msgs([payload])
|
||||
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
|
||||
|
||||
async def console_input(self) -> str:
|
||||
if self.ui:
|
||||
@@ -459,6 +478,7 @@ class CommonContext:
|
||||
return False
|
||||
|
||||
def slot_concerns_self(self, slot) -> bool:
|
||||
"""Helper function to abstract player groups, should be used instead of checking slot == self.slot directly."""
|
||||
if slot == self.slot:
|
||||
return True
|
||||
if slot in self.slot_info:
|
||||
@@ -466,6 +486,7 @@ class CommonContext:
|
||||
return False
|
||||
|
||||
def is_echoed_chat(self, print_json_packet: dict) -> bool:
|
||||
"""Helper function for filtering out messages sent by self."""
|
||||
return print_json_packet.get("type", "") == "Chat" \
|
||||
and print_json_packet.get("team", None) == self.team \
|
||||
and print_json_packet.get("slot", None) == self.slot
|
||||
@@ -497,13 +518,14 @@ class CommonContext:
|
||||
"""Gets called before sending a Say to the server from the user.
|
||||
Returned text is sent, or sending is aborted if None is returned."""
|
||||
return text
|
||||
|
||||
|
||||
def on_ui_command(self, text: str) -> None:
|
||||
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
|
||||
The command processor is still called; this is just intended for command echoing."""
|
||||
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
|
||||
|
||||
def update_permissions(self, permissions: typing.Dict[str, int]):
|
||||
"""Internal method to parse and save server permissions from RoomInfo"""
|
||||
for permission_name, permission_flag in permissions.items():
|
||||
try:
|
||||
flag = Permission(permission_flag)
|
||||
@@ -530,7 +552,14 @@ class CommonContext:
|
||||
await self.ui_task
|
||||
if self.input_task:
|
||||
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
|
||||
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
||||
remote_date_package_versions: typing.Dict[str, int],
|
||||
@@ -552,26 +581,34 @@ class CommonContext:
|
||||
needed_updates.add(game)
|
||||
continue
|
||||
|
||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
||||
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||
# no action required if local version is new enough
|
||||
if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
|
||||
or remote_checksum != local_checksum:
|
||||
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
||||
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)
|
||||
cached_version: int = self.versions.get(game, 0)
|
||||
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
||||
# no action required if cached version is new enough
|
||||
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
|
||||
or remote_checksum != cached_checksum:
|
||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
||||
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
|
||||
and remote_checksum == local_checksum):
|
||||
self.update_game(network_data_package["games"][game], game)
|
||||
else:
|
||||
self.update_game(cached_game, game)
|
||||
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
||||
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:
|
||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
|
||||
|
||||
def update_game(self, game_package: dict, game: str):
|
||||
self.item_names.update_game(game, game_package["item_name_to_id"])
|
||||
self.location_names.update_game(game, game_package["location_name_to_id"])
|
||||
self.versions[game] = game_package.get("version", 0)
|
||||
self.checksums[game] = game_package.get("checksum")
|
||||
|
||||
def update_data_package(self, data_package: dict):
|
||||
for game, game_data in data_package["games"].items():
|
||||
@@ -613,6 +650,7 @@ class CommonContext:
|
||||
logger.info(f"DeathLink: Received from {data['source']}")
|
||||
|
||||
async def send_death(self, death_text: str = ""):
|
||||
"""Helper function to send a deathlink using death_text as the unique death cause string."""
|
||||
if self.server and self.server.socket:
|
||||
logger.info("DeathLink: Sending death to your friends...")
|
||||
self.last_death_link = time.time()
|
||||
@@ -626,6 +664,7 @@ class CommonContext:
|
||||
}])
|
||||
|
||||
async def update_death_link(self, death_link: bool):
|
||||
"""Helper function to set Death Link connection tag on/off and update the connection if already connected."""
|
||||
old_tags = self.tags.copy()
|
||||
if death_link:
|
||||
self.tags.add("DeathLink")
|
||||
@@ -635,7 +674,7 @@ class CommonContext:
|
||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||
|
||||
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
|
||||
"""Displays an error messagebox"""
|
||||
"""Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework"""
|
||||
if not self.ui:
|
||||
return None
|
||||
title = title or "Error"
|
||||
@@ -679,6 +718,11 @@ class CommonContext:
|
||||
|
||||
def run_cli(self):
|
||||
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
|
||||
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
|
||||
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
|
||||
@@ -987,6 +1031,7 @@ async def console_loop(ctx: CommonContext):
|
||||
|
||||
|
||||
def get_base_parser(description: typing.Optional[str] = None):
|
||||
"""Base argument parser to be reused for components subclassing off of CommonClient"""
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||
@@ -1037,6 +1082,7 @@ def run_as_textclient(*args):
|
||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||
args = parser.parse_args(args)
|
||||
|
||||
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
|
||||
if args.url:
|
||||
url = urllib.parse.urlparse(args.url)
|
||||
if url.scheme == "archipelago":
|
||||
@@ -1048,6 +1094,7 @@ def run_as_textclient(*args):
|
||||
else:
|
||||
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
||||
|
||||
# use colorama to display colored text highlighting on windows
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main(args))
|
||||
|
||||
59
Fill.py
@@ -36,7 +36,8 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
||||
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,
|
||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
||||
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
|
||||
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True,
|
||||
name: str = "Unknown") -> None:
|
||||
"""
|
||||
:param multiworld: Multiworld to be filled.
|
||||
:param base_state: State assumed before fill.
|
||||
@@ -63,14 +64,22 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
placed = 0
|
||||
|
||||
while any(reachable_items.values()) and locations:
|
||||
# grab one item per player
|
||||
items_to_place = [items.pop()
|
||||
for items in reachable_items.values() if items]
|
||||
if one_item_per_player:
|
||||
# grab one item per player
|
||||
items_to_place = [items.pop()
|
||||
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 p, pool_item in enumerate(item_pool):
|
||||
if pool_item is item:
|
||||
item_pool.pop(p)
|
||||
break
|
||||
|
||||
maximum_exploration_state = sweep_from_pool(
|
||||
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
|
||||
if single_player_placement else None)
|
||||
@@ -480,7 +489,8 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if prioritylocations:
|
||||
# "priority fill"
|
||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
|
||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||
name="Priority", one_item_per_player=False)
|
||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
@@ -978,15 +988,32 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
multiworld.random.shuffle(items)
|
||||
count = 0
|
||||
err: typing.List[str] = []
|
||||
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
|
||||
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
|
||||
claimed_indices: typing.Set[typing.Optional[int]] = set()
|
||||
for item_name in items:
|
||||
item = multiworld.worlds[player].create_item(item_name)
|
||||
index_to_delete: typing.Optional[int] = None
|
||||
if from_pool:
|
||||
try:
|
||||
# If from_pool, try to find an existing item with this name & player in the itempool and use it
|
||||
index_to_delete, item = next(
|
||||
(i, item) for i, item in enumerate(multiworld.itempool)
|
||||
if item.player == player and item.name == item_name and i not in claimed_indices
|
||||
)
|
||||
except StopIteration:
|
||||
warn(
|
||||
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
||||
placement['force'])
|
||||
item = multiworld.worlds[player].create_item(item_name)
|
||||
else:
|
||||
item = multiworld.worlds[player].create_item(item_name)
|
||||
|
||||
for location in reversed(candidates):
|
||||
if (location.address is None) == (item.code is None): # either both None or both not None
|
||||
if not location.item:
|
||||
if location.item_rule(item):
|
||||
if location.can_fill(multiworld.state, item, False):
|
||||
successful_pairs.append((item, location))
|
||||
successful_pairs.append((index_to_delete, item, location))
|
||||
claimed_indices.add(index_to_delete)
|
||||
candidates.remove(location)
|
||||
count = count + 1
|
||||
break
|
||||
@@ -998,6 +1025,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
||||
else:
|
||||
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
|
||||
|
||||
if count == maxcount:
|
||||
break
|
||||
if count < placement['count']['min']:
|
||||
@@ -1005,17 +1033,16 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
failed(
|
||||
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
|
||||
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)
|
||||
location.locked = True
|
||||
logging.debug(f"Plando placed {item} at {location}")
|
||||
if from_pool:
|
||||
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'])
|
||||
if index is not None: # If this item is from_pool and was found in the pool, remove it.
|
||||
multiworld.itempool.pop(index)
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
|
||||
15
Generate.py
@@ -110,11 +110,18 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
player_files = {}
|
||||
for file in os.scandir(args.player_files_path):
|
||||
fname = file.name
|
||||
if file.is_file() and not fname.startswith(".") and \
|
||||
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
|
||||
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)
|
||||
try:
|
||||
weights_cache[fname] = read_weights_yamls(path)
|
||||
weights_for_file = []
|
||||
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:
|
||||
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
||||
|
||||
@@ -453,6 +460,10 @@ 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.")
|
||||
|
||||
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:
|
||||
from worlds import failed_world_loads
|
||||
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
|
||||
|
||||
86
Launcher.py
@@ -22,20 +22,21 @@ from os.path import isfile
|
||||
from shutil import which
|
||||
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__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
|
||||
is_windows, is_macos, is_linux
|
||||
import settings
|
||||
import Utils
|
||||
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():
|
||||
file = settings.get_settings().filename
|
||||
s = settings.get_settings()
|
||||
file = s.filename
|
||||
s.save()
|
||||
assert file, "host.yaml missing"
|
||||
if is_linux:
|
||||
exe = which('sensible-editor') or which('gedit') or \
|
||||
@@ -102,6 +103,7 @@ components.extend([
|
||||
Component("Open host.yaml", func=open_host_yaml),
|
||||
Component("Open Patch", func=open_patch),
|
||||
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("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||
Component("Browse Files", func=browse_files),
|
||||
@@ -124,12 +126,13 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||
elif component.display_name == "Text Client":
|
||||
text_client_component = component
|
||||
|
||||
from kvui import App, Button, BoxLayout, Label, Clock, Window
|
||||
if client_component is None:
|
||||
run_component(text_client_component, *launch_args)
|
||||
return
|
||||
|
||||
from kvui import App, Button, BoxLayout, Label, Window
|
||||
|
||||
class Popup(App):
|
||||
timer_label: Label
|
||||
remaining_time: Optional[int]
|
||||
|
||||
def __init__(self):
|
||||
self.title = "Connect to Multiworld"
|
||||
self.icon = r"data/icon.png"
|
||||
@@ -137,47 +140,29 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||
|
||||
def build(self):
|
||||
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))
|
||||
|
||||
if client_component is None:
|
||||
self.remaining_time = 7
|
||||
label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
|
||||
f"Launching Text Client in 7 seconds...")
|
||||
self.timer_label = Label(text=label_text)
|
||||
layout.add_widget(self.timer_label)
|
||||
Clock.schedule_interval(self.update_label, 1)
|
||||
else:
|
||||
layout.add_widget(Label(text="Select client to open and connect with."))
|
||||
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
|
||||
text_client_button = Button(
|
||||
text=text_client_component.display_name,
|
||||
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
||||
)
|
||||
button_row.add_widget(text_client_button)
|
||||
|
||||
text_client_button = Button(
|
||||
text=text_client_component.display_name,
|
||||
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
||||
)
|
||||
button_row.add_widget(text_client_button)
|
||||
game_client_button = Button(
|
||||
text=client_component.display_name,
|
||||
on_release=lambda *args: run_component(client_component, *launch_args)
|
||||
)
|
||||
button_row.add_widget(game_client_button)
|
||||
|
||||
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)
|
||||
layout.add_widget(button_row)
|
||||
|
||||
return layout
|
||||
|
||||
def update_label(self, dt):
|
||||
if self.remaining_time > 1:
|
||||
# countdown the timer and string replace the number
|
||||
self.remaining_time -= 1
|
||||
self.timer_label.text = self.timer_label.text.replace(
|
||||
str(self.remaining_time + 1), str(self.remaining_time)
|
||||
)
|
||||
else:
|
||||
# our timer is finished so launch text client and close down
|
||||
run_component(text_client_component, *launch_args)
|
||||
Clock.unschedule(self.update_label)
|
||||
App.get_running_app().stop()
|
||||
Window.close()
|
||||
def _stop(self, *largs):
|
||||
# see run_gui Launcher _stop comment for details
|
||||
self.root_window.close()
|
||||
super()._stop(*largs)
|
||||
|
||||
Popup().run()
|
||||
|
||||
@@ -239,9 +224,8 @@ refresh_components: Optional[Callable[[], None]] = None
|
||||
|
||||
|
||||
def run_gui():
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage
|
||||
from kivy.core.window import Window
|
||||
from kivy.uix.image import AsyncImage
|
||||
from kivy.uix.relativelayout import RelativeLayout
|
||||
|
||||
class Launcher(App):
|
||||
@@ -252,7 +236,7 @@ def run_gui():
|
||||
_client_layout: Optional[ScrollBox] = None
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
self.title = self.base_title
|
||||
self.title = self.base_title + " " + Utils.__version__
|
||||
self.ctx = ctx
|
||||
self.icon = r"data/icon.png"
|
||||
super().__init__()
|
||||
@@ -274,8 +258,8 @@ def run_gui():
|
||||
button.component = component
|
||||
button.bind(on_release=self.component_action)
|
||||
if component.icon != "icon":
|
||||
image = AsyncImage(source=icon_paths[component.icon],
|
||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
||||
image = ApAsyncImage(source=icon_paths[component.icon],
|
||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
||||
box_layout = RelativeLayout(size_hint_y=None, height=40)
|
||||
box_layout.add_widget(button)
|
||||
box_layout.add_widget(image)
|
||||
|
||||
91
Main.py
@@ -153,45 +153,38 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
# 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.
|
||||
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
|
||||
new_items: List[Item] = []
|
||||
old_items: List[Item] = []
|
||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||
player: getattr(multiworld.worlds[player].options,
|
||||
"start_inventory_from_pool",
|
||||
StartInventoryPool({})).value.copy()
|
||||
for player in multiworld.player_ids
|
||||
}
|
||||
for player, items in depletion_pool.items():
|
||||
player_world: AutoWorld.World = multiworld.worlds[player]
|
||||
for count in items.values():
|
||||
for _ in range(count):
|
||||
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):
|
||||
target -= 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:
|
||||
old_items.append(item)
|
||||
fallback_inventory = StartInventoryPool({})
|
||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
||||
for player in multiworld.player_ids
|
||||
}
|
||||
target_per_player = {
|
||||
player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items
|
||||
}
|
||||
|
||||
# leftovers?
|
||||
if target:
|
||||
for player, remaining_items in depletion_pool.items():
|
||||
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
||||
if remaining_items:
|
||||
logger.warning(f"{multiworld.get_player_name(player)}"
|
||||
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
||||
# find all filler we generated for the current player and remove until it matches
|
||||
removables = [item for item in new_items if item.player == player]
|
||||
for _ in range(sum(remaining_items.values())):
|
||||
new_items.remove(removables.pop())
|
||||
assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change."
|
||||
multiworld.itempool[:] = new_items + old_items
|
||||
if target_per_player:
|
||||
new_itempool: List[Item] = []
|
||||
|
||||
# Make new itempool with start_inventory_from_pool items removed
|
||||
for item in multiworld.itempool:
|
||||
if depletion_pool[item.player].get(item.name, 0):
|
||||
depletion_pool[item.player][item.name] -= 1
|
||||
else:
|
||||
new_itempool.append(item)
|
||||
|
||||
# Create filler in place of the removed items, warn if any items couldn't be found in the multiworld itempool
|
||||
for player, target in target_per_player.items():
|
||||
unfound_items = {item: count for item, count in depletion_pool[player].items() if count}
|
||||
|
||||
if unfound_items:
|
||||
player_name = multiworld.get_player_name(player)
|
||||
logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}")
|
||||
|
||||
needed_items = target_per_player[player] - sum(unfound_items.values())
|
||||
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)]
|
||||
|
||||
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
|
||||
multiworld.itempool[:] = new_itempool
|
||||
|
||||
multiworld.link_items()
|
||||
|
||||
@@ -249,6 +242,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
def write_multidata():
|
||||
import NetUtils
|
||||
from NetUtils import HintStatus
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
games = {}
|
||||
@@ -273,10 +267,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
for slot in multiworld.player_ids:
|
||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
||||
|
||||
def precollect_hint(location):
|
||||
def precollect_hint(location: Location, auto_status: HintStatus):
|
||||
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||
location.item.code, False, entrance, location.item.flags)
|
||||
location.item.code, False, entrance, location.item.flags, auto_status)
|
||||
precollected_hints[location.player].add(hint)
|
||||
if location.item.player not in multiworld.groups:
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
@@ -289,19 +283,22 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
if type(location.address) == int:
|
||||
assert location.item.code is not None, "item code None should be event, " \
|
||||
"location.address should then also be None. Location: " \
|
||||
f" {location}"
|
||||
f" {location}, Item: {location.item}"
|
||||
assert location.address not in locations_data[location.player], (
|
||||
f"Locations with duplicate address. {location} and "
|
||||
f"{locations_data[location.player][location.address]}")
|
||||
locations_data[location.player][location.address] = \
|
||||
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:
|
||||
precollect_hint(location)
|
||||
if not location.item.trap: # Unspecified status for location hints, except traps
|
||||
auto_status = HintStatus.HINT_UNSPECIFIED
|
||||
precollect_hint(location, auto_status)
|
||||
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
|
||||
precollect_hint(location)
|
||||
precollect_hint(location, auto_status)
|
||||
elif any([location.item.name in multiworld.worlds[player].options.start_hints
|
||||
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
|
||||
precollect_hint(location)
|
||||
precollect_hint(location, auto_status)
|
||||
|
||||
# embedded data package
|
||||
data_package = {
|
||||
@@ -313,11 +310,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
# get spheres -> filter address==None -> skip empty
|
||||
spheres: List[Dict[int, Set[int]]] = []
|
||||
for sphere in multiworld.get_spheres():
|
||||
for sphere in multiworld.get_sendable_spheres():
|
||||
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
|
||||
for sphere_location in sphere:
|
||||
if type(sphere_location.address) is int:
|
||||
current_sphere[sphere_location.player].add(sphere_location.address)
|
||||
current_sphere[sphere_location.player].add(sphere_location.address)
|
||||
|
||||
if current_sphere:
|
||||
spheres.append(dict(current_sphere))
|
||||
@@ -338,6 +334,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
"seed_name": multiworld.seed_name,
|
||||
"spheres": spheres,
|
||||
"datapackage": data_package,
|
||||
"race_mode": int(multiworld.is_race),
|
||||
}
|
||||
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
||||
|
||||
|
||||
@@ -5,8 +5,15 @@ import multiprocessing
|
||||
import warnings
|
||||
|
||||
|
||||
if sys.version_info < (3, 8, 6):
|
||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
||||
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
|
||||
# Official micro version updates. This should match the number in docs/running from source.md.
|
||||
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)
|
||||
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
||||
|
||||
236
MultiServer.py
@@ -15,6 +15,7 @@ import math
|
||||
import operator
|
||||
import pickle
|
||||
import random
|
||||
import shlex
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
@@ -40,7 +41,8 @@ import NetUtils
|
||||
import Utils
|
||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||
SlotType, LocationStore
|
||||
SlotType, LocationStore, Hint, HintStatus
|
||||
from BaseClasses import ItemClassification
|
||||
|
||||
min_client_version = Version(0, 1, 6)
|
||||
colorama.init()
|
||||
@@ -184,11 +186,9 @@ class Context:
|
||||
slot_info: typing.Dict[int, NetworkSlot]
|
||||
generator_version = Version(0, 0, 0)
|
||||
checksums: typing.Dict[str, str]
|
||||
item_names: typing.Dict[str, typing.Dict[int, str]] = (
|
||||
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')))
|
||||
item_names: typing.Dict[str, typing.Dict[int, str]]
|
||||
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||
location_names: typing.Dict[str, typing.Dict[int, str]] = (
|
||||
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')))
|
||||
location_names: typing.Dict[str, typing.Dict[int, 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_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||
@@ -197,7 +197,6 @@ class Context:
|
||||
""" each sphere is { player: { location_id, ... } } """
|
||||
logger: logging.Logger
|
||||
|
||||
|
||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||
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,
|
||||
@@ -230,7 +229,7 @@ class Context:
|
||||
self.hint_cost = hint_cost
|
||||
self.location_check_points = location_check_points
|
||||
self.hints_used = collections.defaultdict(int)
|
||||
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
|
||||
self.hints: typing.Dict[team_slot, typing.Set[Hint]] = collections.defaultdict(set)
|
||||
self.release_mode: str = release_mode
|
||||
self.remaining_mode: str = remaining_mode
|
||||
self.collect_mode: str = collect_mode
|
||||
@@ -268,6 +267,10 @@ class Context:
|
||||
self.location_name_groups = {}
|
||||
self.all_item_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._load_game_data()
|
||||
@@ -427,6 +430,8 @@ class Context:
|
||||
use_embedded_server_options: bool):
|
||||
|
||||
self.read_data = {}
|
||||
# there might be a better place to put this.
|
||||
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
|
||||
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
||||
if mdata_ver > version_tuple:
|
||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
||||
@@ -652,13 +657,29 @@ class Context:
|
||||
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
||||
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:
|
||||
if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
|
||||
self.hints[hint_team, hint_slot] = {
|
||||
hint.re_check(self, hint_team) for hint in
|
||||
self.hints[hint_team, hint_slot]
|
||||
}
|
||||
if team != hint_team and team is not None:
|
||||
continue # Check specified team only, all if team is None
|
||||
if slot != hint_slot and slot is not None:
|
||||
continue # Check specified slot only, all if slot is None
|
||||
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):
|
||||
self.recheck_hints(team, slot)
|
||||
@@ -707,7 +728,7 @@ class Context:
|
||||
else:
|
||||
return self.player_names[team, slot]
|
||||
|
||||
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
|
||||
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
|
||||
recipients: typing.Sequence[int] = None):
|
||||
"""Send and remember hints."""
|
||||
if only_new:
|
||||
@@ -723,15 +744,15 @@ class Context:
|
||||
if not hint.local and data not in concerns[hint.finding_player]:
|
||||
concerns[hint.finding_player].append(data)
|
||||
# remember hints in all cases
|
||||
if not hint.found:
|
||||
# since hints are bidirectional, finding player and receiving player,
|
||||
# we can check once if hint already exists
|
||||
if hint not in self.hints[team, hint.finding_player]:
|
||||
self.hints[team, hint.finding_player].add(hint)
|
||||
new_hint_events.add(hint.finding_player)
|
||||
for player in self.slot_set(hint.receiving_player):
|
||||
self.hints[team, player].add(hint)
|
||||
new_hint_events.add(player)
|
||||
|
||||
# since hints are bidirectional, finding player and receiving player,
|
||||
# we can check once if hint already exists
|
||||
if hint not in self.hints[team, hint.finding_player]:
|
||||
self.hints[team, hint.finding_player].add(hint)
|
||||
new_hint_events.add(hint.finding_player)
|
||||
for player in self.slot_set(hint.receiving_player):
|
||||
self.hints[team, player].add(hint)
|
||||
new_hint_events.add(player)
|
||||
|
||||
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
||||
for slot in new_hint_events:
|
||||
@@ -745,6 +766,17 @@ class Context:
|
||||
for client in clients:
|
||||
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:
|
||||
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"
|
||||
|
||||
def on_goal_achieved(self, client: Client):
|
||||
@@ -943,9 +975,13 @@ 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])
|
||||
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 ""
|
||||
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
|
||||
status_text = (
|
||||
" 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'}" \
|
||||
f"{tag_text}{goal_text} {completion_text}"
|
||||
f"{tag_text}{status_text} {completion_text}"
|
||||
return text
|
||||
|
||||
|
||||
@@ -1046,14 +1082,15 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
||||
"hint_points": get_slot_points(ctx, team, slot),
|
||||
"checked_locations": new_locations, # send back new checks only
|
||||
}])
|
||||
old_hints = ctx.hints[team, slot].copy()
|
||||
ctx.recheck_hints(team, slot)
|
||||
if old_hints != ctx.hints[team, slot]:
|
||||
ctx.on_changed_hints(team, slot)
|
||||
updated_slots: typing.Set[tuple[int, int]] = set()
|
||||
ctx.recheck_hints(team, slot, updated_slots)
|
||||
for hint_team, hint_slot in updated_slots:
|
||||
ctx.on_changed_hints(hint_team, hint_slot)
|
||||
ctx.save()
|
||||
|
||||
|
||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
|
||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
|
||||
-> typing.List[Hint]:
|
||||
hints = []
|
||||
slots: typing.Set[int] = {slot}
|
||||
for group_id, group in ctx.groups.items():
|
||||
@@ -1063,31 +1100,58 @@ 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]
|
||||
for finding_player, location_id, item_id, receiving_player, item_flags \
|
||||
in ctx.locations.find_item(slots, seeked_item_id):
|
||||
found = location_id in ctx.location_checks[team, finding_player]
|
||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||
item_flags))
|
||||
prev_hint = ctx.get_hint(team, slot, location_id)
|
||||
if prev_hint:
|
||||
hints.append(prev_hint)
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
|
||||
-> typing.List[Hint]:
|
||||
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
||||
return collect_hint_location_id(ctx, team, slot, seeked_location)
|
||||
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
|
||||
|
||||
|
||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]:
|
||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
|
||||
-> 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))
|
||||
if any(result):
|
||||
item_id, receiving_player, item_flags = result
|
||||
|
||||
found = seeked_location in ctx.location_checks[team, slot]
|
||||
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
||||
return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags)]
|
||||
new_status = auto_status
|
||||
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 []
|
||||
|
||||
|
||||
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
||||
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)",
|
||||
}
|
||||
def format_hint(ctx: Context, team: int, hint: Hint) -> str:
|
||||
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
||||
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
|
||||
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
|
||||
@@ -1095,7 +1159,8 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
||||
|
||||
if 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):
|
||||
@@ -1150,7 +1215,10 @@ class CommandProcessor(metaclass=CommandMeta):
|
||||
if not raw:
|
||||
return
|
||||
try:
|
||||
command = raw.split()
|
||||
try:
|
||||
command = shlex.split(raw, comments=False)
|
||||
except ValueError: # most likely: "ValueError: No closing quotation"
|
||||
command = raw.split()
|
||||
basecommand = command[0]
|
||||
if basecommand[0] == self.marker:
|
||||
method = self.commands.get(basecommand[1:].lower(), None)
|
||||
@@ -1496,7 +1564,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||
points_available = get_client_points(self.ctx, self.client)
|
||||
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:
|
||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||
self.ctx.hints[self.client.team, self.client.slot]}
|
||||
@@ -1522,9 +1590,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||
hints = []
|
||||
elif not for_location:
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||
else:
|
||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||
|
||||
else:
|
||||
game = self.ctx.games[self.client.slot]
|
||||
@@ -1544,16 +1612,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
hints = []
|
||||
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
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
|
||||
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)
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
||||
hints = []
|
||||
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
||||
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))
|
||||
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
|
||||
else: # location name
|
||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||
|
||||
else:
|
||||
self.output(response)
|
||||
@@ -1825,13 +1893,56 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
|
||||
target_item, target_player, flags = ctx.locations[client.slot][location]
|
||||
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))
|
||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
||||
if locs and create_as_hint:
|
||||
ctx.save()
|
||||
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':
|
||||
update_client_status(ctx, client, args["status"])
|
||||
|
||||
@@ -1953,8 +2064,10 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
|
||||
def _cmd_exit(self) -> bool:
|
||||
"""Shutdown the server"""
|
||||
self.ctx.server.ws_server.close()
|
||||
self.ctx.exit_event.set()
|
||||
try:
|
||||
self.ctx.server.ws_server.close()
|
||||
finally:
|
||||
self.ctx.exit_event.set()
|
||||
return True
|
||||
|
||||
@mark_raw
|
||||
@@ -2134,9 +2247,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
hints = []
|
||||
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
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
|
||||
else: # item name or id
|
||||
hints = collect_hints(self.ctx, team, slot, item)
|
||||
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
|
||||
|
||||
if hints:
|
||||
self.ctx.notify_hints(team, hints)
|
||||
@@ -2170,14 +2283,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
|
||||
if usable:
|
||||
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]:
|
||||
hints = []
|
||||
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):
|
||||
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:
|
||||
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:
|
||||
self.ctx.notify_hints(team, hints)
|
||||
else:
|
||||
@@ -2267,6 +2383,8 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
|
||||
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
||||
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('--hint_cost', default=defaults["hint_cost"], type=int)
|
||||
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
|
||||
@@ -2347,7 +2465,9 @@ def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLConte
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace):
|
||||
Utils.init_logging("Server", loglevel=args.loglevel.lower())
|
||||
Utils.init_logging(name="Server",
|
||||
loglevel=args.loglevel.lower(),
|
||||
add_timestamp=args.logtime)
|
||||
|
||||
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,
|
||||
|
||||
45
NetUtils.py
@@ -29,6 +29,14 @@ class ClientStatus(ByValue, enum.IntEnum):
|
||||
CLIENT_GOAL = 30
|
||||
|
||||
|
||||
class HintStatus(enum.IntEnum):
|
||||
HINT_FOUND = 0
|
||||
HINT_UNSPECIFIED = 1
|
||||
HINT_NO_PRIORITY = 10
|
||||
HINT_AVOID = 20
|
||||
HINT_PRIORITY = 30
|
||||
|
||||
|
||||
class SlotType(ByValue, enum.IntFlag):
|
||||
spectator = 0b00
|
||||
player = 0b01
|
||||
@@ -224,7 +232,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
|
||||
def _handle_player_id(self, node: JSONMessagePart):
|
||||
player = int(node["text"])
|
||||
node["color"] = 'magenta' if player == self.ctx.slot else 'yellow'
|
||||
node["color"] = 'magenta' if self.ctx.slot_concerns_self(player) else 'yellow'
|
||||
node["text"] = self.ctx.player_names[player]
|
||||
return self._handle_color(node)
|
||||
|
||||
@@ -297,6 +305,20 @@ 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})
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
class Hint(typing.NamedTuple):
|
||||
receiving_player: int
|
||||
finding_player: int
|
||||
@@ -305,14 +327,21 @@ class Hint(typing.NamedTuple):
|
||||
found: bool
|
||||
entrance: str = ""
|
||||
item_flags: int = 0
|
||||
status: HintStatus = HintStatus.HINT_UNSPECIFIED
|
||||
|
||||
def re_check(self, ctx, team) -> Hint:
|
||||
if self.found:
|
||||
if self.found and self.status == HintStatus.HINT_FOUND:
|
||||
return self
|
||||
found = self.location in ctx.location_checks[team, self.finding_player]
|
||||
if found:
|
||||
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
|
||||
self.item_flags)
|
||||
return self._replace(found=found, status=HintStatus.HINT_FOUND)
|
||||
return self
|
||||
|
||||
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
|
||||
|
||||
def __hash__(self):
|
||||
@@ -334,10 +363,8 @@ class Hint(typing.NamedTuple):
|
||||
else:
|
||||
add_json_text(parts, "'s World")
|
||||
add_json_text(parts, ". ")
|
||||
if self.found:
|
||||
add_json_text(parts, "(found)", type="color", color="green")
|
||||
else:
|
||||
add_json_text(parts, "(not found)", type="color", color="red")
|
||||
add_json_text(parts, status_names.get(self.status, "(unknown)"), type="color",
|
||||
color=status_colors.get(self.status, "red"))
|
||||
|
||||
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
||||
"receiving": self.receiving_player,
|
||||
@@ -383,6 +410,8 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
||||
checked = state[team, slot]
|
||||
if not checked:
|
||||
# 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 [location_id for
|
||||
location_id in self[slot] if
|
||||
|
||||
55
Options.py
@@ -15,7 +15,7 @@ from dataclasses import dataclass
|
||||
from schema import And, Optional, Or, Schema
|
||||
from typing_extensions import Self
|
||||
|
||||
from Utils import get_fuzzy_results, is_iterable_except_str, output_path
|
||||
from Utils import get_file_safe_name, get_fuzzy_results, is_iterable_except_str, output_path
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from BaseClasses import MultiWorld, PlandoOptions
|
||||
@@ -754,7 +754,7 @@ class NamedRange(Range):
|
||||
elif value > self.range_end and value not in self.special_range_names.values():
|
||||
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
|
||||
f"and is also not one of the supported named special values: {self.special_range_names}")
|
||||
|
||||
|
||||
# See docstring
|
||||
for key in self.special_range_names:
|
||||
if key != key.lower():
|
||||
@@ -828,7 +828,10 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
||||
f"is not a valid location name from {world.game}. "
|
||||
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]):
|
||||
default = {}
|
||||
supports_weighting = False
|
||||
@@ -860,6 +863,8 @@ class ItemDict(OptionDict):
|
||||
verify_item_name = True
|
||||
|
||||
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()):
|
||||
raise Exception("Cannot have non-positive item counts.")
|
||||
super(ItemDict, self).__init__(value)
|
||||
@@ -1175,7 +1180,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
||||
class Accessibility(Choice):
|
||||
"""
|
||||
Set rules for reachability of your items/locations.
|
||||
|
||||
|
||||
**Full:** ensure everything can be reached and acquired.
|
||||
|
||||
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
||||
@@ -1193,7 +1198,7 @@ class Accessibility(Choice):
|
||||
class ItemsAccessibility(Accessibility):
|
||||
"""
|
||||
Set rules for reachability of your items/locations.
|
||||
|
||||
|
||||
**Full:** ensure everything can be reached and acquired.
|
||||
|
||||
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
||||
@@ -1244,12 +1249,16 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
||||
progression_balancing: ProgressionBalancing
|
||||
accessibility: Accessibility
|
||||
|
||||
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
|
||||
def as_dict(self,
|
||||
*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]
|
||||
|
||||
:param option_names: names of the options to return
|
||||
: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."
|
||||
option_results = {}
|
||||
@@ -1271,6 +1280,8 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
||||
value = getattr(self, option_name).value
|
||||
if isinstance(value, set):
|
||||
value = sorted(value)
|
||||
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
|
||||
value = bool(value)
|
||||
option_results[display_name] = value
|
||||
else:
|
||||
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
||||
@@ -1460,22 +1471,26 @@ it.
|
||||
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
|
||||
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
|
||||
"""Generates and returns a dictionary for the option groups of a specified world."""
|
||||
option_groups = {option: option_group.name
|
||||
for option_group in world.web.option_groups
|
||||
for option in option_group.options}
|
||||
option_to_name = {option: option_name for option_name, option in world.options_dataclass.type_hints.items()}
|
||||
|
||||
ordered_groups = {group.name: group.options for group in world.web.option_groups}
|
||||
|
||||
# add a default option group for uncategorized options to get thrown into
|
||||
ordered_groups = ["Game Options"]
|
||||
[ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
|
||||
grouped_options = {group: {} for group in ordered_groups}
|
||||
for option_name, option in world.options_dataclass.type_hints.items():
|
||||
if visibility_level & option.visibility:
|
||||
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
|
||||
if "Game Options" not in ordered_groups:
|
||||
grouped_options = set(option for group in ordered_groups.values() for option in group)
|
||||
ungrouped_options = [option for option in option_to_name if option not in grouped_options]
|
||||
# only add the game options group if we have ungrouped options
|
||||
if ungrouped_options:
|
||||
ordered_groups = {**{"Game Options": ungrouped_options}, **ordered_groups}
|
||||
|
||||
# if the world doesn't have any ungrouped options, this group will be empty so just remove it
|
||||
if not grouped_options["Game Options"]:
|
||||
del grouped_options["Game Options"]
|
||||
|
||||
return grouped_options
|
||||
return {
|
||||
group: {
|
||||
option_to_name[option]: option
|
||||
for option in group_options
|
||||
if (visibility_level in option.visibility and option in option_to_name)
|
||||
}
|
||||
for group, group_options in ordered_groups.items()
|
||||
}
|
||||
|
||||
|
||||
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
||||
@@ -1531,7 +1546,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
|
||||
del file_data
|
||||
|
||||
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||
f.write(res)
|
||||
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ Currently, the following games are supported:
|
||||
* Kingdom Hearts 1
|
||||
* Mega Man 2
|
||||
* Yacht Dice
|
||||
* Faxanadu
|
||||
* Saving Princess
|
||||
* Castlevania: Circle of the Moon
|
||||
|
||||
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
|
||||
|
||||
16
SNIClient.py
@@ -633,7 +633,13 @@ async def game_watcher(ctx: SNIContext) -> None:
|
||||
if not ctx.client_handler:
|
||||
continue
|
||||
|
||||
rom_validated = await ctx.client_handler.validate_rom(ctx)
|
||||
try:
|
||||
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):
|
||||
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
|
||||
@@ -649,7 +655,13 @@ async def game_watcher(ctx: SNIContext) -> None:
|
||||
|
||||
perf_counter = time.perf_counter()
|
||||
|
||||
await ctx.client_handler.game_watcher(ctx)
|
||||
try:
|
||||
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:
|
||||
|
||||
81
Utils.py
@@ -18,8 +18,8 @@ import warnings
|
||||
|
||||
from argparse import Namespace
|
||||
from settings import Settings, get_settings
|
||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
||||
from typing_extensions import TypeGuard
|
||||
from time import sleep
|
||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
|
||||
from yaml import load, load_all, dump
|
||||
|
||||
try:
|
||||
@@ -31,6 +31,7 @@ if typing.TYPE_CHECKING:
|
||||
import tkinter
|
||||
import pathlib
|
||||
from BaseClasses import Region
|
||||
import multiprocessing
|
||||
|
||||
|
||||
def tuplize_version(version: str) -> Version:
|
||||
@@ -46,7 +47,7 @@ class Version(typing.NamedTuple):
|
||||
return ".".join(str(item) for item in self)
|
||||
|
||||
|
||||
__version__ = "0.5.1"
|
||||
__version__ = "0.6.0"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
@@ -420,10 +421,11 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
return getattr(builtins, name)
|
||||
# used by MultiServer -> savegame/multidata
|
||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
|
||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
||||
"SlotType", "NetworkSlot", "HintStatus"}:
|
||||
return getattr(self.net_utils_module, name)
|
||||
# Options and Plando are unpickled by WebHost -> Generate
|
||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||
if module == "worlds.generic" and name == "PlandoItem":
|
||||
if not self.generic_properties_module:
|
||||
self.generic_properties_module = importlib.import_module("worlds.generic")
|
||||
return getattr(self.generic_properties_module, name)
|
||||
@@ -434,7 +436,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
else:
|
||||
mod = importlib.import_module(module)
|
||||
obj = getattr(mod, name)
|
||||
if issubclass(obj, self.options_module.Option):
|
||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)):
|
||||
return obj
|
||||
# Forbid everything else.
|
||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||
@@ -483,9 +485,9 @@ def get_text_after(text: str, start: str) -> str:
|
||||
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, write_mode: str = "w",
|
||||
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||
exception_logger: typing.Optional[str] = None):
|
||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
|
||||
write_mode: str = "w", log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||
add_timestamp: bool = False, exception_logger: typing.Optional[str] = None):
|
||||
import datetime
|
||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||
log_folder = user_path("logs")
|
||||
@@ -512,11 +514,15 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
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)
|
||||
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.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
|
||||
if add_timestamp:
|
||||
stream_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(stream_handler)
|
||||
|
||||
# Relay unhandled exceptions to logger.
|
||||
@@ -551,7 +557,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
import platform
|
||||
logging.info(
|
||||
f"Archipelago ({__version__}) logging initialized"
|
||||
f" on {platform.platform()}"
|
||||
f" on {platform.platform()} process {os.getpid()}"
|
||||
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||
f"{' (frozen)' if is_frozen() else ''}"
|
||||
)
|
||||
@@ -567,6 +573,8 @@ def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"):
|
||||
else:
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
else:
|
||||
sleep(0.01) # non-blocking stream
|
||||
|
||||
from threading import Thread
|
||||
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
|
||||
@@ -664,6 +672,19 @@ def get_input_text_from_response(text: str, command: str) -> typing.Optional[str
|
||||
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 = "") \
|
||||
-> typing.Optional[str]:
|
||||
logging.info(f"Opening file input dialog for {title}.")
|
||||
@@ -693,6 +714,13 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
|
||||
f'This attempt was made because open_filename was used for "{title}".')
|
||||
raise e
|
||||
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:
|
||||
root = tkinter.Tk()
|
||||
except tkinter.TclError:
|
||||
@@ -702,6 +730,12 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
|
||||
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 run(*args: str):
|
||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
||||
@@ -725,9 +759,16 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
||||
import tkinter.filedialog
|
||||
except Exception as e:
|
||||
logging.error('Could not load tkinter, which is likely not installed. '
|
||||
f'This attempt was made because open_filename was used for "{title}".')
|
||||
f'This attempt was made because open_directory was used for "{title}".')
|
||||
raise e
|
||||
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:
|
||||
root = tkinter.Tk()
|
||||
except tkinter.TclError:
|
||||
@@ -740,12 +781,6 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
def run(*args: str):
|
||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
||||
|
||||
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():
|
||||
from kvui import MessageBox
|
||||
MessageBox(title, text, error).open()
|
||||
@@ -824,11 +859,10 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
|
||||
task.add_done_callback(_faf_tasks.discard)
|
||||
|
||||
|
||||
def deprecate(message: str):
|
||||
def deprecate(message: str, add_stacklevels: int = 0):
|
||||
if __debug__:
|
||||
raise Exception(message)
|
||||
import warnings
|
||||
warnings.warn(message)
|
||||
warnings.warn(message, stacklevel=2 + add_stacklevels)
|
||||
|
||||
|
||||
class DeprecateDict(dict):
|
||||
@@ -842,10 +876,9 @@ class DeprecateDict(dict):
|
||||
|
||||
def __getitem__(self, item: Any) -> Any:
|
||||
if self.should_error:
|
||||
deprecate(self.log_message)
|
||||
deprecate(self.log_message, add_stacklevels=1)
|
||||
elif __debug__:
|
||||
import warnings
|
||||
warnings.warn(self.log_message)
|
||||
warnings.warn(self.log_message, stacklevel=2)
|
||||
return super().__getitem__(item)
|
||||
|
||||
|
||||
|
||||
@@ -12,11 +12,12 @@ ModuleUpdate.update()
|
||||
# in case app gets imported by something like gunicorn
|
||||
import Utils
|
||||
import settings
|
||||
from Utils import get_file_safe_name
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from flask import Flask
|
||||
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||
settings.no_gui = True
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
@@ -33,7 +34,7 @@ def get_app() -> "Flask":
|
||||
app.config.from_file(configpath, yaml.safe_load)
|
||||
logging.info(f"Updated config from {configpath}")
|
||||
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument('--config_override', default=None,
|
||||
help="Path to yaml config file that overrules config.yaml.")
|
||||
args = parser.parse_known_args()[0]
|
||||
@@ -71,7 +72,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
||||
shutil.rmtree(base_target_path, ignore_errors=True)
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
target_path = os.path.join(base_target_path, game)
|
||||
target_path = os.path.join(base_target_path, get_file_safe_name(game))
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
if world.zip_path:
|
||||
|
||||
@@ -9,7 +9,7 @@ from flask_compress import Compress
|
||||
from pony.flask import Pony
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from Utils import title_sorted
|
||||
from Utils import title_sorted, get_file_safe_name
|
||||
|
||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||
LOGS_FOLDER = os.path.relpath('logs')
|
||||
@@ -20,6 +20,7 @@ Pony(app)
|
||||
|
||||
app.jinja_env.filters['any'] = any
|
||||
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["GENERATORS"] = 8 # maximum concurrent world gens
|
||||
@@ -38,6 +39,8 @@ app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||
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.
|
||||
app.config["JOB_TIME"] = 600
|
||||
# memory limit for generator processes in bytes
|
||||
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
|
||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||
@@ -84,6 +87,6 @@ def register():
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
# to trigger app routing picking up on it
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
|
||||
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -6,6 +6,7 @@ import multiprocessing
|
||||
import typing
|
||||
from datetime import timedelta, datetime
|
||||
from threading import Event, Thread
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pony.orm import db_session, select, commit
|
||||
@@ -53,7 +54,21 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||
generation.state = STATE_STARTED
|
||||
|
||||
|
||||
def init_db(pony_config: dict):
|
||||
def init_generator(config: dict[str, Any]) -> None:
|
||||
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.generate_mapping()
|
||||
|
||||
@@ -105,8 +120,8 @@ def autogen(config: dict):
|
||||
try:
|
||||
with Locker("autogen"):
|
||||
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
||||
initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
||||
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
||||
with db_session:
|
||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||
|
||||
|
||||
@@ -105,8 +105,9 @@ def roll_options(options: Dict[str, Union[dict, str]],
|
||||
plando_options=plando_options)
|
||||
else:
|
||||
for i, yaml_data in enumerate(yaml_datas):
|
||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||
plando_options=plando_options)
|
||||
if yaml_data is not None:
|
||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||
plando_options=plando_options)
|
||||
except Exception as e:
|
||||
if e.__cause__:
|
||||
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
|
||||
|
||||
@@ -81,6 +81,7 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
|
||||
elif len(gen_options) > app.config["MAX_ROLL"]:
|
||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
|
||||
f"If you have a larger group, please generate it yourself and upload it.")
|
||||
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||
gen = Generation(
|
||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
||||
import jinja2.exceptions
|
||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
from pony.orm import count, commit, db_session
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import app, cache
|
||||
@@ -17,13 +18,6 @@ def get_world_theme(game_name: str):
|
||||
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(jinja2.exceptions.TemplateNotFound)
|
||||
def page_not_found(err):
|
||||
@@ -69,14 +63,40 @@ def tutorial_landing():
|
||||
|
||||
@app.route('/faq/<string:lang>/')
|
||||
@cache.cached()
|
||||
def faq(lang):
|
||||
return render_template("faq.html", lang=lang)
|
||||
def faq(lang: str):
|
||||
import markdown
|
||||
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>/')
|
||||
@cache.cached()
|
||||
def terms(lang):
|
||||
return render_template("glossary.html", lang=lang)
|
||||
def glossary(lang: str):
|
||||
import markdown
|
||||
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>')
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
flask>=3.0.3
|
||||
werkzeug>=3.0.4
|
||||
werkzeug>=3.0.6
|
||||
pony>=0.7.19
|
||||
waitress>=3.0.0
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress>=1.15
|
||||
Flask-Limiter>=3.8.0
|
||||
bokeh>=3.1.1; python_version <= '3.8'
|
||||
bokeh>=3.4.3; python_version == '3.9'
|
||||
bokeh>=3.5.2; python_version >= '3.10'
|
||||
bokeh>=3.5.2
|
||||
markupsafe>=2.1.5
|
||||
Markdown>=3.7
|
||||
mdx-breakless-lists>=1.0.1
|
||||
|
||||
31
WebHostLib/session.py
Normal file
@@ -0,0 +1,31 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
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>`;
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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>`;
|
||||
});
|
||||
});
|
||||
@@ -288,6 +288,11 @@ const applyPresets = (presetName) => {
|
||||
}
|
||||
});
|
||||
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"
|
||||
|
||||
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 2.4 KiB |
BIN
WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 7.8 KiB |
BIN
WebHostLib/static/static/backgrounds/clouds/cloud-0001.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 3.8 KiB |
BIN
WebHostLib/static/static/backgrounds/clouds/cloud-0002.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 2.8 KiB |
BIN
WebHostLib/static/static/backgrounds/clouds/cloud-0003.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.9 KiB |
BIN
WebHostLib/static/static/backgrounds/dirt.webp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
BIN
WebHostLib/static/static/backgrounds/footer/footer-0001.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
BIN
WebHostLib/static/static/backgrounds/footer/footer-0002.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
BIN
WebHostLib/static/static/backgrounds/footer/footer-0003.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 17 KiB |
BIN
WebHostLib/static/static/backgrounds/footer/footer-0004.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
BIN
WebHostLib/static/static/backgrounds/footer/footer-0005.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 4.7 KiB |
BIN
WebHostLib/static/static/backgrounds/grass-flowers.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 4.2 KiB |
BIN
WebHostLib/static/static/backgrounds/grass.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 14 KiB |
BIN
WebHostLib/static/static/backgrounds/header/dirt-header.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 14 KiB |
BIN
WebHostLib/static/static/backgrounds/header/grass-header.webp
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 12 KiB |
BIN
WebHostLib/static/static/backgrounds/header/ocean-header.webp
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 20 KiB |
BIN
WebHostLib/static/static/backgrounds/header/stone-header.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 6.2 KiB |
BIN
WebHostLib/static/static/backgrounds/ice.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 21 KiB |
BIN
WebHostLib/static/static/backgrounds/jungle.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 3.7 KiB |
BIN
WebHostLib/static/static/backgrounds/ocean.webp
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 35 KiB |
BIN
WebHostLib/static/static/backgrounds/party-time.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 119 KiB |
BIN
WebHostLib/static/static/backgrounds/stone.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
66
WebHostLib/static/static/branding/header-logo-full.svg
Normal file
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 240 38" style="enable-background:new 0 0 240 38;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#316B84;}
|
||||
</style>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st0" d="M59.72,27.96L53.03,4.21L42.25,4.04l1.42,4.37l1.41-0.26l-7.9,24.22h8.44l-0.56-2.27l-0.81-3.27l8.9-5.7
|
||||
l1.78,11.24h7.97v-4.73L59.72,27.96z M45.62,20.21l3.13-10.84h1.5l2.02,7.44L45.62,20.21z"/>
|
||||
<path class="st0" d="M78.67,27.96V20.4l-4.11-2.5l3.29-3.78l-0.47-7.46l-2.82-2.45H56.65v5.27l3.81-1.11l2.31,13.36l-2.79,0.73
|
||||
L61,26.34l5.06-0.52l0.36-6.15l4.32,0.13l3.16,3.62v8.94l12.89,1.49v-5.34L78.67,27.96z M73.27,13.33l-2.18,1.45h-4.64l-0.42-6.57
|
||||
h5.68l1.55,1.37V13.33z"/>
|
||||
<polygon class="st0" points="84.65,4.21 93.01,4.21 95.75,6.46 96.26,10.9 92.23,12.43 91.77,9.74 88.97,8.28 85.86,9.82
|
||||
83.88,15.02 85.51,20.94 88.49,22.38 91.99,20.59 91.99,18.59 96.26,16.96 95.85,22.85 91.81,26.87 84.17,26.55 80.79,23.58
|
||||
78.87,14.87 80.79,6.94 "/>
|
||||
<polygon class="st0" points="97.62,4.21 103.33,4.21 102.96,21.08 108.7,20.14 108.34,6.42 113.85,3.28 113.9,19.9 115.75,19.71
|
||||
115.27,25.86 113.88,25.86 114.27,32.36 108.7,32.36 108.7,26.39 102.96,26.39 102.96,32.36 91.77,33.85 92.2,28.85 97.88,27.96
|
||||
"/>
|
||||
<polygon class="st0" points="147.43,28.86 147.43,32.36 162.85,32.36 162.48,25.36 159.5,26 158.89,27.68 154.1,27.24
|
||||
154.1,21.51 160.81,20.85 160.81,16.48 153.86,16.54 153.86,9.18 158.62,8.43 159.22,9.77 161.85,10.06 162.59,4 147.43,4
|
||||
147.43,6.54 148.68,7.46 148.68,28.4 "/>
|
||||
<polygon class="st0" points="163.89,9.24 163.89,4 172.31,4 170.35,26.87 179.55,24.74 179.55,32.36 164.51,32.34 164.65,28.71
|
||||
165.73,27.84 165.73,9.59 "/>
|
||||
<path class="st0" d="M193.69,32.36l-0.63-2.51l-2.84-1.89l-4.29-20.14L185.9,4h-11.27l-0.03,3.2l1.87-0.34l-2.79,14.07l-1.37,0.57
|
||||
v2.85l6.29-1.33l0.4-2.7l4.65-0.89l1.69,12.93H193.69z M179.39,15.11l1.65-6.52l0.89,0.25l0.92,5.45L179.39,15.11z"/>
|
||||
<polygon class="st0" points="208.47,21.68 210.62,21.12 210.04,18.15 200.51,17.46 198.87,21.13 203.56,21.9 203.32,23.91
|
||||
200.58,25.19 196.44,23.77 194.48,17.19 196.2,10.02 200.08,8.52 203.31,9.62 202.85,11.75 207.79,13.6 208.83,9.69 204.71,4.21
|
||||
195.57,4.21 191.24,7.36 189.29,16.87 192.06,27.54 199.03,30.53 203.2,29.3 203.09,32.36 209.01,32.36 209.4,29.95 207.38,28.99
|
||||
"/>
|
||||
<path class="st0" d="M230.45,6.26L226.39,4l-8.59-0.01l-4.07,2.86l-2.58,8.9l1.52,11.82l5.61,4.73l7.65,0.01l5.72-4.59l2.47-12.46
|
||||
L230.45,6.26z M228.23,21.75l-3.95,5.45l-2.16,0.43l-4.6-3.46L216,15.72l2.4-7.02l5.14-0.48l2.97,1.79l1.74,5.83L228.23,21.75z"/>
|
||||
<path class="st0" d="M116.13,27.48l-0.24,4.88l12.26,0.09l-0.83-5.01l-2.86-0.48l0.14-17.62l2.45-0.42l-0.14-4.85l-10.92,0.36
|
||||
l0.1,4.6l3.2,0.63l-0.42,17.67L116.13,27.48z"/>
|
||||
<path class="st0" d="M141.34,4.21l-12.88-0.39v4.26l1.95,0.62v25.15l-1.8,1.41l-0.02,2.63h8.23L136,27.96h6.09l4.57-4.46V7.27
|
||||
L141.34,4.21z M141.38,20.51l-2.54,1.89l-3.23,0.16L135.4,9.32h3.88l2.1,1.68V20.51z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st0" d="M14.14,11.28c0,0.35-0.02,0.71-0.07,1.05c0.38,0.07,0.76,0.11,1.16,0.11s0.79-0.04,1.16-0.11
|
||||
c-0.05-0.34-0.07-0.7-0.07-1.05c0-3.23,1.94-6.02,4.72-7.25C20.17,1.68,17.9,0,15.24,0S10.3,1.68,9.42,4.03
|
||||
C12.2,5.26,14.14,8.04,14.14,11.28z"/>
|
||||
<path class="st0" d="M18.04,11.28c0,0.16,0.01,0.32,0.02,0.48c0.02,0.3,0.06,0.6,0.13,0.88c0.06,0.28,0.15,0.56,0.25,0.83
|
||||
c0.11,0.3,0.24,0.58,0.39,0.85c1.42-1.33,3.33-2.15,5.42-2.15s4.01,0.82,5.42,2.15c0.51-0.9,0.79-1.94,0.79-3.04
|
||||
c0-3.42-2.79-6.22-6.22-6.22c-0.4,0-0.79,0.04-1.16,0.11c-0.28,0.06-0.56,0.13-0.83,0.22c-0.28,0.09-0.56,0.21-0.83,0.35
|
||||
C19.42,6.77,18.04,8.87,18.04,11.28z"/>
|
||||
<path class="st0" d="M6.22,12.16c2.1,0,4.01,0.82,5.42,2.15c0.15-0.27,0.28-0.55,0.39-0.85c0.1-0.27,0.19-0.54,0.25-0.83
|
||||
c0.06-0.28,0.11-0.58,0.13-0.88c0.02-0.15,0.02-0.32,0.02-0.48c0-2.41-1.38-4.51-3.39-5.54C8.77,5.6,8.5,5.49,8.21,5.39
|
||||
c-0.27-0.1-0.55-0.17-0.83-0.22C7,5.1,6.61,5.06,6.22,5.06C2.79,5.06,0,7.85,0,11.28c0,1.1,0.28,2.14,0.79,3.04
|
||||
C2.21,12.98,4.12,12.16,6.22,12.16z"/>
|
||||
<path class="st0" d="M29.21,16.33c-0.18-0.23-0.36-0.44-0.57-0.65c-1.12-1.12-2.67-1.81-4.38-1.81c-1.71,0-3.25,0.69-4.38,1.81
|
||||
c-0.2,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72c-0.2,0.34-0.36,0.71-0.48,1.09c2.83,1.21,4.81,4.02,4.81,7.28
|
||||
c0,0.26-0.01,0.52-0.04,0.78c0.37,0.07,0.75,0.1,1.13,0.1c3.43,0,6.22-2.79,6.22-6.22c0-1.11-0.29-2.14-0.8-3.04
|
||||
C29.54,16.8,29.38,16.56,29.21,16.33z"/>
|
||||
<path class="st0" d="M12.12,18.14c-0.13-0.38-0.28-0.75-0.48-1.09c-0.14-0.26-0.3-0.5-0.47-0.72c-0.17-0.23-0.36-0.44-0.56-0.64
|
||||
c-1.12-1.12-2.67-1.81-4.38-1.81s-3.26,0.69-4.38,1.81c-0.21,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72
|
||||
C0.29,17.94,0,18.98,0,20.08c0,3.43,2.79,6.22,6.22,6.22c0.39,0,0.76-0.03,1.13-0.1c-0.03-0.26-0.04-0.52-0.04-0.78
|
||||
C7.31,22.15,9.29,19.34,12.12,18.14z"/>
|
||||
<path class="st0" d="M18.04,19.87c-0.27-0.14-0.55-0.26-0.84-0.35c-0.27-0.09-0.55-0.17-0.84-0.22c-0.37-0.07-0.75-0.1-1.13-0.1
|
||||
s-0.76,0.03-1.13,0.1c-0.28,0.05-0.57,0.13-0.84,0.22c-0.29,0.1-0.57,0.22-0.84,0.35C10.4,20.9,9.02,23,9.02,25.42
|
||||
c0,0.07,0,0.14,0.01,0.21c0.01,0.31,0.04,0.61,0.1,0.9c0.05,0.28,0.12,0.57,0.21,0.84c0.82,2.48,3.16,4.27,5.9,4.27
|
||||
s5.08-1.79,5.9-4.27c0.09-0.27,0.17-0.55,0.21-0.84c0.06-0.3,0.09-0.6,0.1-0.91c0.01-0.07,0.01-0.14,0.01-0.21
|
||||
C21.45,23,20.07,20.9,18.04,19.87z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 3.3 KiB |
@@ -1,66 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 240 38" style="enable-background:new 0 0 240 38;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#316B84;}
|
||||
</style>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st0" d="M59.72,27.96L53.03,4.21L42.25,4.04l1.42,4.37l1.41-0.26l-7.9,24.22h8.44l-0.56-2.27l-0.81-3.27l8.9-5.7
|
||||
l1.78,11.24h7.97v-4.73L59.72,27.96z M45.62,20.21l3.13-10.84h1.5l2.02,7.44L45.62,20.21z"/>
|
||||
<path class="st0" d="M78.67,27.96V20.4l-4.11-2.5l3.29-3.78l-0.47-7.46l-2.82-2.45H56.65v5.27l3.81-1.11l2.31,13.36l-2.79,0.73
|
||||
L61,26.34l5.06-0.52l0.36-6.15l4.32,0.13l3.16,3.62v8.94l12.89,1.49v-5.34L78.67,27.96z M73.27,13.33l-2.18,1.45h-4.64l-0.42-6.57
|
||||
h5.68l1.55,1.37V13.33z"/>
|
||||
<polygon class="st0" points="84.65,4.21 93.01,4.21 95.75,6.46 96.26,10.9 92.23,12.43 91.77,9.74 88.97,8.28 85.86,9.82
|
||||
83.88,15.02 85.51,20.94 88.49,22.38 91.99,20.59 91.99,18.59 96.26,16.96 95.85,22.85 91.81,26.87 84.17,26.55 80.79,23.58
|
||||
78.87,14.87 80.79,6.94 "/>
|
||||
<polygon class="st0" points="97.62,4.21 103.33,4.21 102.96,21.08 108.7,20.14 108.34,6.42 113.85,3.28 113.9,19.9 115.75,19.71
|
||||
115.27,25.86 113.88,25.86 114.27,32.36 108.7,32.36 108.7,26.39 102.96,26.39 102.96,32.36 91.77,33.85 92.2,28.85 97.88,27.96
|
||||
"/>
|
||||
<polygon class="st0" points="147.43,28.86 147.43,32.36 162.85,32.36 162.48,25.36 159.5,26 158.89,27.68 154.1,27.24
|
||||
154.1,21.51 160.81,20.85 160.81,16.48 153.86,16.54 153.86,9.18 158.62,8.43 159.22,9.77 161.85,10.06 162.59,4 147.43,4
|
||||
147.43,6.54 148.68,7.46 148.68,28.4 "/>
|
||||
<polygon class="st0" points="163.89,9.24 163.89,4 172.31,4 170.35,26.87 179.55,24.74 179.55,32.36 164.51,32.34 164.65,28.71
|
||||
165.73,27.84 165.73,9.59 "/>
|
||||
<path class="st0" d="M193.69,32.36l-0.63-2.51l-2.84-1.89l-4.29-20.14L185.9,4h-11.27l-0.03,3.2l1.87-0.34l-2.79,14.07l-1.37,0.57
|
||||
v2.85l6.29-1.33l0.4-2.7l4.65-0.89l1.69,12.93H193.69z M179.39,15.11l1.65-6.52l0.89,0.25l0.92,5.45L179.39,15.11z"/>
|
||||
<polygon class="st0" points="208.47,21.68 210.62,21.12 210.04,18.15 200.51,17.46 198.87,21.13 203.56,21.9 203.32,23.91
|
||||
200.58,25.19 196.44,23.77 194.48,17.19 196.2,10.02 200.08,8.52 203.31,9.62 202.85,11.75 207.79,13.6 208.83,9.69 204.71,4.21
|
||||
195.57,4.21 191.24,7.36 189.29,16.87 192.06,27.54 199.03,30.53 203.2,29.3 203.09,32.36 209.01,32.36 209.4,29.95 207.38,28.99
|
||||
"/>
|
||||
<path class="st0" d="M230.45,6.26L226.39,4l-8.59-0.01l-4.07,2.86l-2.58,8.9l1.52,11.82l5.61,4.73l7.65,0.01l5.72-4.59l2.47-12.46
|
||||
L230.45,6.26z M228.23,21.75l-3.95,5.45l-2.16,0.43l-4.6-3.46L216,15.72l2.4-7.02l5.14-0.48l2.97,1.79l1.74,5.83L228.23,21.75z"/>
|
||||
<path class="st0" d="M116.13,27.48l-0.24,4.88l12.26,0.09l-0.83-5.01l-2.86-0.48l0.14-17.62l2.45-0.42l-0.14-4.85l-10.92,0.36
|
||||
l0.1,4.6l3.2,0.63l-0.42,17.67L116.13,27.48z"/>
|
||||
<path class="st0" d="M141.34,4.21l-12.88-0.39v4.26l1.95,0.62v25.15l-1.8,1.41l-0.02,2.63h8.23L136,27.96h6.09l4.57-4.46V7.27
|
||||
L141.34,4.21z M141.38,20.51l-2.54,1.89l-3.23,0.16L135.4,9.32h3.88l2.1,1.68V20.51z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st0" d="M14.14,11.28c0,0.35-0.02,0.71-0.07,1.05c0.38,0.07,0.76,0.11,1.16,0.11s0.79-0.04,1.16-0.11
|
||||
c-0.05-0.34-0.07-0.7-0.07-1.05c0-3.23,1.94-6.02,4.72-7.25C20.17,1.68,17.9,0,15.24,0S10.3,1.68,9.42,4.03
|
||||
C12.2,5.26,14.14,8.04,14.14,11.28z"/>
|
||||
<path class="st0" d="M18.04,11.28c0,0.16,0.01,0.32,0.02,0.48c0.02,0.3,0.06,0.6,0.13,0.88c0.06,0.28,0.15,0.56,0.25,0.83
|
||||
c0.11,0.3,0.24,0.58,0.39,0.85c1.42-1.33,3.33-2.15,5.42-2.15s4.01,0.82,5.42,2.15c0.51-0.9,0.79-1.94,0.79-3.04
|
||||
c0-3.42-2.79-6.22-6.22-6.22c-0.4,0-0.79,0.04-1.16,0.11c-0.28,0.06-0.56,0.13-0.83,0.22c-0.28,0.09-0.56,0.21-0.83,0.35
|
||||
C19.42,6.77,18.04,8.87,18.04,11.28z"/>
|
||||
<path class="st0" d="M6.22,12.16c2.1,0,4.01,0.82,5.42,2.15c0.15-0.27,0.28-0.55,0.39-0.85c0.1-0.27,0.19-0.54,0.25-0.83
|
||||
c0.06-0.28,0.11-0.58,0.13-0.88c0.02-0.15,0.02-0.32,0.02-0.48c0-2.41-1.38-4.51-3.39-5.54C8.77,5.6,8.5,5.49,8.21,5.39
|
||||
c-0.27-0.1-0.55-0.17-0.83-0.22C7,5.1,6.61,5.06,6.22,5.06C2.79,5.06,0,7.85,0,11.28c0,1.1,0.28,2.14,0.79,3.04
|
||||
C2.21,12.98,4.12,12.16,6.22,12.16z"/>
|
||||
<path class="st0" d="M29.21,16.33c-0.18-0.23-0.36-0.44-0.57-0.65c-1.12-1.12-2.67-1.81-4.38-1.81c-1.71,0-3.25,0.69-4.38,1.81
|
||||
c-0.2,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72c-0.2,0.34-0.36,0.71-0.48,1.09c2.83,1.21,4.81,4.02,4.81,7.28
|
||||
c0,0.26-0.01,0.52-0.04,0.78c0.37,0.07,0.75,0.1,1.13,0.1c3.43,0,6.22-2.79,6.22-6.22c0-1.11-0.29-2.14-0.8-3.04
|
||||
C29.54,16.8,29.38,16.56,29.21,16.33z"/>
|
||||
<path class="st0" d="M12.12,18.14c-0.13-0.38-0.28-0.75-0.48-1.09c-0.14-0.26-0.3-0.5-0.47-0.72c-0.17-0.23-0.36-0.44-0.56-0.64
|
||||
c-1.12-1.12-2.67-1.81-4.38-1.81s-3.26,0.69-4.38,1.81c-0.21,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72
|
||||
C0.29,17.94,0,18.98,0,20.08c0,3.43,2.79,6.22,6.22,6.22c0.39,0,0.76-0.03,1.13-0.1c-0.03-0.26-0.04-0.52-0.04-0.78
|
||||
C7.31,22.15,9.29,19.34,12.12,18.14z"/>
|
||||
<path class="st0" d="M18.04,19.87c-0.27-0.14-0.55-0.26-0.84-0.35c-0.27-0.09-0.55-0.17-0.84-0.22c-0.37-0.07-0.75-0.1-1.13-0.1
|
||||
s-0.76,0.03-1.13,0.1c-0.28,0.05-0.57,0.13-0.84,0.22c-0.29,0.1-0.57,0.22-0.84,0.35C10.4,20.9,9.02,23,9.02,25.42
|
||||
c0,0.07,0,0.14,0.01,0.21c0.01,0.31,0.04,0.61,0.1,0.9c0.05,0.28,0.12,0.57,0.21,0.84c0.82,2.48,3.16,4.27,5.9,4.27
|
||||
s5.08-1.79,5.9-4.27c0.09-0.27,0.17-0.55,0.21-0.84c0.06-0.3,0.09-0.6,0.1-0.91c0.01-0.07,0.01-0.14,0.01-0.21
|
||||
C21.45,23,20.07,20.9,18.04,19.87z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 240 38" style="enable-background:new 0 0 240 38" xml:space="preserve"><style>.st0{fill:#316b84}</style><path class="st0" d="M59.72 27.96 53.03 4.21l-10.78-.17 1.42 4.37 1.41-.26-7.9 24.22h8.44l-.56-2.27-.81-3.27 8.9-5.7 1.78 11.24h7.97v-4.73l-3.18.32zm-14.1-7.75 3.13-10.84h1.5l2.02 7.44-6.65 3.4z"/><path class="st0" d="M78.67 27.96V20.4l-4.11-2.5 3.29-3.78-.47-7.46-2.82-2.45H56.65v5.27l3.81-1.11 2.31 13.36-2.79.73L61 26.34l5.06-.52.36-6.15 4.32.13 3.16 3.62v8.94l12.89 1.49v-5.34l-8.12-.55zm-5.4-14.63-2.18 1.45h-4.64l-.42-6.57h5.68l1.55 1.37v3.75z"/><path class="st0" d="M84.65 4.21h8.36l2.74 2.25.51 4.44-4.03 1.53-.46-2.69-2.8-1.46-3.11 1.54-1.98 5.2 1.63 5.92 2.98 1.44 3.5-1.79v-2l4.27-1.63-.41 5.89-4.04 4.02-7.64-.32-3.38-2.97-1.92-8.71 1.92-7.93z"/><path class="st0" d="M97.62 4.21h5.71l-.37 16.87 5.74-.94-.36-13.72 5.51-3.14.05 16.62 1.85-.19-.48 6.15h-1.39l.39 6.5h-5.57v-5.97h-5.74v5.97l-11.19 1.49.43-5 5.68-.89zm49.81 24.65v3.5h15.42l-.37-7-2.98.64-.61 1.68-4.79-.44v-5.73l6.71-.66v-4.37l-6.95.06V9.18l4.76-.75.6 1.34 2.63.29.74-6.06h-15.16v2.54l1.25.92V28.4zm16.46-19.62V4h8.42l-1.96 22.87 9.2-2.13v7.62l-15.04-.02.14-3.63 1.08-.87V9.59z"/><path class="st0" d="m193.69 32.36-.63-2.51-2.84-1.89-4.29-20.14L185.9 4h-11.27l-.03 3.2 1.87-.34-2.79 14.07-1.37.57v2.85l6.29-1.33.4-2.7 4.65-.89 1.69 12.93h8.35zm-14.3-17.25 1.65-6.52.89.25.92 5.45-3.46.82z"/><path class="st0" d="m208.47 21.68 2.15-.56-.58-2.97-9.53-.69-1.64 3.67 4.69.77-.24 2.01-2.74 1.28-4.14-1.42-1.96-6.58 1.72-7.17 3.88-1.5 3.23 1.1-.46 2.13 4.94 1.85 1.04-3.91-4.12-5.48h-9.14l-4.33 3.15-1.95 9.51 2.77 10.67 6.97 2.99 4.17-1.23-.11 3.06h5.92l.39-2.41-2.02-.96zm21.98-15.42L226.39 4l-8.59-.01-4.07 2.86-2.58 8.9 1.52 11.82 5.61 4.73 7.65.01 5.72-4.59 2.47-12.46-3.67-9zm-2.22 15.49-3.95 5.45-2.16.43-4.6-3.46-1.52-8.45 2.4-7.02 5.14-.48 2.97 1.79 1.74 5.83-.02 5.91zm-112.1 5.73-.24 4.88 12.26.09-.83-5.01-2.86-.48.14-17.62 2.45-.42-.14-4.85-10.92.36.1 4.6 3.2.63-.42 17.67-2.74.15zm25.21-23.27-12.88-.39v4.26l1.95.62v25.15l-1.8 1.41-.02 2.63h8.23l-.82-9.93h6.09l4.57-4.46V7.27l-5.32-3.06zm.04 16.3-2.54 1.89-3.23.16-.21-13.24h3.88l2.1 1.68v9.51zM14.14 11.28c0 .35-.02.71-.07 1.05.38.07.76.11 1.16.11s.79-.04 1.16-.11a7.933 7.933 0 0 1 4.65-8.3C20.17 1.68 17.9 0 15.24 0S10.3 1.68 9.42 4.03a7.922 7.922 0 0 1 4.72 7.25z"/><path class="st0" d="M18.04 11.28c0 .16.01.32.02.48.02.3.06.6.13.88.06.28.15.56.25.83.11.3.24.58.39.85 1.42-1.33 3.33-2.15 5.42-2.15s4.01.82 5.42 2.15c.51-.9.79-1.94.79-3.04 0-3.42-2.79-6.22-6.22-6.22-.4 0-.79.04-1.16.11-.28.06-.56.13-.83.22-.28.09-.56.21-.83.35a6.24 6.24 0 0 0-3.38 5.54zm-11.82.88c2.1 0 4.01.82 5.42 2.15.15-.27.28-.55.39-.85.1-.27.19-.54.25-.83.06-.28.11-.58.13-.88.02-.15.02-.32.02-.48a6.23 6.23 0 0 0-3.39-5.54c-.27-.13-.54-.24-.83-.34-.27-.1-.55-.17-.83-.22a6.42 6.42 0 0 0-1.16-.11 6.227 6.227 0 0 0-5.43 9.26 7.885 7.885 0 0 1 5.43-2.16z"/><path class="st0" d="M29.21 16.33c-.18-.23-.36-.44-.57-.65a6.174 6.174 0 0 0-4.38-1.81 6.192 6.192 0 0 0-4.94 2.45c-.18.23-.34.47-.47.72-.2.34-.36.71-.48 1.09a7.923 7.923 0 0 1 4.77 8.06c.37.07.75.1 1.13.1 3.43 0 6.22-2.79 6.22-6.22 0-1.11-.29-2.14-.8-3.04-.15-.23-.31-.47-.48-.7zm-17.09 1.81c-.13-.38-.28-.75-.48-1.09-.14-.26-.3-.5-.47-.72-.17-.23-.36-.44-.56-.64-1.12-1.12-2.67-1.81-4.38-1.81s-3.26.69-4.38 1.81c-.21.2-.39.42-.56.64-.18.23-.34.47-.47.72-.53.89-.82 1.93-.82 3.03 0 3.43 2.79 6.22 6.22 6.22.39 0 .76-.03 1.13-.1a7.902 7.902 0 0 1 4.77-8.06z"/><path class="st0" d="M18.04 19.87c-.27-.14-.55-.26-.84-.35-.27-.09-.55-.17-.84-.22-.37-.07-.75-.1-1.13-.1s-.76.03-1.13.1c-.28.05-.57.13-.84.22-.29.1-.57.22-.84.35a6.225 6.225 0 0 0-3.4 5.55c0 .07 0 .14.01.21.01.31.04.61.1.9.05.28.12.57.21.84.82 2.48 3.16 4.27 5.9 4.27s5.08-1.79 5.9-4.27c.09-.27.17-.55.21-.84.06-.3.09-.6.1-.91.01-.07.01-.14.01-.21a6.24 6.24 0 0 0-3.42-5.54z"/></svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 3.9 KiB |
BIN
WebHostLib/static/static/branding/header-logo.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 39 KiB |
BIN
WebHostLib/static/static/branding/landing-logo.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |