mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-10 17:43:53 -07:00
Compare commits
5 Commits
NewSoupVi-
...
multiserve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b77398a7 | ||
|
|
27ae7e081f | ||
|
|
add68329e6 | ||
|
|
0768bc066a | ||
|
|
001fdfbe9f |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
||||
worlds/blasphemous/region_data.py linguist-generated=true
|
||||
61
.github/workflows/build.yml
vendored
61
.github/workflows/build.yml
vendored
@@ -36,15 +36,10 @@ jobs:
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||
choco install innosetup --version=6.2.2 --allow-downgrade
|
||||
- name: Build
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python setup.py build_exe --yes
|
||||
if ( $? -eq $false ) {
|
||||
Write-Error "setup.py failed!"
|
||||
exit 1
|
||||
}
|
||||
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||
echo "$NAME -> $ZIP_NAME"
|
||||
@@ -54,6 +49,12 @@ jobs:
|
||||
Rename-Item "exe.$NAME" Archipelago
|
||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.ZIP_NAME }}
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
- name: Build Setup
|
||||
run: |
|
||||
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
||||
@@ -64,38 +65,11 @@ jobs:
|
||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||
$SETUP_NAME=$contents[0].Name
|
||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||
- name: Check build loads expected worlds
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
mv Players/Templates/meta.yaml .
|
||||
ls -1 Players/Templates | sort > setup-player-templates.txt
|
||||
rm -R Players/Templates
|
||||
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
|
||||
ls -1 Players/Templates | sort > generated-player-templates.txt
|
||||
cmp setup-player-templates.txt generated-player-templates.txt \
|
||||
|| diff setup-player-templates.txt generated-player-templates.txt
|
||||
mv meta.yaml Players/Templates/
|
||||
- name: Test Generate
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
cp Players/Templates/Clique.yaml Players/
|
||||
timeout 30 ./ArchipelagoGenerate
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.ZIP_NAME }}
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
compression-level: 0 # .7z is incompressible by zip
|
||||
if-no-files-found: error
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
- name: Store Setup
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.SETUP_NAME }}
|
||||
path: setups/${{ env.SETUP_NAME }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
|
||||
build-ubuntu2004:
|
||||
@@ -136,7 +110,7 @@ jobs:
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - copy code above to release.yml -
|
||||
@@ -144,36 +118,15 @@ jobs:
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
python setup.py build_exe --yes
|
||||
- name: Check build loads expected worlds
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
mv Players/Templates/meta.yaml .
|
||||
ls -1 Players/Templates | sort > setup-player-templates.txt
|
||||
rm -R Players/Templates
|
||||
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
|
||||
ls -1 Players/Templates | sort > generated-player-templates.txt
|
||||
cmp setup-player-templates.txt generated-player-templates.txt \
|
||||
|| diff setup-player-templates.txt generated-player-templates.txt
|
||||
mv meta.yaml Players/Templates/
|
||||
- name: Test Generate
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
cp Players/Templates/Clique.yaml Players/
|
||||
timeout 30 ./ArchipelagoGenerate
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.APPIMAGE_NAME }}
|
||||
path: dist/${{ env.APPIMAGE_NAME }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: Store .tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.TAR_NAME }}
|
||||
path: dist/${{ env.TAR_NAME }}
|
||||
compression-level: 0 # .gz is incompressible by zip
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
54
.github/workflows/ctest.yml
vendored
54
.github/workflows/ctest.yml
vendored
@@ -1,54 +0,0 @@
|
||||
# Run CMake / CTest C++ unit tests
|
||||
|
||||
name: ctest
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.cc?'
|
||||
- '**.cpp'
|
||||
- '**.cxx'
|
||||
- '**.hh?'
|
||||
- '**.hpp'
|
||||
- '**.hxx'
|
||||
- '**.CMakeLists'
|
||||
- '.github/workflows/ctest.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.cc?'
|
||||
- '**.cpp'
|
||||
- '**.cxx'
|
||||
- '**.hh?'
|
||||
- '**.hpp'
|
||||
- '**.hxx'
|
||||
- '**.CMakeLists'
|
||||
- '.github/workflows/ctest.yml'
|
||||
|
||||
jobs:
|
||||
ctest:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test C++ ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ilammy/msvc-dev-cmd@v1
|
||||
if: startsWith(matrix.os,'windows')
|
||||
- uses: Bacondish2023/setup-googletest@v1
|
||||
with:
|
||||
build-type: 'Release'
|
||||
- name: Build tests
|
||||
run: |
|
||||
cd test/cpp
|
||||
mkdir build
|
||||
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build/ --config Release
|
||||
ls
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd test/cpp
|
||||
ctest --test-dir build/ -C Release --output-on-failure
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - code above copied from build.yml -
|
||||
|
||||
36
.github/workflows/unittests.yml
vendored
36
.github/workflows/unittests.yml
vendored
@@ -24,7 +24,7 @@ on:
|
||||
- '.github/workflows/unittests.yml'
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
|
||||
|
||||
@@ -37,13 +37,12 @@ jobs:
|
||||
- {version: '3.9'}
|
||||
- {version: '3.10'}
|
||||
- {version: '3.11'}
|
||||
- {version: '3.12'}
|
||||
include:
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
- python: {version: '3.11'} # current
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
- python: {version: '3.11'} # current
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
@@ -61,32 +60,3 @@ jobs:
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest -n auto
|
||||
|
||||
hosting:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
python:
|
||||
- {version: '3.12'} # current
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python.version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python.version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
- name: Test hosting
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
export PYTHONPATH=$(pwd)
|
||||
python test/hosting/__main__.py
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -62,7 +62,6 @@ Output Logs/
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
/datapackage
|
||||
/custom_worlds
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -150,7 +149,7 @@ venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
*.code-workspace
|
||||
.code-workspace
|
||||
shell.nix
|
||||
|
||||
# Spyder project settings
|
||||
@@ -178,7 +177,6 @@ dmypy.json
|
||||
cython_debug/
|
||||
|
||||
# Cython intermediates
|
||||
_speedups.c
|
||||
_speedups.cpp
|
||||
_speedups.html
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ class AdventureContext(CommonContext):
|
||||
self.local_item_locations = {}
|
||||
self.dragon_speed_info = {}
|
||||
|
||||
options = Utils.get_settings()
|
||||
options = Utils.get_options()
|
||||
self.display_msgs = options["adventure_options"]["display_msgs"]
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
@@ -102,7 +102,7 @@ class AdventureContext(CommonContext):
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.locations_array = None
|
||||
if Utils.get_settings()["adventure_options"].get("death_link", False):
|
||||
if Utils.get_options()["adventure_options"].get("death_link", False):
|
||||
self.set_deathlink = True
|
||||
async_start(self.get_freeincarnates_used())
|
||||
elif cmd == "RoomInfo":
|
||||
@@ -112,7 +112,7 @@ class AdventureContext(CommonContext):
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "ReceivedItems":
|
||||
msg = f"Received {', '.join([self.item_names.lookup_in_game(item.item) for item in args['items']])}"
|
||||
msg = f"Received {', '.join([self.item_names.lookup_in_slot(item.item) for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "Retrieved":
|
||||
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
|
||||
@@ -415,8 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
|
||||
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
|
||||
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
|
||||
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
|
||||
268
BaseClasses.py
268
BaseClasses.py
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import itertools
|
||||
import functools
|
||||
import logging
|
||||
@@ -11,10 +11,8 @@ from argparse import Namespace
|
||||
from collections import Counter, deque
|
||||
from collections.abc import Collection, MutableSequence
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
||||
Optional, Protocol, Set, Tuple, Union, Type)
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
|
||||
TypedDict, Union, Type, ClassVar
|
||||
|
||||
import NetUtils
|
||||
import Options
|
||||
@@ -24,16 +22,16 @@ if typing.TYPE_CHECKING:
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
class Group(TypedDict):
|
||||
class Group(TypedDict, total=False):
|
||||
name: str
|
||||
game: str
|
||||
world: "AutoWorld.World"
|
||||
players: AbstractSet[int]
|
||||
item_pool: NotRequired[Set[str]]
|
||||
replacement_items: NotRequired[Dict[int, Optional[str]]]
|
||||
local_items: NotRequired[Set[str]]
|
||||
non_local_items: NotRequired[Set[str]]
|
||||
link_replacement: NotRequired[bool]
|
||||
players: Set[int]
|
||||
item_pool: Set[str]
|
||||
replacement_items: Dict[int, Optional[str]]
|
||||
local_items: Set[str]
|
||||
non_local_items: Set[str]
|
||||
link_replacement: bool
|
||||
|
||||
|
||||
class ThreadBarrierProxy:
|
||||
@@ -50,11 +48,6 @@ class ThreadBarrierProxy:
|
||||
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
|
||||
|
||||
|
||||
class HasNameAndPlayer(Protocol):
|
||||
name: str
|
||||
player: int
|
||||
|
||||
|
||||
class MultiWorld():
|
||||
debug_types = False
|
||||
player_name: Dict[int, str]
|
||||
@@ -70,6 +63,7 @@ class MultiWorld():
|
||||
state: CollectionState
|
||||
|
||||
plando_options: PlandoOptions
|
||||
accessibility: Dict[int, Options.Accessibility]
|
||||
early_items: Dict[int, Dict[str, int]]
|
||||
local_early_items: Dict[int, Dict[str, int]]
|
||||
local_items: Dict[int, Options.LocalItems]
|
||||
@@ -163,7 +157,7 @@ class MultiWorld():
|
||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||
|
||||
for player in range(1, players + 1):
|
||||
def set_player_attr(attr: str, val) -> None:
|
||||
def set_player_attr(attr, val):
|
||||
self.__dict__.setdefault(attr, {})[player] = val
|
||||
set_player_attr('plando_items', [])
|
||||
set_player_attr('plando_texts', {})
|
||||
@@ -172,13 +166,13 @@ class MultiWorld():
|
||||
set_player_attr('completion_condition', lambda state: True)
|
||||
self.worlds = {}
|
||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||
"world's random object instead (usually self.random)")
|
||||
"world's random object instead (usually self.random)")
|
||||
self.plando_options = PlandoOptions.none
|
||||
|
||||
def get_all_ids(self) -> Tuple[int, ...]:
|
||||
return self.player_ids + tuple(self.groups)
|
||||
|
||||
def add_group(self, name: str, game: str, players: AbstractSet[int] = frozenset()) -> Tuple[int, Group]:
|
||||
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
||||
"""Create a group with name and return the assigned player ID and group.
|
||||
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
||||
from worlds import AutoWorld
|
||||
@@ -202,7 +196,7 @@ class MultiWorld():
|
||||
|
||||
return new_id, new_group
|
||||
|
||||
def get_player_groups(self, player: int) -> Set[int]:
|
||||
def get_player_groups(self, player) -> Set[int]:
|
||||
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
||||
|
||||
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
||||
@@ -265,7 +259,7 @@ class MultiWorld():
|
||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||
}
|
||||
|
||||
for _name, item_link in item_links.items():
|
||||
for name, item_link in item_links.items():
|
||||
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
|
||||
pool = set()
|
||||
local_items = set()
|
||||
@@ -294,86 +288,6 @@ class MultiWorld():
|
||||
group["non_local_items"] = item_link["non_local_items"]
|
||||
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
|
||||
|
||||
def link_items(self) -> None:
|
||||
"""Called to link together items in the itempool related to the registered item link groups."""
|
||||
from worlds import AutoWorld
|
||||
|
||||
for group_id, group in self.groups.items():
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
||||
]:
|
||||
classifications: Dict[str, int] = collections.defaultdict(int)
|
||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||
for item in self.itempool:
|
||||
if item.player in counters and item.name in shared_pool:
|
||||
counters[item.player][item.name] += 1
|
||||
classifications[item.name] |= item.classification
|
||||
|
||||
for player in players.copy():
|
||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||
players.remove(player)
|
||||
del (counters[player])
|
||||
|
||||
if not players:
|
||||
return None, None
|
||||
|
||||
for item in shared_pool:
|
||||
count = min(counters[player][item] for player in players)
|
||||
if count:
|
||||
for player in players:
|
||||
counters[player][item] = count
|
||||
else:
|
||||
for player in players:
|
||||
del (counters[player][item])
|
||||
return counters, classifications
|
||||
|
||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||
if not common_item_count:
|
||||
continue
|
||||
|
||||
new_itempool: List[Item] = []
|
||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||
for _ in range(item_count):
|
||||
new_item = group["world"].create_item(item_name)
|
||||
# mangle together all original classification bits
|
||||
new_item.classification |= classifications[item_name]
|
||||
new_itempool.append(new_item)
|
||||
|
||||
region = Region("Menu", group_id, self, "ItemLink")
|
||||
self.regions.append(region)
|
||||
locations = region.locations
|
||||
for item in self.itempool:
|
||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||
if count:
|
||||
loc = Location(group_id, f"Item Link: {item.name} -> {self.player_name[item.player]} {count}",
|
||||
None, region)
|
||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||
state.has(item_name, group_id_, count_)
|
||||
|
||||
locations.append(loc)
|
||||
loc.place_locked_item(item)
|
||||
common_item_count[item.player][item.name] -= 1
|
||||
else:
|
||||
new_itempool.append(item)
|
||||
|
||||
itemcount = len(self.itempool)
|
||||
self.itempool = new_itempool
|
||||
|
||||
while itemcount > len(self.itempool):
|
||||
items_to_add = []
|
||||
for player in group["players"]:
|
||||
if group["link_replacement"]:
|
||||
item_player = group_id
|
||||
else:
|
||||
item_player = player
|
||||
if group["replacement_items"][player]:
|
||||
items_to_add.append(AutoWorld.call_single(self, "create_item", item_player,
|
||||
group["replacement_items"][player]))
|
||||
else:
|
||||
items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player))
|
||||
self.random.shuffle(items_to_add)
|
||||
self.itempool.extend(items_to_add[:itemcount - len(self.itempool)])
|
||||
|
||||
def secure(self):
|
||||
self.random = ThreadBarrierProxy(secrets.SystemRandom())
|
||||
self.is_race = True
|
||||
@@ -395,7 +309,7 @@ class MultiWorld():
|
||||
return tuple(world for player, world in self.worlds.items() if
|
||||
player not in self.groups and self.game[player] == game_name)
|
||||
|
||||
def get_name_string_for_object(self, obj: HasNameAndPlayer) -> str:
|
||||
def get_name_string_for_object(self, obj) -> str:
|
||||
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
|
||||
|
||||
def get_player_name(self, player: int) -> str:
|
||||
@@ -437,7 +351,7 @@ class MultiWorld():
|
||||
subworld = self.worlds[player]
|
||||
for item in subworld.get_pre_fill_items():
|
||||
subworld.collect(ret, item)
|
||||
ret.sweep_for_advancements()
|
||||
ret.sweep_for_events()
|
||||
|
||||
if use_cache:
|
||||
self._all_state = ret
|
||||
@@ -446,7 +360,7 @@ class MultiWorld():
|
||||
def get_items(self) -> List[Item]:
|
||||
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
||||
|
||||
def find_item_locations(self, item: str, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
def find_item_locations(self, item, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
if resolve_group_locations:
|
||||
player_groups = self.get_player_groups(player)
|
||||
return [location for location in self.get_locations() if
|
||||
@@ -455,7 +369,7 @@ class MultiWorld():
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player]
|
||||
|
||||
def find_item(self, item: str, player: int) -> Location:
|
||||
def find_item(self, item, player: int) -> Location:
|
||||
return next(location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player)
|
||||
|
||||
@@ -548,9 +462,9 @@ class MultiWorld():
|
||||
return True
|
||||
state = starting_state.copy()
|
||||
else:
|
||||
state = CollectionState(self)
|
||||
if self.has_beaten_game(state):
|
||||
if self.has_beaten_game(self.state):
|
||||
return True
|
||||
state = CollectionState(self)
|
||||
prog_locations = {location for location in self.get_locations() if location.item
|
||||
and location.item.advancement and location not in state.locations_checked}
|
||||
|
||||
@@ -609,21 +523,26 @@ class MultiWorld():
|
||||
players: Dict[str, Set[int]] = {
|
||||
"minimal": set(),
|
||||
"items": set(),
|
||||
"full": set()
|
||||
"locations": set()
|
||||
}
|
||||
for player, world in self.worlds.items():
|
||||
players[world.options.accessibility.current_key].add(player)
|
||||
for player, access in self.accessibility.items():
|
||||
players[access.current_key].add(player)
|
||||
|
||||
beatable_fulfilled = False
|
||||
|
||||
def location_condition(location: Location) -> bool:
|
||||
def location_condition(location: Location):
|
||||
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
||||
return location.player in players["full"] or \
|
||||
(location.item and location.item.player not in players["minimal"])
|
||||
if location.player in players["locations"] or (location.item and location.item.player not in
|
||||
players["minimal"]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def location_relevant(location: Location) -> bool:
|
||||
def location_relevant(location: Location):
|
||||
"""Determine if this location is relevant to sweep."""
|
||||
return location.player in players["full"] or location.advancement
|
||||
if location.progress_type != LocationProgressType.EXCLUDED \
|
||||
and (location.player in players["locations"] or location.advancement):
|
||||
return True
|
||||
return False
|
||||
|
||||
def all_done() -> bool:
|
||||
"""Check if all access rules are fulfilled"""
|
||||
@@ -668,7 +587,7 @@ class CollectionState():
|
||||
multiworld: MultiWorld
|
||||
reachable_regions: Dict[int, Set[Region]]
|
||||
blocked_connections: Dict[int, Set[Entrance]]
|
||||
advancements: Set[Location]
|
||||
events: Set[Location]
|
||||
path: Dict[Union[Region, Entrance], PathValue]
|
||||
locations_checked: Set[Location]
|
||||
stale: Dict[int, bool]
|
||||
@@ -680,7 +599,7 @@ class CollectionState():
|
||||
self.multiworld = parent
|
||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||
self.advancements = set()
|
||||
self.events = set()
|
||||
self.path = {}
|
||||
self.locations_checked = set()
|
||||
self.stale = {player: True for player in parent.get_all_ids()}
|
||||
@@ -724,14 +643,14 @@ class CollectionState():
|
||||
|
||||
def copy(self) -> CollectionState:
|
||||
ret = CollectionState(self.multiworld)
|
||||
ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()}
|
||||
ret.reachable_regions = {player: region_set.copy() for player, region_set in
|
||||
self.reachable_regions.items()}
|
||||
ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in
|
||||
self.blocked_connections.items()}
|
||||
ret.advancements = self.advancements.copy()
|
||||
ret.path = self.path.copy()
|
||||
ret.locations_checked = self.locations_checked.copy()
|
||||
ret.prog_items = copy.deepcopy(self.prog_items)
|
||||
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
||||
self.reachable_regions}
|
||||
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
|
||||
self.blocked_connections}
|
||||
ret.events = copy.copy(self.events)
|
||||
ret.path = copy.copy(self.path)
|
||||
ret.locations_checked = copy.copy(self.locations_checked)
|
||||
for function in self.additional_copy_functions:
|
||||
ret = function(self, ret)
|
||||
return ret
|
||||
@@ -761,25 +680,20 @@ class CollectionState():
|
||||
def can_reach_region(self, spot: str, player: int) -> bool:
|
||||
return self.multiworld.get_region(spot, player).can_reach(self)
|
||||
|
||||
def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
Utils.deprecate("sweep_for_events has been renamed to sweep_for_advancements. The functionality is the same. "
|
||||
"Please switch over to sweep_for_advancements.")
|
||||
return self.sweep_for_advancements(locations)
|
||||
|
||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
if locations is None:
|
||||
locations = self.multiworld.get_filled_locations()
|
||||
reachable_advancements = True
|
||||
# since the loop has a good chance to run more than once, only filter the advancements once
|
||||
locations = {location for location in locations if location.advancement and location not in self.advancements}
|
||||
|
||||
while reachable_advancements:
|
||||
reachable_advancements = {location for location in locations if location.can_reach(self)}
|
||||
locations -= reachable_advancements
|
||||
for advancement in reachable_advancements:
|
||||
self.advancements.add(advancement)
|
||||
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(advancement.item, True, advancement)
|
||||
reachable_events = True
|
||||
# since the loop has a good chance to run more than once, only filter the events once
|
||||
locations = {location for location in locations if location.advancement and location not in self.events and
|
||||
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
||||
while reachable_events:
|
||||
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||
locations -= reachable_events
|
||||
for event in reachable_events:
|
||||
self.events.add(event)
|
||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(event.item, True, event)
|
||||
|
||||
# item name related
|
||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||
@@ -813,7 +727,7 @@ class CollectionState():
|
||||
if found >= count:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
|
||||
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
|
||||
Ignores duplicates of the same item."""
|
||||
@@ -828,7 +742,7 @@ class CollectionState():
|
||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state."""
|
||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
||||
|
||||
|
||||
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
||||
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
||||
@@ -874,16 +788,20 @@ class CollectionState():
|
||||
)
|
||||
|
||||
# Item related
|
||||
def collect(self, item: Item, prevent_sweep: bool = False, location: Optional[Location] = None) -> bool:
|
||||
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
||||
if location:
|
||||
self.locations_checked.add(location)
|
||||
|
||||
changed = self.multiworld.worlds[item.player].collect(self, item)
|
||||
|
||||
if not changed and event:
|
||||
self.prog_items[item.player][item.name] += 1
|
||||
changed = True
|
||||
|
||||
self.stale[item.player] = True
|
||||
|
||||
if changed and not prevent_sweep:
|
||||
self.sweep_for_advancements()
|
||||
if changed and not event:
|
||||
self.sweep_for_events()
|
||||
|
||||
return changed
|
||||
|
||||
@@ -907,7 +825,7 @@ class Entrance:
|
||||
addresses = None
|
||||
target = None
|
||||
|
||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None:
|
||||
def __init__(self, player: int, name: str = '', parent: Region = None):
|
||||
self.name = name
|
||||
self.parent_region = parent
|
||||
self.player = player
|
||||
@@ -927,6 +845,9 @@ class Entrance:
|
||||
region.entrances.append(self)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
multiworld = self.parent_region.multiworld if self.parent_region else None
|
||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
@@ -1052,7 +973,7 @@ class Region:
|
||||
self.locations.append(location_type(self.player, location, address, self))
|
||||
|
||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
|
||||
"""
|
||||
Connects this Region to another Region, placing the provided rule on the connection.
|
||||
|
||||
@@ -1092,6 +1013,9 @@ class Region:
|
||||
rules[connecting_region] if rules and connecting_region in rules else None)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
@@ -1110,9 +1034,9 @@ class Location:
|
||||
locked: bool = False
|
||||
show_in_spoiler: bool = True
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
|
||||
always_allow = staticmethod(lambda state, item: False)
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
|
||||
item_rule = staticmethod(lambda item: True)
|
||||
item: Optional[Item] = None
|
||||
|
||||
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
|
||||
@@ -1121,20 +1045,16 @@ class Location:
|
||||
self.address = address
|
||||
self.parent_region = parent
|
||||
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool:
|
||||
return ((
|
||||
self.always_allow(state, item)
|
||||
and item.name not in state.multiworld.worlds[item.player].options.non_local_items
|
||||
) or (
|
||||
(self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||
and self.item_rule(item)
|
||||
and (not check_access or self.can_reach(state))
|
||||
))
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||
return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
|
||||
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||
and self.item_rule(item)
|
||||
and (not check_access or self.can_reach(state))))
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average
|
||||
# self.access_rule computes faster on average, so placing it first for faster abort
|
||||
assert self.parent_region, "Can't reach location without region"
|
||||
return self.parent_region.can_reach(state) and self.access_rule(state)
|
||||
return self.access_rule(state) and self.parent_region.can_reach(state)
|
||||
|
||||
def place_locked_item(self, item: Item):
|
||||
if self.item:
|
||||
@@ -1144,6 +1064,9 @@ class Location:
|
||||
self.locked = True
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
@@ -1165,7 +1088,7 @@ class Location:
|
||||
@property
|
||||
def native_item(self) -> bool:
|
||||
"""Returns True if the item in this location matches game."""
|
||||
return self.item is not None and self.item.game == self.game
|
||||
return self.item and self.item.game == self.game
|
||||
|
||||
@property
|
||||
def hint_text(self) -> str:
|
||||
@@ -1248,6 +1171,9 @@ class Item:
|
||||
return hash((self.name, self.player))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
|
||||
return self.location.parent_region.multiworld.get_name_string_for_object(self)
|
||||
return f"{self.name} (Player {self.player})"
|
||||
@@ -1325,9 +1251,9 @@ class Spoiler:
|
||||
|
||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||
# reducing each range of influence to the bare minimum required inside it
|
||||
restore_later: Dict[Location, Item] = {}
|
||||
restore_later = {}
|
||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||
to_delete: Set[Location] = set()
|
||||
to_delete = set()
|
||||
for location in sphere:
|
||||
# we remove the item at location and check if game is still beatable
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||
@@ -1345,7 +1271,7 @@ class Spoiler:
|
||||
sphere -= to_delete
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected: List[Item] = []
|
||||
removed_precollected = []
|
||||
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
multiworld.precollected_items[item.player].remove(item)
|
||||
@@ -1365,6 +1291,8 @@ class Spoiler:
|
||||
state = CollectionState(multiworld)
|
||||
collection_spheres = []
|
||||
while required_locations:
|
||||
state.sweep_for_events(key_only=True)
|
||||
|
||||
sphere = set(filter(state.can_reach, required_locations))
|
||||
|
||||
for location in sphere:
|
||||
@@ -1426,7 +1354,7 @@ class Spoiler:
|
||||
# Maybe move the big bomb over to the Event system instead?
|
||||
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
|
||||
for (_, exit_path) in path):
|
||||
if multiworld.worlds[player].options.mode != 'inverted':
|
||||
if multiworld.mode[player] != 'inverted':
|
||||
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
|
||||
get_path(state, multiworld.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
@@ -1498,9 +1426,9 @@ class Spoiler:
|
||||
|
||||
if self.paths:
|
||||
outfile.write('\n\nPaths:\n\n')
|
||||
path_listings: List[str] = []
|
||||
path_listings = []
|
||||
for location, path in sorted(self.paths.items()):
|
||||
path_lines: List[str] = []
|
||||
path_lines = []
|
||||
for region, exit in path:
|
||||
if exit is not None:
|
||||
path_lines.append("{} -> {}".format(region, exit))
|
||||
|
||||
@@ -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)
|
||||
from Utils import Version, stream_input, async_start
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
@@ -61,7 +61,6 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
if address:
|
||||
self.ctx.server_address = None
|
||||
self.ctx.username = None
|
||||
self.ctx.password = None
|
||||
elif not self.ctx.server_address:
|
||||
self.output("Please specify an address.")
|
||||
return False
|
||||
@@ -226,9 +225,6 @@ class CommonContext:
|
||||
def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str:
|
||||
"""Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is
|
||||
omitted.
|
||||
|
||||
Use of `lookup_in_slot` should not be used when not connected to a server. If looking in own game, set
|
||||
`ctx.game` and use `lookup_in_game` method instead.
|
||||
"""
|
||||
if slot is None:
|
||||
slot = self.ctx.slot
|
||||
@@ -252,7 +248,7 @@ class CommonContext:
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
||||
ui: typing.Optional["kvui.GameManager"] = None
|
||||
ui = None
|
||||
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
input_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
@@ -497,11 +493,6 @@ class CommonContext:
|
||||
"""Gets called before sending a Say to the server from the user.
|
||||
Returned text is sent, or sending is aborted if None is returned."""
|
||||
return text
|
||||
|
||||
def on_ui_command(self, text: str) -> None:
|
||||
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
|
||||
The command processor is still called; this is just intended for command echoing."""
|
||||
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
|
||||
|
||||
def update_permissions(self, permissions: typing.Dict[str, int]):
|
||||
for permission_name, permission_flag in permissions.items():
|
||||
@@ -515,7 +506,6 @@ class CommonContext:
|
||||
async def shutdown(self):
|
||||
self.server_address = ""
|
||||
self.username = None
|
||||
self.password = None
|
||||
self.cancel_autoreconnect()
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
@@ -864,8 +854,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.team = args["team"]
|
||||
ctx.slot = args["slot"]
|
||||
# int keys get lost in JSON transfer
|
||||
ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)}
|
||||
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
|
||||
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
||||
ctx.hint_points = args.get("hint_points", 0)
|
||||
ctx.consume_players_package(args["players"])
|
||||
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
|
||||
|
||||
40
Fill.py
40
Fill.py
@@ -12,12 +12,7 @@ from worlds.generic.Rules import add_item_rule
|
||||
|
||||
|
||||
class FillError(RuntimeError):
|
||||
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None:
|
||||
if "multiworld" in kwargs and isinstance(args[0], str):
|
||||
placements = (args[0] + f"\nAll Placements:\n" +
|
||||
f"{[(loc, loc.item) for loc in kwargs['multiworld'].get_filled_locations()]}")
|
||||
args = (placements, *args[1:])
|
||||
super().__init__(*args)
|
||||
pass
|
||||
|
||||
|
||||
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
|
||||
@@ -29,7 +24,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
||||
new_state = base_state.copy()
|
||||
for item in itempool:
|
||||
new_state.collect(item, True)
|
||||
new_state.sweep_for_advancements(locations=locations)
|
||||
new_state.sweep_for_events(locations=locations)
|
||||
return new_state
|
||||
|
||||
|
||||
@@ -217,7 +212,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
f"Unfilled locations:\n"
|
||||
f"{', '.join(str(location) for location in locations)}\n"
|
||||
f"Already placed {len(placements)}:\n"
|
||||
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
||||
f"{', '.join(str(place) for place in placements)}")
|
||||
|
||||
item_pool.extend(unplaced_items)
|
||||
|
||||
@@ -304,7 +299,7 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
f"Unfilled locations:\n"
|
||||
f"{', '.join(str(location) for location in locations)}\n"
|
||||
f"Already placed {len(placements)}:\n"
|
||||
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
||||
f"{', '.join(str(place) for place in placements)}")
|
||||
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
@@ -329,8 +324,8 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
|
||||
pool.append(location.item)
|
||||
state.remove(location.item)
|
||||
location.item = None
|
||||
if location in state.advancements:
|
||||
state.advancements.remove(location)
|
||||
if location in state.events:
|
||||
state.events.remove(location)
|
||||
locations.append(location)
|
||||
if pool and locations:
|
||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||
@@ -363,7 +358,7 @@ def distribute_early_items(multiworld: MultiWorld,
|
||||
early_priority_locations: typing.List[Location] = []
|
||||
loc_indexes_to_remove: typing.Set[int] = set()
|
||||
base_state = multiworld.state.copy()
|
||||
base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
||||
base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
||||
for i, loc in enumerate(fill_locations):
|
||||
if loc.can_reach(base_state):
|
||||
if loc.progress_type == LocationProgressType.PRIORITY:
|
||||
@@ -488,15 +483,15 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if panic_method == "swap":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=True,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
|
||||
elif panic_method == "raise":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=False,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
|
||||
elif panic_method == "start_inventory":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=False, allow_partial=True,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
|
||||
if progitempool:
|
||||
for item in progitempool:
|
||||
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
||||
@@ -511,8 +506,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if progitempool:
|
||||
raise FillError(
|
||||
f"Not enough locations for progression items. "
|
||||
f"There are {len(progitempool)} more progression items than there are available locations.",
|
||||
multiworld=multiworld,
|
||||
f"There are {len(progitempool)} more progression items than there are available locations."
|
||||
)
|
||||
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
||||
|
||||
@@ -529,8 +523,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if excludedlocations:
|
||||
raise FillError(
|
||||
f"Not enough filler items for excluded locations. "
|
||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
|
||||
multiworld=multiworld,
|
||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
|
||||
)
|
||||
|
||||
restitempool = filleritempool + usefulitempool
|
||||
@@ -558,7 +551,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
||||
progress_done = False
|
||||
|
||||
# sweep once to pick up preplaced items
|
||||
multiworld.state.sweep_for_advancements()
|
||||
multiworld.state.sweep_for_events()
|
||||
|
||||
# fill multiworld from top of itempool while we can
|
||||
while not progress_done:
|
||||
@@ -596,7 +589,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
||||
if candidate_item_to_place is not None:
|
||||
item_to_place = candidate_item_to_place
|
||||
else:
|
||||
raise FillError('No more progress items left to place.', multiworld=multiworld)
|
||||
raise FillError('No more progress items left to place.')
|
||||
|
||||
# find item to replace with progress item
|
||||
location_list = multiworld.get_reachable_locations()
|
||||
@@ -653,6 +646,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
|
||||
def get_sphere_locations(sphere_state: CollectionState,
|
||||
locations: typing.Set[Location]) -> typing.Set[Location]:
|
||||
sphere_state.sweep_for_events(key_only=True, locations=locations)
|
||||
return {loc for loc in locations if sphere_state.can_reach(loc)}
|
||||
|
||||
def item_percentage(player: int, num: int) -> float:
|
||||
@@ -746,7 +740,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
), items_to_test):
|
||||
reducing_state.collect(location.item, True, location)
|
||||
|
||||
reducing_state.sweep_for_advancements(locations=locations_to_test)
|
||||
reducing_state.sweep_for_events(locations=locations_to_test)
|
||||
|
||||
if multiworld.has_beaten_game(balancing_state):
|
||||
if not multiworld.has_beaten_game(reducing_state):
|
||||
@@ -829,7 +823,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
warn(warning, force)
|
||||
|
||||
swept_state = multiworld.state.copy()
|
||||
swept_state.sweep_for_advancements()
|
||||
swept_state.sweep_for_events()
|
||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||
|
||||
45
Generate.py
45
Generate.py
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
@@ -17,16 +15,21 @@ import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
import copy
|
||||
import Utils
|
||||
import Options
|
||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
from Main import main as ERmain
|
||||
from settings import get_settings
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from worlds import failed_world_loads
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
from settings import get_settings
|
||||
settings = get_settings()
|
||||
defaults = settings.generator
|
||||
options = get_settings()
|
||||
defaults = options.generator
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
|
||||
@@ -38,7 +41,7 @@ def mystery_argparse():
|
||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
|
||||
parser.add_argument('--outputpath', default=settings.general_options.output_path,
|
||||
parser.add_argument('--outputpath', default=options.general_options.output_path,
|
||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||
@@ -58,23 +61,20 @@ def mystery_argparse():
|
||||
if not os.path.isabs(args.meta_file_path):
|
||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||
return args
|
||||
return args, options
|
||||
|
||||
|
||||
def get_seed_name(random_source) -> str:
|
||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||
|
||||
|
||||
def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
||||
if __name__ == "__main__" and "worlds" in sys.modules:
|
||||
raise Exception("Worlds system should not be loaded before logging init.")
|
||||
|
||||
def main(args=None, callback=ERmain):
|
||||
if not args:
|
||||
args = mystery_argparse()
|
||||
args, options = mystery_argparse()
|
||||
else:
|
||||
options = get_settings()
|
||||
|
||||
seed = get_seed(args.seed)
|
||||
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
random.seed(seed)
|
||||
seed_name = get_seed_name(random)
|
||||
@@ -143,9 +143,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
raise Exception(f"No weights found. "
|
||||
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
f"A mix is also permitted.")
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.plando_options = args.plando
|
||||
@@ -237,7 +234,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||
yaml.dump(important, f)
|
||||
|
||||
return erargs, seed
|
||||
return callback(erargs, seed)
|
||||
|
||||
|
||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||
@@ -362,8 +359,6 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
||||
|
||||
|
||||
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
if not game:
|
||||
return get_choice(option_key, category_dict)
|
||||
if game in AutoWorldRegister.world_types:
|
||||
@@ -441,13 +436,10 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
||||
except Exception as e:
|
||||
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
|
||||
else:
|
||||
from worlds import AutoWorldRegister
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
@@ -474,7 +466,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
|
||||
ret.game = get_choice("game", weights)
|
||||
if ret.game not in AutoWorldRegister.world_types:
|
||||
from worlds import failed_world_loads
|
||||
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
|
||||
if picks[0] in failed_world_loads:
|
||||
raise Exception(f"No functional world found to handle game {ret.game}. "
|
||||
@@ -511,7 +502,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
continue
|
||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
||||
if PlandoOptions.items in plando_options:
|
||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
if ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights)
|
||||
|
||||
@@ -546,9 +537,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights):
|
||||
if __name__ == '__main__':
|
||||
import atexit
|
||||
confirmation = atexit.register(input, "Press enter to close.")
|
||||
erargs, seed = main()
|
||||
from Main import main as ERmain
|
||||
multiworld = ERmain(erargs, seed)
|
||||
multiworld = main()
|
||||
if __debug__:
|
||||
import gc
|
||||
import sys
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
if __name__ == '__main__':
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
Utils.init_logging("KH1Client", exception_logger="Client")
|
||||
|
||||
from worlds.kh1.Client import launch
|
||||
launch()
|
||||
82
Launcher.py
82
Launcher.py
@@ -19,7 +19,7 @@ import sys
|
||||
import webbrowser
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Callable, Sequence, Union, Optional
|
||||
from typing import Sequence, Union, Optional
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
@@ -160,12 +160,8 @@ def launch(exe, in_terminal=False):
|
||||
subprocess.Popen(exe)
|
||||
|
||||
|
||||
refresh_components: Optional[Callable[[], None]] = None
|
||||
|
||||
|
||||
def run_gui():
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
|
||||
from kivy.core.window import Window
|
||||
from kivy.uix.image import AsyncImage
|
||||
from kivy.uix.relativelayout import RelativeLayout
|
||||
|
||||
@@ -173,8 +169,11 @@ def run_gui():
|
||||
base_title: str = "Archipelago Launcher"
|
||||
container: ContainerLayout
|
||||
grid: GridLayout
|
||||
_tool_layout: Optional[ScrollBox] = None
|
||||
_client_layout: Optional[ScrollBox] = None
|
||||
|
||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
self.title = self.base_title
|
||||
@@ -182,7 +181,18 @@ def run_gui():
|
||||
self.icon = r"data/icon.png"
|
||||
super().__init__()
|
||||
|
||||
def _refresh_components(self) -> None:
|
||||
def build(self):
|
||||
self.container = ContainerLayout()
|
||||
self.grid = GridLayout(cols=2)
|
||||
self.container.add_widget(self.grid)
|
||||
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
||||
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
||||
tool_layout = ScrollBox()
|
||||
tool_layout.layout.orientation = "vertical"
|
||||
self.grid.add_widget(tool_layout)
|
||||
client_layout = ScrollBox()
|
||||
client_layout.layout.orientation = "vertical"
|
||||
self.grid.add_widget(client_layout)
|
||||
|
||||
def build_button(component: Component) -> Widget:
|
||||
"""
|
||||
@@ -207,49 +217,14 @@ def run_gui():
|
||||
return box_layout
|
||||
return button
|
||||
|
||||
# clear before repopulating
|
||||
assert self._tool_layout and self._client_layout, "must call `build` first"
|
||||
tool_children = reversed(self._tool_layout.layout.children)
|
||||
for child in tool_children:
|
||||
self._tool_layout.layout.remove_widget(child)
|
||||
client_children = reversed(self._client_layout.layout.children)
|
||||
for child in client_children:
|
||||
self._client_layout.layout.remove_widget(child)
|
||||
|
||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
||||
|
||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||
_tools.items(), _miscs.items(), _adjusters.items()
|
||||
), _clients.items()):
|
||||
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
|
||||
# column 1
|
||||
if tool:
|
||||
self._tool_layout.layout.add_widget(build_button(tool[1]))
|
||||
tool_layout.layout.add_widget(build_button(tool[1]))
|
||||
# column 2
|
||||
if client:
|
||||
self._client_layout.layout.add_widget(build_button(client[1]))
|
||||
|
||||
def build(self):
|
||||
self.container = ContainerLayout()
|
||||
self.grid = GridLayout(cols=2)
|
||||
self.container.add_widget(self.grid)
|
||||
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
||||
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
||||
self._tool_layout = ScrollBox()
|
||||
self._tool_layout.layout.orientation = "vertical"
|
||||
self.grid.add_widget(self._tool_layout)
|
||||
self._client_layout = ScrollBox()
|
||||
self._client_layout.layout.orientation = "vertical"
|
||||
self.grid.add_widget(self._client_layout)
|
||||
|
||||
self._refresh_components()
|
||||
|
||||
global refresh_components
|
||||
refresh_components = self._refresh_components
|
||||
|
||||
Window.bind(on_drop_file=self._on_drop_file)
|
||||
client_layout.layout.add_widget(build_button(client[1]))
|
||||
|
||||
return self.container
|
||||
|
||||
@@ -260,14 +235,6 @@ def run_gui():
|
||||
else:
|
||||
launch(get_exe(button.component), button.component.cli)
|
||||
|
||||
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
|
||||
""" When a patch file is dropped into the window, run the associated component. """
|
||||
file, component = identify(filename.decode())
|
||||
if file and component:
|
||||
run_component(component, file)
|
||||
else:
|
||||
logging.warning(f"unable to identify component for {file}")
|
||||
|
||||
def _stop(self, *largs):
|
||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||
# Closing the window explicitly cleans it up.
|
||||
@@ -276,17 +243,10 @@ def run_gui():
|
||||
|
||||
Launcher().run()
|
||||
|
||||
# avoiding Launcher reference leak
|
||||
# and don't try to do something with widgets after window closed
|
||||
global refresh_components
|
||||
refresh_components = None
|
||||
|
||||
|
||||
def run_component(component: Component, *args):
|
||||
if component.func:
|
||||
component.func(*args)
|
||||
if refresh_components:
|
||||
refresh_components()
|
||||
elif component.script_name:
|
||||
subprocess.run([*get_exe(component.script_name), *args])
|
||||
else:
|
||||
|
||||
@@ -14,7 +14,7 @@ import tkinter as tk
|
||||
from argparse import Namespace
|
||||
from concurrent.futures import as_completed, ThreadPoolExecutor
|
||||
from glob import glob
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, BOTH, TOP, LabelFrame, \
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
|
||||
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
|
||||
from tkinter.constants import DISABLED, NORMAL
|
||||
from urllib.parse import urlparse
|
||||
@@ -29,8 +29,7 @@ from Utils import output_path, local_path, user_path, open_file, get_cert_none_s
|
||||
|
||||
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
WINDOW_MIN_HEIGHT = 525
|
||||
WINDOW_MIN_WIDTH = 425
|
||||
|
||||
|
||||
class AdjusterWorld(object):
|
||||
def __init__(self, sprite_pool):
|
||||
@@ -243,17 +242,16 @@ def adjustGUI():
|
||||
from argparse import Namespace
|
||||
from Utils import __version__ as MWVersion
|
||||
adjustWindow = Tk()
|
||||
adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
|
||||
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
||||
set_icon(adjustWindow)
|
||||
|
||||
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
|
||||
|
||||
bottomFrame2 = Frame(adjustWindow, padx=8, pady=2)
|
||||
bottomFrame2 = Frame(adjustWindow)
|
||||
|
||||
romFrame, romVar = get_rom_frame(adjustWindow)
|
||||
|
||||
romDialogFrame = Frame(adjustWindow, padx=8, pady=2)
|
||||
romDialogFrame = Frame(adjustWindow)
|
||||
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
|
||||
romVar2 = StringVar()
|
||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||
@@ -263,9 +261,9 @@ def adjustGUI():
|
||||
romVar2.set(rom)
|
||||
|
||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||
romDialogFrame.pack(side=TOP, expand=False, fill=X)
|
||||
baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8))
|
||||
romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
|
||||
romDialogFrame.pack(side=TOP, expand=True, fill=X)
|
||||
baseRomLabel2.pack(side=LEFT)
|
||||
romEntry2.pack(side=LEFT, expand=True, fill=X)
|
||||
romSelectButton2.pack(side=LEFT)
|
||||
|
||||
def adjustRom():
|
||||
@@ -333,11 +331,12 @@ def adjustGUI():
|
||||
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
||||
|
||||
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
|
||||
rom_options_frame.pack(side=TOP, padx=8, pady=8, fill=BOTH, expand=True)
|
||||
rom_options_frame.pack(side=TOP)
|
||||
adjustButton.pack(side=LEFT, padx=(5,5))
|
||||
|
||||
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
|
||||
saveButton.pack(side=LEFT, padx=(5,5))
|
||||
|
||||
bottomFrame2.pack(side=TOP, pady=(5,5))
|
||||
|
||||
tkinter_center_window(adjustWindow)
|
||||
@@ -577,7 +576,7 @@ class AttachTooltip(object):
|
||||
def get_rom_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
|
||||
romFrame = Frame(parent, padx=8, pady=8)
|
||||
romFrame = Frame(parent)
|
||||
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||
romVar = StringVar(value=adjuster_settings.baserom)
|
||||
romEntry = Entry(romFrame, textvariable=romVar)
|
||||
@@ -597,19 +596,20 @@ def get_rom_frame(parent=None):
|
||||
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
|
||||
|
||||
baseRomLabel.pack(side=LEFT)
|
||||
romEntry.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
|
||||
romEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
romSelectButton.pack(side=LEFT)
|
||||
romFrame.pack(side=TOP, fill=X)
|
||||
romFrame.pack(side=TOP, expand=True, fill=X)
|
||||
|
||||
return romFrame, romVar
|
||||
|
||||
def get_rom_options_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options", padx=8, pady=8)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
||||
romOptionsFrame.columnconfigure(0, weight=1)
|
||||
romOptionsFrame.columnconfigure(1, weight=1)
|
||||
for i in range(5):
|
||||
romOptionsFrame.rowconfigure(i, weight=0, pad=4)
|
||||
romOptionsFrame.rowconfigure(i, weight=1)
|
||||
vars = Namespace()
|
||||
|
||||
vars.MusicVar = IntVar()
|
||||
@@ -660,7 +660,7 @@ def get_rom_options_frame(parent=None):
|
||||
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
|
||||
|
||||
baseSpriteLabel.pack(side=LEFT)
|
||||
spriteEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
spriteEntry.pack(side=LEFT)
|
||||
spriteSelectButton.pack(side=LEFT)
|
||||
|
||||
oofDialogFrame = Frame(romOptionsFrame)
|
||||
|
||||
112
Main.py
112
Main.py
@@ -11,8 +11,7 @@ from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
import worlds
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
||||
flood_items
|
||||
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
||||
from Options import StartInventoryPool
|
||||
from Utils import __version__, output_path, version_tuple, get_settings
|
||||
from settings import get_settings
|
||||
@@ -101,7 +100,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
multiworld.early_items[player][item_name] = max(0, early-count)
|
||||
remaining_count = count-early
|
||||
if remaining_count > 0:
|
||||
local_early = multiworld.local_early_items[player].get(item_name, 0)
|
||||
local_early = multiworld.early_local_items[player].get(item_name, 0)
|
||||
if local_early:
|
||||
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
|
||||
del local_early
|
||||
@@ -125,19 +124,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
for player in multiworld.player_ids:
|
||||
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
|
||||
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
|
||||
world_excluded_locations = set()
|
||||
for location_name in multiworld.worlds[player].options.priority_locations.value:
|
||||
try:
|
||||
location = multiworld.get_location(location_name, player)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
if location.progress_type != LocationProgressType.EXCLUDED:
|
||||
location.progress_type = LocationProgressType.PRIORITY
|
||||
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
|
||||
if location_name not in multiworld.worlds[player].location_name_to_id:
|
||||
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
|
||||
else:
|
||||
logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.")
|
||||
world_excluded_locations.add(location_name)
|
||||
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
||||
location.progress_type = LocationProgressType.PRIORITY
|
||||
|
||||
# Set local and non-local item rules.
|
||||
if multiworld.players > 1:
|
||||
@@ -152,7 +146,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
|
||||
new_items: List[Item] = []
|
||||
old_items: List[Item] = []
|
||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||
player: getattr(multiworld.worlds[player].options,
|
||||
"start_inventory_from_pool",
|
||||
@@ -171,26 +164,97 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
depletion_pool[item.player][item.name] -= 1
|
||||
# quick abort if we have found all items
|
||||
if not target:
|
||||
old_items.extend(multiworld.itempool[i+1:])
|
||||
new_items.extend(multiworld.itempool[i+1:])
|
||||
break
|
||||
else:
|
||||
old_items.append(item)
|
||||
new_items.append(item)
|
||||
|
||||
# leftovers?
|
||||
if target:
|
||||
for player, remaining_items in depletion_pool.items():
|
||||
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
||||
if remaining_items:
|
||||
logger.warning(f"{multiworld.get_player_name(player)}"
|
||||
raise Exception(f"{multiworld.get_player_name(player)}"
|
||||
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
||||
# find all filler we generated for the current player and remove until it matches
|
||||
removables = [item for item in new_items if item.player == player]
|
||||
for _ in range(sum(remaining_items.values())):
|
||||
new_items.remove(removables.pop())
|
||||
assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change."
|
||||
multiworld.itempool[:] = new_items + old_items
|
||||
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
|
||||
multiworld.itempool[:] = new_items
|
||||
|
||||
multiworld.link_items()
|
||||
# temporary home for item links, should be moved out of Main
|
||||
for group_id, group in multiworld.groups.items():
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
||||
]:
|
||||
classifications: Dict[str, int] = collections.defaultdict(int)
|
||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||
for item in multiworld.itempool:
|
||||
if item.player in counters and item.name in shared_pool:
|
||||
counters[item.player][item.name] += 1
|
||||
classifications[item.name] |= item.classification
|
||||
|
||||
for player in players.copy():
|
||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||
players.remove(player)
|
||||
del (counters[player])
|
||||
|
||||
if not players:
|
||||
return None, None
|
||||
|
||||
for item in shared_pool:
|
||||
count = min(counters[player][item] for player in players)
|
||||
if count:
|
||||
for player in players:
|
||||
counters[player][item] = count
|
||||
else:
|
||||
for player in players:
|
||||
del (counters[player][item])
|
||||
return counters, classifications
|
||||
|
||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||
if not common_item_count:
|
||||
continue
|
||||
|
||||
new_itempool: List[Item] = []
|
||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||
for _ in range(item_count):
|
||||
new_item = group["world"].create_item(item_name)
|
||||
# mangle together all original classification bits
|
||||
new_item.classification |= classifications[item_name]
|
||||
new_itempool.append(new_item)
|
||||
|
||||
region = Region("Menu", group_id, multiworld, "ItemLink")
|
||||
multiworld.regions.append(region)
|
||||
locations = region.locations
|
||||
for item in multiworld.itempool:
|
||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||
if count:
|
||||
loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}",
|
||||
None, region)
|
||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||
state.has(item_name, group_id_, count_)
|
||||
|
||||
locations.append(loc)
|
||||
loc.place_locked_item(item)
|
||||
common_item_count[item.player][item.name] -= 1
|
||||
else:
|
||||
new_itempool.append(item)
|
||||
|
||||
itemcount = len(multiworld.itempool)
|
||||
multiworld.itempool = new_itempool
|
||||
|
||||
while itemcount > len(multiworld.itempool):
|
||||
items_to_add = []
|
||||
for player in group["players"]:
|
||||
if group["link_replacement"]:
|
||||
item_player = group_id
|
||||
else:
|
||||
item_player = player
|
||||
if group["replacement_items"][player]:
|
||||
items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player,
|
||||
group["replacement_items"][player]))
|
||||
else:
|
||||
items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player))
|
||||
multiworld.random.shuffle(items_to_add)
|
||||
multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)])
|
||||
|
||||
if any(multiworld.item_links.values()):
|
||||
multiworld._all_state = None
|
||||
@@ -347,7 +411,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
output_file_futures.append(pool.submit(write_multidata))
|
||||
if not check_accessibility_task.result():
|
||||
if not multiworld.can_beat_game():
|
||||
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld)
|
||||
raise Exception("Game appears as unbeatable. Aborting.")
|
||||
else:
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
|
||||
|
||||
@@ -75,13 +75,13 @@ def update(yes: bool = False, force: bool = False) -> None:
|
||||
if not update_ran:
|
||||
update_ran = True
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
if force:
|
||||
update_command()
|
||||
return
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
prev = "" # if a line ends in \ we store here and merge later
|
||||
for req_file in requirements_files:
|
||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||
|
||||
116
MultiServer.py
116
MultiServer.py
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import asyncio
|
||||
import collections
|
||||
import contextlib
|
||||
import copy
|
||||
import datetime
|
||||
import functools
|
||||
@@ -67,21 +66,6 @@ def update_dict(dictionary, entries):
|
||||
return dictionary
|
||||
|
||||
|
||||
def queue_gc():
|
||||
import gc
|
||||
from threading import Thread
|
||||
|
||||
gc_thread: typing.Optional[Thread] = getattr(queue_gc, "_thread", None)
|
||||
def async_collect():
|
||||
time.sleep(2)
|
||||
setattr(queue_gc, "_thread", None)
|
||||
gc.collect()
|
||||
if not gc_thread:
|
||||
gc_thread = Thread(target=async_collect)
|
||||
setattr(queue_gc, "_thread", gc_thread)
|
||||
gc_thread.start()
|
||||
|
||||
|
||||
# functions callable on storable data on the server by clients
|
||||
modify_functions = {
|
||||
# generic:
|
||||
@@ -192,12 +176,12 @@ class Context:
|
||||
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
|
||||
non_hintable_names: typing.Dict[str, typing.Set[str]]
|
||||
public_stored_data_keys: typing.Set[str] # keys that can be retrieved by a client that has not reached "auth" yet
|
||||
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
|
||||
""" each sphere is { player: { location_id, ... } } """
|
||||
logger: logging.Logger
|
||||
|
||||
|
||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||
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,
|
||||
@@ -247,7 +231,7 @@ class Context:
|
||||
self.embedded_blacklist = {"host", "port"}
|
||||
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
|
||||
self.auto_save_interval = 60 # in seconds
|
||||
self.auto_saver_thread: typing.Optional[threading.Thread] = None
|
||||
self.auto_saver_thread = None
|
||||
self.save_dirty = False
|
||||
self.tags = ['AP']
|
||||
self.games: typing.Dict[int, str] = {}
|
||||
@@ -269,6 +253,7 @@ class Context:
|
||||
self.all_item_and_group_names = {}
|
||||
self.all_location_and_group_names = {}
|
||||
self.non_hintable_names = collections.defaultdict(frozenset)
|
||||
self.public_stored_data_keys = set()
|
||||
|
||||
self._load_game_data()
|
||||
|
||||
@@ -284,11 +269,6 @@ class Context:
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
self.non_hintable_names[world_name] = world.hint_blacklist
|
||||
|
||||
for game_package in self.gamespackage.values():
|
||||
# remove groups from data sent to clients
|
||||
del game_package["item_name_groups"]
|
||||
del game_package["location_name_groups"]
|
||||
|
||||
def _init_game_data(self):
|
||||
for game_name, game_package in self.gamespackage.items():
|
||||
if "checksum" in game_package:
|
||||
@@ -495,10 +475,26 @@ class Context:
|
||||
del data["location_name_groups"]
|
||||
del data["item_name_groups"] # remove from data package, but keep in self.item_name_groups
|
||||
self._init_game_data()
|
||||
|
||||
def _add_public_data_store_key(key: str, retriever: typing.Callable[[], typing.Any]):
|
||||
"""Add key to read_data and also public_stored_data_keys, to allow retrieval before auth."""
|
||||
self.public_stored_data_keys.add(key)
|
||||
self.read_data[key] = retriever
|
||||
|
||||
for game_name, game_package in self.gamespackage.items():
|
||||
_add_public_data_store_key(f"datapackage_checksum_{game_name}",
|
||||
lambda lgame=game_name: self.checksums.get(lgame, None))
|
||||
_add_public_data_store_key(f"item_name_to_id_{game_name}",
|
||||
lambda lgame=game_name: self.gamespackage[lgame]["item_name_to_id"])
|
||||
_add_public_data_store_key(f"location_name_to_id_{game_name}",
|
||||
lambda lgame=game_name: self.gamespackage[lgame]["location_name_to_id"])
|
||||
|
||||
for game_name, data in self.item_name_groups.items():
|
||||
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
|
||||
_add_public_data_store_key(f"item_name_groups_{game_name}",
|
||||
lambda lgame=game_name: self.item_name_groups[lgame])
|
||||
for game_name, data in self.location_name_groups.items():
|
||||
self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame]
|
||||
_add_public_data_store_key(f"location_name_groups_{game_name}",
|
||||
lambda lgame=game_name: self.location_name_groups[lgame])
|
||||
|
||||
# sorted access spheres
|
||||
self.spheres = decoded_obj.get("spheres", [])
|
||||
@@ -566,9 +562,6 @@ class Context:
|
||||
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
||||
else:
|
||||
self.save_dirty = False
|
||||
if not atexit_save: # if atexit is used, that keeps a reference anyway
|
||||
queue_gc()
|
||||
|
||||
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
||||
self.auto_saver_thread.start()
|
||||
|
||||
@@ -1009,7 +1002,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
|
||||
collect_player(ctx, team, group, True)
|
||||
|
||||
|
||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[typing.Tuple[int, int]]:
|
||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
||||
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
|
||||
|
||||
|
||||
@@ -1221,10 +1214,6 @@ class CommonCommandProcessor(CommandProcessor):
|
||||
timer = int(seconds, 10)
|
||||
except ValueError:
|
||||
timer = 10
|
||||
else:
|
||||
if timer > 60 * 60:
|
||||
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
||||
|
||||
async_start(countdown(self.ctx, timer))
|
||||
return True
|
||||
|
||||
@@ -1372,10 +1361,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
def _cmd_remaining(self) -> bool:
|
||||
"""List remaining items in your game, but not their location or recipient"""
|
||||
if self.ctx.remaining_mode == "enabled":
|
||||
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if rest_locations:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
||||
for slot, item_id in rest_locations))
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if remaining_item_ids:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
|
||||
for item_id in remaining_item_ids))
|
||||
else:
|
||||
self.output("No remaining items found.")
|
||||
return True
|
||||
@@ -1385,10 +1374,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
return False
|
||||
else: # is goal
|
||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if rest_locations:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
||||
for slot, item_id in rest_locations))
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if remaining_item_ids:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
|
||||
for item_id in remaining_item_ids))
|
||||
else:
|
||||
self.output("No remaining items found.")
|
||||
return True
|
||||
@@ -1656,10 +1645,25 @@ def get_slot_points(ctx: Context, team: int, slot: int) -> int:
|
||||
ctx.get_hint_cost(slot) * ctx.hints_used[team, slot])
|
||||
|
||||
|
||||
async def process_get(ctx: Context, client: Client, args: dict, cmd: dict):
|
||||
if "keys" not in args or not isinstance(args["keys"], list):
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
|
||||
"text": 'Retrieve', "original_cmd": cmd}])
|
||||
return
|
||||
args["cmd"] = "Retrieved"
|
||||
keys = args["keys"]
|
||||
args["keys"] = {
|
||||
key: ctx.read_data.get(key[6:], lambda: None)() if key.startswith("_read_") else
|
||||
ctx.stored_data.get(key, None)
|
||||
for key in keys
|
||||
}
|
||||
await ctx.send_msgs(client, [args])
|
||||
|
||||
|
||||
async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
try:
|
||||
cmd: str = args["cmd"]
|
||||
except:
|
||||
except Exception:
|
||||
ctx.logger.exception(f"Could not get command from {args}")
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", "original_cmd": None,
|
||||
"text": f"Could not get command from {args} at `cmd`"}])
|
||||
@@ -1762,6 +1766,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": {"games": ctx.gamespackage}}])
|
||||
|
||||
elif cmd == "Get" and args.get("keys", None) and all(key in ctx.public_stored_data_keys for key in args["keys"]):
|
||||
await process_get(ctx, client, args, cmd)
|
||||
|
||||
elif client.auth:
|
||||
if cmd == "ConnectUpdate":
|
||||
if not args:
|
||||
@@ -1857,18 +1864,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
await ctx.send_encoded_msgs(bounceclient, msg)
|
||||
|
||||
elif cmd == "Get":
|
||||
if "keys" not in args or type(args["keys"]) != list:
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
|
||||
"text": 'Retrieve', "original_cmd": cmd}])
|
||||
return
|
||||
args["cmd"] = "Retrieved"
|
||||
keys = args["keys"]
|
||||
args["keys"] = {
|
||||
key: ctx.read_data.get(key[6:], lambda: None)() if key.startswith("_read_") else
|
||||
ctx.stored_data.get(key, None)
|
||||
for key in keys
|
||||
}
|
||||
await ctx.send_msgs(client, [args])
|
||||
await process_get(ctx, client, args, cmd)
|
||||
|
||||
elif cmd == "Set":
|
||||
if "key" not in args or args["key"].startswith("_read_") or \
|
||||
@@ -1954,6 +1950,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
def _cmd_exit(self) -> bool:
|
||||
"""Shutdown the server"""
|
||||
self.ctx.server.ws_server.close()
|
||||
if self.ctx.shutdown_task:
|
||||
self.ctx.shutdown_task.cancel()
|
||||
self.ctx.exit_event.set()
|
||||
return True
|
||||
|
||||
@@ -2061,8 +2059,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
item_name, usable, response = get_intended_text(item_name, names)
|
||||
if usable:
|
||||
amount: int = int(amount)
|
||||
if amount > 100:
|
||||
raise ValueError(f"{amount} is invalid. Maximum is 100.")
|
||||
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
|
||||
send_items_to(self.ctx, team, slot, *new_items)
|
||||
|
||||
@@ -2313,8 +2309,7 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
|
||||
async def auto_shutdown(ctx, to_cancel=None):
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)
|
||||
await asyncio.sleep(ctx.auto_shutdown)
|
||||
|
||||
def inactivity_shutdown():
|
||||
ctx.server.ws_server.close()
|
||||
@@ -2334,8 +2329,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
||||
if seconds < 0:
|
||||
inactivity_shutdown()
|
||||
else:
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(ctx.exit_event.wait(), seconds)
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
|
||||
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":
|
||||
|
||||
12
NetUtils.py
12
NetUtils.py
@@ -79,7 +79,6 @@ class NetworkItem(typing.NamedTuple):
|
||||
item: int
|
||||
location: int
|
||||
player: int
|
||||
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
|
||||
flags: int = 0
|
||||
|
||||
|
||||
@@ -199,8 +198,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
"slateblue": "6D8BE8",
|
||||
"plum": "AF99EF",
|
||||
"salmon": "FA8072",
|
||||
"white": "FFFFFF",
|
||||
"orange": "FF7700",
|
||||
"white": "FFFFFF"
|
||||
}
|
||||
|
||||
def __init__(self, ctx):
|
||||
@@ -398,12 +396,12 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
||||
location_id not in checked]
|
||||
|
||||
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
||||
) -> typing.List[typing.Tuple[int, int]]:
|
||||
) -> typing.List[int]:
|
||||
checked = state[team, slot]
|
||||
player_locations = self[slot]
|
||||
return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for
|
||||
location_id in player_locations if
|
||||
location_id not in checked])
|
||||
return sorted([player_locations[location_id][0] for
|
||||
location_id in player_locations if
|
||||
location_id not in checked])
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||
|
||||
165
Options.py
165
Options.py
@@ -53,8 +53,8 @@ class AssembleOptions(abc.ABCMeta):
|
||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||
options.update(new_options)
|
||||
# apply aliases, without name_lookup
|
||||
aliases = attrs["aliases"] = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("alias_")}
|
||||
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("alias_")}
|
||||
|
||||
assert (
|
||||
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
|
||||
@@ -126,28 +126,10 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
# can be weighted between selections
|
||||
supports_weighting = True
|
||||
|
||||
rich_text_doc: typing.Optional[bool] = None
|
||||
"""Whether the WebHost should render the Option's docstring as rich text.
|
||||
|
||||
If this is True, the Option's docstring is interpreted as reStructuredText_,
|
||||
the standard Python markup format. In the WebHost, it's rendered to HTML so
|
||||
that lists, emphasis, and other rich text features are displayed properly.
|
||||
|
||||
If this is False, the docstring is instead interpreted as plain text, and
|
||||
displayed as-is on the WebHost with whitespace preserved.
|
||||
|
||||
If this is None, it inherits the value of `World.rich_text_options_doc`. For
|
||||
backwards compatibility, this defaults to False, but worlds are encouraged to
|
||||
set it to True and use reStructuredText for their Option documentation.
|
||||
|
||||
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
|
||||
"""
|
||||
|
||||
# filled by AssembleOptions:
|
||||
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
|
||||
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore
|
||||
options: typing.ClassVar[typing.Dict[str, int]]
|
||||
aliases: typing.ClassVar[typing.Dict[str, int]]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.current_option_name})"
|
||||
@@ -753,12 +735,6 @@ class NamedRange(Range):
|
||||
elif value > self.range_end and value not in self.special_range_names.values():
|
||||
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
|
||||
f"and is also not one of the supported named special values: {self.special_range_names}")
|
||||
|
||||
# See docstring
|
||||
for key in self.special_range_names:
|
||||
if key != key.lower():
|
||||
raise Exception(f"{self.__class__.__name__} has an invalid special_range_names key: {key}. "
|
||||
f"NamedRange keys must use only lowercase letters, and ideally should be snake_case.")
|
||||
self.value = value
|
||||
|
||||
@classmethod
|
||||
@@ -786,22 +762,17 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
||||
verify_location_name: bool = False
|
||||
value: typing.Any
|
||||
|
||||
def verify_keys(self) -> None:
|
||||
if self.valid_keys:
|
||||
data = set(self.value)
|
||||
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
|
||||
extra = dataset - self._valid_keys
|
||||
@classmethod
|
||||
def verify_keys(cls, data: typing.Iterable[str]) -> None:
|
||||
if cls.valid_keys:
|
||||
data = set(data)
|
||||
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
||||
extra = dataset - cls._valid_keys
|
||||
if extra:
|
||||
raise OptionError(
|
||||
f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
|
||||
f"Allowed keys: {self._valid_keys}."
|
||||
)
|
||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||
f"Allowed keys: {cls._valid_keys}.")
|
||||
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||
try:
|
||||
self.verify_keys()
|
||||
except OptionError as validation_error:
|
||||
raise OptionError(f"Player {player_name} has invalid option keys:\n{validation_error}")
|
||||
if self.convert_name_groups and self.verify_item_name:
|
||||
new_value = type(self.value)() # empty container of whatever value is
|
||||
for item_name in self.value:
|
||||
@@ -838,6 +809,7 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||
if type(data) == dict:
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
else:
|
||||
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
||||
@@ -883,6 +855,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
if is_iterable_except_str(data):
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
@@ -908,6 +881,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
if is_iterable_except_str(data):
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
@@ -950,19 +924,6 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
self.value = []
|
||||
logging.warning(f"The plando texts module is turned off, "
|
||||
f"so text for {player_name} will be ignored.")
|
||||
else:
|
||||
super().verify(world, player_name, plando_options)
|
||||
|
||||
def verify_keys(self) -> None:
|
||||
if self.valid_keys:
|
||||
data = set(text.at for text in self)
|
||||
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
|
||||
extra = dataset - self._valid_keys
|
||||
if extra:
|
||||
raise OptionError(
|
||||
f"Invalid \"at\" placement {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
|
||||
f"Allowed placements: {self._valid_keys}."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
|
||||
@@ -986,6 +947,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
texts.append(text)
|
||||
else:
|
||||
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
||||
cls.verify_keys([text.at for text in texts])
|
||||
return cls(texts)
|
||||
else:
|
||||
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
|
||||
@@ -1158,48 +1120,27 @@ 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.
|
||||
"""
|
||||
"""Set rules for reachability of your items/locations.
|
||||
Locations: ensure everything can be reached and acquired.
|
||||
Items: ensure all logically relevant items can be acquired.
|
||||
Minimal: ensure what is needed to reach your goal can be acquired."""
|
||||
display_name = "Accessibility"
|
||||
rich_text_doc = True
|
||||
option_full = 0
|
||||
option_locations = 0
|
||||
option_items = 1
|
||||
option_minimal = 2
|
||||
alias_none = 2
|
||||
alias_locations = 0
|
||||
alias_items = 0
|
||||
default = 0
|
||||
|
||||
|
||||
class ItemsAccessibility(Accessibility):
|
||||
"""
|
||||
Set rules for reachability of your items/locations.
|
||||
|
||||
**Full:** ensure everything can be reached and acquired.
|
||||
|
||||
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
||||
|
||||
**Items:** ensure all logically relevant items can be acquired. Some items, such as keys, may be self-locking, and
|
||||
some locations may be inaccessible.
|
||||
"""
|
||||
option_items = 1
|
||||
default = 1
|
||||
|
||||
|
||||
class ProgressionBalancing(NamedRange):
|
||||
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||
|
||||
"""
|
||||
A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||
A lower setting means more getting stuck. A higher setting means less getting stuck.
|
||||
"""
|
||||
default = 50
|
||||
range_start = 0
|
||||
range_end = 99
|
||||
display_name = "Progression Balancing"
|
||||
rich_text_doc = True
|
||||
special_range_names = {
|
||||
"disabled": 0,
|
||||
"normal": 50,
|
||||
@@ -1236,7 +1177,6 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
||||
:param option_names: names of the options to return
|
||||
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
||||
"""
|
||||
assert option_names, "options.as_dict() was used without any option names."
|
||||
option_results = {}
|
||||
for option_name in option_names:
|
||||
if option_name in type(self).type_hints:
|
||||
@@ -1265,36 +1205,29 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
||||
class LocalItems(ItemSet):
|
||||
"""Forces these items to be in their native world."""
|
||||
display_name = "Local Items"
|
||||
rich_text_doc = True
|
||||
|
||||
|
||||
class NonLocalItems(ItemSet):
|
||||
"""Forces these items to be outside their native world."""
|
||||
display_name = "Non-local Items"
|
||||
rich_text_doc = True
|
||||
|
||||
|
||||
class StartInventory(ItemDict):
|
||||
"""Start with these items."""
|
||||
verify_item_name = True
|
||||
display_name = "Start Inventory"
|
||||
rich_text_doc = True
|
||||
|
||||
|
||||
class StartInventoryPool(StartInventory):
|
||||
"""Start with these items and don't place them in the world.
|
||||
|
||||
The game decides what the replacement items will be.
|
||||
"""
|
||||
The game decides what the replacement items will be."""
|
||||
verify_item_name = True
|
||||
display_name = "Start Inventory from Pool"
|
||||
rich_text_doc = True
|
||||
|
||||
|
||||
class StartHints(ItemSet):
|
||||
"""Start with these item's locations prefilled into the ``!hint`` command."""
|
||||
"""Start with these item's locations prefilled into the !hint command."""
|
||||
display_name = "Start Hints"
|
||||
rich_text_doc = True
|
||||
|
||||
|
||||
class LocationSet(OptionSet):
|
||||
@@ -1303,33 +1236,28 @@ class LocationSet(OptionSet):
|
||||
|
||||
|
||||
class StartLocationHints(LocationSet):
|
||||
"""Start with these locations and their item prefilled into the ``!hint`` command."""
|
||||
"""Start with these locations and their item prefilled into the !hint command"""
|
||||
display_name = "Start Location Hints"
|
||||
rich_text_doc = True
|
||||
|
||||
|
||||
class ExcludeLocations(LocationSet):
|
||||
"""Prevent these locations from having an important item."""
|
||||
"""Prevent these locations from having an important item"""
|
||||
display_name = "Excluded Locations"
|
||||
rich_text_doc = True
|
||||
|
||||
|
||||
class PriorityLocations(LocationSet):
|
||||
"""Prevent these locations from having an unimportant item."""
|
||||
"""Prevent these locations from having an unimportant item"""
|
||||
display_name = "Priority Locations"
|
||||
rich_text_doc = True
|
||||
|
||||
|
||||
class DeathLink(Toggle):
|
||||
"""When you die, everyone dies. Of course the reverse is true too."""
|
||||
display_name = "Death Link"
|
||||
rich_text_doc = True
|
||||
|
||||
|
||||
class ItemLinks(OptionList):
|
||||
"""Share part of your item pool with other players."""
|
||||
display_name = "Item Links"
|
||||
rich_text_doc = True
|
||||
default = []
|
||||
schema = Schema([
|
||||
{
|
||||
@@ -1396,7 +1324,6 @@ class ItemLinks(OptionList):
|
||||
|
||||
class Removed(FreeText):
|
||||
"""This Option has been Removed."""
|
||||
rich_text_doc = True
|
||||
default = ""
|
||||
visibility = Visibility.none
|
||||
|
||||
@@ -1499,18 +1426,14 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
|
||||
return data, notes
|
||||
|
||||
def yaml_dump_scalar(scalar) -> str:
|
||||
# yaml dump may add end of document marker and newlines.
|
||||
return yaml.dump(scalar).replace("...\n", "").strip()
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden or generate_hidden:
|
||||
option_groups = get_option_groups(world)
|
||||
grouped_options = get_option_groups(world)
|
||||
with open(local_path("data", "options.yaml")) as f:
|
||||
file_data = f.read()
|
||||
res = Template(file_data).render(
|
||||
option_groups=option_groups,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
||||
option_groups=grouped_options,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range,
|
||||
)
|
||||
|
||||
@@ -1518,3 +1441,31 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
|
||||
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||
f.write(res)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
from worlds.alttp.Options import Logic
|
||||
import argparse
|
||||
|
||||
map_shuffle = Toggle
|
||||
compass_shuffle = Toggle
|
||||
key_shuffle = Toggle
|
||||
big_key_shuffle = Toggle
|
||||
hints = Toggle
|
||||
test = argparse.Namespace()
|
||||
test.logic = Logic.from_text("no_logic")
|
||||
test.map_shuffle = map_shuffle.from_text("ON")
|
||||
test.hints = hints.from_text('OFF')
|
||||
try:
|
||||
test.logic = Logic.from_text("overworld_glitches_typo")
|
||||
except KeyError as e:
|
||||
print(e)
|
||||
try:
|
||||
test.logic_owg = Logic.from_text("owg")
|
||||
except KeyError as e:
|
||||
print(e)
|
||||
if test.map_shuffle:
|
||||
print("map_shuffle is on")
|
||||
print(f"Hints are {bool(test.hints)}")
|
||||
print(test)
|
||||
|
||||
@@ -72,10 +72,6 @@ Currently, the following games are supported:
|
||||
* Aquaria
|
||||
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
||||
* A Hat in Time
|
||||
* Old School Runescape
|
||||
* Kingdom Hearts 1
|
||||
* Mega Man 2
|
||||
* Yacht Dice
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
||||
@@ -29,7 +29,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_patch(self):
|
||||
"""Patch the game. Only use this command if /auto_patch fails."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
|
||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
|
||||
self.ctx.patch_game()
|
||||
self.output("Patched.")
|
||||
|
||||
@@ -43,7 +43,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
|
||||
"""Patch the game automatically."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
|
||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
|
||||
tempInstall = steaminstall
|
||||
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||
tempInstall = None
|
||||
@@ -62,7 +62,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||
for file_name in os.listdir(tempInstall):
|
||||
if file_name != "steam_api.dll":
|
||||
shutil.copy(os.path.join(tempInstall, file_name),
|
||||
Utils.user_path("Undertale", file_name))
|
||||
os.path.join(os.getcwd(), "Undertale", file_name))
|
||||
self.ctx.patch_game()
|
||||
self.output("Patching successful!")
|
||||
|
||||
@@ -111,12 +111,12 @@ class UndertaleContext(CommonContext):
|
||||
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
|
||||
|
||||
def patch_game(self):
|
||||
with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
|
||||
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f:
|
||||
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
|
||||
with open(Utils.user_path("Undertale", "data.win"), "wb") as f:
|
||||
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f:
|
||||
f.write(patchedFile)
|
||||
os.makedirs(name=Utils.user_path("Undertale", "Custom Sprites"), exist_ok=True)
|
||||
with open(os.path.expandvars(Utils.user_path("Undertale", "Custom Sprites",
|
||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
|
||||
with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites",
|
||||
"Which Character.txt")), "w") as f:
|
||||
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
|
||||
"line other than this one.\n", "frisk"])
|
||||
@@ -247,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
toDraw = ""
|
||||
for i in range(20):
|
||||
if i < len(str(ctx.item_names.lookup_in_game(l.item))):
|
||||
toDraw += str(ctx.item_names.lookup_in_game(l.item))[i]
|
||||
if i < len(str(ctx.item_names.lookup_in_slot(l.item))):
|
||||
toDraw += str(ctx.item_names.lookup_in_slot(l.item))[i]
|
||||
else:
|
||||
break
|
||||
f.write(toDraw)
|
||||
|
||||
1
Utils.py
1
Utils.py
@@ -553,7 +553,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
f"Archipelago ({__version__}) logging initialized"
|
||||
f" on {platform.platform()}"
|
||||
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||
f"{' (frozen)' if is_frozen() else ''}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ class WargrooveContext(CommonContext):
|
||||
if not os.path.isfile(path):
|
||||
open(path, 'w').close()
|
||||
# Announcing commander unlocks
|
||||
item_name = self.item_names.lookup_in_game(network_item.item)
|
||||
item_name = self.item_names.lookup_in_slot(network_item.item)
|
||||
if item_name in faction_table.keys():
|
||||
for commander in faction_table[item_name]:
|
||||
logger.info(f"{commander.name} has been unlocked!")
|
||||
@@ -197,7 +197,7 @@ class WargrooveContext(CommonContext):
|
||||
open(print_path, 'w').close()
|
||||
with open(print_path, 'w') as f:
|
||||
f.write("Received " +
|
||||
self.item_names.lookup_in_game(network_item.item) +
|
||||
self.item_names.lookup_in_slot(network_item.item) +
|
||||
" from " +
|
||||
self.player_names[network_item.player])
|
||||
f.close()
|
||||
@@ -342,7 +342,7 @@ class WargrooveContext(CommonContext):
|
||||
faction_items = 0
|
||||
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
|
||||
for network_item in self.items_received:
|
||||
if self.item_names.lookup_in_game(network_item.item) in faction_item_names:
|
||||
if self.item_names.lookup_in_slot(network_item.item) in faction_item_names:
|
||||
faction_items += 1
|
||||
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
|
||||
# Must be an integer larger than 0
|
||||
|
||||
16
WebHost.py
16
WebHost.py
@@ -1,4 +1,3 @@
|
||||
import argparse
|
||||
import os
|
||||
import multiprocessing
|
||||
import logging
|
||||
@@ -13,9 +12,6 @@ ModuleUpdate.update()
|
||||
import Utils
|
||||
import settings
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from flask import Flask
|
||||
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||
settings.no_gui = True
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
@@ -23,7 +19,7 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||
|
||||
|
||||
def get_app() -> "Flask":
|
||||
def get_app():
|
||||
from WebHostLib import register, cache, app as raw_app
|
||||
from WebHostLib.models import db
|
||||
|
||||
@@ -32,15 +28,6 @@ def get_app() -> "Flask":
|
||||
import yaml
|
||||
app.config.from_file(configpath, yaml.safe_load)
|
||||
logging.info(f"Updated config from {configpath}")
|
||||
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--config_override', default=None,
|
||||
help="Path to yaml config file that overrules config.yaml.")
|
||||
args = parser.parse_known_args()[0]
|
||||
if args.config_override:
|
||||
import yaml
|
||||
app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
|
||||
logging.info(f"Updated config from {args.config_override}")
|
||||
if not app.config["HOST_ADDRESS"]:
|
||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||
@@ -68,7 +55,6 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
||||
worlds[game] = world
|
||||
|
||||
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
|
||||
shutil.rmtree(base_target_path, ignore_errors=True)
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
target_path = os.path.join(base_target_path, game)
|
||||
|
||||
@@ -5,6 +5,7 @@ from uuid import UUID
|
||||
from flask import Blueprint, abort, url_for
|
||||
|
||||
import worlds.Files
|
||||
from .. import cache
|
||||
from ..models import Room, Seed
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
@@ -48,4 +49,21 @@ def room_info(room: UUID):
|
||||
}
|
||||
|
||||
|
||||
from . import generate, user, datapackage # trigger registration
|
||||
@api_endpoints.route('/datapackage')
|
||||
@cache.cached()
|
||||
def get_datapackage():
|
||||
from worlds import network_data_package
|
||||
return network_data_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_checksum')
|
||||
@cache.cached()
|
||||
def get_datapackage_checksums():
|
||||
from worlds import network_data_package
|
||||
version_package = {
|
||||
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
|
||||
}
|
||||
return version_package
|
||||
|
||||
|
||||
from . import generate, user # trigger registration
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
from flask import abort
|
||||
|
||||
from Utils import restricted_loads
|
||||
from WebHostLib import cache
|
||||
from WebHostLib.models import GameDataPackage
|
||||
from . import api_endpoints
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage')
|
||||
@cache.cached()
|
||||
def get_datapackage():
|
||||
from worlds import network_data_package
|
||||
return network_data_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage/<string:checksum>')
|
||||
@cache.memoize(timeout=3600)
|
||||
def get_datapackage_by_checksum(checksum: str):
|
||||
package = GameDataPackage.get(checksum=checksum)
|
||||
if package:
|
||||
return restricted_loads(package.data)
|
||||
return abort(404)
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_checksum')
|
||||
@cache.cached()
|
||||
def get_datapackage_checksums():
|
||||
from worlds import network_data_package
|
||||
version_package = {
|
||||
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
|
||||
}
|
||||
return version_package
|
||||
@@ -72,14 +72,6 @@ class WebHostContext(Context):
|
||||
self.video = {}
|
||||
self.tags = ["AP", "WebHost"]
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
import psutil
|
||||
from Utils import format_SI_prefix
|
||||
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
|
||||
except ImportError:
|
||||
self.logger.debug("Context destroyed")
|
||||
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
||||
@@ -176,28 +168,17 @@ def get_random_port():
|
||||
def get_static_server_data() -> dict:
|
||||
import worlds
|
||||
data = {
|
||||
"non_hintable_names": {
|
||||
world_name: world.hint_blacklist
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"gamespackage": {
|
||||
world_name: {
|
||||
key: value
|
||||
for key, value in game_package.items()
|
||||
if key not in ("item_name_groups", "location_name_groups")
|
||||
}
|
||||
for world_name, game_package in worlds.network_data_package["games"].items()
|
||||
},
|
||||
"item_name_groups": {
|
||||
world_name: world.item_name_groups
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"location_name_groups": {
|
||||
world_name: world.location_name_groups
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"non_hintable_names": {},
|
||||
"gamespackage": worlds.network_data_package["games"],
|
||||
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
}
|
||||
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -257,7 +238,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
ctx = WebHostContext(static_server_data, logger)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
assert ctx.server is None
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||
@@ -286,16 +266,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
||||
with db_session:
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
if ctx.saving:
|
||||
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
|
||||
assert ctx.shutdown_task is None
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
await ctx.shutdown_task
|
||||
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
if ctx.saving:
|
||||
ctx._save()
|
||||
setattr(asyncio.current_task(), "save", None)
|
||||
except Exception as e:
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
@@ -305,12 +281,8 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
else:
|
||||
if ctx.saving:
|
||||
ctx._save()
|
||||
setattr(asyncio.current_task(), "save", None)
|
||||
finally:
|
||||
try:
|
||||
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
|
||||
ctx.exit_event.set() # make sure the saving thread stops at some point
|
||||
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
|
||||
with (db_session):
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room = Room.get(id=room_id)
|
||||
@@ -322,34 +294,13 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
rooms_shutting_down.put(room_id)
|
||||
|
||||
class Starter(threading.Thread):
|
||||
_tasks: typing.List[asyncio.Future]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._tasks = []
|
||||
|
||||
def _done(self, task: asyncio.Future):
|
||||
self._tasks.remove(task)
|
||||
task.result()
|
||||
|
||||
def run(self):
|
||||
while 1:
|
||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||
gc.collect()
|
||||
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||
self._tasks.append(task)
|
||||
task.add_done_callback(self._done)
|
||||
asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||
logging.info(f"Starting room {next_room} on {name}.")
|
||||
del task # delete reference to task object
|
||||
|
||||
starter = Starter()
|
||||
starter.daemon = True
|
||||
starter.start()
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
# save all tasks that want to be saved during shutdown
|
||||
for task in asyncio.all_tasks(loop):
|
||||
save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None)
|
||||
if save:
|
||||
save()
|
||||
loop.run_forever()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime
|
||||
import os
|
||||
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
||||
from typing import List, Dict, Union
|
||||
|
||||
import jinja2.exceptions
|
||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
@@ -97,37 +97,25 @@ def new_room(seed: UUID):
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]:
|
||||
marker = log.read(3) # skip optional BOM
|
||||
if marker != b'\xEF\xBB\xBF':
|
||||
log.seek(0, os.SEEK_SET)
|
||||
log.seek(offset, os.SEEK_CUR)
|
||||
yield from log
|
||||
log.close() # free file handle as soon as possible
|
||||
def _read_log(path: str):
|
||||
if os.path.exists(path):
|
||||
with open(path, encoding="utf-8-sig") as log:
|
||||
yield from log
|
||||
else:
|
||||
yield f"Logfile {path} does not exist. " \
|
||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
|
||||
|
||||
|
||||
@app.route('/log/<suuid:room>')
|
||||
def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
|
||||
def display_log(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if room.owner == session["_id"]:
|
||||
file_path = os.path.join("logs", str(room.id) + ".txt")
|
||||
try:
|
||||
log = open(file_path, "rb")
|
||||
range_header = request.headers.get("Range")
|
||||
if range_header:
|
||||
range_type, range_values = range_header.split('=')
|
||||
start, end = map(str.strip, range_values.split('-', 1))
|
||||
if range_type != "bytes" or end != "":
|
||||
return "Unsupported range", 500
|
||||
# NOTE: we skip Content-Range in the response here, which isn't great but works for our JS
|
||||
return Response(_read_log(log, int(start)), mimetype="text/plain", status=206)
|
||||
return Response(_read_log(log), mimetype="text/plain")
|
||||
except FileNotFoundError:
|
||||
return Response(f"Logfile {file_path} does not exist. "
|
||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up.",
|
||||
mimetype="text/plain")
|
||||
if os.path.exists(file_path):
|
||||
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
|
||||
return "Log File does not exist."
|
||||
|
||||
return "Access Denied", 403
|
||||
|
||||
@@ -151,22 +139,7 @@ def host_room(room: UUID):
|
||||
with db_session:
|
||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||
|
||||
def get_log(max_size: int = 1024000) -> str:
|
||||
try:
|
||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
||||
raw_size = 0
|
||||
fragments: List[str] = []
|
||||
for block in _read_log(log):
|
||||
if raw_size + len(block) > max_size:
|
||||
fragments.append("…")
|
||||
break
|
||||
raw_size += len(block)
|
||||
fragments.append(block.decode("utf-8"))
|
||||
return "".join(fragments)
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
|
||||
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
|
||||
@@ -3,7 +3,6 @@ import json
|
||||
import os
|
||||
from textwrap import dedent
|
||||
from typing import Dict, Union
|
||||
from docutils.core import publish_parts
|
||||
|
||||
import yaml
|
||||
from flask import redirect, render_template, request, Response
|
||||
@@ -67,22 +66,6 @@ def filter_dedent(text: str) -> str:
|
||||
return dedent(text).strip("\n ")
|
||||
|
||||
|
||||
@app.template_filter("rst_to_html")
|
||||
def filter_rst_to_html(text: str) -> str:
|
||||
"""Converts reStructuredText (such as a Python docstring) to HTML."""
|
||||
if text.startswith(" ") or text.startswith("\t"):
|
||||
text = dedent(text)
|
||||
elif "\n" in text:
|
||||
lines = text.splitlines()
|
||||
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
||||
|
||||
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
|
||||
'raw_enable': False,
|
||||
'file_insertion_enabled': False,
|
||||
'output_encoding': 'unicode'
|
||||
})['body']
|
||||
|
||||
|
||||
@app.template_test("ordered")
|
||||
def test_ordered(obj):
|
||||
return isinstance(obj, collections.abc.Sequence)
|
||||
@@ -231,13 +214,6 @@ def generate_yaml(game: str):
|
||||
|
||||
del options[key]
|
||||
|
||||
# Detect keys which end with -range, indicating a NamedRange with a possible custom value
|
||||
elif key_parts[-1].endswith("-range"):
|
||||
if options[key_parts[-1][:-6]] == "custom":
|
||||
options[key_parts[-1][:-6]] = val
|
||||
|
||||
del options[key]
|
||||
|
||||
# Detect random-* keys and set their options accordingly
|
||||
for key, val in options.copy().items():
|
||||
if key.startswith("random-"):
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
flask>=3.0.3
|
||||
werkzeug>=3.0.4
|
||||
pony>=0.7.19
|
||||
waitress>=3.0.0
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress>=1.15
|
||||
Flask-Limiter>=3.8.0
|
||||
flask>=3.0.0
|
||||
pony>=0.7.17
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.1.0
|
||||
Flask-Compress>=1.14
|
||||
Flask-Limiter>=3.5.0
|
||||
bokeh>=3.1.1; python_version <= '3.8'
|
||||
bokeh>=3.4.3; python_version == '3.9'
|
||||
bokeh>=3.5.2; python_version >= '3.10'
|
||||
markupsafe>=2.1.5
|
||||
bokeh>=3.3.2; python_version >= '3.9'
|
||||
markupsafe>=2.1.3
|
||||
|
||||
@@ -8,8 +8,7 @@ from . import cache
|
||||
def robots():
|
||||
# If this host is not official, do not allow search engine crawling
|
||||
if not app.config["ASSET_RIGHTS"]:
|
||||
# filename changed in case the path is intercepted and served by an outside service
|
||||
return app.send_static_file('robots_file.txt')
|
||||
return app.send_static_file('robots.txt')
|
||||
|
||||
# Send 404 if the host has affirmed this to be the official WebHost
|
||||
abort(404)
|
||||
|
||||
@@ -15,7 +15,7 @@ html {
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #eeffeb;
|
||||
word-break: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
#player-options #player-options-header h1 {
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -16,7 +16,7 @@ html{
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #eeffeb;
|
||||
word-break: break-word;
|
||||
word-break: break-all;
|
||||
|
||||
#player-options-header{
|
||||
h1{
|
||||
|
||||
@@ -12,12 +12,12 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
*/
|
||||
|
||||
/* Base styles for the element that has a tooltip */
|
||||
[data-tooltip], .tooltip-container {
|
||||
[data-tooltip], .tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Base styles for the entire tooltip */
|
||||
[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
|
||||
[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
@@ -39,15 +39,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
|
||||
.tooltip-container:hover .tooltip {
|
||||
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/** Directional arrow styles */
|
||||
[data-tooltip]:before, .tooltip-container:before {
|
||||
.tooltip:before, [data-tooltip]:before {
|
||||
z-index: 10000;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
@@ -55,7 +54,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
}
|
||||
|
||||
/** Content styles */
|
||||
[data-tooltip]:after, .tooltip {
|
||||
.tooltip:after, [data-tooltip]:after {
|
||||
width: 260px;
|
||||
z-index: 10000;
|
||||
padding: 8px;
|
||||
@@ -64,26 +63,24 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
background-color: hsla(0, 0%, 20%, 0.9);
|
||||
color: #fff;
|
||||
content: attr(data-tooltip);
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
[data-tooltip]:after {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
|
||||
[data-tooltip]:before, [data-tooltip]:after{
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
|
||||
[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after,
|
||||
.tooltip-top:before, .tooltip-top:after {
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
[data-tooltip]:before, .tooltip-container:before {
|
||||
[data-tooltip]:before, .tooltip:before, .tooltip-top:before {
|
||||
margin-left: -6px;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #000;
|
||||
@@ -91,19 +88,19 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
}
|
||||
|
||||
/** Horizontally align tooltips on the top and bottom */
|
||||
[data-tooltip]:after, .tooltip {
|
||||
[data-tooltip]:after, .tooltip:after, .tooltip-top:after {
|
||||
margin-left: -80px;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
|
||||
.tooltip-container:hover .tooltip {
|
||||
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after,
|
||||
.tooltip-top:hover:before, .tooltip-top:hover:after {
|
||||
-webkit-transform: translateY(-12px);
|
||||
-moz-transform: translateY(-12px);
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
|
||||
/** Tooltips on the left */
|
||||
.tooltip-left:before, [data-tooltip].tooltip-left:after, .tooltip-left .tooltip {
|
||||
.tooltip-left:before, .tooltip-left:after {
|
||||
right: 100%;
|
||||
bottom: 50%;
|
||||
left: auto;
|
||||
@@ -118,14 +115,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
border-left-color: hsla(0, 0%, 20%, 0.9);
|
||||
}
|
||||
|
||||
.tooltip-left:hover:before, [data-tooltip].tooltip-left:hover:after, .tooltip-left:hover .tooltip {
|
||||
.tooltip-left:hover:before, .tooltip-left:hover:after {
|
||||
-webkit-transform: translateX(-12px);
|
||||
-moz-transform: translateX(-12px);
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
|
||||
/** Tooltips on the bottom */
|
||||
.tooltip-bottom:before, [data-tooltip].tooltip-bottom:after, .tooltip-bottom .tooltip {
|
||||
.tooltip-bottom:before, .tooltip-bottom:after {
|
||||
top: 100%;
|
||||
bottom: auto;
|
||||
left: 50%;
|
||||
@@ -139,15 +136,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
border-bottom-color: hsla(0, 0%, 20%, 0.9);
|
||||
}
|
||||
|
||||
.tooltip-bottom:hover:before, [data-tooltip].tooltip-bottom:hover:after,
|
||||
.tooltip-bottom:hover .tooltip {
|
||||
.tooltip-bottom:hover:before, .tooltip-bottom:hover:after {
|
||||
-webkit-transform: translateY(12px);
|
||||
-moz-transform: translateY(12px);
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
/** Tooltips on the right */
|
||||
.tooltip-right:before, [data-tooltip].tooltip-right:after, .tooltip-right .tooltip {
|
||||
.tooltip-right:before, .tooltip-right:after {
|
||||
bottom: 50%;
|
||||
left: 100%;
|
||||
}
|
||||
@@ -160,8 +156,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
border-right-color: hsla(0, 0%, 20%, 0.9);
|
||||
}
|
||||
|
||||
.tooltip-right:hover:before, [data-tooltip].tooltip-right:hover:after,
|
||||
.tooltip-right:hover .tooltip {
|
||||
.tooltip-right:hover:before, .tooltip-right:hover:after {
|
||||
-webkit-transform: translateX(12px);
|
||||
-moz-transform: translateX(12px);
|
||||
transform: translateX(12px);
|
||||
@@ -173,16 +168,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
}
|
||||
|
||||
/** Center content vertically for tooltips ont he left and right */
|
||||
[data-tooltip].tooltip-left:after, [data-tooltip].tooltip-right:after,
|
||||
.tooltip-left .tooltip, .tooltip-right .tooltip {
|
||||
.tooltip-left:after, .tooltip-right:after {
|
||||
margin-left: 0;
|
||||
margin-bottom: -16px;
|
||||
}
|
||||
|
||||
.tooltip ul, .tooltip ol {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.tooltip :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
{{ macros.list_patches_room(room) }}
|
||||
{% if room.owner == session["_id"] %}
|
||||
<div style="display: flex; align-items: center;">
|
||||
<form method="post" id="command-form" style="flex-grow: 1; margin-right: 1em;">
|
||||
<form method=post style="flex-grow: 1; margin-right: 1em;">
|
||||
<div class="form-group">
|
||||
<label for="cmd"></label>
|
||||
<input class="form-control" type="text" id="cmd" name="cmd"
|
||||
@@ -55,89 +55,24 @@
|
||||
Open Log File...
|
||||
</a>
|
||||
</div>
|
||||
{% set log = get_log() -%}
|
||||
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
|
||||
<div id="logger" style="white-space: pre">{{ log }}</div>
|
||||
<script>
|
||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||
let bytesReceived = {{ log_len }};
|
||||
let updateLogTimeout;
|
||||
let awaitingCommandResponse = false;
|
||||
let logger = document.getElementById("logger");
|
||||
<div id="logger"></div>
|
||||
<script type="application/ecmascript">
|
||||
let xmlhttp = new XMLHttpRequest();
|
||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||
|
||||
function scrollToBottom(el) {
|
||||
let bot = el.scrollHeight - el.clientHeight;
|
||||
el.scrollTop += Math.ceil((bot - el.scrollTop)/10);
|
||||
if (bot - el.scrollTop >= 1) {
|
||||
window.clearTimeout(el.scrollTimer);
|
||||
el.scrollTimer = window.setTimeout(() => {
|
||||
scrollToBottom(el)
|
||||
}, 16);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLog() {
|
||||
try {
|
||||
let res = await fetch(url, {
|
||||
headers: {
|
||||
'Range': `bytes=${bytesReceived}-`,
|
||||
xmlhttp.onreadystatechange = function () {
|
||||
if (this.readyState === 4 && this.status === 200) {
|
||||
document.getElementById("logger").innerText = this.responseText;
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
let text = await res.text();
|
||||
if (text.length > 0) {
|
||||
awaitingCommandResponse = false;
|
||||
if (bytesReceived === 0 || res.status !== 206) {
|
||||
logger.innerHTML = '';
|
||||
}
|
||||
if (res.status !== 206) {
|
||||
bytesReceived = 0;
|
||||
} else {
|
||||
bytesReceived += new Blob([text]).size;
|
||||
}
|
||||
if (logger.innerHTML.endsWith('…')) {
|
||||
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
|
||||
}
|
||||
logger.appendChild(document.createTextNode(text));
|
||||
scrollToBottom(logger);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
window.clearTimeout(updateLogTimeout);
|
||||
updateLogTimeout = window.setTimeout(updateLog, awaitingCommandResponse ? 500 : 10000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function postForm(ev) {
|
||||
/** @type {HTMLInputElement} */
|
||||
let cmd = document.getElementById("cmd");
|
||||
if (cmd.value === "") {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
function request_new() {
|
||||
xmlhttp.open("GET", url, true);
|
||||
xmlhttp.send();
|
||||
}
|
||||
/** @type {HTMLFormElement} */
|
||||
let form = document.getElementById("command-form");
|
||||
let req = fetch(form.action || window.location.href, {
|
||||
method: form.method,
|
||||
body: new FormData(form),
|
||||
redirect: "manual",
|
||||
});
|
||||
ev.preventDefault(); // has to happen before first await
|
||||
form.reset();
|
||||
let res = await req;
|
||||
if (res.ok || res.type === 'opaqueredirect') {
|
||||
awaitingCommandResponse = true;
|
||||
window.clearTimeout(updateLogTimeout);
|
||||
updateLogTimeout = window.setTimeout(updateLog, 100);
|
||||
} else {
|
||||
window.alert(res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("command-form").addEventListener("submit", postForm);
|
||||
updateLogTimeout = window.setTimeout(updateLog, 1000);
|
||||
logger.scrollTop = logger.scrollHeight;
|
||||
window.setTimeout(request_new, 1000);
|
||||
window.setInterval(request_new, 10000);
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -54,27 +54,27 @@
|
||||
{% macro NamedRange(option_name, option) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="named-range-container">
|
||||
<select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
<select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
{% for key, val in option.special_range_names.items() %}
|
||||
{% if option.default == val %}
|
||||
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
|
||||
<option value="{{ val }}" selected>{{ key }} ({{ val }})</option>
|
||||
{% else %}
|
||||
<option value="{{ val }}">{{ key|replace("_", " ")|title }} ({{ val }})</option>
|
||||
<option value="{{ val }}">{{ key }} ({{ val }})</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<option value="custom" hidden>Custom</option>
|
||||
</select>
|
||||
<div class="named-range-wrapper js-required">
|
||||
<div class="named-range-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
id="{{ option_name }}"
|
||||
name="{{ option_name }}-range"
|
||||
name="{{ option_name }}"
|
||||
min="{{ option.range_start }}"
|
||||
max="{{ option.range_end }}"
|
||||
value="{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}"
|
||||
{{ "disabled" if option.default == "random" }}
|
||||
/>
|
||||
<span id="{{ option_name }}-value" class="range-value">
|
||||
<span id="{{ option_name }}-value" class="range-value js-required">
|
||||
{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}
|
||||
</span>
|
||||
{{ RandomizeButton(option_name, option) }}
|
||||
@@ -111,7 +111,7 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro ItemDict(option_name, option) %}
|
||||
{% macro ItemDict(option_name, option, world) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="option-container">
|
||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
||||
@@ -135,7 +135,7 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro LocationSet(option_name, option) %}
|
||||
{% macro LocationSet(option_name, option, world) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="option-container">
|
||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||
@@ -158,7 +158,7 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro ItemSet(option_name, option) %}
|
||||
{% macro ItemSet(option_name, option, world) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="option-container">
|
||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||
@@ -196,18 +196,7 @@
|
||||
{% macro OptionTitle(option_name, option) %}
|
||||
<label for="{{ option_name }}">
|
||||
{{ option.display_name|default(option_name) }}:
|
||||
<span
|
||||
class="interactive tooltip-container"
|
||||
{% if not (option.rich_text_doc | default(world.web.rich_text_options_doc, true)) %}
|
||||
data-tooltip="{{(option.__doc__ | default("Please document me!"))|replace('\n ', '\n')|escape|trim}}"
|
||||
{% endif %}>
|
||||
(?)
|
||||
{% if option.rich_text_doc | default(world.web.rich_text_options_doc, true) %}
|
||||
<div class="tooltip">
|
||||
{{ option.__doc__ | default("**Please document me!**") | rst_to_html | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="interactive" data-tooltip="{% filter dedent %}{{(option.__doc__ | default("Please document me!"))|escape }}{% endfilter %}">(?)</span>
|
||||
</label>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
{% import 'playerOptions/macros.html' as inputs with context %}
|
||||
{% import 'playerOptions/macros.html' as inputs %}
|
||||
|
||||
{% block head %}
|
||||
<title>{{ world_name }} Options</title>
|
||||
@@ -11,7 +11,7 @@
|
||||
<noscript>
|
||||
<style>
|
||||
.js-required{
|
||||
display: none !important;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
@@ -94,16 +94,16 @@
|
||||
{{ inputs.FreeText(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||
{{ inputs.ItemDict(option_name, option) }}
|
||||
{{ inputs.ItemDict(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||
{{ inputs.OptionList(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
|
||||
{{ inputs.LocationSet(option_name, option) }}
|
||||
{{ inputs.LocationSet(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
|
||||
{{ inputs.ItemSet(option_name, option) }}
|
||||
{{ inputs.ItemSet(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
|
||||
{{ inputs.OptionSet(option_name, option) }}
|
||||
@@ -134,16 +134,16 @@
|
||||
{{ inputs.FreeText(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||
{{ inputs.ItemDict(option_name, option) }}
|
||||
{{ inputs.ItemDict(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||
{{ inputs.OptionList(option_name, option) }}
|
||||
|
||||
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
|
||||
{{ inputs.LocationSet(option_name, option) }}
|
||||
{{ inputs.LocationSet(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
|
||||
{{ inputs.ItemSet(option_name, option) }}
|
||||
{{ inputs.ItemSet(option_name, option, world) }}
|
||||
|
||||
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
|
||||
{{ inputs.OptionSet(option_name, option) }}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{% macro Toggle(option_name, option) %}
|
||||
<table>
|
||||
<tbody>
|
||||
{{ RangeRow(option_name, option, "No", "false", False, "true" if option.default else "false") }}
|
||||
{{ RangeRow(option_name, option, "Yes", "true", False, "true" if option.default else "false") }}
|
||||
{{ RandomRow(option_name, option) }}
|
||||
{{ RangeRow(option_name, option, "No", "false") }}
|
||||
{{ RangeRow(option_name, option, "Yes", "true") }}
|
||||
{{ RandomRows(option_name, option) }}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
@@ -18,14 +18,10 @@
|
||||
<tbody>
|
||||
{% for id, name in option.name_lookup.items() %}
|
||||
{% if name != 'random' %}
|
||||
{% if option.default != 'random' %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
|
||||
{% else %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
||||
{% endif %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ RandomRow(option_name, option) }}
|
||||
{{ RandomRows(option_name, option) }}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
@@ -38,16 +34,16 @@
|
||||
Normal range: {{ option.range_start }} - {{ option.range_end }}
|
||||
{% if option.special_range_names %}
|
||||
<br /><br />
|
||||
The following values have special meanings, and may fall outside the normal range.
|
||||
The following values has special meaning, and may fall outside the normal range.
|
||||
<ul>
|
||||
{% for name, value in option.special_range_names.items() %}
|
||||
<li>{{ value }}: {{ name|replace("_", " ")|title }}</li>
|
||||
<li>{{ value }}: {{ name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<div class="add-option-div">
|
||||
<input type="number" class="range-option-value" data-option="{{ option_name }}" />
|
||||
<button type="button" class="add-range-option-button" data-option="{{ option_name }}">Add</button>
|
||||
<button class="add-range-option-button" data-option="{{ option_name }}">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="range-rows" data-option="{{ option_name }}">
|
||||
@@ -72,13 +68,11 @@
|
||||
This option allows custom values only. Please enter your desired values below.
|
||||
<div class="custom-value-wrapper">
|
||||
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
||||
<button type="button" data-option="{{ option_name }}">Add</button>
|
||||
<button data-option="{{ option_name }}">Add</button>
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
{% if option.default %}
|
||||
{{ RangeRow(option_name, option, option.default, option.default) }}
|
||||
{% endif %}
|
||||
<!-- This table to be filled by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -89,21 +83,17 @@
|
||||
Custom values are also allowed for this option. To create one, enter it into the input box below.
|
||||
<div class="custom-value-wrapper">
|
||||
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
||||
<button type="button" data-option="{{ option_name }}">Add</button>
|
||||
<button data-option="{{ option_name }}">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
{% for id, name in option.name_lookup.items() %}
|
||||
{% if name != 'random' %}
|
||||
{% if option.default != 'random' %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
|
||||
{% else %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
||||
{% endif %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ RandomRow(option_name, option) }}
|
||||
{{ RandomRows(option_name, option) }}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
@@ -122,7 +112,7 @@
|
||||
type="number"
|
||||
id="{{ option_name }}-{{ item_name }}-qty"
|
||||
name="{{ option_name }}||{{ item_name }}"
|
||||
value="{{ option.default[item_name] if item_name in option.default else "0" }}"
|
||||
value="0"
|
||||
/>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -131,14 +121,13 @@
|
||||
|
||||
{% macro OptionList(option_name, option) %}
|
||||
<div class="list-container">
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||
{% for key in option.valid_keys|sort %}
|
||||
<div class="list-entry">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="{{ option_name }}-{{ key }}"
|
||||
name="{{ option_name }}||{{ key }}"
|
||||
value="1"
|
||||
{{ "checked" if key in option.default }}
|
||||
/>
|
||||
<label for="{{ option_name }}-{{ key }}">
|
||||
{{ key }}
|
||||
@@ -194,7 +183,7 @@
|
||||
|
||||
{% macro OptionSet(option_name, option) %}
|
||||
<div class="set-container">
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||
{% for key in option.valid_keys|sort %}
|
||||
<div class="set-entry">
|
||||
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}||{{ key }}" value="1" {{ "checked" if key in option.default }} />
|
||||
<label for="{{ option_name }}-{{ key }}">{{ key }}</label>
|
||||
@@ -211,17 +200,13 @@
|
||||
</td>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro RandomRow(option_name, option, extra_column=False) %}
|
||||
{{ RangeRow(option_name, option, "Random", "random") }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro RandomRows(option_name, option, extra_column=False) %}
|
||||
{% for key, value in {"Random": "random", "Random (Low)": "random-low", "Random (Middle)": "random-middle", "Random (High)": "random-high"}.items() %}
|
||||
{{ RangeRow(option_name, option, key, value) }}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro RangeRow(option_name, option, display_value, value, can_delete=False, default_override=None) %}
|
||||
{% macro RangeRow(option_name, option, display_value, value, can_delete=False) %}
|
||||
<tr data-row="{{ option_name }}-{{ value }}-row" data-option-name="{{ option_name }}" data-value="{{ value }}">
|
||||
<td class="td-left">
|
||||
<label for="{{ option_name }}||{{ value }}">
|
||||
@@ -235,7 +220,7 @@
|
||||
name="{{ option_name }}||{{ value }}"
|
||||
min="0"
|
||||
max="50"
|
||||
{% if option.default == value or default_override == value %}
|
||||
{% if option.default == value %}
|
||||
value="25"
|
||||
{% else %}
|
||||
value="0"
|
||||
@@ -244,7 +229,7 @@
|
||||
</td>
|
||||
<td class="td-right">
|
||||
<span id="{{ option_name }}||{{ value }}-value">
|
||||
{% if option.default == value or default_override == value %}
|
||||
{% if option.default == value %}
|
||||
25
|
||||
{% else %}
|
||||
0
|
||||
|
||||
@@ -79,7 +79,7 @@ class TrackerData:
|
||||
|
||||
# Normal lookup tables as well.
|
||||
self.item_name_to_id[game] = game_package["item_name_to_id"]
|
||||
self.location_name_to_id[game] = game_package["location_name_to_id"]
|
||||
self.location_name_to_id[game] = game_package["item_name_to_id"]
|
||||
|
||||
def get_seed_name(self) -> str:
|
||||
"""Retrieves the seed name."""
|
||||
@@ -1366,28 +1366,28 @@ if "Starcraft 2" in network_data_package["games"]:
|
||||
organics_icon_base_url = "https://0rganics.org/archipelago/sc2wol/"
|
||||
|
||||
icons = {
|
||||
"Starting Minerals": github_icon_base_url + "blizzard/icon-mineral-nobg.png",
|
||||
"Starting Vespene": github_icon_base_url + "blizzard/icon-gas-terran-nobg.png",
|
||||
"Starting Minerals": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-mineral-protoss.png",
|
||||
"Starting Vespene": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-gas-terran.png",
|
||||
"Starting Supply": github_icon_base_url + "blizzard/icon-supply-terran_nobg.png",
|
||||
|
||||
"Terran Infantry Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel1.png",
|
||||
"Terran Infantry Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel2.png",
|
||||
"Terran Infantry Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel3.png",
|
||||
"Terran Infantry Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel1.png",
|
||||
"Terran Infantry Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel2.png",
|
||||
"Terran Infantry Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel3.png",
|
||||
"Terran Vehicle Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel1.png",
|
||||
"Terran Vehicle Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel2.png",
|
||||
"Terran Vehicle Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel3.png",
|
||||
"Terran Vehicle Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel1.png",
|
||||
"Terran Vehicle Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel2.png",
|
||||
"Terran Vehicle Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel3.png",
|
||||
"Terran Ship Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel1.png",
|
||||
"Terran Ship Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel2.png",
|
||||
"Terran Ship Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel3.png",
|
||||
"Terran Ship Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel1.png",
|
||||
"Terran Ship Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel2.png",
|
||||
"Terran Ship Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel3.png",
|
||||
"Terran Infantry Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel1.png",
|
||||
"Terran Infantry Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel2.png",
|
||||
"Terran Infantry Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel3.png",
|
||||
"Terran Infantry Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel1.png",
|
||||
"Terran Infantry Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel2.png",
|
||||
"Terran Infantry Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel3.png",
|
||||
"Terran Vehicle Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel1.png",
|
||||
"Terran Vehicle Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel2.png",
|
||||
"Terran Vehicle Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel3.png",
|
||||
"Terran Vehicle Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel1.png",
|
||||
"Terran Vehicle Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel2.png",
|
||||
"Terran Vehicle Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel3.png",
|
||||
"Terran Ship Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel1.png",
|
||||
"Terran Ship Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel2.png",
|
||||
"Terran Ship Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel3.png",
|
||||
"Terran Ship Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel1.png",
|
||||
"Terran Ship Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel2.png",
|
||||
"Terran Ship Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel3.png",
|
||||
|
||||
"Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg",
|
||||
"Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg",
|
||||
|
||||
@@ -152,7 +152,7 @@ def get_payload(ctx: ZeldaContext):
|
||||
|
||||
|
||||
def reconcile_shops(ctx: ZeldaContext):
|
||||
checked_location_names = [ctx.location_names.lookup_in_game(location) for location in ctx.checked_locations]
|
||||
checked_location_names = [ctx.location_names.lookup_in_slot(location) for location in ctx.checked_locations]
|
||||
shops = [location for location in checked_location_names if "Shop" in location]
|
||||
left_slots = [shop for shop in shops if "Left" in shop]
|
||||
middle_slots = [shop for shop in shops if "Middle" in shop]
|
||||
@@ -190,7 +190,7 @@ async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone=
|
||||
locations_checked = []
|
||||
location = None
|
||||
for location in ctx.missing_locations:
|
||||
location_name = ctx.location_names.lookup_in_game(location)
|
||||
location_name = ctx.location_names.lookup_in_slot(location)
|
||||
|
||||
if location_name in Locations.overworld_locations and zone == "overworld":
|
||||
status = locations_array[Locations.major_location_offsets[location_name]]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#cython: language_level=3
|
||||
#distutils: language = c
|
||||
#distutils: depends = intset.h
|
||||
#distutils: language = c++
|
||||
|
||||
"""
|
||||
Provides faster implementation of some core parts.
|
||||
@@ -14,6 +13,7 @@ from cpython cimport PyObject
|
||||
from typing import Any, Dict, Iterable, Iterator, Generator, Sequence, Tuple, TypeVar, Union, Set, List, TYPE_CHECKING
|
||||
from cymem.cymem cimport Pool
|
||||
from libc.stdint cimport int64_t, uint32_t
|
||||
from libcpp.set cimport set as std_set
|
||||
from collections import defaultdict
|
||||
|
||||
cdef extern from *:
|
||||
@@ -31,27 +31,6 @@ ctypedef int64_t ap_id_t
|
||||
cdef ap_player_t MAX_PLAYER_ID = 1000000 # limit the size of indexing array
|
||||
cdef size_t INVALID_SIZE = <size_t>(-1) # this is all 0xff... adding 1 results in 0, but it's not negative
|
||||
|
||||
# configure INTSET for player
|
||||
cdef extern from *:
|
||||
"""
|
||||
#define INTSET_NAME ap_player_set
|
||||
#define INTSET_TYPE uint32_t // has to match ap_player_t
|
||||
"""
|
||||
|
||||
# create INTSET for player
|
||||
cdef extern from "intset.h":
|
||||
"""
|
||||
#undef INTSET_NAME
|
||||
#undef INTSET_TYPE
|
||||
"""
|
||||
ctypedef struct ap_player_set:
|
||||
pass
|
||||
|
||||
ap_player_set* ap_player_set_new(size_t bucket_count) nogil
|
||||
void ap_player_set_free(ap_player_set* set) nogil
|
||||
bint ap_player_set_add(ap_player_set* set, ap_player_t val) nogil
|
||||
bint ap_player_set_contains(ap_player_set* set, ap_player_t val) nogil
|
||||
|
||||
|
||||
cdef struct LocationEntry:
|
||||
# layout is so that
|
||||
@@ -206,7 +185,7 @@ cdef class LocationStore:
|
||||
def find_item(self, slots: Set[int], seeked_item_id: int) -> Generator[Tuple[int, int, int, int, int], None, None]:
|
||||
cdef ap_id_t item = seeked_item_id
|
||||
cdef ap_player_t receiver
|
||||
cdef ap_player_set* receivers
|
||||
cdef std_set[ap_player_t] receivers
|
||||
cdef size_t slot_count = len(slots)
|
||||
if slot_count == 1:
|
||||
# specialized implementation for single slot
|
||||
@@ -218,20 +197,13 @@ cdef class LocationStore:
|
||||
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
|
||||
elif slot_count:
|
||||
# generic implementation with lookup in set
|
||||
receivers = ap_player_set_new(min(1023, slot_count)) # limit top level struct to 16KB
|
||||
if not receivers:
|
||||
raise MemoryError()
|
||||
try:
|
||||
for receiver in slots:
|
||||
if not ap_player_set_add(receivers, receiver):
|
||||
raise MemoryError()
|
||||
with nogil:
|
||||
for entry in self.entries[:self.entry_count]:
|
||||
if entry.item == item and ap_player_set_contains(receivers, entry.receiver):
|
||||
with gil:
|
||||
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
|
||||
finally:
|
||||
ap_player_set_free(receivers)
|
||||
for receiver in slots:
|
||||
receivers.insert(receiver)
|
||||
with nogil:
|
||||
for entry in self.entries[:self.entry_count]:
|
||||
if entry.item == item and receivers.count(entry.receiver):
|
||||
with gil:
|
||||
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
|
||||
|
||||
def get_for_player(self, slot: int) -> Dict[int, Set[int]]:
|
||||
cdef ap_player_t receiver = slot
|
||||
@@ -287,15 +259,15 @@ cdef class LocationStore:
|
||||
entry in self.entries[start:start + count] if
|
||||
entry.location not in checked]
|
||||
|
||||
def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]:
|
||||
def get_remaining(self, state: State, team: int, slot: int) -> List[int]:
|
||||
cdef LocationEntry* entry
|
||||
cdef ap_player_t sender = slot
|
||||
cdef size_t start = self.sender_index[sender].start
|
||||
cdef size_t count = self.sender_index[sender].count
|
||||
cdef set checked = state[team, slot]
|
||||
return sorted([(entry.receiver, entry.item) for
|
||||
entry in self.entries[start:start+count] if
|
||||
entry.location not in checked])
|
||||
return sorted([entry.item for
|
||||
entry in self.entries[start:start+count] if
|
||||
entry.location not in checked])
|
||||
|
||||
|
||||
@cython.auto_pickle(False)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# This file is used when doing pyximport
|
||||
import os
|
||||
# This file is required to get pyximport to work with C++.
|
||||
# Switching from std::set to a pure C implementation is still on the table to simplify everything.
|
||||
|
||||
def make_ext(modname, pyxfilename):
|
||||
from distutils.extension import Extension
|
||||
return Extension(name=modname,
|
||||
sources=[pyxfilename],
|
||||
depends=["intset.h"],
|
||||
include_dirs=[os.getcwd()],
|
||||
language="c")
|
||||
language='c++')
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
plum: "AF99EF" # typically progression item
|
||||
salmon: "FA8072" # typically trap item
|
||||
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
|
||||
orange: "FF7700" # Used for command echo
|
||||
<Label>:
|
||||
color: "FFFFFF"
|
||||
<TabbedPanel>:
|
||||
|
||||
@@ -68,21 +68,21 @@ requires:
|
||||
|
||||
{%- elif option.options -%}
|
||||
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
||||
{{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
||||
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
||||
{%- endfor -%}
|
||||
|
||||
{%- if option.name_lookup[option.default] not in option.options %}
|
||||
{{ yaml_dump(option.default) }}: 50
|
||||
{{ option.default }}: 50
|
||||
{%- endif -%}
|
||||
|
||||
{%- elif option.default is string %}
|
||||
{{ yaml_dump(option.default) }}: 50
|
||||
{{ option.default }}: 50
|
||||
|
||||
{%- elif option.default is iterable and option.default is not mapping %}
|
||||
{{ option.default | list }}
|
||||
|
||||
{%- else %}
|
||||
{{ yaml_dump(option.default) | indent(4, first=false) }}
|
||||
{{ yaml_dump(option.default) | trim | indent(4, first=false) }}
|
||||
{%- endif -%}
|
||||
{{ "\n" }}
|
||||
{%- endfor %}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Archipelago World Code Owners / Maintainers Document
|
||||
#
|
||||
# This file is used to notate the current "owners" or "maintainers" of any currently merged world folder as well as
|
||||
# certain documentation. For any pull requests that modify these worlds/docs, a code owner must approve the PR in
|
||||
# addition to a core maintainer. All other files and folders are owned and maintained by core maintainers directly.
|
||||
# This file is used to notate the current "owners" or "maintainers" of any currently merged world folder. For any pull
|
||||
# requests that modify these worlds, a code owner must approve the PR in addition to a core maintainer. This is not to
|
||||
# be used for files/folders outside the /worlds folder, those will always need sign off from a core maintainer.
|
||||
#
|
||||
# All usernames must be GitHub usernames (and are case sensitive).
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
/worlds/heretic/ @Daivuk
|
||||
|
||||
# Hollow Knight
|
||||
/worlds/hk/ @BadMagic100 @qwint
|
||||
/worlds/hk/ @BadMagic100 @ThePhar
|
||||
|
||||
# Hylics 2
|
||||
/worlds/hylics2/ @TRPG0
|
||||
@@ -78,9 +78,6 @@
|
||||
# Kirby's Dream Land 3
|
||||
/worlds/kdl3/ @Silvris
|
||||
|
||||
# Kingdom Hearts
|
||||
/worlds/kh1/ @gaithern
|
||||
|
||||
# Kingdom Hearts 2
|
||||
/worlds/kh2/ @JaredWeakStrike
|
||||
|
||||
@@ -90,6 +87,9 @@
|
||||
# Lingo
|
||||
/worlds/lingo/ @hatkirby
|
||||
|
||||
# Links Awakening DX
|
||||
/worlds/ladx/ @zig-for
|
||||
|
||||
# Lufia II Ancient Cave
|
||||
/worlds/lufia2ac/ @el-u
|
||||
/worlds/lufia2ac/docs/ @wordfcuk @el-u
|
||||
@@ -106,9 +106,6 @@
|
||||
# Minecraft
|
||||
/worlds/minecraft/ @KonoTyran @espeon65536
|
||||
|
||||
# Mega Man 2
|
||||
/worlds/mm2/ @Silvris
|
||||
|
||||
# MegaMan Battle Network 3
|
||||
/worlds/mmbn3/ @digiholic
|
||||
|
||||
@@ -121,9 +118,6 @@
|
||||
# Ocarina of Time
|
||||
/worlds/oot/ @espeon65536
|
||||
|
||||
# Old School Runescape
|
||||
/worlds/osrs @digiholic
|
||||
|
||||
# Overcooked! 2
|
||||
/worlds/overcooked2/ @toasterparty
|
||||
|
||||
@@ -202,9 +196,6 @@
|
||||
# The Witness
|
||||
/worlds/witness/ @NewSoupVi @blastron
|
||||
|
||||
# Yacht Dice
|
||||
/worlds/yachtdice/ @spinerak
|
||||
|
||||
# Yoshi's Island
|
||||
/worlds/yoshisisland/ @PinkSwitch
|
||||
|
||||
@@ -227,8 +218,6 @@
|
||||
# Final Fantasy (1)
|
||||
# /worlds/ff1/
|
||||
|
||||
# Links Awakening DX
|
||||
# /worlds/ladx/
|
||||
|
||||
## Disabled Unmaintained Worlds
|
||||
|
||||
@@ -238,11 +227,3 @@
|
||||
|
||||
# Ori and the Blind Forest
|
||||
# /worlds_disabled/oribf/
|
||||
|
||||
###################
|
||||
## Documentation ##
|
||||
###################
|
||||
|
||||
# Apworld Dev Faq
|
||||
/docs/apworld_dev_faq.md @qwint @ScipioWright
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# APWorld Dev FAQ
|
||||
|
||||
This document is meant as a reference tool to show solutions to common problems when developing an apworld.
|
||||
It is not intended to answer every question about Archipelago and it assumes you have read the other docs,
|
||||
including [Contributing](contributing.md), [Adding Games](<adding games.md>), and [World API](<world api.md>).
|
||||
|
||||
---
|
||||
|
||||
### My game has a restrictive start that leads to fill errors
|
||||
|
||||
Hint to the Generator that an item needs to be in sphere one with local_early_items. Here, `1` represents the number of "Sword" items to attempt to place in sphere one.
|
||||
```py
|
||||
early_item_name = "Sword"
|
||||
self.multiworld.local_early_items[self.player][early_item_name] = 1
|
||||
```
|
||||
|
||||
Some alternative ways to try to fix this problem are:
|
||||
* Add more locations to sphere one of your world, potentially only when there would be a restrictive start
|
||||
* Pre-place items yourself, such as during `create_items`
|
||||
* Put items into the player's starting inventory using `push_precollected`
|
||||
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a restrictive start
|
||||
|
||||
---
|
||||
|
||||
### I have multiple settings that change the item/location pool counts and need to balance them out
|
||||
|
||||
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be unbalanced. But in real, complex situations, that might be unfeasible.
|
||||
|
||||
If that's the case, you can create extra filler based on the difference between your unfilled locations and your itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations) to your list of items to submit
|
||||
|
||||
Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names
|
||||
```py
|
||||
total_locations = len(self.multiworld.get_unfilled_locations(self.player))
|
||||
item_pool = self.create_non_filler_items()
|
||||
|
||||
for _ in range(total_locations - len(item_pool)):
|
||||
item_pool.append(self.create_filler())
|
||||
|
||||
self.multiworld.itempool += item_pool
|
||||
```
|
||||
|
||||
A faster alternative to the `for` loop would be to use a [list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
|
||||
```py
|
||||
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
|
||||
```
|
||||
@@ -702,18 +702,14 @@ GameData is a **dict** but contains these keys and values. It's broken out into
|
||||
| checksum | str | A checksum hash of this game's data. |
|
||||
|
||||
### Tags
|
||||
Tags are represented as a list of strings, the common client tags follow:
|
||||
Tags are represented as a list of strings, the common Client tags follow:
|
||||
|
||||
| Name | Notes |
|
||||
|-----------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets. |
|
||||
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
||||
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||
|
||||
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
|
||||
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
|
||||
| Name | Notes |
|
||||
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
|
||||
| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
|
||||
### DeathLink
|
||||
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
|
||||
|
||||
@@ -85,74 +85,18 @@ class ExampleWorld(World):
|
||||
options: ExampleGameOptions
|
||||
```
|
||||
|
||||
### Option Documentation
|
||||
|
||||
Options' [docstrings] are used as their user-facing documentation. They're displayed on the WebHost setup page when a
|
||||
user hovers over the yellow "(?)" icon, and included in the YAML templates generated for each game.
|
||||
|
||||
[docstrings]: /docs/world%20api.md#docstrings
|
||||
|
||||
The WebHost can display Option documentation either as plain text with all whitespace preserved (other than the base
|
||||
indentation), or as HTML generated from the standard Python [reStructuredText] format. Although plain text is the
|
||||
default for backwards compatibility, world authors are encouraged to write their Option documentation as
|
||||
reStructuredText and enable rich text rendering by setting `World.rich_text_options_doc = True`.
|
||||
|
||||
[reStructuredText]: https://docutils.sourceforge.io/rst.html
|
||||
|
||||
```python
|
||||
from worlds.AutoWorld import WebWorld
|
||||
|
||||
|
||||
class ExampleWebWorld(WebWorld):
|
||||
# Render all this world's options as rich text.
|
||||
rich_text_options_doc = True
|
||||
```
|
||||
|
||||
You can set a single option to use rich or plain text by setting
|
||||
`Option.rich_text_doc`.
|
||||
|
||||
```python
|
||||
from Options import Toggle, Range, Choice, PerGameCommonOptions
|
||||
|
||||
|
||||
class Difficulty(Choice):
|
||||
"""Sets overall game difficulty.
|
||||
|
||||
- **Easy:** All enemies die in one hit.
|
||||
- **Normal:** Enemies and the player both have normal health bars.
|
||||
- **Hard:** The player dies in one hit."""
|
||||
display_name = "Difficulty"
|
||||
rich_text_doc = True
|
||||
option_easy = 0
|
||||
option_normal = 1
|
||||
option_hard = 2
|
||||
default = 1
|
||||
```
|
||||
|
||||
### Option Groups
|
||||
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
|
||||
by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment
|
||||
with the group name at the beginning of each group of options. The `start_collapsed` Boolean only affects how the groups
|
||||
appear on the WebHost, with the grouping being collapsed when this is `True`.
|
||||
|
||||
Options without a group name are categorized into a generic "Game Options" group, which is always the first group. If
|
||||
every option for your world is in a group, this group will be removed. There is also an "Items & Location Options"
|
||||
group, which is automatically created using certain specified `item_and_loc_options`. These specified options cannot be
|
||||
removed from this group.
|
||||
|
||||
Both the "Game Options" and "Item & Location Options" groups can be overridden by creating your own groups with
|
||||
those names, letting you add options to them and change whether they start collapsed. The "Item &
|
||||
Location Options" group can also be moved to a different position in the group ordering, but "Game Options" will always
|
||||
be first, regardless of where it is in your list.
|
||||
Options may be categorized into groups for display on the WebHost. Option groups are displayed alphabetically on the
|
||||
player-options and weighted-options pages. Options without a group name are categorized into a generic "Game Options"
|
||||
group.
|
||||
|
||||
```python
|
||||
from worlds.AutoWorld import WebWorld
|
||||
from Options import OptionGroup
|
||||
from . import Options
|
||||
|
||||
class MyWorldWeb(WebWorld):
|
||||
option_groups = [
|
||||
OptionGroup("Color Options", [
|
||||
OptionGroup('Color Options', [
|
||||
Options.ColorblindMode,
|
||||
Options.FlashReduction,
|
||||
Options.UIColors,
|
||||
@@ -176,8 +120,7 @@ or if I need a boolean object, such as in my slot_data I can access it as:
|
||||
start_with_sword = bool(self.options.starting_sword.value)
|
||||
```
|
||||
All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes,
|
||||
strings that match the option attributes after "option_" is stripped, and the attributes themselves. The option can
|
||||
also be checked to see if it exists within a collection, but this will fail for a set of strings due to hashing.
|
||||
strings that match the option attributes after "option_" is stripped, and the attributes themselves.
|
||||
```python
|
||||
# options.py
|
||||
class Logic(Choice):
|
||||
@@ -189,12 +132,6 @@ class Logic(Choice):
|
||||
alias_extra_hard = 2
|
||||
crazy = 4 # won't be listed as an option and only exists as an attribute on the class
|
||||
|
||||
class Weapon(Choice):
|
||||
option_none = 0
|
||||
option_sword = 1
|
||||
option_bow = 2
|
||||
option_hammer = 3
|
||||
|
||||
# __init__.py
|
||||
from .options import Logic
|
||||
|
||||
@@ -208,16 +145,6 @@ elif self.options.logic == Logic.option_extreme:
|
||||
do_extreme_things()
|
||||
elif self.options.logic == "crazy":
|
||||
do_insane_things()
|
||||
|
||||
# check if the current option is in a collection of integers using the class attributes
|
||||
if self.options.weapon in {Weapon.option_bow, Weapon.option_sword}:
|
||||
do_stuff()
|
||||
# in order to make a set of strings work, we have to compare against current_key
|
||||
elif self.options.weapon.current_key in {"none", "hammer"}:
|
||||
do_something_else()
|
||||
# though it's usually better to just use a tuple instead
|
||||
elif self.options.weapon in ("none", "hammer"):
|
||||
do_something_else()
|
||||
```
|
||||
## Generic Option Classes
|
||||
These options are generically available to every game automatically, but can be overridden for slightly different
|
||||
|
||||
@@ -8,7 +8,7 @@ use that version. These steps are for developers or platforms without compiled r
|
||||
|
||||
What you'll need:
|
||||
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
|
||||
* Python 3.12.x is currently the newest supported version
|
||||
* **Python 3.12 is currently unsupported**
|
||||
* pip: included in downloads from python.org, separate in many Linux distributions
|
||||
* Matching C compiler
|
||||
* possibly optional, read operating system specific sections
|
||||
@@ -31,7 +31,7 @@ After this, you should be able to run the programs.
|
||||
|
||||
Recommended steps
|
||||
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
|
||||
* [read above](#General) which versions are supported
|
||||
* **Python 3.12 is currently unsupported**
|
||||
|
||||
* **Optional**: Download and install Visual Studio Build Tools from
|
||||
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/).
|
||||
|
||||
@@ -56,12 +56,6 @@ webhost:
|
||||
|
||||
* `options_page` can be changed to a link instead of an AP-generated options page.
|
||||
|
||||
* `rich_text_options_doc` controls whether [Option documentation] uses plain text (`False`) or rich text (`True`). It
|
||||
defaults to `False`, but world authors are encouraged to set it to `True` for nicer-looking documentation that looks
|
||||
good on both the WebHost and the YAML template.
|
||||
|
||||
[Option documentation]: /docs/options%20api.md#option-documentation
|
||||
|
||||
* `theme` to be used for your game-specific AP pages. Available themes:
|
||||
|
||||
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone |
|
||||
@@ -456,9 +450,8 @@ In addition, the following methods can be implemented and are called in this ord
|
||||
called to place player's regions and their locations into the MultiWorld's regions list.
|
||||
If it's hard to separate, this can be done during `generate_early` or `create_items` as well.
|
||||
* `create_items(self)`
|
||||
called to place player's items into the MultiWorld's itempool. By the end of this step all regions, locations and
|
||||
items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions
|
||||
after this step. Locations cannot be moved to different regions after this step.
|
||||
called to place player's items into the MultiWorld's itempool. After this step all regions
|
||||
and items have to be in the MultiWorld's regions and itempool, and these lists should not be modified afterward.
|
||||
* `set_rules(self)`
|
||||
called to set access and item rules on locations and entrances.
|
||||
* `generate_basic(self)`
|
||||
|
||||
@@ -26,17 +26,8 @@ Unless these are shared between multiple people, we expect the following from ea
|
||||
### Adding a World
|
||||
|
||||
When we merge your world into the core Archipelago repository, you automatically become world maintainer unless you
|
||||
nominate someone else (i.e. there are multiple devs).
|
||||
|
||||
### Being added as a maintainer to an existing implementation
|
||||
|
||||
At any point, a world maintainer can approve the addition of another maintainer to their world.
|
||||
In order to do this, either an existing maintainer or the new maintainer must open a PR updating the
|
||||
[CODEOWNERS](/docs/CODEOWNERS) file.
|
||||
This change must be approved by all existing maintainers of the affected world, the new maintainer candidate, and
|
||||
one core maintainer.
|
||||
To help the core team review the change, information about the new maintainer and their contributions should be
|
||||
included in the PR description.
|
||||
nominate someone else (i.e. there are multiple devs). You can define who is allowed to approve changes to your world
|
||||
in the [CODEOWNERS](/docs/CODEOWNERS) document.
|
||||
|
||||
### Getting Voted
|
||||
|
||||
|
||||
@@ -87,14 +87,7 @@ Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
|
||||
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
|
||||
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
|
||||
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
|
||||
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy"
|
||||
Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy"
|
||||
Type: files; Name: "{app}\lib\worlds\sc2wol.apworld"
|
||||
Type: filesandordirs; Name: "{app}\lib\worlds\sc2wol"
|
||||
Type: dirifempty; Name: "{app}\lib\worlds\sc2wol"
|
||||
Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku"
|
||||
Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku"
|
||||
Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe"
|
||||
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
|
||||
Type: filesandordirs; Name: "{app}\SNI\lua*"
|
||||
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
|
||||
#include "installdelete.iss"
|
||||
@@ -186,11 +179,6 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc
|
||||
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
|
||||
@@ -221,11 +209,6 @@ Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Arc
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apworld"; ValueData: "{#MyAppName}worlddata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}worlddata"; ValueData: "Archipelago World Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}worlddata\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
|
||||
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
|
||||
|
||||
135
intset.h
135
intset.h
@@ -1,135 +0,0 @@
|
||||
/* A specialized unordered_set implementation for literals, where bucket_count
|
||||
* is defined at initialization rather than increased automatically.
|
||||
*/
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifndef INTSET_NAME
|
||||
#error "Please #define INTSET_NAME ... before including intset.h"
|
||||
#endif
|
||||
|
||||
#ifndef INTSET_TYPE
|
||||
#error "Please #define INTSET_TYPE ... before including intset.h"
|
||||
#endif
|
||||
|
||||
/* macros to generate unique names from INTSET_NAME */
|
||||
#ifndef INTSET_CONCAT
|
||||
#define INTSET_CONCAT_(a, b) a ## b
|
||||
#define INTSET_CONCAT(a, b) INTSET_CONCAT_(a, b)
|
||||
#define INTSET_FUNC_(a, b) INTSET_CONCAT(a, _ ## b)
|
||||
#endif
|
||||
|
||||
#define INTSET_FUNC(name) INTSET_FUNC_(INTSET_NAME, name)
|
||||
#define INTSET_BUCKET INTSET_CONCAT(INTSET_NAME, Bucket)
|
||||
#define INTSET_UNION INTSET_CONCAT(INTSET_NAME, Union)
|
||||
|
||||
#if defined(_MSC_VER)
|
||||
#pragma warning(push)
|
||||
#pragma warning(disable : 4200)
|
||||
#endif
|
||||
|
||||
|
||||
typedef struct {
|
||||
size_t count;
|
||||
union INTSET_UNION {
|
||||
INTSET_TYPE val;
|
||||
INTSET_TYPE *data;
|
||||
} v;
|
||||
} INTSET_BUCKET;
|
||||
|
||||
typedef struct {
|
||||
size_t bucket_count;
|
||||
INTSET_BUCKET buckets[];
|
||||
} INTSET_NAME;
|
||||
|
||||
static INTSET_NAME *INTSET_FUNC(new)(size_t buckets)
|
||||
{
|
||||
size_t i, size;
|
||||
INTSET_NAME *set;
|
||||
|
||||
if (buckets < 1)
|
||||
buckets = 1;
|
||||
if ((SIZE_MAX - sizeof(INTSET_NAME)) / sizeof(INTSET_BUCKET) < buckets)
|
||||
return NULL;
|
||||
size = sizeof(INTSET_NAME) + buckets * sizeof(INTSET_BUCKET);
|
||||
set = (INTSET_NAME*)malloc(size);
|
||||
if (!set)
|
||||
return NULL;
|
||||
memset(set, 0, size); /* gcc -fanalyzer does not understand this sets all buckets' count to 0 */
|
||||
for (i = 0; i < buckets; i++) {
|
||||
set->buckets[i].count = 0;
|
||||
}
|
||||
set->bucket_count = buckets;
|
||||
return set;
|
||||
}
|
||||
|
||||
static void INTSET_FUNC(free)(INTSET_NAME *set)
|
||||
{
|
||||
size_t i;
|
||||
if (!set)
|
||||
return;
|
||||
for (i = 0; i < set->bucket_count; i++) {
|
||||
if (set->buckets[i].count > 1)
|
||||
free(set->buckets[i].v.data);
|
||||
}
|
||||
free(set);
|
||||
}
|
||||
|
||||
static bool INTSET_FUNC(contains)(INTSET_NAME *set, INTSET_TYPE val)
|
||||
{
|
||||
size_t i;
|
||||
INTSET_BUCKET* bucket = &set->buckets[(size_t)val % set->bucket_count];
|
||||
if (bucket->count == 1)
|
||||
return bucket->v.val == val;
|
||||
for (i = 0; i < bucket->count; ++i) {
|
||||
if (bucket->v.data[i] == val)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool INTSET_FUNC(add)(INTSET_NAME *set, INTSET_TYPE val)
|
||||
{
|
||||
INTSET_BUCKET* bucket;
|
||||
|
||||
if (INTSET_FUNC(contains)(set, val))
|
||||
return true; /* ok */
|
||||
|
||||
bucket = &set->buckets[(size_t)val % set->bucket_count];
|
||||
if (bucket->count == 0) {
|
||||
bucket->v.val = val;
|
||||
bucket->count = 1;
|
||||
} else if (bucket->count == 1) {
|
||||
INTSET_TYPE old = bucket->v.val;
|
||||
bucket->v.data = (INTSET_TYPE*)malloc(2 * sizeof(INTSET_TYPE));
|
||||
if (!bucket->v.data) {
|
||||
bucket->v.val = old;
|
||||
return false; /* error */
|
||||
}
|
||||
bucket->v.data[0] = old;
|
||||
bucket->v.data[1] = val;
|
||||
bucket->count = 2;
|
||||
} else {
|
||||
size_t new_bucket_size;
|
||||
INTSET_TYPE* new_bucket_data;
|
||||
|
||||
new_bucket_size = (bucket->count + 1) * sizeof(INTSET_TYPE);
|
||||
new_bucket_data = (INTSET_TYPE*)realloc(bucket->v.data, new_bucket_size);
|
||||
if (!new_bucket_data)
|
||||
return false; /* error */
|
||||
bucket->v.data = new_bucket_data;
|
||||
bucket->v.data[bucket->count++] = val;
|
||||
}
|
||||
return true; /* success */
|
||||
}
|
||||
|
||||
|
||||
#if defined(_MSC_VER)
|
||||
#pragma warning(pop)
|
||||
#endif
|
||||
|
||||
#undef INTSET_FUNC
|
||||
#undef INTSET_BUCKET
|
||||
#undef INTSET_UNION
|
||||
71
kvui.py
71
kvui.py
@@ -3,9 +3,6 @@ import logging
|
||||
import sys
|
||||
import typing
|
||||
import re
|
||||
from collections import deque
|
||||
|
||||
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
|
||||
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
@@ -383,57 +380,6 @@ class ConnectBarTextInput(TextInput):
|
||||
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
|
||||
|
||||
|
||||
def is_command_input(string: str) -> bool:
|
||||
return len(string) > 0 and string[0] in "/!"
|
||||
|
||||
|
||||
class CommandPromptTextInput(TextInput):
|
||||
MAXIMUM_HISTORY_MESSAGES = 50
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._command_history_index = -1
|
||||
self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES)
|
||||
|
||||
def update_history(self, new_entry: str) -> None:
|
||||
self._command_history_index = -1
|
||||
if is_command_input(new_entry):
|
||||
self._command_history.appendleft(new_entry)
|
||||
|
||||
def keyboard_on_key_down(
|
||||
self,
|
||||
window,
|
||||
keycode: typing.Tuple[int, str],
|
||||
text: typing.Optional[str],
|
||||
modifiers: typing.List[str]
|
||||
) -> bool:
|
||||
"""
|
||||
:param window: The kivy window object
|
||||
:param keycode: A tuple of (keycode, keyname). Keynames are always lowercase
|
||||
:param text: The text printed by this key, not accounting for modifiers, or `None` if no text.
|
||||
Seems to pretty naively interpret the keycode as unicode, so numlock can return odd characters.
|
||||
:param modifiers: A list of string modifiers, like `ctrl` or `numlock`
|
||||
"""
|
||||
if keycode[1] == 'up':
|
||||
self._change_to_history_text_if_available(self._command_history_index + 1)
|
||||
return True
|
||||
if keycode[1] == 'down':
|
||||
self._change_to_history_text_if_available(self._command_history_index - 1)
|
||||
return True
|
||||
return super().keyboard_on_key_down(window, keycode, text, modifiers)
|
||||
|
||||
def _change_to_history_text_if_available(self, new_index: int) -> None:
|
||||
if new_index < -1:
|
||||
return
|
||||
if new_index >= len(self._command_history):
|
||||
return
|
||||
self._command_history_index = new_index
|
||||
if new_index == -1:
|
||||
self.text = ""
|
||||
return
|
||||
self.text = self._command_history[self._command_history_index]
|
||||
|
||||
|
||||
class MessageBox(Popup):
|
||||
class MessageBoxLabel(Label):
|
||||
def __init__(self, **kwargs):
|
||||
@@ -469,7 +415,7 @@ class GameManager(App):
|
||||
self.commandprocessor = ctx.command_processor(ctx)
|
||||
self.icon = r"data/icon.png"
|
||||
self.json_to_kivy_parser = KivyJSONtoTextParser(ctx)
|
||||
self.log_panels: typing.Dict[str, Widget] = {}
|
||||
self.log_panels = {}
|
||||
|
||||
# keep track of last used command to autofill on click
|
||||
self.last_autofillable_command = "hint"
|
||||
@@ -553,7 +499,7 @@ class GameManager(App):
|
||||
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None)
|
||||
info_button.bind(on_release=self.command_button_action)
|
||||
bottom_layout.add_widget(info_button)
|
||||
self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
|
||||
self.textinput = TextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
|
||||
self.textinput.bind(on_text_validate=self.on_message)
|
||||
self.textinput.text_validate_unfocus = False
|
||||
bottom_layout.add_widget(self.textinput)
|
||||
@@ -597,9 +543,8 @@ class GameManager(App):
|
||||
"!help for server commands.")
|
||||
|
||||
def connect_button_action(self, button):
|
||||
self.ctx.username = None
|
||||
self.ctx.password = None
|
||||
if self.ctx.server:
|
||||
self.ctx.username = None
|
||||
async_start(self.ctx.disconnect())
|
||||
else:
|
||||
async_start(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
|
||||
@@ -612,18 +557,14 @@ class GameManager(App):
|
||||
|
||||
self.ctx.exit_event.set()
|
||||
|
||||
def on_message(self, textinput: CommandPromptTextInput):
|
||||
def on_message(self, textinput: TextInput):
|
||||
try:
|
||||
input_text = textinput.text.strip()
|
||||
textinput.text = ""
|
||||
textinput.update_history(input_text)
|
||||
|
||||
if self.ctx.input_requests > 0:
|
||||
self.ctx.input_requests -= 1
|
||||
self.ctx.input_queue.put_nowait(input_text)
|
||||
elif is_command_input(input_text):
|
||||
self.ctx.on_ui_command(input_text)
|
||||
self.commandprocessor(input_text)
|
||||
elif input_text:
|
||||
self.commandprocessor(input_text)
|
||||
|
||||
@@ -839,10 +780,6 @@ class KivyJSONtoTextParser(JSONtoTextParser):
|
||||
return self._handle_text(node)
|
||||
|
||||
def _handle_text(self, node: JSONMessagePart):
|
||||
# All other text goes through _handle_color, and we don't want to escape markup twice,
|
||||
# or mess up text that already has intentional markup applied to it
|
||||
if node.get("type", "text") == "text":
|
||||
node["text"] = escape_markup(node["text"])
|
||||
for ref in node.get("refs", []):
|
||||
node["text"] = f"[ref={self.ref_count}|{ref}]{node['text']}[/ref]"
|
||||
self.ref_count += 1
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
colorama>=0.4.6
|
||||
websockets>=13.0.1
|
||||
PyYAML>=6.0.2
|
||||
jellyfish>=1.1.0
|
||||
jinja2>=3.1.4
|
||||
schema>=0.7.7
|
||||
websockets>=12.0
|
||||
PyYAML>=6.0.1
|
||||
jellyfish>=1.0.3
|
||||
jinja2>=3.1.3
|
||||
schema>=0.7.5
|
||||
kivy>=2.3.0
|
||||
bsdiff4>=1.2.4
|
||||
platformdirs>=4.2.2
|
||||
certifi>=2024.8.30
|
||||
cython>=3.0.11
|
||||
platformdirs>=4.1.0
|
||||
certifi>=2023.11.17
|
||||
cython>=3.0.8
|
||||
cymem>=2.0.8
|
||||
orjson>=3.10.7
|
||||
typing_extensions>=4.12.2
|
||||
orjson>=3.9.10
|
||||
typing_extensions>=4.7.0
|
||||
|
||||
18
settings.py
18
settings.py
@@ -3,7 +3,6 @@ Application settings / host.yaml interface using type hints.
|
||||
This is different from player options.
|
||||
"""
|
||||
|
||||
import os
|
||||
import os.path
|
||||
import shutil
|
||||
import sys
|
||||
@@ -12,6 +11,7 @@ import warnings
|
||||
from enum import IntEnum
|
||||
from threading import Lock
|
||||
from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar
|
||||
import os
|
||||
|
||||
__all__ = [
|
||||
"get_settings", "fmt_doc", "no_gui",
|
||||
@@ -798,7 +798,6 @@ class Settings(Group):
|
||||
atexit.register(autosave)
|
||||
|
||||
def save(self, location: Optional[str] = None) -> None: # as above
|
||||
from Utils import parse_yaml
|
||||
location = location or self._filename
|
||||
assert location, "No file specified"
|
||||
temp_location = location + ".tmp" # not using tempfile to test expected file access
|
||||
@@ -808,18 +807,10 @@ class Settings(Group):
|
||||
# can't use utf-8-sig because it breaks backward compat: pyyaml on Windows with bytes does not strip the BOM
|
||||
with open(temp_location, "w", encoding="utf-8") as f:
|
||||
self.dump(f)
|
||||
f.flush()
|
||||
if hasattr(os, "fsync"):
|
||||
os.fsync(f.fileno())
|
||||
# validate new file is valid yaml
|
||||
with open(temp_location, encoding="utf-8") as f:
|
||||
parse_yaml(f.read())
|
||||
# replace old with new, try atomic operation first
|
||||
try:
|
||||
os.rename(temp_location, location)
|
||||
except (OSError, FileExistsError):
|
||||
# replace old with new
|
||||
if os.path.exists(location):
|
||||
os.unlink(location)
|
||||
os.rename(temp_location, location)
|
||||
os.rename(temp_location, location)
|
||||
self._filename = location
|
||||
|
||||
def dump(self, f: TextIO, level: int = 0) -> None:
|
||||
@@ -841,6 +832,7 @@ def get_settings() -> Settings:
|
||||
with _lock: # make sure we only have one instance
|
||||
res = getattr(get_settings, "_cache", None)
|
||||
if not res:
|
||||
import os
|
||||
from Utils import user_path, local_path
|
||||
filenames = ("options.yaml", "host.yaml")
|
||||
locations: List[str] = []
|
||||
|
||||
5
setup.py
5
setup.py
@@ -21,7 +21,7 @@ from pathlib import Path
|
||||
|
||||
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||
try:
|
||||
requirement = 'cx-Freeze==7.2.0'
|
||||
requirement = 'cx-Freeze==7.0.0'
|
||||
import pkg_resources
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
@@ -66,6 +66,7 @@ non_apworlds: set = {
|
||||
"Adventure",
|
||||
"ArchipIDLE",
|
||||
"Archipelago",
|
||||
"ChecksFinder",
|
||||
"Clique",
|
||||
"Final Fantasy",
|
||||
"Lufia II Ancient Cave",
|
||||
@@ -189,7 +190,7 @@ if is_windows:
|
||||
c = next(component for component in components if component.script_name == "Launcher")
|
||||
exes.append(cx_Freeze.Executable(
|
||||
script=f"{c.script_name}.py",
|
||||
target_name=f"{c.frozen_name}Debug.exe",
|
||||
target_name=f"{c.frozen_name}(DEBUG).exe",
|
||||
icon=resolve_icon(c.icon),
|
||||
))
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ class TestBase(unittest.TestCase):
|
||||
state = CollectionState(self.multiworld)
|
||||
for item in items:
|
||||
item.classification = ItemClassification.progression
|
||||
state.collect(item, prevent_sweep=True)
|
||||
state.sweep_for_advancements()
|
||||
state.collect(item, event=True)
|
||||
state.sweep_for_events()
|
||||
state.update_reachable_regions(1)
|
||||
self._state_cache[self.multiworld, tuple(items)] = state
|
||||
return state
|
||||
@@ -221,8 +221,8 @@ class WorldTestBase(unittest.TestCase):
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
if item.location and item.advancement and item.location in self.multiworld.state.advancements:
|
||||
self.multiworld.state.advancements.remove(item.location)
|
||||
if item.location and item.advancement and item.location in self.multiworld.state.events:
|
||||
self.multiworld.state.events.remove(item.location)
|
||||
self.multiworld.state.remove(item)
|
||||
|
||||
def can_reach_location(self, location: str) -> bool:
|
||||
@@ -292,12 +292,14 @@ class WorldTestBase(unittest.TestCase):
|
||||
"""Ensure all state can reach everything and complete the game with the defined options"""
|
||||
if not (self.run_default_tests and self.constructed):
|
||||
return
|
||||
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
|
||||
with self.subTest("Game", game=self.game):
|
||||
excluded = self.multiworld.worlds[self.player].options.exclude_locations.value
|
||||
state = self.multiworld.get_all_state(False)
|
||||
for location in self.multiworld.get_locations():
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
reachable = location.can_reach(state)
|
||||
self.assertTrue(reachable, f"{location.name} unreachable")
|
||||
if location.name not in excluded:
|
||||
with self.subTest("Location should be reached", location=location):
|
||||
reachable = location.can_reach(state)
|
||||
self.assertTrue(reachable, f"{location.name} unreachable")
|
||||
with self.subTest("Beatable"):
|
||||
self.multiworld.state = state
|
||||
self.assertBeatable(True)
|
||||
@@ -306,7 +308,7 @@ class WorldTestBase(unittest.TestCase):
|
||||
"""Ensure empty state can reach at least one location with the defined options"""
|
||||
if not (self.run_default_tests and self.constructed):
|
||||
return
|
||||
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
|
||||
with self.subTest("Game", game=self.game):
|
||||
state = CollectionState(self.multiworld)
|
||||
locations = self.multiworld.get_reachable_locations(state, self.player)
|
||||
self.assertGreater(len(locations), 0,
|
||||
@@ -327,7 +329,7 @@ class WorldTestBase(unittest.TestCase):
|
||||
for n in range(len(locations) - 1, -1, -1):
|
||||
if locations[n].can_reach(state):
|
||||
sphere.append(locations.pop(n))
|
||||
self.assertTrue(sphere or self.multiworld.worlds[1].options.accessibility == "minimal",
|
||||
self.assertTrue(sphere or self.multiworld.accessibility[1] == "minimal",
|
||||
f"Unreachable locations: {locations}")
|
||||
if not sphere:
|
||||
break
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
project(ap-cpp-tests)
|
||||
|
||||
enable_testing()
|
||||
|
||||
find_package(GTest REQUIRED)
|
||||
|
||||
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
|
||||
add_definitions("/source-charset:utf-8")
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
|
||||
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
|
||||
# enable static analysis for gcc
|
||||
add_compile_options(-fanalyzer -Werror)
|
||||
# disable stuff that gets triggered by googletest
|
||||
add_compile_options(-Wno-analyzer-malloc-leak)
|
||||
# enable asan for gcc
|
||||
add_compile_options(-fsanitize=address)
|
||||
add_link_options(-fsanitize=address)
|
||||
endif ()
|
||||
|
||||
add_executable(test_default)
|
||||
|
||||
target_include_directories(test_default
|
||||
PRIVATE
|
||||
${GTEST_INCLUDE_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(test_default
|
||||
${GTEST_BOTH_LIBRARIES}
|
||||
)
|
||||
|
||||
add_test(
|
||||
NAME test_default
|
||||
COMMAND test_default
|
||||
)
|
||||
|
||||
set_property(
|
||||
TEST test_default
|
||||
PROPERTY ENVIRONMENT "ASAN_OPTIONS=allocator_may_return_null=1"
|
||||
)
|
||||
|
||||
file(GLOB ITEMS *)
|
||||
foreach(item ${ITEMS})
|
||||
if(IS_DIRECTORY ${item} AND EXISTS ${item}/CMakeLists.txt)
|
||||
message(${item})
|
||||
add_subdirectory(${item})
|
||||
endif()
|
||||
endforeach()
|
||||
@@ -1,32 +0,0 @@
|
||||
# C++ tests
|
||||
|
||||
Test framework for C and C++ code in AP.
|
||||
|
||||
## Adding a Test
|
||||
|
||||
### GoogleTest
|
||||
|
||||
Adding GoogleTests is as simple as creating a directory with
|
||||
* one or more `test_*.cpp` files that define tests using
|
||||
[GoogleTest API](https://google.github.io/googletest/)
|
||||
* a `CMakeLists.txt` that adds the .cpp files to `test_default` target using
|
||||
[target_sources](https://cmake.org/cmake/help/latest/command/target_sources.html)
|
||||
|
||||
### CTest
|
||||
|
||||
If either GoogleTest is not suitable for the test or the build flags / sources / libraries are incompatible,
|
||||
you can add another CTest to the project using add_target and add_test, similar to how it's done for `test_default`.
|
||||
|
||||
## Running Tests
|
||||
|
||||
* Install [CMake](https://cmake.org/).
|
||||
* Build and/or install GoogleTest and make sure
|
||||
[CMake can find it](https://cmake.org/cmake/help/latest/module/FindGTest.html), or
|
||||
[create a parent `CMakeLists.txt` that fetches GoogleTest](https://google.github.io/googletest/quickstart-cmake.html).
|
||||
* Enter the directory with the top-most `CMakeLists.txt` and run
|
||||
```sh
|
||||
mkdir build
|
||||
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build/ --config Release && \
|
||||
ctest --test-dir build/ -C Release --output-on-failure
|
||||
```
|
||||
@@ -1,4 +0,0 @@
|
||||
target_sources(test_default
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/test_intset.cpp
|
||||
)
|
||||
@@ -1,105 +0,0 @@
|
||||
#include <limits>
|
||||
#include <cstdint>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
// uint32Set
|
||||
#define INTSET_NAME uint32Set
|
||||
#define INTSET_TYPE uint32_t
|
||||
#include "../../../intset.h"
|
||||
#undef INTSET_NAME
|
||||
#undef INTSET_TYPE
|
||||
|
||||
// int64Set
|
||||
#define INTSET_NAME int64Set
|
||||
#define INTSET_TYPE int64_t
|
||||
#include "../../../intset.h"
|
||||
|
||||
|
||||
TEST(IntsetTest, ZeroBuckets)
|
||||
{
|
||||
// trying to allocate with zero buckets has to either fail or be functioning
|
||||
uint32Set *set = uint32Set_new(0);
|
||||
if (!set)
|
||||
return; // failed -> OK
|
||||
|
||||
EXPECT_FALSE(uint32Set_contains(set, 1));
|
||||
EXPECT_TRUE(uint32Set_add(set, 1));
|
||||
EXPECT_TRUE(uint32Set_contains(set, 1));
|
||||
uint32Set_free(set);
|
||||
}
|
||||
|
||||
TEST(IntsetTest, Duplicate)
|
||||
{
|
||||
// adding the same number again can't fail
|
||||
uint32Set *set = uint32Set_new(2);
|
||||
ASSERT_TRUE(set);
|
||||
EXPECT_TRUE(uint32Set_add(set, 0));
|
||||
EXPECT_TRUE(uint32Set_add(set, 0));
|
||||
EXPECT_TRUE(uint32Set_contains(set, 0));
|
||||
uint32Set_free(set);
|
||||
}
|
||||
|
||||
TEST(IntsetTest, SetAllocFailure)
|
||||
{
|
||||
// try to allocate 100TB of RAM, should fail and return NULL
|
||||
if (sizeof(size_t) < 8)
|
||||
GTEST_SKIP() << "Alloc error not testable on 32bit";
|
||||
int64Set *set = int64Set_new(6250000000000ULL);
|
||||
EXPECT_FALSE(set);
|
||||
int64Set_free(set);
|
||||
}
|
||||
|
||||
TEST(IntsetTest, SetAllocOverflow)
|
||||
{
|
||||
// try to overflow argument passed to malloc
|
||||
int64Set *set = int64Set_new(std::numeric_limits<size_t>::max());
|
||||
EXPECT_FALSE(set);
|
||||
int64Set_free(set);
|
||||
}
|
||||
|
||||
TEST(IntsetTest, NullFree)
|
||||
{
|
||||
// free(NULL) should not try to free buckets
|
||||
uint32Set_free(NULL);
|
||||
int64Set_free(NULL);
|
||||
}
|
||||
|
||||
TEST(IntsetTest, BucketRealloc)
|
||||
{
|
||||
// add a couple of values to the same bucket to test growing the bucket
|
||||
uint32Set* set = uint32Set_new(1);
|
||||
ASSERT_TRUE(set);
|
||||
EXPECT_FALSE(uint32Set_contains(set, 0));
|
||||
EXPECT_TRUE(uint32Set_add(set, 0));
|
||||
EXPECT_TRUE(uint32Set_contains(set, 0));
|
||||
for (uint32_t i = 1; i < 32; ++i) {
|
||||
EXPECT_TRUE(uint32Set_add(set, i));
|
||||
EXPECT_TRUE(uint32Set_contains(set, i - 1));
|
||||
EXPECT_TRUE(uint32Set_contains(set, i));
|
||||
EXPECT_FALSE(uint32Set_contains(set, i + 1));
|
||||
}
|
||||
uint32Set_free(set);
|
||||
}
|
||||
|
||||
TEST(IntSet, Max)
|
||||
{
|
||||
constexpr auto n = std::numeric_limits<uint32_t>::max();
|
||||
uint32Set *set = uint32Set_new(1);
|
||||
ASSERT_TRUE(set);
|
||||
EXPECT_FALSE(uint32Set_contains(set, n));
|
||||
EXPECT_TRUE(uint32Set_add(set, n));
|
||||
EXPECT_TRUE(uint32Set_contains(set, n));
|
||||
uint32Set_free(set);
|
||||
}
|
||||
|
||||
TEST(InsetTest, Negative)
|
||||
{
|
||||
constexpr auto n = std::numeric_limits<int64_t>::min();
|
||||
static_assert(n < 0, "n not negative");
|
||||
int64Set *set = int64Set_new(3);
|
||||
ASSERT_TRUE(set);
|
||||
EXPECT_FALSE(int64Set_contains(set, n));
|
||||
EXPECT_TRUE(int64Set_add(set, n));
|
||||
EXPECT_TRUE(int64Set_contains(set, n));
|
||||
int64Set_free(set);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ from argparse import Namespace
|
||||
from typing import List, Optional, Tuple, Type, Union
|
||||
|
||||
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
|
||||
from worlds import network_data_package
|
||||
from worlds.AutoWorld import World, call_all
|
||||
|
||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
|
||||
@@ -61,10 +60,6 @@ class TestWorld(World):
|
||||
hidden = True
|
||||
|
||||
|
||||
# add our test world to the data package, so we can test it later
|
||||
network_data_package["games"][TestWorld.game] = TestWorld.get_data_package_data()
|
||||
|
||||
|
||||
def generate_test_multiworld(players: int = 1) -> MultiWorld:
|
||||
"""
|
||||
Generates a multiworld using a special Test Case World class, and seed of 0.
|
||||
|
||||
@@ -174,8 +174,8 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
player1 = generate_player_data(multiworld, 1, 3, 3)
|
||||
player2 = generate_player_data(multiworld, 2, 3, 3)
|
||||
|
||||
multiworld.worlds[player1.id].options.accessibility.value = Accessibility.option_minimal
|
||||
multiworld.worlds[player2.id].options.accessibility.value = Accessibility.option_full
|
||||
multiworld.accessibility[player1.id].value = multiworld.accessibility[player1.id].option_minimal
|
||||
multiworld.accessibility[player2.id].value = multiworld.accessibility[player2.id].option_locations
|
||||
|
||||
multiworld.completion_condition[player1.id] = lambda state: True
|
||||
multiworld.completion_condition[player2.id] = lambda state: state.has(player2.prog_items[2].name, player2.id)
|
||||
@@ -192,7 +192,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
location_pool = player1.locations[1:] + player2.locations
|
||||
item_pool = player1.prog_items[:-1] + player2.prog_items
|
||||
fill_restrictive(multiworld, multiworld.state, location_pool, item_pool)
|
||||
multiworld.state.sweep_for_advancements() # collect everything
|
||||
multiworld.state.sweep_for_events() # collect everything
|
||||
|
||||
# all of player2's locations and items should be accessible (not all of player1's)
|
||||
for item in player2.prog_items:
|
||||
@@ -443,8 +443,8 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
item = player1.prog_items[0]
|
||||
item.code = None
|
||||
location.place_locked_item(item)
|
||||
multiworld.state.sweep_for_advancements()
|
||||
multiworld.state.sweep_for_advancements()
|
||||
multiworld.state.sweep_for_events()
|
||||
multiworld.state.sweep_for_events()
|
||||
self.assertTrue(multiworld.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed")
|
||||
self.assertEqual(multiworld.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times")
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import os
|
||||
import os.path
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from tempfile import TemporaryDirectory, TemporaryFile
|
||||
from tempfile import TemporaryFile
|
||||
from typing import Any, Dict, List, cast
|
||||
|
||||
import Utils
|
||||
from settings import Group, Settings, ServerOptions
|
||||
from settings import Settings, Group
|
||||
|
||||
|
||||
class TestIDs(unittest.TestCase):
|
||||
@@ -81,27 +80,3 @@ class TestSettingsDumper(unittest.TestCase):
|
||||
self.assertEqual(value_spaces[2], value_spaces[0]) # start of sub-list
|
||||
self.assertGreater(value_spaces[3], value_spaces[0],
|
||||
f"{value_lines[3]} should have more indentation than {value_lines[0]} in {lines}")
|
||||
|
||||
|
||||
class TestSettingsSave(unittest.TestCase):
|
||||
def test_save(self) -> None:
|
||||
"""Test that saving and updating works"""
|
||||
with TemporaryDirectory() as d:
|
||||
filename = os.path.join(d, "host.yaml")
|
||||
new_release_mode = ServerOptions.ReleaseMode("enabled")
|
||||
# create default host.yaml
|
||||
settings = Settings(None)
|
||||
settings.save(filename)
|
||||
self.assertTrue(os.path.exists(filename),
|
||||
"Default settings could not be saved")
|
||||
self.assertNotEqual(settings.server_options.release_mode, new_release_mode,
|
||||
"Unexpected default release mode")
|
||||
# update host.yaml
|
||||
settings.server_options.release_mode = new_release_mode
|
||||
settings.save(filename)
|
||||
self.assertFalse(os.path.exists(filename + ".tmp"),
|
||||
"Temp file was not removed during save")
|
||||
# read back host.yaml
|
||||
settings = Settings(filename)
|
||||
self.assertEqual(settings.server_options.release_mode, new_release_mode,
|
||||
"Settings were not overwritten")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import unittest
|
||||
|
||||
from Fill import distribute_items_restrictive
|
||||
from worlds import network_data_package
|
||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
@@ -85,4 +84,3 @@ class TestIDs(unittest.TestCase):
|
||||
f"{loc_name} is not a valid item name for location_name_to_id")
|
||||
self.assertIsInstance(loc_id, int,
|
||||
f"{loc_id} for {loc_name} should be an int")
|
||||
self.assertEqual(datapackage["checksum"], network_data_package["games"][gamename]["checksum"])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import MultiWorld, PlandoOptions
|
||||
from BaseClasses import PlandoOptions
|
||||
from Options import ItemLinks
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
@@ -47,15 +47,3 @@ class TestOptions(unittest.TestCase):
|
||||
self.assertIn("Bow", link.value[0]["item_pool"])
|
||||
|
||||
# TODO test that the group created using these options has the items
|
||||
|
||||
def test_item_links_resolve(self):
|
||||
"""Test item link option resolves correctly."""
|
||||
item_link_group = [{
|
||||
"name": "ItemLinkTest",
|
||||
"item_pool": ["Everything"],
|
||||
"link_replacement": False,
|
||||
"replacement_item": None,
|
||||
}]
|
||||
item_links = {1: ItemLinks.from_any(item_link_group), 2: ItemLinks.from_any(item_link_group)}
|
||||
for link in item_links.values():
|
||||
self.assertEqual(link.value[0], item_link_group[0])
|
||||
|
||||
@@ -14,18 +14,6 @@ class TestBase(unittest.TestCase):
|
||||
"Desert Northern Cliffs", # on top of mountain, only reachable via OWG
|
||||
"Dark Death Mountain Bunny Descent Area" # OWG Mountain descent
|
||||
},
|
||||
# These Blasphemous regions are not reachable with default options
|
||||
"Blasphemous": {
|
||||
"D01Z04S13[SE]", # difficulty must be hard
|
||||
"D01Z05S25[E]", # difficulty must be hard
|
||||
"D02Z02S05[W]", # difficulty must be hard and purified_hand must be true
|
||||
"D04Z01S06[E]", # purified_hand must be true
|
||||
"D04Z02S02[NE]", # difficulty must be hard and purified_hand must be true
|
||||
"D05Z01S11[SW]", # difficulty must be hard
|
||||
"D06Z01S08[N]", # difficulty must be hard and purified_hand must be true
|
||||
"D20Z02S11[NW]", # difficulty must be hard
|
||||
"D20Z02S11[E]", # difficulty must be hard
|
||||
},
|
||||
"Ocarina of Time": {
|
||||
"Prelude of Light Warp", # Prelude is not progression by default
|
||||
"Serenade of Water Warp", # Serenade is not progression by default
|
||||
@@ -49,17 +37,19 @@ class TestBase(unittest.TestCase):
|
||||
unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set())
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
excluded = multiworld.worlds[1].options.exclude_locations.value
|
||||
state = multiworld.get_all_state(False)
|
||||
for location in multiworld.get_locations():
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
if location.name not in excluded:
|
||||
with self.subTest("Location should be reached", location=location):
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
|
||||
for region in multiworld.get_regions():
|
||||
if region.name in unreachable_regions:
|
||||
with self.subTest("Region should be unreachable", region=region.name):
|
||||
with self.subTest("Region should be unreachable", region=region):
|
||||
self.assertFalse(region.can_reach(state))
|
||||
else:
|
||||
with self.subTest("Region should be reached", region=region.name):
|
||||
with self.subTest("Region should be reached", region=region):
|
||||
self.assertTrue(region.can_reach(state))
|
||||
|
||||
with self.subTest("Completion Condition"):
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
# A bunch of tests to verify MultiServer and custom webhost server work as expected.
|
||||
# This spawns processes and may modify your local AP, so this is not run as part of unit testing.
|
||||
# Run with `python test/hosting` instead,
|
||||
import logging
|
||||
import traceback
|
||||
from tempfile import TemporaryDirectory
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
|
||||
from test.hosting.client import Client
|
||||
from test.hosting.generate import generate_local
|
||||
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
|
||||
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
|
||||
stop_autohost, upload_multidata)
|
||||
from test.hosting.world import copy as copy_world, delete as delete_world
|
||||
|
||||
failure = False
|
||||
fail_fast = True
|
||||
|
||||
|
||||
def assert_true(condition: Any, msg: str = "") -> None:
|
||||
global failure
|
||||
if not condition:
|
||||
failure = True
|
||||
msg = f": {msg}" if msg else ""
|
||||
raise AssertionError(f"Assertion failed{msg}")
|
||||
|
||||
|
||||
def assert_equal(first: Any, second: Any, msg: str = "") -> None:
|
||||
global failure
|
||||
if first != second:
|
||||
failure = True
|
||||
msg = f": {msg}" if msg else ""
|
||||
raise AssertionError(f"Assertion failed: {first} == {second}{msg}")
|
||||
|
||||
|
||||
if fail_fast:
|
||||
expect_true = assert_true
|
||||
expect_equal = assert_equal
|
||||
else:
|
||||
def expect_true(condition: Any, msg: str = "") -> None:
|
||||
global failure
|
||||
if not condition:
|
||||
failure = True
|
||||
tb = "".join(traceback.format_stack()[:-1])
|
||||
msg = f": {msg}" if msg else ""
|
||||
logging.error(f"Expectation failed{msg}\n{tb}")
|
||||
|
||||
def expect_equal(first: Any, second: Any, msg: str = "") -> None:
|
||||
global failure
|
||||
if first != second:
|
||||
failure = True
|
||||
tb = "".join(traceback.format_stack()[:-1])
|
||||
msg = f": {msg}" if msg else ""
|
||||
logging.error(f"Expectation failed {first} == {second}{msg}\n{tb}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import warnings
|
||||
warnings.simplefilter("ignore", ResourceWarning)
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
|
||||
spacer = '=' * 80
|
||||
|
||||
with TemporaryDirectory() as tempdir:
|
||||
multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]]
|
||||
p1_games = []
|
||||
data_paths = []
|
||||
rooms = []
|
||||
|
||||
copy_world("Clique", "Temp World")
|
||||
try:
|
||||
for n, games in enumerate(multis, 1):
|
||||
print(f"Generating [{n}] {', '.join(games)}")
|
||||
multidata = generate_local(games, tempdir)
|
||||
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
|
||||
p1_games.append(games[0])
|
||||
data_paths.append(multidata)
|
||||
finally:
|
||||
delete_world("Temp World")
|
||||
|
||||
webapp = get_app(tempdir)
|
||||
webhost_client = webapp.test_client()
|
||||
for n, multidata in enumerate(data_paths, 1):
|
||||
seed = upload_multidata(webhost_client, multidata)
|
||||
room = create_room(webhost_client, seed)
|
||||
print(f"Uploaded [{n}] {multidata} as {room}\n")
|
||||
rooms.append(room)
|
||||
|
||||
print("Starting autohost")
|
||||
from WebHostLib.autolauncher import autohost
|
||||
try:
|
||||
autohost(webapp.config)
|
||||
|
||||
host: ServeGame
|
||||
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
|
||||
involved_games = {"Archipelago"} | set(multi_games)
|
||||
for collected_items in range(3):
|
||||
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
|
||||
with LocalServeGame(multidata) as host:
|
||||
with Client(host.address, game, "Player1") as client:
|
||||
local_data_packages = client.games_packages
|
||||
local_collected_items = len(client.checked_locations)
|
||||
if collected_items < 2: # Clique only has 2 Locations
|
||||
client.collect_any()
|
||||
# TODO: Ctrl+C test here as well
|
||||
|
||||
for game_name in sorted(involved_games):
|
||||
expect_true(game_name in local_data_packages,
|
||||
f"{game_name} missing from MultiServer datap ackage")
|
||||
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
|
||||
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
|
||||
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||
for game_name in local_data_packages:
|
||||
expect_true(game_name in involved_games,
|
||||
f"Received unexpected extra data package for {game_name} from MultiServer")
|
||||
assert_equal(local_collected_items, collected_items,
|
||||
"MultiServer did not load or save correctly")
|
||||
|
||||
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
|
||||
prev_host_adr: str
|
||||
with WebHostServeGame(webhost_client, room) as host:
|
||||
prev_host_adr = host.address
|
||||
with Client(host.address, game, "Player1") as client:
|
||||
web_data_packages = client.games_packages
|
||||
web_collected_items = len(client.checked_locations)
|
||||
if collected_items < 2: # Clique only has 2 Locations
|
||||
client.collect_any()
|
||||
if collected_items == 1:
|
||||
sleep(1) # wait for the server to collect the item
|
||||
stop_autohost(True) # simulate Ctrl+C
|
||||
sleep(3)
|
||||
autohost(webapp.config) # this will spin the room right up again
|
||||
sleep(1) # make log less annoying
|
||||
# if saving failed, the next iteration will fail below
|
||||
|
||||
# verify server shut down
|
||||
try:
|
||||
with Client(prev_host_adr, game, "Player1") as client:
|
||||
assert_true(False, "Server did not shut down")
|
||||
except ConnectionError:
|
||||
pass
|
||||
|
||||
for game_name in sorted(involved_games):
|
||||
expect_true(game_name in web_data_packages,
|
||||
f"{game_name} missing from customserver data package")
|
||||
expect_true("item_name_groups" not in web_data_packages.get(game_name, {}),
|
||||
f"item_name_groups are not supposed to be in customserver data for {game_name}")
|
||||
expect_true("location_name_groups" not in web_data_packages.get(game_name, {}),
|
||||
f"location_name_groups are not supposed to be in customserver data for {game_name}")
|
||||
for game_name in web_data_packages:
|
||||
expect_true(game_name in involved_games,
|
||||
f"Received unexpected extra data package for {game_name} from customserver")
|
||||
assert_equal(web_collected_items, collected_items,
|
||||
"customserver did not load or save correctly during/after "
|
||||
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
|
||||
|
||||
# compare customserver to MultiServer
|
||||
expect_equal(local_data_packages, web_data_packages,
|
||||
"customserver datapackage differs from MultiServer")
|
||||
|
||||
sleep(5.5) # make sure all tasks actually stopped
|
||||
|
||||
# raise an exception in customserver and verify the save doesn't get destroyed
|
||||
# local variables room is the last room's id here
|
||||
old_data = get_multidata_for_room(webhost_client, room)
|
||||
print(f"Destroying multidata for {room}")
|
||||
set_multidata_for_room(webhost_client, room, bytes([0]))
|
||||
try:
|
||||
start_room(webhost_client, room, timeout=7)
|
||||
except TimeoutError:
|
||||
pass
|
||||
else:
|
||||
assert_true(False, "Room started with destroyed multidata")
|
||||
print(f"Restoring multidata for {room}")
|
||||
set_multidata_for_room(webhost_client, room, old_data)
|
||||
with WebHostServeGame(webhost_client, room) as host:
|
||||
with Client(host.address, game, "Player1") as client:
|
||||
assert_equal(len(client.checked_locations), 2,
|
||||
"Save was destroyed during exception in customserver")
|
||||
print("Save file is not busted 🥳")
|
||||
|
||||
finally:
|
||||
print("Stopping autohost")
|
||||
stop_autohost(False)
|
||||
|
||||
if failure:
|
||||
print("Some tests failed")
|
||||
exit(1)
|
||||
exit(0)
|
||||
@@ -1,110 +0,0 @@
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Collection, Dict, Iterable, Optional
|
||||
from websockets import ConnectionClosed
|
||||
from websockets.sync.client import connect, ClientConnection
|
||||
from threading import Thread
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Client"
|
||||
]
|
||||
|
||||
|
||||
class Client:
|
||||
"""Incomplete, minimalistic sync test client for AP network protocol"""
|
||||
|
||||
recv_timeout = 1.0
|
||||
|
||||
host: str
|
||||
game: str
|
||||
slot: str
|
||||
password: Optional[str]
|
||||
|
||||
_ws: Optional[ClientConnection]
|
||||
|
||||
games: Iterable[str]
|
||||
data_package_checksums: Dict[str, Any]
|
||||
games_packages: Dict[str, Any]
|
||||
missing_locations: Collection[int]
|
||||
checked_locations: Collection[int]
|
||||
|
||||
def __init__(self, host: str, game: str, slot: str, password: Optional[str] = None) -> None:
|
||||
self.host = host
|
||||
self.game = game
|
||||
self.slot = slot
|
||||
self.password = password
|
||||
self._ws = None
|
||||
self.games = []
|
||||
self.data_package_checksums = {}
|
||||
self.games_packages = {}
|
||||
self.missing_locations = []
|
||||
self.checked_locations = []
|
||||
|
||||
def __enter__(self) -> "Client":
|
||||
try:
|
||||
self.connect()
|
||||
except BaseException:
|
||||
self.__exit__(*sys.exc_info())
|
||||
raise
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
|
||||
self.close()
|
||||
|
||||
def _poll(self) -> None:
|
||||
assert self._ws
|
||||
try:
|
||||
while True:
|
||||
self._ws.recv()
|
||||
except (TimeoutError, ConnectionClosed, KeyboardInterrupt, SystemExit):
|
||||
pass
|
||||
|
||||
def connect(self) -> None:
|
||||
self._ws = connect(f"ws://{self.host}")
|
||||
room_info = json.loads(self._ws.recv(self.recv_timeout))[0]
|
||||
self.games = sorted(room_info["games"])
|
||||
self.data_package_checksums = room_info["datapackage_checksums"]
|
||||
self._ws.send(json.dumps([{
|
||||
"cmd": "GetDataPackage",
|
||||
"games": list(self.games),
|
||||
}]))
|
||||
data_package_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
|
||||
self.games_packages = data_package_msg["data"]["games"]
|
||||
self._ws.send(json.dumps([{
|
||||
"cmd": "Connect",
|
||||
"game": self.game,
|
||||
"name": self.slot,
|
||||
"password": self.password,
|
||||
"uuid": "",
|
||||
"version": {
|
||||
"class": "Version",
|
||||
"major": 0,
|
||||
"minor": 4,
|
||||
"build": 6,
|
||||
},
|
||||
"items_handling": 0,
|
||||
"tags": [],
|
||||
"slot_data": False,
|
||||
}]))
|
||||
connect_result_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
|
||||
if connect_result_msg["cmd"] != "Connected":
|
||||
raise ConnectionError(", ".join(connect_result_msg.get("errors", [connect_result_msg["cmd"]])))
|
||||
self.missing_locations = connect_result_msg["missing_locations"]
|
||||
self.checked_locations = connect_result_msg["checked_locations"]
|
||||
|
||||
def close(self) -> None:
|
||||
if self._ws:
|
||||
Thread(target=self._poll).start()
|
||||
self._ws.close()
|
||||
|
||||
def collect(self, locations: Iterable[int]) -> None:
|
||||
if not self._ws:
|
||||
raise ValueError("Not connected")
|
||||
self._ws.send(json.dumps([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": locations,
|
||||
}]))
|
||||
|
||||
def collect_any(self) -> None:
|
||||
self.collect([next(iter(self.missing_locations))])
|
||||
@@ -1,76 +0,0 @@
|
||||
import json
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Union, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from multiprocessing.managers import ListProxy # noqa
|
||||
|
||||
__all__ = [
|
||||
"generate_local",
|
||||
]
|
||||
|
||||
|
||||
def _generate_local_inner(games: Iterable[str],
|
||||
dest: Union[Path, str],
|
||||
results: "ListProxy[Union[Path, BaseException]]") -> None:
|
||||
original_argv = sys.argv
|
||||
warnings.simplefilter("ignore")
|
||||
try:
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
if not isinstance(dest, Path):
|
||||
dest = Path(dest)
|
||||
|
||||
with TemporaryDirectory() as players_dir:
|
||||
with TemporaryDirectory() as output_dir:
|
||||
import Generate
|
||||
import Main
|
||||
|
||||
for n, game in enumerate(games, 1):
|
||||
player_path = Path(players_dir) / f"{n}.yaml"
|
||||
with open(player_path, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({
|
||||
"name": f"Player{n}",
|
||||
"game": game,
|
||||
game: {"hard_mode": "true"},
|
||||
"description": f"generate_local slot {n} ('Player{n}'): {game}",
|
||||
}))
|
||||
|
||||
# this is basically copied from test/programs/test_generate.py
|
||||
# uses a reproducible seed that is different for each set of games
|
||||
sys.argv = [sys.argv[0], "--seed", str(hash(tuple(games))),
|
||||
"--player_files_path", players_dir,
|
||||
"--outputpath", output_dir]
|
||||
Main.main(*Generate.main())
|
||||
output_files = list(Path(output_dir).glob('*.zip'))
|
||||
assert len(output_files) == 1
|
||||
final_file = dest / output_files[0].name
|
||||
output_files[0].rename(final_file)
|
||||
results.append(final_file)
|
||||
except BaseException as e:
|
||||
results.append(e)
|
||||
raise e
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
def generate_local(games: Iterable[str], dest: Union[Path, str]) -> Path:
|
||||
from multiprocessing import Manager, Process, set_start_method
|
||||
|
||||
try:
|
||||
set_start_method("spawn")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
manager = Manager()
|
||||
results: "ListProxy[Union[Path, Exception]]" = manager.list()
|
||||
|
||||
p = Process(target=_generate_local_inner, args=(games, dest, results))
|
||||
p.start()
|
||||
p.join()
|
||||
result = results[0]
|
||||
if isinstance(result, BaseException):
|
||||
raise Exception("Could not generate multiworld") from result
|
||||
return result
|
||||
@@ -1,115 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from threading import Event
|
||||
from werkzeug.test import Client as FlaskClient
|
||||
|
||||
__all__ = [
|
||||
"ServeGame",
|
||||
"LocalServeGame",
|
||||
"WebHostServeGame",
|
||||
]
|
||||
|
||||
|
||||
class ServeGame:
|
||||
address: str
|
||||
|
||||
|
||||
def _launch_multiserver(multidata: Path, ready: "Event", stop: "Event") -> None:
|
||||
import os
|
||||
import warnings
|
||||
|
||||
original_argv = sys.argv
|
||||
original_stdin = sys.stdin
|
||||
warnings.simplefilter("ignore")
|
||||
try:
|
||||
import asyncio
|
||||
from MultiServer import main, parse_args
|
||||
|
||||
sys.argv = [sys.argv[0], str(multidata), "--host", "127.0.0.1"]
|
||||
r, w = os.pipe()
|
||||
sys.stdin = os.fdopen(r, "r")
|
||||
|
||||
async def set_ready() -> None:
|
||||
await asyncio.sleep(.01) # switch back to other task once more
|
||||
ready.set() # server should be up, set ready state
|
||||
|
||||
async def wait_stop() -> None:
|
||||
await asyncio.get_event_loop().run_in_executor(None, stop.wait)
|
||||
os.fdopen(w, "w").write("/exit")
|
||||
|
||||
async def run() -> None:
|
||||
# this will run main() until first await, then switch to set_ready()
|
||||
await asyncio.gather(
|
||||
main(parse_args()),
|
||||
set_ready(),
|
||||
wait_stop(),
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
sys.stdin = original_stdin
|
||||
|
||||
|
||||
class LocalServeGame(ServeGame):
|
||||
from multiprocessing import Process
|
||||
|
||||
_multidata: Path
|
||||
_proc: Process
|
||||
_stop: "Event"
|
||||
|
||||
def __init__(self, multidata: Path) -> None:
|
||||
self.address = ""
|
||||
self._multidata = multidata
|
||||
|
||||
def __enter__(self) -> "LocalServeGame":
|
||||
from multiprocessing import Manager, Process, set_start_method
|
||||
|
||||
try:
|
||||
set_start_method("spawn")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
manager = Manager()
|
||||
ready: "Event" = manager.Event()
|
||||
self._stop = manager.Event()
|
||||
|
||||
self._proc = Process(target=_launch_multiserver, args=(self._multidata, ready, self._stop))
|
||||
try:
|
||||
self._proc.start()
|
||||
ready.wait(30)
|
||||
self.address = "localhost:38281"
|
||||
return self
|
||||
except BaseException:
|
||||
self.__exit__(*sys.exc_info())
|
||||
raise
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
|
||||
try:
|
||||
self._stop.set()
|
||||
self._proc.join(30)
|
||||
except TimeoutError:
|
||||
self._proc.terminate()
|
||||
self._proc.join()
|
||||
|
||||
|
||||
class WebHostServeGame(ServeGame):
|
||||
_client: "FlaskClient"
|
||||
_room: str
|
||||
|
||||
def __init__(self, app_client: "FlaskClient", room: str) -> None:
|
||||
self.address = ""
|
||||
self._client = app_client
|
||||
self._room = room
|
||||
|
||||
def __enter__(self) -> "WebHostServeGame":
|
||||
from .webhost import start_room
|
||||
self.address = start_room(self._client, self._room)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
|
||||
from .webhost import stop_room
|
||||
stop_room(self._client, self._room, timeout=30)
|
||||
@@ -1,208 +0,0 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from flask import Flask
|
||||
from werkzeug.test import Client as FlaskClient
|
||||
|
||||
__all__ = [
|
||||
"get_app",
|
||||
"upload_multidata",
|
||||
"create_room",
|
||||
"start_room",
|
||||
"stop_room",
|
||||
"set_room_timeout",
|
||||
"get_multidata_for_room",
|
||||
"set_multidata_for_room",
|
||||
"stop_autohost",
|
||||
]
|
||||
|
||||
|
||||
def get_app(tempdir: str) -> "Flask":
|
||||
from WebHostLib import app as raw_app
|
||||
from WebHost import get_app
|
||||
raw_app.config["PONY"] = {
|
||||
"provider": "sqlite",
|
||||
"filename": str(Path(tempdir) / "host.db"),
|
||||
"create_db": True,
|
||||
}
|
||||
raw_app.config.update({
|
||||
"TESTING": True,
|
||||
"HOST_ADDRESS": "localhost",
|
||||
"HOSTERS": 1,
|
||||
})
|
||||
return get_app()
|
||||
|
||||
|
||||
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
|
||||
response = app_client.post("/uploads", data={
|
||||
"file": multidata.open("rb"),
|
||||
})
|
||||
assert response.status_code < 400, f"Upload of {multidata} failed: status {response.status_code}"
|
||||
assert "Location" in response.headers, f"Upload of {multidata} failed: no redirect"
|
||||
location = response.headers["Location"]
|
||||
assert isinstance(location, str)
|
||||
assert location.startswith("/seed/"), f"Upload of {multidata} failed: unexpected redirect"
|
||||
return location[6:]
|
||||
|
||||
|
||||
def create_room(app_client: "FlaskClient", seed: str, auto_start: bool = False) -> str:
|
||||
response = app_client.get(f"/new_room/{seed}")
|
||||
assert response.status_code < 400, f"Creating room for {seed} failed: status {response.status_code}"
|
||||
assert "Location" in response.headers, f"Creating room for {seed} failed: no redirect"
|
||||
location = response.headers["Location"]
|
||||
assert isinstance(location, str)
|
||||
assert location.startswith("/room/"), f"Creating room for {seed} failed: unexpected redirect"
|
||||
room_id = location[6:]
|
||||
|
||||
if not auto_start:
|
||||
# by default, creating a room will auto-start it, so we update last activity here
|
||||
stop_room(app_client, room_id, simulate_idle=False)
|
||||
|
||||
return room_id
|
||||
|
||||
|
||||
def start_room(app_client: "FlaskClient", room_id: str, timeout: float = 30) -> str:
|
||||
from time import sleep
|
||||
|
||||
import pony.orm
|
||||
|
||||
poll_interval = .2
|
||||
|
||||
print(f"Starting room {room_id}")
|
||||
no_timeout = timeout <= 0
|
||||
while no_timeout or timeout > 0:
|
||||
try:
|
||||
response = app_client.get(f"/room/{room_id}")
|
||||
except pony.orm.core.OptimisticCheckError:
|
||||
# hoster wrote to room during our transaction
|
||||
continue
|
||||
|
||||
assert response.status_code == 200, f"Starting room for {room_id} failed: status {response.status_code}"
|
||||
match = re.search(r"/connect ([\w:.\-]+)", response.text)
|
||||
if match:
|
||||
return match[1]
|
||||
timeout -= poll_interval
|
||||
sleep(poll_interval)
|
||||
raise TimeoutError("Room did not start")
|
||||
|
||||
|
||||
def stop_room(app_client: "FlaskClient",
|
||||
room_id: str,
|
||||
timeout: Optional[float] = None,
|
||||
simulate_idle: bool = True) -> None:
|
||||
from datetime import datetime, timedelta
|
||||
from time import sleep
|
||||
|
||||
from pony.orm import db_session
|
||||
|
||||
from WebHostLib.models import Command, Room
|
||||
from WebHostLib import app
|
||||
|
||||
poll_interval = 2
|
||||
|
||||
print(f"Stopping room {room_id}")
|
||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
||||
|
||||
if timeout is not None:
|
||||
sleep(.1) # should not be required, but other things might use threading
|
||||
|
||||
with db_session:
|
||||
room: Room = Room.get(id=room_uuid)
|
||||
if simulate_idle:
|
||||
new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5)
|
||||
else:
|
||||
new_last_activity = datetime.utcnow() - timedelta(days=3)
|
||||
room.last_activity = new_last_activity
|
||||
address = f"localhost:{room.last_port}" if room.last_port > 0 else None
|
||||
if address:
|
||||
original_timeout = room.timeout
|
||||
room.timeout = 1 # avoid spinning it up again
|
||||
Command(room=room, commandtext="/exit")
|
||||
|
||||
try:
|
||||
if address and timeout is not None:
|
||||
print("waiting for shutdown")
|
||||
import socket
|
||||
host_str, port_str = tuple(address.split(":"))
|
||||
address_tuple = host_str, int(port_str)
|
||||
|
||||
no_timeout = timeout <= 0
|
||||
while no_timeout or timeout > 0:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.connect(address_tuple)
|
||||
s.close()
|
||||
except ConnectionRefusedError:
|
||||
return
|
||||
sleep(poll_interval)
|
||||
timeout -= poll_interval
|
||||
|
||||
raise TimeoutError("Room did not stop")
|
||||
finally:
|
||||
with db_session:
|
||||
room = Room.get(id=room_uuid)
|
||||
room.last_port = 0 # easier to detect when the host is up this way
|
||||
if address:
|
||||
room.timeout = original_timeout
|
||||
room.last_activity = new_last_activity
|
||||
print("timeout restored")
|
||||
|
||||
|
||||
def set_room_timeout(room_id: str, timeout: float) -> None:
|
||||
from pony.orm import db_session
|
||||
|
||||
from WebHostLib.models import Room
|
||||
from WebHostLib import app
|
||||
|
||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
||||
with db_session:
|
||||
room: Room = Room.get(id=room_uuid)
|
||||
room.timeout = timeout
|
||||
|
||||
|
||||
def get_multidata_for_room(webhost_client: "FlaskClient", room_id: str) -> bytes:
|
||||
from pony.orm import db_session
|
||||
|
||||
from WebHostLib.models import Room
|
||||
from WebHostLib import app
|
||||
|
||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
||||
with db_session:
|
||||
room: Room = Room.get(id=room_uuid)
|
||||
return cast(bytes, room.seed.multidata)
|
||||
|
||||
|
||||
def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: bytes) -> None:
|
||||
from pony.orm import db_session
|
||||
|
||||
from WebHostLib.models import Room
|
||||
from WebHostLib import app
|
||||
|
||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
||||
with db_session:
|
||||
room: Room = Room.get(id=room_uuid)
|
||||
room.seed.multidata = data
|
||||
|
||||
|
||||
def stop_autohost(graceful: bool = True) -> None:
|
||||
import os
|
||||
import signal
|
||||
|
||||
import multiprocessing
|
||||
|
||||
from WebHostLib.autolauncher import stop
|
||||
|
||||
stop()
|
||||
proc: multiprocessing.process.BaseProcess
|
||||
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()):
|
||||
if graceful and proc.pid:
|
||||
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
|
||||
else:
|
||||
proc.kill()
|
||||
try:
|
||||
proc.join(30)
|
||||
except TimeoutError:
|
||||
proc.kill()
|
||||
proc.join()
|
||||
@@ -1,42 +0,0 @@
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
|
||||
__all__ = ["copy", "delete"]
|
||||
|
||||
|
||||
_new_worlds: Dict[str, str] = {}
|
||||
|
||||
|
||||
def copy(src: str, dst: str) -> None:
|
||||
from Utils import get_file_safe_name
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
assert dst not in _new_worlds, "World already created"
|
||||
if '"' in dst or "\\" in dst: # easier to reject than to escape
|
||||
raise ValueError(f"Unsupported symbols in {dst}")
|
||||
dst_folder_name = get_file_safe_name(dst.lower())
|
||||
src_cls = AutoWorldRegister.world_types[src]
|
||||
src_folder = Path(src_cls.__file__).parent
|
||||
worlds_folder = src_folder.parent
|
||||
if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir()
|
||||
or not (worlds_folder / "generic").is_dir()):
|
||||
raise ValueError(f"Unsupported layout for copy_world from {src}")
|
||||
dst_folder = worlds_folder / dst_folder_name
|
||||
if dst_folder.is_dir():
|
||||
raise ValueError(f"Destination {dst_folder} already exists")
|
||||
shutil.copytree(src_folder, dst_folder)
|
||||
_new_worlds[dst] = str(dst_folder)
|
||||
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
|
||||
contents = f.read()
|
||||
contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
|
||||
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
|
||||
f.write(contents)
|
||||
|
||||
|
||||
def delete(name: str) -> None:
|
||||
assert name in _new_worlds, "World not created by this script"
|
||||
shutil.rmtree(_new_worlds[name])
|
||||
del _new_worlds[name]
|
||||
@@ -55,7 +55,7 @@ class TestAllGamesMultiworld(MultiworldTestBase):
|
||||
all_worlds = list(AutoWorldRegister.world_types.values())
|
||||
self.multiworld = setup_multiworld(all_worlds, ())
|
||||
for world in self.multiworld.worlds.values():
|
||||
world.options.accessibility.value = Accessibility.option_full
|
||||
world.options.accessibility.value = Accessibility.option_locations
|
||||
self.assertSteps(gen_steps)
|
||||
with self.subTest("filling multiworld", seed=self.multiworld.seed):
|
||||
distribute_items_restrictive(self.multiworld)
|
||||
@@ -66,10 +66,10 @@ class TestAllGamesMultiworld(MultiworldTestBase):
|
||||
class TestTwoPlayerMulti(MultiworldTestBase):
|
||||
def test_two_player_single_game_fills(self) -> None:
|
||||
"""Tests that a multiworld of two players for each registered game world can generate."""
|
||||
for world_type in AutoWorldRegister.world_types.values():
|
||||
self.multiworld = setup_multiworld([world_type, world_type], ())
|
||||
for world in AutoWorldRegister.world_types.values():
|
||||
self.multiworld = setup_multiworld([world, world], ())
|
||||
for world in self.multiworld.worlds.values():
|
||||
world.options.accessibility.value = Accessibility.option_full
|
||||
world.options.accessibility.value = Accessibility.option_locations
|
||||
self.assertSteps(gen_steps)
|
||||
with self.subTest("filling multiworld", seed=self.multiworld.seed):
|
||||
distribute_items_restrictive(self.multiworld)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Tests for _speedups.LocationStore and NetUtils._LocationStore
|
||||
import os
|
||||
import typing
|
||||
import unittest
|
||||
import warnings
|
||||
@@ -8,8 +7,6 @@ from NetUtils import LocationStore, _LocationStore
|
||||
State = typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||
RawLocations = typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||
|
||||
ci = bool(os.environ.get("CI")) # always set in GitHub actions
|
||||
|
||||
sample_data: RawLocations = {
|
||||
1: {
|
||||
11: (21, 2, 7),
|
||||
@@ -27,9 +24,6 @@ sample_data: RawLocations = {
|
||||
3: {
|
||||
9: (99, 4, 0),
|
||||
},
|
||||
5: {
|
||||
9: (99, 5, 0),
|
||||
}
|
||||
}
|
||||
|
||||
empty_state: State = {
|
||||
@@ -51,14 +45,14 @@ class Base:
|
||||
store: typing.Union[LocationStore, _LocationStore]
|
||||
|
||||
def test_len(self) -> None:
|
||||
self.assertEqual(len(self.store), 5)
|
||||
self.assertEqual(len(self.store), 4)
|
||||
self.assertEqual(len(self.store[1]), 3)
|
||||
|
||||
def test_key_error(self) -> None:
|
||||
with self.assertRaises(KeyError):
|
||||
_ = self.store[0]
|
||||
with self.assertRaises(KeyError):
|
||||
_ = self.store[6]
|
||||
_ = self.store[5]
|
||||
locations = self.store[1] # no Exception
|
||||
with self.assertRaises(KeyError):
|
||||
_ = locations[7]
|
||||
@@ -77,7 +71,7 @@ class Base:
|
||||
self.assertEqual(self.store[1].get(10, (None, None, None)), (None, None, None))
|
||||
|
||||
def test_iter(self) -> None:
|
||||
self.assertEqual(sorted(self.store), [1, 2, 3, 4, 5])
|
||||
self.assertEqual(sorted(self.store), [1, 2, 3, 4])
|
||||
self.assertEqual(len(self.store), len(sample_data))
|
||||
self.assertEqual(list(self.store[1]), [11, 12, 13])
|
||||
self.assertEqual(len(self.store[1]), len(sample_data[1]))
|
||||
@@ -91,26 +85,13 @@ class Base:
|
||||
self.assertEqual(sorted(self.store[1].items())[0][1], self.store[1][11])
|
||||
|
||||
def test_find_item(self) -> None:
|
||||
# empty player set
|
||||
self.assertEqual(sorted(self.store.find_item(set(), 99)), [])
|
||||
# no such player, single
|
||||
self.assertEqual(sorted(self.store.find_item({6}, 99)), [])
|
||||
# no such player, set
|
||||
self.assertEqual(sorted(self.store.find_item({7, 8, 9}, 99)), [])
|
||||
# no such item
|
||||
self.assertEqual(sorted(self.store.find_item({3}, 1)), [])
|
||||
# valid matches
|
||||
self.assertEqual(sorted(self.store.find_item({5}, 99)), [])
|
||||
self.assertEqual(sorted(self.store.find_item({3}, 99)),
|
||||
[(4, 9, 99, 3, 0)])
|
||||
self.assertEqual(sorted(self.store.find_item({3, 4}, 99)),
|
||||
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
|
||||
self.assertEqual(sorted(self.store.find_item({2, 3, 4}, 99)),
|
||||
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
|
||||
# test hash collision in set
|
||||
self.assertEqual(sorted(self.store.find_item({3, 5}, 99)),
|
||||
[(4, 9, 99, 3, 0), (5, 9, 99, 5, 0)])
|
||||
self.assertEqual(sorted(self.store.find_item(set(range(2048)), 13)),
|
||||
[(1, 13, 13, 1, 0)])
|
||||
|
||||
def test_get_for_player(self) -> None:
|
||||
self.assertEqual(self.store.get_for_player(3), {4: {9}})
|
||||
@@ -130,9 +111,9 @@ class Base:
|
||||
|
||||
def test_get_remaining(self) -> None:
|
||||
self.assertEqual(self.store.get_remaining(full_state, 0, 1), [])
|
||||
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [(1, 13), (2, 21)])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [(1, 13), (2, 21), (2, 22)])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [(4, 99)])
|
||||
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [13, 21])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [13, 21, 22])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [99])
|
||||
|
||||
def test_location_set_intersection(self) -> None:
|
||||
locations = {10, 11, 12}
|
||||
@@ -215,20 +196,18 @@ class TestPurePythonLocationStoreConstructor(Base.TestLocationStoreConstructor):
|
||||
super().setUp()
|
||||
|
||||
|
||||
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
|
||||
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
|
||||
class TestSpeedupsLocationStore(Base.TestLocationStore):
|
||||
"""Run base method tests for cython implementation."""
|
||||
def setUp(self) -> None:
|
||||
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
|
||||
self.store = LocationStore(sample_data)
|
||||
super().setUp()
|
||||
|
||||
|
||||
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
|
||||
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
|
||||
class TestSpeedupsLocationStoreConstructor(Base.TestLocationStoreConstructor):
|
||||
"""Run base constructor tests and tests the additional constraints for cython implementation."""
|
||||
def setUp(self) -> None:
|
||||
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
|
||||
self.type = LocationStore
|
||||
super().setUp()
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from Options import Choice, DefaultOnToggle, Toggle
|
||||
|
||||
|
||||
class TestNumericOptions(unittest.TestCase):
|
||||
def test_numeric_option(self) -> None:
|
||||
"""Tests the initialization and equivalency comparisons of the base Numeric Option class."""
|
||||
class TestChoice(Choice):
|
||||
option_zero = 0
|
||||
option_one = 1
|
||||
option_two = 2
|
||||
alias_three = 1
|
||||
non_option_attr = 2
|
||||
|
||||
class TestToggle(Toggle):
|
||||
pass
|
||||
|
||||
class TestDefaultOnToggle(DefaultOnToggle):
|
||||
pass
|
||||
|
||||
with self.subTest("choice"):
|
||||
choice_option_default = TestChoice.from_any(TestChoice.default)
|
||||
choice_option_string = TestChoice.from_any("one")
|
||||
choice_option_int = TestChoice.from_any(2)
|
||||
choice_option_alias = TestChoice.from_any("three")
|
||||
choice_option_attr = TestChoice.from_any(TestChoice.option_two)
|
||||
|
||||
self.assertEqual(choice_option_default, TestChoice.option_zero,
|
||||
"assigning default didn't match default value")
|
||||
self.assertEqual(choice_option_string, "one")
|
||||
self.assertEqual(choice_option_int, 2)
|
||||
self.assertEqual(choice_option_alias, TestChoice.alias_three)
|
||||
self.assertEqual(choice_option_attr, TestChoice.non_option_attr)
|
||||
|
||||
self.assertRaises(KeyError, TestChoice.from_any, "four")
|
||||
|
||||
self.assertIn(choice_option_int, [1, 2, 3])
|
||||
self.assertIn(choice_option_int, {2})
|
||||
self.assertIn(choice_option_int, (2,))
|
||||
|
||||
self.assertIn(choice_option_string, ["one", "two", "three"])
|
||||
# this fails since the hash is derived from the value
|
||||
self.assertNotIn(choice_option_string, {"one"})
|
||||
self.assertIn(choice_option_string, ("one",))
|
||||
|
||||
with self.subTest("toggle"):
|
||||
toggle_default = TestToggle.from_any(TestToggle.default)
|
||||
toggle_string = TestToggle.from_any("false")
|
||||
toggle_int = TestToggle.from_any(0)
|
||||
toggle_alias = TestToggle.from_any("off")
|
||||
|
||||
self.assertFalse(toggle_default)
|
||||
self.assertFalse(toggle_string)
|
||||
self.assertFalse(toggle_int)
|
||||
self.assertFalse(toggle_alias)
|
||||
|
||||
with self.subTest("on toggle"):
|
||||
toggle_default = TestDefaultOnToggle.from_any(TestDefaultOnToggle.default)
|
||||
toggle_string = TestDefaultOnToggle.from_any("true")
|
||||
toggle_int = TestDefaultOnToggle.from_any(1)
|
||||
toggle_alias = TestDefaultOnToggle.from_any("on")
|
||||
|
||||
self.assertTrue(toggle_default)
|
||||
self.assertTrue(toggle_string)
|
||||
self.assertTrue(toggle_int)
|
||||
self.assertTrue(toggle_alias)
|
||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import Generate
|
||||
import Main
|
||||
|
||||
|
||||
class TestGenerateMain(unittest.TestCase):
|
||||
@@ -59,7 +58,7 @@ class TestGenerateMain(unittest.TestCase):
|
||||
'--player_files_path', str(self.abs_input_dir),
|
||||
'--outputpath', self.output_tempdir.name]
|
||||
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
|
||||
Main.main(*Generate.main())
|
||||
Generate.main()
|
||||
|
||||
self.assertOutput(self.output_tempdir.name)
|
||||
|
||||
@@ -68,7 +67,7 @@ class TestGenerateMain(unittest.TestCase):
|
||||
'--player_files_path', str(self.rel_input_dir),
|
||||
'--outputpath', self.output_tempdir.name]
|
||||
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
|
||||
Main.main(*Generate.main())
|
||||
Generate.main()
|
||||
|
||||
self.assertOutput(self.output_tempdir.name)
|
||||
|
||||
@@ -87,7 +86,7 @@ class TestGenerateMain(unittest.TestCase):
|
||||
sys.argv = [sys.argv[0], '--seed', '0',
|
||||
'--outputpath', self.output_tempdir.name]
|
||||
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}, player_files_path={self.yaml_input_dir}')
|
||||
Main.main(*Generate.main())
|
||||
Generate.main()
|
||||
finally:
|
||||
user_path.cached_path = user_path_backup
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import unittest
|
||||
import typing
|
||||
from uuid import uuid4
|
||||
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
app: typing.ClassVar[Flask]
|
||||
client: FlaskClient
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
from WebHostLib import app as raw_app
|
||||
from WebHost import get_app
|
||||
|
||||
raw_app.config["PONY"] = {
|
||||
"provider": "sqlite",
|
||||
"filename": ":memory:",
|
||||
"create_db": True,
|
||||
}
|
||||
raw_app.config.update({
|
||||
"TESTING": True,
|
||||
"DEBUG": True,
|
||||
})
|
||||
try:
|
||||
cls.app = get_app()
|
||||
except AssertionError as e:
|
||||
# since we only have 1 global app object, this might fail, but luckily all tests use the same config
|
||||
if "register_blueprint" not in e.args[0]:
|
||||
raise
|
||||
cls.app = raw_app
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.client = self.app.test_client()
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import io
|
||||
import unittest
|
||||
import json
|
||||
import yaml
|
||||
|
||||
from . import TestBase
|
||||
|
||||
class TestDocs(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
from WebHostLib import app as raw_app
|
||||
from WebHost import get_app
|
||||
raw_app.config["PONY"] = {
|
||||
"provider": "sqlite",
|
||||
"filename": ":memory:",
|
||||
"create_db": True,
|
||||
}
|
||||
raw_app.config.update({
|
||||
"TESTING": True,
|
||||
})
|
||||
app = get_app()
|
||||
|
||||
class TestAPIGenerate(TestBase):
|
||||
def test_correct_error_empty_request(self) -> None:
|
||||
cls.client = app.test_client()
|
||||
|
||||
def test_correct_error_empty_request(self):
|
||||
response = self.client.post("/api/generate")
|
||||
self.assertIn("No options found. Expected file attachment or json weights.", response.text)
|
||||
|
||||
def test_generation_queued_weights(self) -> None:
|
||||
def test_generation_queued_weights(self):
|
||||
options = {
|
||||
"Tester1":
|
||||
{
|
||||
@@ -28,7 +43,7 @@ class TestAPIGenerate(TestBase):
|
||||
self.assertTrue(json_data["text"].startswith("Generation of seed "))
|
||||
self.assertTrue(json_data["text"].endswith(" started successfully."))
|
||||
|
||||
def test_generation_queued_file(self) -> None:
|
||||
def test_generation_queued_file(self):
|
||||
options = {
|
||||
"game": "Archipelago",
|
||||
"name": "Tester",
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import os
|
||||
from uuid import UUID, uuid4, uuid5
|
||||
|
||||
from flask import url_for
|
||||
|
||||
from . import TestBase
|
||||
|
||||
|
||||
class TestHostFakeRoom(TestBase):
|
||||
room_id: UUID
|
||||
log_filename: str
|
||||
|
||||
def setUp(self) -> None:
|
||||
from pony.orm import db_session
|
||||
from Utils import user_path
|
||||
from WebHostLib.models import Room, Seed
|
||||
|
||||
super().setUp()
|
||||
|
||||
with self.client.session_transaction() as session:
|
||||
session["_id"] = uuid4()
|
||||
with db_session:
|
||||
# create an empty seed and a room from it
|
||||
seed = Seed(multidata=b"", owner=session["_id"])
|
||||
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
|
||||
self.room_id = room.id
|
||||
self.log_filename = user_path("logs", f"{self.room_id}.txt")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
from pony.orm import db_session, select
|
||||
from WebHostLib.models import Command, Room
|
||||
|
||||
with db_session:
|
||||
for command in select(command for command in Command if command.room.id == self.room_id): # type: ignore
|
||||
command.delete()
|
||||
room: Room = Room.get(id=self.room_id)
|
||||
room.seed.delete()
|
||||
room.delete()
|
||||
|
||||
try:
|
||||
os.unlink(self.log_filename)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def test_display_log_missing_full(self) -> None:
|
||||
"""
|
||||
Verify that we get a 200 response even if log is missing.
|
||||
This is required to not get an error for fetch.
|
||||
"""
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = self.client.get(url_for("display_log", room=self.room_id))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_display_log_missing_range(self) -> None:
|
||||
"""
|
||||
Verify that we get a full response for missing log even if we asked for range.
|
||||
This is required for the JS logic to differentiate between log update and log error message.
|
||||
"""
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = self.client.get(url_for("display_log", room=self.room_id), headers={
|
||||
"Range": "bytes=100-"
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_display_log_denied(self) -> None:
|
||||
"""Verify that only the owner can see the log."""
|
||||
other_client = self.app.test_client()
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = other_client.get(url_for("display_log", room=self.room_id))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_display_log_missing_room(self) -> None:
|
||||
"""Verify log for missing room gives an error as opposed to missing log for existing room."""
|
||||
missing_room_id = uuid5(uuid4(), "") # rooms are always uuid4, so this can't exist
|
||||
other_client = self.app.test_client()
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = other_client.get(url_for("display_log", room=missing_room_id))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_display_log_full(self) -> None:
|
||||
"""Verify full log response."""
|
||||
with open(self.log_filename, "w", encoding="utf-8") as f:
|
||||
text = "x" * 200
|
||||
f.write(text)
|
||||
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = self.client.get(url_for("display_log", room=self.room_id))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.get_data(True), text)
|
||||
|
||||
def test_display_log_range(self) -> None:
|
||||
"""Verify that Range header in request gives a range in response."""
|
||||
with open(self.log_filename, "w", encoding="utf-8") as f:
|
||||
f.write(" " * 100)
|
||||
text = "x" * 100
|
||||
f.write(text)
|
||||
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = self.client.get(url_for("display_log", room=self.room_id), headers={
|
||||
"Range": "bytes=100-"
|
||||
})
|
||||
self.assertEqual(response.status_code, 206)
|
||||
self.assertEqual(response.get_data(True), text)
|
||||
|
||||
def test_display_log_range_bom(self) -> None:
|
||||
"""Verify that a BOM in the log file is skipped for range."""
|
||||
with open(self.log_filename, "w", encoding="utf-8-sig") as f:
|
||||
f.write(" " * 100)
|
||||
text = "x" * 100
|
||||
f.write(text)
|
||||
self.assertEqual(f.tell(), 203) # including BOM
|
||||
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = self.client.get(url_for("display_log", room=self.room_id), headers={
|
||||
"Range": "bytes=100-"
|
||||
})
|
||||
self.assertEqual(response.status_code, 206)
|
||||
self.assertEqual(response.get_data(True), text)
|
||||
|
||||
def test_host_room_missing(self) -> None:
|
||||
"""Verify that missing room gives a 404 response."""
|
||||
missing_room_id = uuid5(uuid4(), "") # rooms are always uuid4, so this can't exist
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = self.client.get(url_for("host_room", room=missing_room_id))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_host_room_own(self) -> None:
|
||||
"""Verify that own room gives the full output."""
|
||||
with open(self.log_filename, "w", encoding="utf-8-sig") as f:
|
||||
text = "* should be visible *"
|
||||
f.write(text)
|
||||
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = self.client.get(url_for("host_room", room=self.room_id))
|
||||
response_text = response.get_data(True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("href=\"/seed/", response_text)
|
||||
self.assertIn(text, response_text)
|
||||
|
||||
def test_host_room_other(self) -> None:
|
||||
"""Verify that non-own room gives the reduced output."""
|
||||
from pony.orm import db_session
|
||||
from WebHostLib.models import Room
|
||||
|
||||
with db_session:
|
||||
room: Room = Room.get(id=self.room_id)
|
||||
room.last_port = 12345
|
||||
|
||||
with open(self.log_filename, "w", encoding="utf-8-sig") as f:
|
||||
text = "* should not be visible *"
|
||||
f.write(text)
|
||||
|
||||
other_client = self.app.test_client()
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = other_client.get(url_for("host_room", room=self.room_id))
|
||||
response_text = response.get_data(True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn("href=\"/seed/", response_text)
|
||||
self.assertNotIn(text, response_text)
|
||||
self.assertIn("/connect ", response_text)
|
||||
self.assertIn(":12345", response_text)
|
||||
|
||||
def test_host_room_own_post(self) -> None:
|
||||
"""Verify command from owner gets queued for the server and response is redirect."""
|
||||
from pony.orm import db_session, select
|
||||
from WebHostLib.models import Command
|
||||
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = self.client.post(url_for("host_room", room=self.room_id), data={
|
||||
"cmd": "/help"
|
||||
})
|
||||
self.assertEqual(response.status_code, 302, response.text)\
|
||||
|
||||
with db_session:
|
||||
commands = select(command for command in Command if command.room.id == self.room_id) # type: ignore
|
||||
self.assertIn("/help", (command.commandtext for command in commands))
|
||||
|
||||
def test_host_room_other_post(self) -> None:
|
||||
"""Verify command from non-owner does not get queued for the server."""
|
||||
from pony.orm import db_session, select
|
||||
from WebHostLib.models import Command
|
||||
|
||||
other_client = self.app.test_client()
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = other_client.post(url_for("host_room", room=self.room_id), data={
|
||||
"cmd": "/help"
|
||||
})
|
||||
self.assertLess(response.status_code, 500)
|
||||
|
||||
with db_session:
|
||||
commands = select(command for command in Command if command.room.id == self.room_id) # type: ignore
|
||||
self.assertNotIn("/help", (command.commandtext for command in commands))
|
||||
@@ -1,15 +0,0 @@
|
||||
from typing import Callable, ClassVar
|
||||
|
||||
from kivy.event import EventDispatcher
|
||||
|
||||
|
||||
class WindowBase(EventDispatcher):
|
||||
width: ClassVar[int] # readonly AliasProperty
|
||||
height: ClassVar[int] # readonly AliasProperty
|
||||
|
||||
@staticmethod
|
||||
def bind(**kwargs: Callable[..., None]) -> None: ...
|
||||
|
||||
|
||||
class Window(WindowBase):
|
||||
...
|
||||
@@ -1,2 +0,0 @@
|
||||
class EventDispatcher:
|
||||
...
|
||||
@@ -1,6 +0,0 @@
|
||||
from typing import Literal
|
||||
from .layout import Layout
|
||||
|
||||
|
||||
class BoxLayout(Layout):
|
||||
orientation: Literal['horizontal', 'vertical']
|
||||
@@ -1,14 +1,8 @@
|
||||
from typing import Any, Sequence
|
||||
|
||||
from typing import Any
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
class Layout(Widget):
|
||||
@property
|
||||
def children(self) -> Sequence[Widget]: ...
|
||||
|
||||
def add_widget(self, widget: Widget) -> None: ...
|
||||
|
||||
def remove_widget(self, widget: Widget) -> None: ...
|
||||
|
||||
def do_layout(self, *largs: Any, **kwargs: Any) -> None: ...
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class And:
|
||||
def __init__(self, __type: type, __func: Callable[[Any], bool]) -> None: ...
|
||||
|
||||
|
||||
class Or:
|
||||
def __init__(self, *args: object) -> None: ...
|
||||
|
||||
|
||||
class Schema:
|
||||
def __init__(self, __x: object) -> None: ...
|
||||
|
||||
|
||||
class Optional(Schema):
|
||||
...
|
||||
@@ -123,8 +123,8 @@ class WebWorldRegister(type):
|
||||
assert group.options, "A custom defined Option Group must contain at least one Option."
|
||||
# catch incorrectly titled versions of the prebuilt groups so they don't create extra groups
|
||||
title_name = group.name.title()
|
||||
assert title_name not in prebuilt_options or title_name == group.name, \
|
||||
f"Prebuilt group name \"{group.name}\" must be \"{title_name}\""
|
||||
if title_name in prebuilt_options:
|
||||
group.name = title_name
|
||||
|
||||
if group.name == "Item & Location Options":
|
||||
assert not any(option in item_and_loc_options for option in group.options), \
|
||||
@@ -223,21 +223,6 @@ class WebWorld(metaclass=WebWorldRegister):
|
||||
option_groups: ClassVar[List[OptionGroup]] = []
|
||||
"""Ordered list of option groupings. Any options not set in a group will be placed in a pre-built "Game Options"."""
|
||||
|
||||
rich_text_options_doc = False
|
||||
"""Whether the WebHost should render Options' docstrings as rich text.
|
||||
|
||||
If this is True, Options' docstrings are interpreted as reStructuredText_,
|
||||
the standard Python markup format. In the WebHost, they're rendered to HTML
|
||||
so that lists, emphasis, and other rich text features are displayed
|
||||
properly.
|
||||
|
||||
If this is False, the docstrings are instead interpreted as plain text, and
|
||||
displayed as-is on the WebHost with whitespace preserved. For backwards
|
||||
compatibility, this is the default.
|
||||
|
||||
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
|
||||
"""
|
||||
|
||||
location_descriptions: Dict[str, str] = {}
|
||||
"""An optional map from location names (or location group names) to brief descriptions for users."""
|
||||
|
||||
@@ -280,7 +265,7 @@ class World(metaclass=AutoWorldRegister):
|
||||
future. Protocol level compatibility check moved to MultiServer.min_client_version.
|
||||
"""
|
||||
|
||||
required_server_version: Tuple[int, int, int] = (0, 5, 0)
|
||||
required_server_version: Tuple[int, int, int] = (0, 2, 4)
|
||||
"""update this if the resulting multidata breaks forward-compatibility of the server"""
|
||||
|
||||
hint_blacklist: ClassVar[FrozenSet[str]] = frozenset()
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import bisect
|
||||
import logging
|
||||
import pathlib
|
||||
import weakref
|
||||
from enum import Enum, auto
|
||||
from typing import Optional, Callable, List, Iterable, Tuple
|
||||
from typing import Optional, Callable, List, Iterable
|
||||
|
||||
from Utils import local_path, open_filename
|
||||
from Utils import local_path
|
||||
|
||||
|
||||
class Type(Enum):
|
||||
@@ -52,10 +49,8 @@ class Component:
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({self.display_name})"
|
||||
|
||||
|
||||
processes = weakref.WeakSet()
|
||||
|
||||
|
||||
def launch_subprocess(func: Callable, name: str = None):
|
||||
global processes
|
||||
import multiprocessing
|
||||
@@ -63,7 +58,6 @@ def launch_subprocess(func: Callable, name: str = None):
|
||||
process.start()
|
||||
processes.add(process)
|
||||
|
||||
|
||||
class SuffixIdentifier:
|
||||
suffixes: Iterable[str]
|
||||
|
||||
@@ -83,81 +77,6 @@ def launch_textclient():
|
||||
launch_subprocess(CommonClient.run_as_textclient, name="TextClient")
|
||||
|
||||
|
||||
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
|
||||
if not apworld_src:
|
||||
apworld_src = open_filename('Select APWorld file to install', (('APWorld', ('.apworld',)),))
|
||||
if not apworld_src:
|
||||
# user closed menu
|
||||
return
|
||||
|
||||
if not apworld_src.endswith(".apworld"):
|
||||
raise Exception(f"Wrong file format, looking for .apworld. File identified: {apworld_src}")
|
||||
|
||||
apworld_path = pathlib.Path(apworld_src)
|
||||
|
||||
module_name = pathlib.Path(apworld_path.name).stem
|
||||
try:
|
||||
import zipfile
|
||||
zipfile.ZipFile(apworld_path).open(module_name + "/__init__.py")
|
||||
except ValueError as e:
|
||||
raise Exception("Archive appears invalid or damaged.") from e
|
||||
except KeyError as e:
|
||||
raise Exception("Archive appears to not be an apworld. (missing __init__.py)") from e
|
||||
|
||||
import worlds
|
||||
if worlds.user_folder is None:
|
||||
raise Exception("Custom Worlds directory appears to not be writable.")
|
||||
for world_source in worlds.world_sources:
|
||||
if apworld_path.samefile(world_source.resolved_path):
|
||||
# Note that this doesn't check if the same world is already installed.
|
||||
# It only checks if the user is trying to install the apworld file
|
||||
# that comes from the installation location (worlds or custom_worlds)
|
||||
raise Exception(f"APWorld is already installed at {world_source.resolved_path}.")
|
||||
|
||||
# TODO: run generic test suite over the apworld.
|
||||
# TODO: have some kind of version system to tell from metadata if the apworld should be compatible.
|
||||
|
||||
target = pathlib.Path(worlds.user_folder) / apworld_path.name
|
||||
import shutil
|
||||
shutil.copyfile(apworld_path, target)
|
||||
|
||||
# If a module with this name is already loaded, then we can't load it now.
|
||||
# TODO: We need to be able to unload a world module,
|
||||
# so the user can update a world without restarting the application.
|
||||
found_already_loaded = False
|
||||
for loaded_world in worlds.world_sources:
|
||||
loaded_name = pathlib.Path(loaded_world.path).stem
|
||||
if module_name == loaded_name:
|
||||
found_already_loaded = True
|
||||
break
|
||||
if found_already_loaded:
|
||||
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
|
||||
"so a Launcher restart is required to use the new installation.\n"
|
||||
"If the Launcher is not open, no action needs to be taken.")
|
||||
world_source = worlds.WorldSource(str(target), is_zip=True)
|
||||
bisect.insort(worlds.world_sources, world_source)
|
||||
world_source.load()
|
||||
|
||||
return apworld_path, target
|
||||
|
||||
|
||||
def install_apworld(apworld_path: str = "") -> None:
|
||||
try:
|
||||
res = _install_apworld(apworld_path)
|
||||
if res is None:
|
||||
logging.info("Aborting APWorld installation.")
|
||||
return
|
||||
source, target = res
|
||||
except Exception as e:
|
||||
import Utils
|
||||
Utils.messagebox(e.__class__.__name__, str(e), error=True)
|
||||
logging.exception(e)
|
||||
else:
|
||||
import Utils
|
||||
logging.info(f"Installed APWorld successfully, copied {source} to {target}.")
|
||||
Utils.messagebox("Install complete.", f"Installed APWorld from {source}.")
|
||||
|
||||
|
||||
components: List[Component] = [
|
||||
# Launcher
|
||||
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
|
||||
@@ -165,7 +84,6 @@ components: List[Component] = [
|
||||
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
|
||||
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
|
||||
Component('Generate', 'Generate', cli=True),
|
||||
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld")),
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
|
||||
Component('Links Awakening DX Client', 'LinksAwakeningClient',
|
||||
file_identifier=SuffixIdentifier('.apladx')),
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import importlib
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
import zipimport
|
||||
import time
|
||||
import dataclasses
|
||||
from typing import Dict, List, TypedDict
|
||||
from typing import Dict, List, TypedDict, Optional
|
||||
|
||||
from Utils import local_path, user_path
|
||||
|
||||
local_folder = os.path.dirname(__file__)
|
||||
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
|
||||
try:
|
||||
os.makedirs(user_folder, exist_ok=True)
|
||||
except OSError: # can't access/write?
|
||||
user_folder = None
|
||||
user_folder = user_path("worlds") if user_path() != local_path() else None
|
||||
|
||||
__all__ = {
|
||||
"network_data_package",
|
||||
@@ -50,7 +44,7 @@ class WorldSource:
|
||||
path: str # typically relative path from this module
|
||||
is_zip: bool = False
|
||||
relative: bool = True # relative to regular world import folder
|
||||
time_taken: float = -1.0
|
||||
time_taken: Optional[float] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
|
||||
@@ -73,12 +67,7 @@ class WorldSource:
|
||||
else: # TODO: remove with 3.8 support
|
||||
mod = importer.load_module(os.path.basename(self.path).rsplit(".", 1)[0])
|
||||
|
||||
if mod.__package__ is not None:
|
||||
mod.__package__ = f"worlds.{mod.__package__}"
|
||||
else:
|
||||
# load_module does not populate package, we'll have to assume mod.__name__ is correct here
|
||||
# probably safe to remove with 3.8 support
|
||||
mod.__package__ = f"worlds.{mod.__name__}"
|
||||
mod.__package__ = f"worlds.{mod.__package__}"
|
||||
mod.__name__ = f"worlds.{mod.__name__}"
|
||||
sys.modules[mod.__name__] = mod
|
||||
with warnings.catch_warnings():
|
||||
@@ -99,6 +88,7 @@ class WorldSource:
|
||||
print(f"Could not load world {self}:", file=file_like)
|
||||
traceback.print_exc(file=file_like)
|
||||
file_like.seek(0)
|
||||
import logging
|
||||
logging.exception(file_like.read())
|
||||
failed_world_loads.append(os.path.basename(self.path).rsplit(".", 1)[0])
|
||||
return False
|
||||
@@ -113,12 +103,7 @@ for folder in (folder for folder in (user_folder, local_folder) if folder):
|
||||
if not entry.name.startswith(("_", ".")):
|
||||
file_name = entry.name if relative else os.path.join(folder, entry.name)
|
||||
if entry.is_dir():
|
||||
if os.path.isfile(os.path.join(entry.path, '__init__.py')):
|
||||
world_sources.append(WorldSource(file_name, relative=relative))
|
||||
elif os.path.isfile(os.path.join(entry.path, '__init__.pyc')):
|
||||
world_sources.append(WorldSource(file_name, relative=relative))
|
||||
else:
|
||||
logging.warning(f"excluding {entry.name} from world sources because it has no __init__.py")
|
||||
world_sources.append(WorldSource(file_name, relative=relative))
|
||||
elif entry.is_file() and entry.name.endswith(".apworld"):
|
||||
world_sources.append(WorldSource(file_name, is_zip=True, relative=relative))
|
||||
|
||||
@@ -133,4 +118,3 @@ from .AutoWorld import AutoWorldRegister
|
||||
network_data_package: DataPackage = {
|
||||
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,6 @@ async def _game_watcher(ctx: BizHawkClientContext):
|
||||
ctx.auth = None
|
||||
ctx.username = None
|
||||
ctx.client_handler = None
|
||||
ctx.finished_game = False
|
||||
await ctx.disconnect(False)
|
||||
ctx.rom_hash = rom_hash
|
||||
|
||||
|
||||
@@ -28,11 +28,6 @@ class kill_switch:
|
||||
logger.debug("kill_switch: Add switch")
|
||||
cls._to_kill.append(value)
|
||||
|
||||
@classmethod
|
||||
def kill(cls, value):
|
||||
logger.info(f"kill_switch: Process cleanup for 1 process")
|
||||
value._clean(verbose=False)
|
||||
|
||||
@classmethod
|
||||
def kill_all(cls):
|
||||
logger.info(f"kill_switch: Process cleanup for {len(cls._to_kill)} processes")
|
||||
@@ -121,7 +116,7 @@ class SC2Process:
|
||||
async def __aexit__(self, *args):
|
||||
logger.exception("async exit")
|
||||
await self._close_connection()
|
||||
kill_switch.kill(self)
|
||||
kill_switch.kill_all()
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,8 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from dataclasses import dataclass
|
||||
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
|
||||
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle
|
||||
|
||||
|
||||
class FreeincarnateMax(Range):
|
||||
@@ -224,22 +223,22 @@ class StartCastle(Choice):
|
||||
option_white = 2
|
||||
default = option_yellow
|
||||
|
||||
@dataclass
|
||||
class AdventureOptions(PerGameCommonOptions):
|
||||
dragon_slay_check: DragonSlayCheck
|
||||
death_link: DeathLink
|
||||
bat_logic: BatLogic
|
||||
freeincarnate_max: FreeincarnateMax
|
||||
dragon_rando_type: DragonRandoType
|
||||
connector_multi_slot: ConnectorMultiSlot
|
||||
yorgle_speed: YorgleStartingSpeed
|
||||
yorgle_min_speed: YorgleMinimumSpeed
|
||||
grundle_speed: GrundleStartingSpeed
|
||||
grundle_min_speed: GrundleMinimumSpeed
|
||||
rhindle_speed: RhindleStartingSpeed
|
||||
rhindle_min_speed: RhindleMinimumSpeed
|
||||
difficulty_switch_a: DifficultySwitchA
|
||||
difficulty_switch_b: DifficultySwitchB
|
||||
start_castle: StartCastle
|
||||
|
||||
adventure_option_definitions: Dict[str, type(Option)] = {
|
||||
"dragon_slay_check": DragonSlayCheck,
|
||||
"death_link": DeathLink,
|
||||
"bat_logic": BatLogic,
|
||||
"freeincarnate_max": FreeincarnateMax,
|
||||
"dragon_rando_type": DragonRandoType,
|
||||
"connector_multi_slot": ConnectorMultiSlot,
|
||||
"yorgle_speed": YorgleStartingSpeed,
|
||||
"yorgle_min_speed": YorgleMinimumSpeed,
|
||||
"grundle_speed": GrundleStartingSpeed,
|
||||
"grundle_min_speed": GrundleMinimumSpeed,
|
||||
"rhindle_speed": RhindleStartingSpeed,
|
||||
"rhindle_min_speed": RhindleMinimumSpeed,
|
||||
"difficulty_switch_a": DifficultySwitchA,
|
||||
"difficulty_switch_b": DifficultySwitchB,
|
||||
"start_castle": StartCastle,
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
|
||||
from Options import PerGameCommonOptions
|
||||
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
|
||||
|
||||
|
||||
@@ -25,7 +24,7 @@ def connect(world: MultiWorld, player: int, source: str, target: str, rule: call
|
||||
connect(world, player, target, source, rule, True)
|
||||
|
||||
|
||||
def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
|
||||
def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
|
||||
|
||||
menu = Region("Menu", player, multiworld)
|
||||
|
||||
@@ -75,7 +74,7 @@ def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player
|
||||
credits_room_far_side.exits.append(Entrance(player, "CreditsFromFarSide", credits_room_far_side))
|
||||
multiworld.regions.append(credits_room_far_side)
|
||||
|
||||
dragon_slay_check = options.dragon_slay_check.value
|
||||
dragon_slay_check = multiworld.dragon_slay_check[player].value
|
||||
priority_locations = determine_priority_locations(multiworld, dragon_slay_check)
|
||||
|
||||
for name, location_data in location_table.items():
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from .Options import BatLogic, DifficultySwitchB
|
||||
from worlds.adventure import location_table
|
||||
from worlds.adventure.Options import BatLogic, DifficultySwitchB, DifficultySwitchA
|
||||
from worlds.generic.Rules import add_rule, set_rule, forbid_item
|
||||
from BaseClasses import LocationProgressType
|
||||
|
||||
|
||||
def set_rules(self) -> None:
|
||||
world = self.multiworld
|
||||
use_bat_logic = self.options.bat_logic.value == BatLogic.option_use_logic
|
||||
use_bat_logic = world.bat_logic[self.player].value == BatLogic.option_use_logic
|
||||
|
||||
set_rule(world.get_entrance("YellowCastlePort", self.player),
|
||||
lambda state: state.has("Yellow Key", self.player))
|
||||
@@ -26,7 +28,7 @@ def set_rules(self) -> None:
|
||||
lambda state: state.has("Bridge", self.player) or
|
||||
state.has("Magnet", self.player))
|
||||
|
||||
dragon_slay_check = self.options.dragon_slay_check.value
|
||||
dragon_slay_check = world.dragon_slay_check[self.player].value
|
||||
if dragon_slay_check:
|
||||
if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item:
|
||||
set_rule(world.get_location("Slay Yorgle", self.player),
|
||||
|
||||
@@ -15,8 +15,7 @@ from Options import AssembleOptions
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
from Fill import fill_restrictive
|
||||
from worlds.generic.Rules import add_rule, set_rule
|
||||
from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, \
|
||||
AdventureOptions
|
||||
from .Options import adventure_option_definitions, DragonRandoType, DifficultySwitchA, DifficultySwitchB
|
||||
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \
|
||||
AdventureAutoCollectLocation
|
||||
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
|
||||
@@ -110,7 +109,7 @@ class AdventureWorld(World):
|
||||
game: ClassVar[str] = "Adventure"
|
||||
web: ClassVar[WebWorld] = AdventureWeb()
|
||||
|
||||
options_dataclass = AdventureOptions
|
||||
option_definitions: ClassVar[Dict[str, AssembleOptions]] = adventure_option_definitions
|
||||
settings: ClassVar[AdventureSettings]
|
||||
item_name_to_id: ClassVar[Dict[str, int]] = {name: data.id for name, data in item_table.items()}
|
||||
location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()}
|
||||
@@ -150,18 +149,18 @@ class AdventureWorld(World):
|
||||
bytearray(f"ADVENTURE{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21]
|
||||
self.rom_name.extend([0] * (21 - len(self.rom_name)))
|
||||
|
||||
self.dragon_rando_type = self.options.dragon_rando_type.value
|
||||
self.dragon_slay_check = self.options.dragon_slay_check.value
|
||||
self.connector_multi_slot = self.options.connector_multi_slot.value
|
||||
self.yorgle_speed = self.options.yorgle_speed.value
|
||||
self.yorgle_min_speed = self.options.yorgle_min_speed.value
|
||||
self.grundle_speed = self.options.grundle_speed.value
|
||||
self.grundle_min_speed = self.options.grundle_min_speed.value
|
||||
self.rhindle_speed = self.options.rhindle_speed.value
|
||||
self.rhindle_min_speed = self.options.rhindle_min_speed.value
|
||||
self.difficulty_switch_a = self.options.difficulty_switch_a.value
|
||||
self.difficulty_switch_b = self.options.difficulty_switch_b.value
|
||||
self.start_castle = self.options.start_castle.value
|
||||
self.dragon_rando_type = self.multiworld.dragon_rando_type[self.player].value
|
||||
self.dragon_slay_check = self.multiworld.dragon_slay_check[self.player].value
|
||||
self.connector_multi_slot = self.multiworld.connector_multi_slot[self.player].value
|
||||
self.yorgle_speed = self.multiworld.yorgle_speed[self.player].value
|
||||
self.yorgle_min_speed = self.multiworld.yorgle_min_speed[self.player].value
|
||||
self.grundle_speed = self.multiworld.grundle_speed[self.player].value
|
||||
self.grundle_min_speed = self.multiworld.grundle_min_speed[self.player].value
|
||||
self.rhindle_speed = self.multiworld.rhindle_speed[self.player].value
|
||||
self.rhindle_min_speed = self.multiworld.rhindle_min_speed[self.player].value
|
||||
self.difficulty_switch_a = self.multiworld.difficulty_switch_a[self.player].value
|
||||
self.difficulty_switch_b = self.multiworld.difficulty_switch_b[self.player].value
|
||||
self.start_castle = self.multiworld.start_castle[self.player].value
|
||||
self.created_items = 0
|
||||
|
||||
if self.dragon_slay_check == 0:
|
||||
@@ -228,7 +227,7 @@ class AdventureWorld(World):
|
||||
extra_filler_count = num_locations - self.created_items
|
||||
|
||||
# traps would probably go here, if enabled
|
||||
freeincarnate_max = self.options.freeincarnate_max.value
|
||||
freeincarnate_max = self.multiworld.freeincarnate_max[self.player].value
|
||||
actual_freeincarnates = min(extra_filler_count, freeincarnate_max)
|
||||
self.multiworld.itempool += [self.create_item("Freeincarnate") for _ in range(actual_freeincarnates)]
|
||||
self.created_items += actual_freeincarnates
|
||||
@@ -248,7 +247,7 @@ class AdventureWorld(World):
|
||||
self.created_items += 1
|
||||
|
||||
def create_regions(self) -> None:
|
||||
create_regions(self.options, self.multiworld, self.player, self.dragon_rooms)
|
||||
create_regions(self.multiworld, self.player, self.dragon_rooms)
|
||||
|
||||
set_rules = set_rules
|
||||
|
||||
@@ -355,7 +354,7 @@ class AdventureWorld(World):
|
||||
auto_collect_locations: [AdventureAutoCollectLocation] = []
|
||||
local_item_to_location: {int, int} = {}
|
||||
bat_no_touch_locs: [LocationData] = []
|
||||
bat_logic: int = self.options.bat_logic.value
|
||||
bat_logic: int = self.multiworld.bat_logic[self.player].value
|
||||
try:
|
||||
rom_deltas: { int, int } = {}
|
||||
self.place_dragons(rom_deltas)
|
||||
@@ -422,7 +421,7 @@ class AdventureWorld(World):
|
||||
item_position_data_start = get_item_position_data_start(unplaced_item.table_index)
|
||||
rom_deltas[item_position_data_start] = 0xff
|
||||
|
||||
if self.options.connector_multi_slot.value:
|
||||
if self.multiworld.connector_multi_slot[self.player].value:
|
||||
rom_deltas[connector_port_offset] = (self.player & 0xff)
|
||||
else:
|
||||
rom_deltas[connector_port_offset] = 0
|
||||
|
||||
@@ -35,7 +35,7 @@ dw_requirements = {
|
||||
|
||||
"The Mustache Gauntlet": LocData(hookshot=True, required_hats=[HatType.DWELLER]),
|
||||
|
||||
"Rift Collapse: Deep Sea": LocData(hookshot=True),
|
||||
"Rift Collapse - Deep Sea": LocData(hookshot=True),
|
||||
}
|
||||
|
||||
# Includes main objective requirements
|
||||
@@ -55,7 +55,7 @@ dw_bonus_requirements = {
|
||||
|
||||
"The Mustache Gauntlet": LocData(required_hats=[HatType.ICE]),
|
||||
|
||||
"Rift Collapse: Deep Sea": LocData(required_hats=[HatType.DWELLER]),
|
||||
"Rift Collapse - Deep Sea": LocData(required_hats=[HatType.DWELLER]),
|
||||
}
|
||||
|
||||
dw_stamp_costs = {
|
||||
@@ -178,9 +178,9 @@ def set_dw_rules(world: "HatInTimeWorld"):
|
||||
def add_dw_rules(world: "HatInTimeWorld", loc: Location):
|
||||
bonus: bool = "All Clear" in loc.name
|
||||
if not bonus:
|
||||
data = dw_requirements.get(loc.parent_region.name)
|
||||
data = dw_requirements.get(loc.name)
|
||||
else:
|
||||
data = dw_bonus_requirements.get(loc.parent_region.name)
|
||||
data = dw_bonus_requirements.get(loc.name)
|
||||
|
||||
if data is None:
|
||||
return
|
||||
|
||||
@@ -39,7 +39,7 @@ def create_itempool(world: "HatInTimeWorld") -> List[Item]:
|
||||
continue
|
||||
else:
|
||||
if name == "Scooter Badge":
|
||||
if world.options.CTRLogic == CTRLogic.option_scooter or get_difficulty(world) >= Difficulty.MODERATE:
|
||||
if world.options.CTRLogic is CTRLogic.option_scooter or get_difficulty(world) >= Difficulty.MODERATE:
|
||||
item_type = ItemClassification.progression
|
||||
elif name == "No Bonk Badge" and world.is_dw():
|
||||
item_type = ItemClassification.progression
|
||||
|
||||
@@ -292,9 +292,6 @@ blacklisted_combos = {
|
||||
# See above comment
|
||||
"Time Rift - Deep Sea": ["Alpine Free Roam", "Nyakuza Free Roam", "Contractual Obligations",
|
||||
"Murder on the Owl Express"],
|
||||
|
||||
# was causing test failures
|
||||
"Time Rift - Balcony": ["Alpine Free Roam"],
|
||||
}
|
||||
|
||||
|
||||
@@ -659,10 +656,6 @@ def is_valid_act_combo(world: "HatInTimeWorld", entrance_act: Region,
|
||||
if exit_act.name not in chapter_finales:
|
||||
return False
|
||||
|
||||
exit_chapter: str = act_chapters.get(exit_act.name)
|
||||
# make sure that certain time rift combinations never happen
|
||||
always_block: bool = exit_chapter != "Mafia Town" and exit_chapter != "Subcon Forest"
|
||||
if not ignore_certain_rules or always_block:
|
||||
if entrance_act.name in rift_access_regions and exit_act.name in rift_access_regions[entrance_act.name]:
|
||||
return False
|
||||
|
||||
@@ -688,12 +681,9 @@ def is_valid_first_act(world: "HatInTimeWorld", act: Region) -> bool:
|
||||
if act.name not in guaranteed_first_acts:
|
||||
return False
|
||||
|
||||
if world.options.ActRandomizer == ActRandomizer.option_light and "Time Rift" in act.name:
|
||||
return False
|
||||
|
||||
# If there's only a single level in the starting chapter, only allow Mafia Town or Subcon Forest levels
|
||||
start_chapter = world.options.StartingChapter
|
||||
if start_chapter == ChapterIndex.ALPINE or start_chapter == ChapterIndex.SUBCON:
|
||||
if start_chapter is ChapterIndex.ALPINE or start_chapter is ChapterIndex.SUBCON:
|
||||
if "Time Rift" in act.name:
|
||||
return False
|
||||
|
||||
@@ -730,8 +720,7 @@ def is_valid_first_act(world: "HatInTimeWorld", act: Region) -> bool:
|
||||
elif act.name == "Contractual Obligations" and world.options.ShuffleSubconPaintings:
|
||||
return False
|
||||
|
||||
if world.options.ShuffleSubconPaintings and "Time Rift" not in act.name \
|
||||
and act_chapters.get(act.name, "") == "Subcon Forest":
|
||||
if world.options.ShuffleSubconPaintings and act_chapters.get(act.name, "") == "Subcon Forest":
|
||||
# Only allow Subcon levels if painting skips are allowed
|
||||
if diff < Difficulty.MODERATE or world.options.NoPaintingSkips:
|
||||
return False
|
||||
@@ -968,35 +957,40 @@ def get_act_by_number(world: "HatInTimeWorld", chapter_name: str, num: int) -> R
|
||||
def create_thug_shops(world: "HatInTimeWorld"):
|
||||
min_items: int = world.options.NyakuzaThugMinShopItems.value
|
||||
max_items: int = world.options.NyakuzaThugMaxShopItems.value
|
||||
|
||||
thug_location_counts: Dict[str, int] = {}
|
||||
count = -1
|
||||
step = 0
|
||||
old_name = ""
|
||||
|
||||
for key, data in shop_locations.items():
|
||||
thug_name = data.nyakuza_thug
|
||||
if thug_name == "":
|
||||
# Different shop type.
|
||||
if data.nyakuza_thug == "":
|
||||
continue
|
||||
|
||||
if thug_name not in world.nyakuza_thug_items:
|
||||
shop_item_count = world.random.randint(min_items, max_items)
|
||||
world.nyakuza_thug_items[thug_name] = shop_item_count
|
||||
else:
|
||||
shop_item_count = world.nyakuza_thug_items[thug_name]
|
||||
|
||||
if shop_item_count <= 0:
|
||||
if old_name != "" and old_name == data.nyakuza_thug:
|
||||
continue
|
||||
|
||||
location_count = thug_location_counts.setdefault(thug_name, 0)
|
||||
if location_count >= shop_item_count:
|
||||
# Already created all the locations for this thug.
|
||||
continue
|
||||
try:
|
||||
if world.nyakuza_thug_items[data.nyakuza_thug] <= 0:
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Create the shop location.
|
||||
region = world.multiworld.get_region(data.region, world.player)
|
||||
loc = HatInTimeLocation(world.player, key, data.id, region)
|
||||
region.locations.append(loc)
|
||||
world.shop_locs.append(loc.name)
|
||||
thug_location_counts[thug_name] = location_count + 1
|
||||
if count == -1:
|
||||
count = world.random.randint(min_items, max_items)
|
||||
world.nyakuza_thug_items.setdefault(data.nyakuza_thug, count)
|
||||
if count <= 0:
|
||||
continue
|
||||
|
||||
if count >= 1:
|
||||
region = world.multiworld.get_region(data.region, world.player)
|
||||
loc = HatInTimeLocation(world.player, key, data.id, region)
|
||||
region.locations.append(loc)
|
||||
world.shop_locs.append(loc.name)
|
||||
|
||||
step += 1
|
||||
if step >= count:
|
||||
old_name = data.nyakuza_thug
|
||||
step = 0
|
||||
count = -1
|
||||
|
||||
|
||||
def create_events(world: "HatInTimeWorld") -> int:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user