Compare commits
260 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68f90571fd | ||
|
|
f709d61d04 | ||
|
|
c6d2971d67 | ||
|
|
af14045c3a | ||
|
|
ede59ef5a1 | ||
|
|
63d471514f | ||
|
|
ff297f2951 | ||
|
|
a0f49dd7d9 | ||
|
|
79cec89e24 | ||
|
|
2b0cab82fa | ||
|
|
48822227b5 | ||
|
|
375b5796d9 | ||
|
|
c12ed316cf | ||
|
|
26577b16dc | ||
|
|
af0b5f8cf2 | ||
|
|
618564c60a | ||
|
|
f2ac937d1e | ||
|
|
d4d777b101 | ||
|
|
b772d42df5 | ||
|
|
e8f3aa96da | ||
|
|
2d0bdebaa9 | ||
|
|
ef4d1e77e3 | ||
|
|
f495bf7261 | ||
|
|
2751ccdaab | ||
|
|
6287bc27a6 | ||
|
|
97f2c25924 | ||
|
|
e5a0ef799f | ||
|
|
216e0603e1 | ||
|
|
05a67386c6 | ||
|
|
0ec9039ca6 | ||
|
|
f06f95d03d | ||
|
|
5a853dfccd | ||
|
|
23469fa5c3 | ||
|
|
dc1da4e88b | ||
|
|
67f6b458d7 | ||
|
|
8193fa12b2 | ||
|
|
de0c498470 | ||
|
|
7337309426 | ||
|
|
3205e9b3a0 | ||
|
|
05439012dc | ||
|
|
177c0fef52 | ||
|
|
5c4e81d046 | ||
|
|
a2d585ba5c | ||
|
|
5ea55d77b0 | ||
|
|
ab8caea8be | ||
|
|
a043ed50a6 | ||
|
|
e85a835b47 | ||
|
|
9a9fea0ca2 | ||
|
|
e910a37273 | ||
|
|
f06d4503d8 | ||
|
|
8021b457b6 | ||
|
|
d43dc62485 | ||
|
|
f7ec3d7508 | ||
|
|
99c02a3eb3 | ||
|
|
449782a4d8 | ||
|
|
97ca2ad258 | ||
|
|
2b88be5791 | ||
|
|
204e940f47 | ||
|
|
69d3db21df | ||
|
|
41ddb96b24 | ||
|
|
ba8f03516e | ||
|
|
0095eecf2b | ||
|
|
79942c09c2 | ||
|
|
1b15c6920d | ||
|
|
499d79f089 | ||
|
|
926e08513c | ||
|
|
025c550991 | ||
|
|
fced9050a4 | ||
|
|
2ee8b7535d | ||
|
|
0d35cd4679 | ||
|
|
db5d9fbf70 | ||
|
|
51a6dc150c | ||
|
|
710609fa60 | ||
|
|
da781bb4ac | ||
|
|
69487661dd | ||
|
|
f73c0d9894 | ||
|
|
6fac83b84c | ||
|
|
debb936618 | ||
|
|
8c5b65ff26 | ||
|
|
a7c96436d9 | ||
|
|
4e60f3cc54 | ||
|
|
30a0b337a2 | ||
|
|
4ea1dddd2f | ||
|
|
dc218b7997 | ||
|
|
78c5489189 | ||
|
|
d1a7bc66e6 | ||
|
|
b982e9ebb4 | ||
|
|
8f7e0dc441 | ||
|
|
5aea8d4ab5 | ||
|
|
97be5f1dde | ||
|
|
dae3fe188d | ||
|
|
96542fb2d8 | ||
|
|
ec50b0716a | ||
|
|
f8d3c26e3c | ||
|
|
1c0cec0de2 | ||
|
|
4692e6f08a | ||
|
|
b8d23ec595 | ||
|
|
ce42e42af7 | ||
|
|
ee12dda361 | ||
|
|
84805a4e54 | ||
|
|
5530d181da | ||
|
|
ed948e3e5b | ||
|
|
7621889b8b | ||
|
|
c9f1a21bd2 | ||
|
|
874392756b | ||
|
|
7ff201e32c | ||
|
|
170aedba8f | ||
|
|
09c7f5f909 | ||
|
|
4aab317665 | ||
|
|
e52ce0149a | ||
|
|
5a5162c9d3 | ||
|
|
cf375cbcc4 | ||
|
|
6d6d35d598 | ||
|
|
05b257adf9 | ||
|
|
cabfef669a | ||
|
|
e4a5ed1cc4 | ||
|
|
5021997df0 | ||
|
|
d90cf0db65 | ||
|
|
dad228cd4a | ||
|
|
a652108472 | ||
|
|
5348f693fe | ||
|
|
b8c2e14e8b | ||
|
|
430b71a092 | ||
|
|
a40744e6db | ||
|
|
d802f9652a | ||
|
|
cbdb4d7ce3 | ||
|
|
691ce6a248 | ||
|
|
f9fc6944d3 | ||
|
|
e984583e5e | ||
|
|
7e03a87608 | ||
|
|
456bc481a3 | ||
|
|
b4752cd32d | ||
|
|
ceec51b9e1 | ||
|
|
d3312287a8 | ||
|
|
d65863ffa2 | ||
|
|
b8d7ef24f7 | ||
|
|
b2949dfbe8 | ||
|
|
2aa0653b6d | ||
|
|
d63efa5846 | ||
|
|
765721888a | ||
|
|
73701292b5 | ||
|
|
3ab71daa8d | ||
|
|
6f46397185 | ||
|
|
1a41e1acc8 | ||
|
|
34a3b5f058 | ||
|
|
456b4adaa1 | ||
|
|
fc8462f4e9 | ||
|
|
499dad53b1 | ||
|
|
8a809be67a | ||
|
|
7e0219c214 | ||
|
|
b37bb60891 | ||
|
|
f81335d614 | ||
|
|
8ed466bf24 | ||
|
|
920cffda2d | ||
|
|
b1be597451 | ||
|
|
08dc7e522e | ||
|
|
0f64bd08e1 | ||
|
|
d52827ebd2 | ||
|
|
0e55ddc7cf | ||
|
|
ab5b986716 | ||
|
|
97c313c1c4 | ||
|
|
701a7faa71 | ||
|
|
9a4e84efdc | ||
|
|
906b23088c | ||
|
|
0fb69dce33 | ||
|
|
e99f027b42 | ||
|
|
dddffa1660 | ||
|
|
83367c6946 | ||
|
|
0fcca25870 | ||
|
|
d1a7fd7da1 | ||
|
|
5c5f2ffc94 | ||
|
|
6f617e302d | ||
|
|
35c9061c9c | ||
|
|
e61d521ba8 | ||
|
|
6efa065867 | ||
|
|
56dbba6a31 | ||
|
|
43cb9611fb | ||
|
|
64b654d42e | ||
|
|
74aab81f79 | ||
|
|
f390b33c17 | ||
|
|
31852801c9 | ||
|
|
e35addf5b2 | ||
|
|
3cdcb8c455 | ||
|
|
48c6a6fb4c | ||
|
|
eaa8156061 | ||
|
|
54a7bb5664 | ||
|
|
0e6e359747 | ||
|
|
c4e7b6ca82 | ||
|
|
f253dffc07 | ||
|
|
c010c8c938 | ||
|
|
1e8a8e7482 | ||
|
|
182f7e24e5 | ||
|
|
9277cb39ef | ||
|
|
28a9709516 | ||
|
|
49a5b52774 | ||
|
|
2b1802ccee | ||
|
|
f5218faea7 | ||
|
|
81092247c6 | ||
|
|
ca96e7e294 | ||
|
|
c014c5a54a | ||
|
|
e9c863dffd | ||
|
|
7eda4c47f8 | ||
|
|
474a3181c6 | ||
|
|
4af6927e23 | ||
|
|
06df072095 | ||
|
|
56aabe51b8 | ||
|
|
5e5f24cdd2 | ||
|
|
9fbaa6050f | ||
|
|
0af31c71e0 | ||
|
|
169da1b1e0 | ||
|
|
8e7ea06f39 | ||
|
|
96d48a923a | ||
|
|
dcaa2f7b97 | ||
|
|
50330cf32f | ||
|
|
67520adcea | ||
|
|
a3e54a951f | ||
|
|
ae0abd3821 | ||
|
|
21bbf5fb95 | ||
|
|
09e052c750 | ||
|
|
68a92b0c6f | ||
|
|
8e06ab4f68 | ||
|
|
9dba39b606 | ||
|
|
a6f376b02e | ||
|
|
c66a8605da | ||
|
|
ac7590e621 | ||
|
|
30f97dd7de | ||
|
|
6e41c60672 | ||
|
|
5efb3fd2b0 | ||
|
|
6803c373e5 | ||
|
|
575c338aa3 | ||
|
|
05ce29f7dc | ||
|
|
74697b679e | ||
|
|
cf6661439e | ||
|
|
6297a4efa5 | ||
|
|
8ddb49f071 | ||
|
|
90446ad175 | ||
|
|
98bb8517e1 | ||
|
|
203c8f4d89 | ||
|
|
c0ef02d6fa | ||
|
|
4620493828 | ||
|
|
75b8c7891c | ||
|
|
53bc4ffa52 | ||
|
|
91f7cf16de | ||
|
|
7c8ea34a02 | ||
|
|
a05dbac55f | ||
|
|
83521e99d9 | ||
|
|
1d19da0c76 | ||
|
|
77e3f9fbef | ||
|
|
954d728005 | ||
|
|
80daa092a7 | ||
|
|
fac72dbc20 | ||
|
|
e764da3dc6 | ||
|
|
ab0903679c | ||
|
|
67f329b96f | ||
|
|
b273852512 | ||
|
|
b77805e5ee | ||
|
|
34141f8de0 | ||
|
|
e38f5d0a61 | ||
|
|
35ed0d4e19 | ||
|
|
e5c9b8ad0c |
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
worlds/blasphemous/region_data.py linguist-generated=true
|
||||||
7
.github/workflows/unittests.yml
vendored
@@ -37,12 +37,13 @@ jobs:
|
|||||||
- {version: '3.9'}
|
- {version: '3.9'}
|
||||||
- {version: '3.10'}
|
- {version: '3.10'}
|
||||||
- {version: '3.11'}
|
- {version: '3.11'}
|
||||||
|
- {version: '3.12'}
|
||||||
include:
|
include:
|
||||||
- python: {version: '3.8'} # win7 compat
|
- python: {version: '3.8'} # win7 compat
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.11'} # current
|
- python: {version: '3.12'} # current
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.11'} # current
|
- python: {version: '3.12'} # current
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -70,7 +71,7 @@ jobs:
|
|||||||
os:
|
os:
|
||||||
- ubuntu-latest
|
- ubuntu-latest
|
||||||
python:
|
python:
|
||||||
- {version: '3.11'} # current
|
- {version: '3.12'} # current
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
322
BaseClasses.py
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import collections
|
||||||
import itertools
|
import itertools
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
@@ -11,8 +11,10 @@ from argparse import Namespace
|
|||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
from collections.abc import Collection, MutableSequence
|
from collections.abc import Collection, MutableSequence
|
||||||
from enum import IntEnum, IntFlag
|
from enum import IntEnum, IntFlag
|
||||||
from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
|
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
||||||
TypedDict, Union, Type, ClassVar
|
Optional, Protocol, Set, Tuple, Union, Type)
|
||||||
|
|
||||||
|
from typing_extensions import NotRequired, TypedDict
|
||||||
|
|
||||||
import NetUtils
|
import NetUtils
|
||||||
import Options
|
import Options
|
||||||
@@ -22,16 +24,16 @@ if typing.TYPE_CHECKING:
|
|||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
|
||||||
|
|
||||||
class Group(TypedDict, total=False):
|
class Group(TypedDict):
|
||||||
name: str
|
name: str
|
||||||
game: str
|
game: str
|
||||||
world: "AutoWorld.World"
|
world: "AutoWorld.World"
|
||||||
players: Set[int]
|
players: AbstractSet[int]
|
||||||
item_pool: Set[str]
|
item_pool: NotRequired[Set[str]]
|
||||||
replacement_items: Dict[int, Optional[str]]
|
replacement_items: NotRequired[Dict[int, Optional[str]]]
|
||||||
local_items: Set[str]
|
local_items: NotRequired[Set[str]]
|
||||||
non_local_items: Set[str]
|
non_local_items: NotRequired[Set[str]]
|
||||||
link_replacement: bool
|
link_replacement: NotRequired[bool]
|
||||||
|
|
||||||
|
|
||||||
class ThreadBarrierProxy:
|
class ThreadBarrierProxy:
|
||||||
@@ -48,6 +50,11 @@ class ThreadBarrierProxy:
|
|||||||
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
|
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
|
||||||
|
|
||||||
|
|
||||||
|
class HasNameAndPlayer(Protocol):
|
||||||
|
name: str
|
||||||
|
player: int
|
||||||
|
|
||||||
|
|
||||||
class MultiWorld():
|
class MultiWorld():
|
||||||
debug_types = False
|
debug_types = False
|
||||||
player_name: Dict[int, str]
|
player_name: Dict[int, str]
|
||||||
@@ -63,7 +70,6 @@ class MultiWorld():
|
|||||||
state: CollectionState
|
state: CollectionState
|
||||||
|
|
||||||
plando_options: PlandoOptions
|
plando_options: PlandoOptions
|
||||||
accessibility: Dict[int, Options.Accessibility]
|
|
||||||
early_items: Dict[int, Dict[str, int]]
|
early_items: Dict[int, Dict[str, int]]
|
||||||
local_early_items: Dict[int, Dict[str, int]]
|
local_early_items: Dict[int, Dict[str, int]]
|
||||||
local_items: Dict[int, Options.LocalItems]
|
local_items: Dict[int, Options.LocalItems]
|
||||||
@@ -157,7 +163,7 @@ class MultiWorld():
|
|||||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||||
|
|
||||||
for player in range(1, players + 1):
|
for player in range(1, players + 1):
|
||||||
def set_player_attr(attr, val):
|
def set_player_attr(attr: str, val) -> None:
|
||||||
self.__dict__.setdefault(attr, {})[player] = val
|
self.__dict__.setdefault(attr, {})[player] = val
|
||||||
set_player_attr('plando_items', [])
|
set_player_attr('plando_items', [])
|
||||||
set_player_attr('plando_texts', {})
|
set_player_attr('plando_texts', {})
|
||||||
@@ -166,13 +172,13 @@ class MultiWorld():
|
|||||||
set_player_attr('completion_condition', lambda state: True)
|
set_player_attr('completion_condition', lambda state: True)
|
||||||
self.worlds = {}
|
self.worlds = {}
|
||||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||||
"world's random object instead (usually self.random)")
|
"world's random object instead (usually self.random)")
|
||||||
self.plando_options = PlandoOptions.none
|
self.plando_options = PlandoOptions.none
|
||||||
|
|
||||||
def get_all_ids(self) -> Tuple[int, ...]:
|
def get_all_ids(self) -> Tuple[int, ...]:
|
||||||
return self.player_ids + tuple(self.groups)
|
return self.player_ids + tuple(self.groups)
|
||||||
|
|
||||||
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
def add_group(self, name: str, game: str, players: AbstractSet[int] = frozenset()) -> Tuple[int, Group]:
|
||||||
"""Create a group with name and return the assigned player ID and group.
|
"""Create a group with name and return the assigned player ID and group.
|
||||||
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
@@ -188,7 +194,9 @@ class MultiWorld():
|
|||||||
self.player_types[new_id] = NetUtils.SlotType.group
|
self.player_types[new_id] = NetUtils.SlotType.group
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
||||||
self.worlds[new_id] = world_type.create_group(self, new_id, players)
|
self.worlds[new_id] = world_type.create_group(self, new_id, players)
|
||||||
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
|
self.worlds[new_id].collect_item = AutoWorld.World.collect_item.__get__(self.worlds[new_id])
|
||||||
|
self.worlds[new_id].collect = AutoWorld.World.collect.__get__(self.worlds[new_id])
|
||||||
|
self.worlds[new_id].remove = AutoWorld.World.remove.__get__(self.worlds[new_id])
|
||||||
self.player_name[new_id] = name
|
self.player_name[new_id] = name
|
||||||
|
|
||||||
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
|
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
|
||||||
@@ -196,7 +204,7 @@ class MultiWorld():
|
|||||||
|
|
||||||
return new_id, new_group
|
return new_id, new_group
|
||||||
|
|
||||||
def get_player_groups(self, player) -> Set[int]:
|
def get_player_groups(self, player: int) -> Set[int]:
|
||||||
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
||||||
|
|
||||||
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
||||||
@@ -259,7 +267,7 @@ class MultiWorld():
|
|||||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, item_link in item_links.items():
|
for _name, item_link in item_links.items():
|
||||||
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
|
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
|
||||||
pool = set()
|
pool = set()
|
||||||
local_items = set()
|
local_items = set()
|
||||||
@@ -288,6 +296,88 @@ class MultiWorld():
|
|||||||
group["non_local_items"] = item_link["non_local_items"]
|
group["non_local_items"] = item_link["non_local_items"]
|
||||||
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
|
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
|
||||||
|
|
||||||
|
def link_items(self) -> None:
|
||||||
|
"""Called to link together items in the itempool related to the registered item link groups."""
|
||||||
|
from worlds import AutoWorld
|
||||||
|
|
||||||
|
for group_id, group in self.groups.items():
|
||||||
|
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||||
|
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
||||||
|
]:
|
||||||
|
classifications: Dict[str, int] = collections.defaultdict(int)
|
||||||
|
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||||
|
for item in self.itempool:
|
||||||
|
if item.player in counters and item.name in shared_pool:
|
||||||
|
counters[item.player][item.name] += 1
|
||||||
|
classifications[item.name] |= item.classification
|
||||||
|
|
||||||
|
for player in players.copy():
|
||||||
|
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||||
|
players.remove(player)
|
||||||
|
del (counters[player])
|
||||||
|
|
||||||
|
if not players:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
for item in shared_pool:
|
||||||
|
count = min(counters[player][item] for player in players)
|
||||||
|
if count:
|
||||||
|
for player in players:
|
||||||
|
counters[player][item] = count
|
||||||
|
else:
|
||||||
|
for player in players:
|
||||||
|
del (counters[player][item])
|
||||||
|
return counters, classifications
|
||||||
|
|
||||||
|
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||||
|
if not common_item_count:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_itempool: List[Item] = []
|
||||||
|
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||||
|
for _ in range(item_count):
|
||||||
|
new_item = group["world"].create_item(item_name)
|
||||||
|
# mangle together all original classification bits
|
||||||
|
new_item.classification |= classifications[item_name]
|
||||||
|
new_itempool.append(new_item)
|
||||||
|
|
||||||
|
region = Region("Menu", group_id, self, "ItemLink")
|
||||||
|
self.regions.append(region)
|
||||||
|
locations = region.locations
|
||||||
|
# ensure that progression items are linked first, then non-progression
|
||||||
|
self.itempool.sort(key=lambda item: item.advancement)
|
||||||
|
for item in self.itempool:
|
||||||
|
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||||
|
if count:
|
||||||
|
loc = Location(group_id, f"Item Link: {item.name} -> {self.player_name[item.player]} {count}",
|
||||||
|
None, region)
|
||||||
|
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||||
|
state.has(item_name, group_id_, count_)
|
||||||
|
|
||||||
|
locations.append(loc)
|
||||||
|
loc.place_locked_item(item)
|
||||||
|
common_item_count[item.player][item.name] -= 1
|
||||||
|
else:
|
||||||
|
new_itempool.append(item)
|
||||||
|
|
||||||
|
itemcount = len(self.itempool)
|
||||||
|
self.itempool = new_itempool
|
||||||
|
|
||||||
|
while itemcount > len(self.itempool):
|
||||||
|
items_to_add = []
|
||||||
|
for player in group["players"]:
|
||||||
|
if group["link_replacement"]:
|
||||||
|
item_player = group_id
|
||||||
|
else:
|
||||||
|
item_player = player
|
||||||
|
if group["replacement_items"][player]:
|
||||||
|
items_to_add.append(AutoWorld.call_single(self, "create_item", item_player,
|
||||||
|
group["replacement_items"][player]))
|
||||||
|
else:
|
||||||
|
items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player))
|
||||||
|
self.random.shuffle(items_to_add)
|
||||||
|
self.itempool.extend(items_to_add[:itemcount - len(self.itempool)])
|
||||||
|
|
||||||
def secure(self):
|
def secure(self):
|
||||||
self.random = ThreadBarrierProxy(secrets.SystemRandom())
|
self.random = ThreadBarrierProxy(secrets.SystemRandom())
|
||||||
self.is_race = True
|
self.is_race = True
|
||||||
@@ -309,7 +399,7 @@ class MultiWorld():
|
|||||||
return tuple(world for player, world in self.worlds.items() if
|
return tuple(world for player, world in self.worlds.items() if
|
||||||
player not in self.groups and self.game[player] == game_name)
|
player not in self.groups and self.game[player] == game_name)
|
||||||
|
|
||||||
def get_name_string_for_object(self, obj) -> str:
|
def get_name_string_for_object(self, obj: HasNameAndPlayer) -> str:
|
||||||
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
|
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
|
||||||
|
|
||||||
def get_player_name(self, player: int) -> str:
|
def get_player_name(self, player: int) -> str:
|
||||||
@@ -351,7 +441,7 @@ class MultiWorld():
|
|||||||
subworld = self.worlds[player]
|
subworld = self.worlds[player]
|
||||||
for item in subworld.get_pre_fill_items():
|
for item in subworld.get_pre_fill_items():
|
||||||
subworld.collect(ret, item)
|
subworld.collect(ret, item)
|
||||||
ret.sweep_for_events()
|
ret.sweep_for_advancements()
|
||||||
|
|
||||||
if use_cache:
|
if use_cache:
|
||||||
self._all_state = ret
|
self._all_state = ret
|
||||||
@@ -360,7 +450,7 @@ class MultiWorld():
|
|||||||
def get_items(self) -> List[Item]:
|
def get_items(self) -> List[Item]:
|
||||||
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
||||||
|
|
||||||
def find_item_locations(self, item, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
def find_item_locations(self, item: str, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||||
if resolve_group_locations:
|
if resolve_group_locations:
|
||||||
player_groups = self.get_player_groups(player)
|
player_groups = self.get_player_groups(player)
|
||||||
return [location for location in self.get_locations() if
|
return [location for location in self.get_locations() if
|
||||||
@@ -369,7 +459,7 @@ class MultiWorld():
|
|||||||
return [location for location in self.get_locations() if
|
return [location for location in self.get_locations() if
|
||||||
location.item and location.item.name == item and location.item.player == player]
|
location.item and location.item.name == item and location.item.player == player]
|
||||||
|
|
||||||
def find_item(self, item, player: int) -> Location:
|
def find_item(self, item: str, player: int) -> Location:
|
||||||
return next(location for location in self.get_locations() if
|
return next(location for location in self.get_locations() if
|
||||||
location.item and location.item.name == item and location.item.player == player)
|
location.item and location.item.name == item and location.item.player == player)
|
||||||
|
|
||||||
@@ -462,9 +552,9 @@ class MultiWorld():
|
|||||||
return True
|
return True
|
||||||
state = starting_state.copy()
|
state = starting_state.copy()
|
||||||
else:
|
else:
|
||||||
if self.has_beaten_game(self.state):
|
|
||||||
return True
|
|
||||||
state = CollectionState(self)
|
state = CollectionState(self)
|
||||||
|
if self.has_beaten_game(state):
|
||||||
|
return True
|
||||||
prog_locations = {location for location in self.get_locations() if location.item
|
prog_locations = {location for location in self.get_locations() if location.item
|
||||||
and location.item.advancement and location not in state.locations_checked}
|
and location.item.advancement and location not in state.locations_checked}
|
||||||
|
|
||||||
@@ -523,26 +613,21 @@ class MultiWorld():
|
|||||||
players: Dict[str, Set[int]] = {
|
players: Dict[str, Set[int]] = {
|
||||||
"minimal": set(),
|
"minimal": set(),
|
||||||
"items": set(),
|
"items": set(),
|
||||||
"locations": set()
|
"full": set()
|
||||||
}
|
}
|
||||||
for player, access in self.accessibility.items():
|
for player, world in self.worlds.items():
|
||||||
players[access.current_key].add(player)
|
players[world.options.accessibility.current_key].add(player)
|
||||||
|
|
||||||
beatable_fulfilled = False
|
beatable_fulfilled = False
|
||||||
|
|
||||||
def location_condition(location: Location):
|
def location_condition(location: Location) -> bool:
|
||||||
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
||||||
if location.player in players["locations"] or (location.item and location.item.player not in
|
return location.player in players["full"] or \
|
||||||
players["minimal"]):
|
(location.item and location.item.player not in players["minimal"])
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def location_relevant(location: Location):
|
def location_relevant(location: Location) -> bool:
|
||||||
"""Determine if this location is relevant to sweep."""
|
"""Determine if this location is relevant to sweep."""
|
||||||
if location.progress_type != LocationProgressType.EXCLUDED \
|
return location.player in players["full"] or location.advancement
|
||||||
and (location.player in players["locations"] or location.advancement):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def all_done() -> bool:
|
def all_done() -> bool:
|
||||||
"""Check if all access rules are fulfilled"""
|
"""Check if all access rules are fulfilled"""
|
||||||
@@ -587,7 +672,7 @@ class CollectionState():
|
|||||||
multiworld: MultiWorld
|
multiworld: MultiWorld
|
||||||
reachable_regions: Dict[int, Set[Region]]
|
reachable_regions: Dict[int, Set[Region]]
|
||||||
blocked_connections: Dict[int, Set[Entrance]]
|
blocked_connections: Dict[int, Set[Entrance]]
|
||||||
events: Set[Location]
|
advancements: Set[Location]
|
||||||
path: Dict[Union[Region, Entrance], PathValue]
|
path: Dict[Union[Region, Entrance], PathValue]
|
||||||
locations_checked: Set[Location]
|
locations_checked: Set[Location]
|
||||||
stale: Dict[int, bool]
|
stale: Dict[int, bool]
|
||||||
@@ -599,7 +684,7 @@ class CollectionState():
|
|||||||
self.multiworld = parent
|
self.multiworld = parent
|
||||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||||
self.events = set()
|
self.advancements = set()
|
||||||
self.path = {}
|
self.path = {}
|
||||||
self.locations_checked = set()
|
self.locations_checked = set()
|
||||||
self.stale = {player: True for player in parent.get_all_ids()}
|
self.stale = {player: True for player in parent.get_all_ids()}
|
||||||
@@ -611,17 +696,25 @@ class CollectionState():
|
|||||||
|
|
||||||
def update_reachable_regions(self, player: int):
|
def update_reachable_regions(self, player: int):
|
||||||
self.stale[player] = False
|
self.stale[player] = False
|
||||||
|
world: AutoWorld.World = self.multiworld.worlds[player]
|
||||||
reachable_regions = self.reachable_regions[player]
|
reachable_regions = self.reachable_regions[player]
|
||||||
blocked_connections = self.blocked_connections[player]
|
|
||||||
queue = deque(self.blocked_connections[player])
|
queue = deque(self.blocked_connections[player])
|
||||||
start = self.multiworld.get_region("Menu", player)
|
start: Region = world.get_region(world.origin_region_name)
|
||||||
|
|
||||||
# init on first call - this can't be done on construction since the regions don't exist yet
|
# init on first call - this can't be done on construction since the regions don't exist yet
|
||||||
if start not in reachable_regions:
|
if start not in reachable_regions:
|
||||||
reachable_regions.add(start)
|
reachable_regions.add(start)
|
||||||
blocked_connections.update(start.exits)
|
self.blocked_connections[player].update(start.exits)
|
||||||
queue.extend(start.exits)
|
queue.extend(start.exits)
|
||||||
|
|
||||||
|
if world.explicit_indirect_conditions:
|
||||||
|
self._update_reachable_regions_explicit_indirect_conditions(player, queue)
|
||||||
|
else:
|
||||||
|
self._update_reachable_regions_auto_indirect_conditions(player, queue)
|
||||||
|
|
||||||
|
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
|
||||||
|
reachable_regions = self.reachable_regions[player]
|
||||||
|
blocked_connections = self.blocked_connections[player]
|
||||||
# run BFS on all connections, and keep track of those blocked by missing items
|
# run BFS on all connections, and keep track of those blocked by missing items
|
||||||
while queue:
|
while queue:
|
||||||
connection = queue.popleft()
|
connection = queue.popleft()
|
||||||
@@ -629,7 +722,7 @@ class CollectionState():
|
|||||||
if new_region in reachable_regions:
|
if new_region in reachable_regions:
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
elif connection.can_reach(self):
|
elif connection.can_reach(self):
|
||||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
||||||
reachable_regions.add(new_region)
|
reachable_regions.add(new_region)
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
blocked_connections.update(new_region.exits)
|
blocked_connections.update(new_region.exits)
|
||||||
@@ -641,16 +734,39 @@ class CollectionState():
|
|||||||
if new_entrance in blocked_connections and new_entrance not in queue:
|
if new_entrance in blocked_connections and new_entrance not in queue:
|
||||||
queue.append(new_entrance)
|
queue.append(new_entrance)
|
||||||
|
|
||||||
|
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
|
||||||
|
reachable_regions = self.reachable_regions[player]
|
||||||
|
blocked_connections = self.blocked_connections[player]
|
||||||
|
new_connection: bool = True
|
||||||
|
# run BFS on all connections, and keep track of those blocked by missing items
|
||||||
|
while new_connection:
|
||||||
|
new_connection = False
|
||||||
|
while queue:
|
||||||
|
connection = queue.popleft()
|
||||||
|
new_region = connection.connected_region
|
||||||
|
if new_region in reachable_regions:
|
||||||
|
blocked_connections.remove(connection)
|
||||||
|
elif connection.can_reach(self):
|
||||||
|
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||||
|
reachable_regions.add(new_region)
|
||||||
|
blocked_connections.remove(connection)
|
||||||
|
blocked_connections.update(new_region.exits)
|
||||||
|
queue.extend(new_region.exits)
|
||||||
|
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||||
|
new_connection = True
|
||||||
|
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
|
||||||
|
queue.extend(blocked_connections)
|
||||||
|
|
||||||
def copy(self) -> CollectionState:
|
def copy(self) -> CollectionState:
|
||||||
ret = CollectionState(self.multiworld)
|
ret = CollectionState(self.multiworld)
|
||||||
ret.prog_items = copy.deepcopy(self.prog_items)
|
ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()}
|
||||||
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
ret.reachable_regions = {player: region_set.copy() for player, region_set in
|
||||||
self.reachable_regions}
|
self.reachable_regions.items()}
|
||||||
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
|
ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in
|
||||||
self.blocked_connections}
|
self.blocked_connections.items()}
|
||||||
ret.events = copy.copy(self.events)
|
ret.advancements = self.advancements.copy()
|
||||||
ret.path = copy.copy(self.path)
|
ret.path = self.path.copy()
|
||||||
ret.locations_checked = copy.copy(self.locations_checked)
|
ret.locations_checked = self.locations_checked.copy()
|
||||||
for function in self.additional_copy_functions:
|
for function in self.additional_copy_functions:
|
||||||
ret = function(self, ret)
|
ret = function(self, ret)
|
||||||
return ret
|
return ret
|
||||||
@@ -680,20 +796,25 @@ class CollectionState():
|
|||||||
def can_reach_region(self, spot: str, player: int) -> bool:
|
def can_reach_region(self, spot: str, player: int) -> bool:
|
||||||
return self.multiworld.get_region(spot, player).can_reach(self)
|
return self.multiworld.get_region(spot, player).can_reach(self)
|
||||||
|
|
||||||
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
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:
|
||||||
if locations is None:
|
if locations is None:
|
||||||
locations = self.multiworld.get_filled_locations()
|
locations = self.multiworld.get_filled_locations()
|
||||||
reachable_events = True
|
reachable_advancements = True
|
||||||
# since the loop has a good chance to run more than once, only filter the events once
|
# 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.events and
|
locations = {location for location in locations if location.advancement and location not in self.advancements}
|
||||||
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
|
||||||
while reachable_events:
|
while reachable_advancements:
|
||||||
reachable_events = {location for location in locations if location.can_reach(self)}
|
reachable_advancements = {location for location in locations if location.can_reach(self)}
|
||||||
locations -= reachable_events
|
locations -= reachable_advancements
|
||||||
for event in reachable_events:
|
for advancement in reachable_advancements:
|
||||||
self.events.add(event)
|
self.advancements.add(advancement)
|
||||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
|
||||||
self.collect(event.item, True, event)
|
self.collect(advancement.item, True, advancement)
|
||||||
|
|
||||||
# item name related
|
# item name related
|
||||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||||
@@ -727,7 +848,7 @@ class CollectionState():
|
|||||||
if found >= count:
|
if found >= count:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
|
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.
|
"""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."""
|
Ignores duplicates of the same item."""
|
||||||
@@ -742,7 +863,7 @@ class CollectionState():
|
|||||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
||||||
"""Returns the cumulative count of items from a list present in state."""
|
"""Returns the cumulative count of items from a list present in state."""
|
||||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
return sum(self.prog_items[player][item_name] for item_name in items)
|
||||||
|
|
||||||
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
||||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
||||||
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
||||||
@@ -788,20 +909,16 @@ class CollectionState():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Item related
|
# Item related
|
||||||
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
def collect(self, item: Item, prevent_sweep: bool = False, location: Optional[Location] = None) -> bool:
|
||||||
if location:
|
if location:
|
||||||
self.locations_checked.add(location)
|
self.locations_checked.add(location)
|
||||||
|
|
||||||
changed = self.multiworld.worlds[item.player].collect(self, item)
|
changed = self.multiworld.worlds[item.player].collect(self, item)
|
||||||
|
|
||||||
if not changed and event:
|
|
||||||
self.prog_items[item.player][item.name] += 1
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
self.stale[item.player] = True
|
self.stale[item.player] = True
|
||||||
|
|
||||||
if changed and not event:
|
if changed and not prevent_sweep:
|
||||||
self.sweep_for_events()
|
self.sweep_for_advancements()
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
@@ -825,12 +942,13 @@ class Entrance:
|
|||||||
addresses = None
|
addresses = None
|
||||||
target = None
|
target = None
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = '', parent: Region = None):
|
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.parent_region = parent
|
self.parent_region = parent
|
||||||
self.player = player
|
self.player = player
|
||||||
|
|
||||||
def can_reach(self, state: CollectionState) -> bool:
|
def can_reach(self, state: CollectionState) -> bool:
|
||||||
|
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
|
||||||
if self.parent_region.can_reach(state) and self.access_rule(state):
|
if self.parent_region.can_reach(state) and self.access_rule(state):
|
||||||
if not self.hide_path and not self in state.path:
|
if not self.hide_path and not self in state.path:
|
||||||
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
||||||
@@ -845,9 +963,6 @@ class Entrance:
|
|||||||
region.entrances.append(self)
|
region.entrances.append(self)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__str__()
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
multiworld = self.parent_region.multiworld if self.parent_region else None
|
multiworld = self.parent_region.multiworld if self.parent_region else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
@@ -973,7 +1088,7 @@ class Region:
|
|||||||
self.locations.append(location_type(self.player, location, address, self))
|
self.locations.append(location_type(self.player, location, address, self))
|
||||||
|
|
||||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
|
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||||
"""
|
"""
|
||||||
Connects this Region to another Region, placing the provided rule on the connection.
|
Connects this Region to another Region, placing the provided rule on the connection.
|
||||||
|
|
||||||
@@ -1013,9 +1128,6 @@ class Region:
|
|||||||
rules[connecting_region] if rules and connecting_region in rules else None)
|
rules[connecting_region] if rules and connecting_region in rules else None)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__str__()
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
|
|
||||||
@@ -1034,9 +1146,9 @@ class Location:
|
|||||||
locked: bool = False
|
locked: bool = False
|
||||||
show_in_spoiler: bool = True
|
show_in_spoiler: bool = True
|
||||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||||
always_allow = staticmethod(lambda state, item: False)
|
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
|
||||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||||
item_rule = staticmethod(lambda item: True)
|
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
|
||||||
item: Optional[Item] = None
|
item: Optional[Item] = None
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
|
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
|
||||||
@@ -1045,16 +1157,20 @@ class Location:
|
|||||||
self.address = address
|
self.address = address
|
||||||
self.parent_region = parent
|
self.parent_region = parent
|
||||||
|
|
||||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
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)
|
return ((
|
||||||
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
self.always_allow(state, item)
|
||||||
and self.item_rule(item)
|
and item.name not in state.multiworld.worlds[item.player].options.non_local_items
|
||||||
and (not check_access or self.can_reach(state))))
|
) 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:
|
def can_reach(self, state: CollectionState) -> bool:
|
||||||
# self.access_rule computes faster on average, so placing it first for faster abort
|
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average
|
||||||
assert self.parent_region, "Can't reach location without region"
|
assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region"
|
||||||
return self.access_rule(state) and self.parent_region.can_reach(state)
|
return self.parent_region.can_reach(state) and self.access_rule(state)
|
||||||
|
|
||||||
def place_locked_item(self, item: Item):
|
def place_locked_item(self, item: Item):
|
||||||
if self.item:
|
if self.item:
|
||||||
@@ -1064,9 +1180,6 @@ class Location:
|
|||||||
self.locked = True
|
self.locked = True
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__str__()
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
@@ -1088,7 +1201,7 @@ class Location:
|
|||||||
@property
|
@property
|
||||||
def native_item(self) -> bool:
|
def native_item(self) -> bool:
|
||||||
"""Returns True if the item in this location matches game."""
|
"""Returns True if the item in this location matches game."""
|
||||||
return self.item and self.item.game == self.game
|
return self.item is not None and self.item.game == self.game
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hint_text(self) -> str:
|
def hint_text(self) -> str:
|
||||||
@@ -1099,7 +1212,7 @@ class ItemClassification(IntFlag):
|
|||||||
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
|
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
|
||||||
progression = 0b0001 # Item that is logically relevant
|
progression = 0b0001 # Item that is logically relevant
|
||||||
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
|
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
|
||||||
trap = 0b0100 # detrimental or entirely useless (nothing) item
|
trap = 0b0100 # detrimental item
|
||||||
skip_balancing = 0b1000 # should technically never occur on its own
|
skip_balancing = 0b1000 # should technically never occur on its own
|
||||||
# Item that is logically relevant, but progression balancing should not touch.
|
# Item that is logically relevant, but progression balancing should not touch.
|
||||||
# Typically currency or other counted items.
|
# Typically currency or other counted items.
|
||||||
@@ -1151,6 +1264,10 @@ class Item:
|
|||||||
def trap(self) -> bool:
|
def trap(self) -> bool:
|
||||||
return ItemClassification.trap in self.classification
|
return ItemClassification.trap in self.classification
|
||||||
|
|
||||||
|
@property
|
||||||
|
def excludable(self) -> bool:
|
||||||
|
return not (self.advancement or self.useful)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def flags(self) -> int:
|
def flags(self) -> int:
|
||||||
return self.classification.as_flag()
|
return self.classification.as_flag()
|
||||||
@@ -1171,9 +1288,6 @@ class Item:
|
|||||||
return hash((self.name, self.player))
|
return hash((self.name, self.player))
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return self.__str__()
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
|
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
|
||||||
return self.location.parent_region.multiworld.get_name_string_for_object(self)
|
return self.location.parent_region.multiworld.get_name_string_for_object(self)
|
||||||
return f"{self.name} (Player {self.player})"
|
return f"{self.name} (Player {self.player})"
|
||||||
@@ -1251,9 +1365,9 @@ class Spoiler:
|
|||||||
|
|
||||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||||
# reducing each range of influence to the bare minimum required inside it
|
# reducing each range of influence to the bare minimum required inside it
|
||||||
restore_later = {}
|
restore_later: Dict[Location, Item] = {}
|
||||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||||
to_delete = set()
|
to_delete: Set[Location] = set()
|
||||||
for location in sphere:
|
for location in sphere:
|
||||||
# we remove the item at location and check if game is still beatable
|
# we remove the item at location and check if game is still beatable
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||||
@@ -1271,7 +1385,7 @@ class Spoiler:
|
|||||||
sphere -= to_delete
|
sphere -= to_delete
|
||||||
|
|
||||||
# second phase, sphere 0
|
# second phase, sphere 0
|
||||||
removed_precollected = []
|
removed_precollected: List[Item] = []
|
||||||
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||||
multiworld.precollected_items[item.player].remove(item)
|
multiworld.precollected_items[item.player].remove(item)
|
||||||
@@ -1291,8 +1405,6 @@ class Spoiler:
|
|||||||
state = CollectionState(multiworld)
|
state = CollectionState(multiworld)
|
||||||
collection_spheres = []
|
collection_spheres = []
|
||||||
while required_locations:
|
while required_locations:
|
||||||
state.sweep_for_events(key_only=True)
|
|
||||||
|
|
||||||
sphere = set(filter(state.can_reach, required_locations))
|
sphere = set(filter(state.can_reach, required_locations))
|
||||||
|
|
||||||
for location in sphere:
|
for location in sphere:
|
||||||
@@ -1354,7 +1466,7 @@ class Spoiler:
|
|||||||
# Maybe move the big bomb over to the Event system instead?
|
# Maybe move the big bomb over to the Event system instead?
|
||||||
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
|
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
|
||||||
for (_, exit_path) in path):
|
for (_, exit_path) in path):
|
||||||
if multiworld.mode[player] != 'inverted':
|
if multiworld.worlds[player].options.mode != 'inverted':
|
||||||
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
|
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
|
||||||
get_path(state, multiworld.get_region('Big Bomb Shop', player))
|
get_path(state, multiworld.get_region('Big Bomb Shop', player))
|
||||||
else:
|
else:
|
||||||
@@ -1426,9 +1538,9 @@ class Spoiler:
|
|||||||
|
|
||||||
if self.paths:
|
if self.paths:
|
||||||
outfile.write('\n\nPaths:\n\n')
|
outfile.write('\n\nPaths:\n\n')
|
||||||
path_listings = []
|
path_listings: List[str] = []
|
||||||
for location, path in sorted(self.paths.items()):
|
for location, path in sorted(self.paths.items()):
|
||||||
path_lines = []
|
path_lines: List[str] = []
|
||||||
for region, exit in path:
|
for region, exit in path:
|
||||||
if exit is not None:
|
if exit is not None:
|
||||||
path_lines.append("{} -> {}".format(region, exit))
|
path_lines.append("{} -> {}".format(region, exit))
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
from worlds._bizhawk.context import launch
|
from worlds._bizhawk.context import launch
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
launch()
|
launch(*sys.argv[1:])
|
||||||
|
|||||||
103
CommonClient.py
@@ -45,10 +45,21 @@ def get_ssl_context():
|
|||||||
|
|
||||||
|
|
||||||
class ClientCommandProcessor(CommandProcessor):
|
class ClientCommandProcessor(CommandProcessor):
|
||||||
|
"""
|
||||||
|
The Command Processor will parse every method of the class that starts with "_cmd_" as a command to be called
|
||||||
|
when parsing user input, i.e. _cmd_exit will be called when the user sends the command "/exit".
|
||||||
|
|
||||||
|
The decorator @mark_raw can be imported from MultiServer and tells the parser to only split on the first
|
||||||
|
space after the command i.e. "/exit one two three" will be passed in as method("one two three") with mark_raw
|
||||||
|
and method("one", "two", "three") without.
|
||||||
|
|
||||||
|
In addition all docstrings for command methods will be displayed to the user on launch and when using "/help"
|
||||||
|
"""
|
||||||
def __init__(self, ctx: CommonContext):
|
def __init__(self, ctx: CommonContext):
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
|
|
||||||
def output(self, text: str):
|
def output(self, text: str):
|
||||||
|
"""Helper function to abstract logging to the CommonClient UI"""
|
||||||
logger.info(text)
|
logger.info(text)
|
||||||
|
|
||||||
def _cmd_exit(self) -> bool:
|
def _cmd_exit(self) -> bool:
|
||||||
@@ -164,13 +175,14 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||||
|
|
||||||
def default(self, raw: str):
|
def default(self, raw: str):
|
||||||
|
"""The default message parser to be used when parsing any messages that do not match a command"""
|
||||||
raw = self.ctx.on_user_say(raw)
|
raw = self.ctx.on_user_say(raw)
|
||||||
if raw:
|
if raw:
|
||||||
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||||
|
|
||||||
|
|
||||||
class CommonContext:
|
class CommonContext:
|
||||||
# Should be adjusted as needed in subclasses
|
# The following attributes are used to Connect and should be adjusted as needed in subclasses
|
||||||
tags: typing.Set[str] = {"AP"}
|
tags: typing.Set[str] = {"AP"}
|
||||||
game: typing.Optional[str] = None
|
game: typing.Optional[str] = None
|
||||||
items_handling: typing.Optional[int] = None
|
items_handling: typing.Optional[int] = None
|
||||||
@@ -252,7 +264,7 @@ class CommonContext:
|
|||||||
starting_reconnect_delay: int = 5
|
starting_reconnect_delay: int = 5
|
||||||
current_reconnect_delay: int = starting_reconnect_delay
|
current_reconnect_delay: int = starting_reconnect_delay
|
||||||
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
||||||
ui = None
|
ui: typing.Optional["kvui.GameManager"] = None
|
||||||
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
||||||
input_task: typing.Optional["asyncio.Task[None]"] = None
|
input_task: typing.Optional["asyncio.Task[None]"] = None
|
||||||
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
||||||
@@ -343,6 +355,8 @@ class CommonContext:
|
|||||||
|
|
||||||
self.item_names = self.NameLookupDict(self, "item")
|
self.item_names = self.NameLookupDict(self, "item")
|
||||||
self.location_names = self.NameLookupDict(self, "location")
|
self.location_names = self.NameLookupDict(self, "location")
|
||||||
|
self.versions = {}
|
||||||
|
self.checksums = {}
|
||||||
|
|
||||||
self.jsontotextparser = JSONtoTextParser(self)
|
self.jsontotextparser = JSONtoTextParser(self)
|
||||||
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
||||||
@@ -429,7 +443,10 @@ class CommonContext:
|
|||||||
self.auth = await self.console_input()
|
self.auth = await self.console_input()
|
||||||
|
|
||||||
async def send_connect(self, **kwargs: typing.Any) -> None:
|
async def send_connect(self, **kwargs: typing.Any) -> None:
|
||||||
""" send `Connect` packet to log in to server """
|
"""
|
||||||
|
Send a `Connect` packet to log in to the server,
|
||||||
|
additional keyword args can override any value in the connection packet
|
||||||
|
"""
|
||||||
payload = {
|
payload = {
|
||||||
'cmd': 'Connect',
|
'cmd': 'Connect',
|
||||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||||
@@ -439,6 +456,7 @@ class CommonContext:
|
|||||||
if kwargs:
|
if kwargs:
|
||||||
payload.update(kwargs)
|
payload.update(kwargs)
|
||||||
await self.send_msgs([payload])
|
await self.send_msgs([payload])
|
||||||
|
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
|
||||||
|
|
||||||
async def console_input(self) -> str:
|
async def console_input(self) -> str:
|
||||||
if self.ui:
|
if self.ui:
|
||||||
@@ -459,6 +477,7 @@ class CommonContext:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def slot_concerns_self(self, slot) -> bool:
|
def slot_concerns_self(self, slot) -> bool:
|
||||||
|
"""Helper function to abstract player groups, should be used instead of checking slot == self.slot directly."""
|
||||||
if slot == self.slot:
|
if slot == self.slot:
|
||||||
return True
|
return True
|
||||||
if slot in self.slot_info:
|
if slot in self.slot_info:
|
||||||
@@ -466,6 +485,7 @@ class CommonContext:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def is_echoed_chat(self, print_json_packet: dict) -> bool:
|
def is_echoed_chat(self, print_json_packet: dict) -> bool:
|
||||||
|
"""Helper function for filtering out messages sent by self."""
|
||||||
return print_json_packet.get("type", "") == "Chat" \
|
return print_json_packet.get("type", "") == "Chat" \
|
||||||
and print_json_packet.get("team", None) == self.team \
|
and print_json_packet.get("team", None) == self.team \
|
||||||
and print_json_packet.get("slot", None) == self.slot
|
and print_json_packet.get("slot", None) == self.slot
|
||||||
@@ -497,13 +517,14 @@ class CommonContext:
|
|||||||
"""Gets called before sending a Say to the server from the user.
|
"""Gets called before sending a Say to the server from the user.
|
||||||
Returned text is sent, or sending is aborted if None is returned."""
|
Returned text is sent, or sending is aborted if None is returned."""
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def on_ui_command(self, text: str) -> None:
|
def on_ui_command(self, text: str) -> None:
|
||||||
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
|
"""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."""
|
The command processor is still called; this is just intended for command echoing."""
|
||||||
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
|
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
|
||||||
|
|
||||||
def update_permissions(self, permissions: typing.Dict[str, int]):
|
def update_permissions(self, permissions: typing.Dict[str, int]):
|
||||||
|
"""Internal method to parse and save server permissions from RoomInfo"""
|
||||||
for permission_name, permission_flag in permissions.items():
|
for permission_name, permission_flag in permissions.items():
|
||||||
try:
|
try:
|
||||||
flag = Permission(permission_flag)
|
flag = Permission(permission_flag)
|
||||||
@@ -552,26 +573,34 @@ class CommonContext:
|
|||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
cached_version: int = self.versions.get(game, 0)
|
||||||
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
||||||
# no action required if local version is new enough
|
# no action required if cached version is new enough
|
||||||
if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
|
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
|
||||||
or remote_checksum != local_checksum:
|
or remote_checksum != cached_checksum:
|
||||||
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
||||||
cache_version: int = cached_game.get("version", 0)
|
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||||
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
|
||||||
# download remote version if cache is not new enough
|
and remote_checksum == local_checksum):
|
||||||
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
self.update_game(network_data_package["games"][game], game)
|
||||||
or remote_checksum != cache_checksum:
|
|
||||||
needed_updates.add(game)
|
|
||||||
else:
|
else:
|
||||||
self.update_game(cached_game, game)
|
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
||||||
|
cache_version: int = cached_game.get("version", 0)
|
||||||
|
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
||||||
|
# download remote version if cache is not new enough
|
||||||
|
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
||||||
|
or remote_checksum != cache_checksum:
|
||||||
|
needed_updates.add(game)
|
||||||
|
else:
|
||||||
|
self.update_game(cached_game, game)
|
||||||
if needed_updates:
|
if needed_updates:
|
||||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
|
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
|
||||||
|
|
||||||
def update_game(self, game_package: dict, game: str):
|
def update_game(self, game_package: dict, game: str):
|
||||||
self.item_names.update_game(game, game_package["item_name_to_id"])
|
self.item_names.update_game(game, game_package["item_name_to_id"])
|
||||||
self.location_names.update_game(game, game_package["location_name_to_id"])
|
self.location_names.update_game(game, game_package["location_name_to_id"])
|
||||||
|
self.versions[game] = game_package.get("version", 0)
|
||||||
|
self.checksums[game] = game_package.get("checksum")
|
||||||
|
|
||||||
def update_data_package(self, data_package: dict):
|
def update_data_package(self, data_package: dict):
|
||||||
for game, game_data in data_package["games"].items():
|
for game, game_data in data_package["games"].items():
|
||||||
@@ -613,6 +642,7 @@ class CommonContext:
|
|||||||
logger.info(f"DeathLink: Received from {data['source']}")
|
logger.info(f"DeathLink: Received from {data['source']}")
|
||||||
|
|
||||||
async def send_death(self, death_text: str = ""):
|
async def send_death(self, death_text: str = ""):
|
||||||
|
"""Helper function to send a deathlink using death_text as the unique death cause string."""
|
||||||
if self.server and self.server.socket:
|
if self.server and self.server.socket:
|
||||||
logger.info("DeathLink: Sending death to your friends...")
|
logger.info("DeathLink: Sending death to your friends...")
|
||||||
self.last_death_link = time.time()
|
self.last_death_link = time.time()
|
||||||
@@ -626,6 +656,7 @@ class CommonContext:
|
|||||||
}])
|
}])
|
||||||
|
|
||||||
async def update_death_link(self, death_link: bool):
|
async def update_death_link(self, death_link: bool):
|
||||||
|
"""Helper function to set Death Link connection tag on/off and update the connection if already connected."""
|
||||||
old_tags = self.tags.copy()
|
old_tags = self.tags.copy()
|
||||||
if death_link:
|
if death_link:
|
||||||
self.tags.add("DeathLink")
|
self.tags.add("DeathLink")
|
||||||
@@ -635,7 +666,7 @@ class CommonContext:
|
|||||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||||
|
|
||||||
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
|
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
|
||||||
"""Displays an error messagebox"""
|
"""Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework"""
|
||||||
if not self.ui:
|
if not self.ui:
|
||||||
return None
|
return None
|
||||||
title = title or "Error"
|
title = title or "Error"
|
||||||
@@ -662,17 +693,19 @@ class CommonContext:
|
|||||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||||
|
|
||||||
def run_gui(self):
|
def make_gui(self) -> typing.Type["kvui.GameManager"]:
|
||||||
"""Import kivy UI system and start running it as self.ui_task."""
|
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
|
||||||
from kvui import GameManager
|
from kvui import GameManager
|
||||||
|
|
||||||
class TextManager(GameManager):
|
class TextManager(GameManager):
|
||||||
logging_pairs = [
|
|
||||||
("Client", "Archipelago")
|
|
||||||
]
|
|
||||||
base_title = "Archipelago Text Client"
|
base_title = "Archipelago Text Client"
|
||||||
|
|
||||||
self.ui = TextManager(self)
|
return TextManager
|
||||||
|
|
||||||
|
def run_gui(self):
|
||||||
|
"""Import kivy UI system from make_gui() and start running it as self.ui_task."""
|
||||||
|
ui_class = self.make_gui()
|
||||||
|
self.ui = ui_class(self)
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
def run_cli(self):
|
def run_cli(self):
|
||||||
@@ -985,6 +1018,7 @@ async def console_loop(ctx: CommonContext):
|
|||||||
|
|
||||||
|
|
||||||
def get_base_parser(description: typing.Optional[str] = None):
|
def get_base_parser(description: typing.Optional[str] = None):
|
||||||
|
"""Base argument parser to be reused for components subclassing off of CommonClient"""
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description=description)
|
parser = argparse.ArgumentParser(description=description)
|
||||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||||
@@ -994,7 +1028,7 @@ def get_base_parser(description: typing.Optional[str] = None):
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def run_as_textclient():
|
def run_as_textclient(*args):
|
||||||
class TextContext(CommonContext):
|
class TextContext(CommonContext):
|
||||||
# Text Mode to use !hint and such with games that have no text entry
|
# Text Mode to use !hint and such with games that have no text entry
|
||||||
tags = CommonContext.tags | {"TextOnly"}
|
tags = CommonContext.tags | {"TextOnly"}
|
||||||
@@ -1033,16 +1067,21 @@ def run_as_textclient():
|
|||||||
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
||||||
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
|
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
|
||||||
if args.url:
|
if args.url:
|
||||||
url = urllib.parse.urlparse(args.url)
|
url = urllib.parse.urlparse(args.url)
|
||||||
args.connect = url.netloc
|
if url.scheme == "archipelago":
|
||||||
if url.username:
|
args.connect = url.netloc
|
||||||
args.name = urllib.parse.unquote(url.username)
|
if url.username:
|
||||||
if url.password:
|
args.name = urllib.parse.unquote(url.username)
|
||||||
args.password = urllib.parse.unquote(url.password)
|
if url.password:
|
||||||
|
args.password = urllib.parse.unquote(url.password)
|
||||||
|
else:
|
||||||
|
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
||||||
|
|
||||||
|
# use colorama to display colored text highlighting on windows
|
||||||
colorama.init()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
@@ -1051,4 +1090,4 @@ def run_as_textclient():
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
|
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
|
||||||
run_as_textclient()
|
run_as_textclient(*sys.argv[1:]) # default value for parse_args
|
||||||
|
|||||||
54
Fill.py
@@ -12,7 +12,12 @@ from worlds.generic.Rules import add_item_rule
|
|||||||
|
|
||||||
|
|
||||||
class FillError(RuntimeError):
|
class FillError(RuntimeError):
|
||||||
pass
|
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)
|
||||||
|
|
||||||
|
|
||||||
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
|
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
|
||||||
@@ -24,7 +29,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
|||||||
new_state = base_state.copy()
|
new_state = base_state.copy()
|
||||||
for item in itempool:
|
for item in itempool:
|
||||||
new_state.collect(item, True)
|
new_state.collect(item, True)
|
||||||
new_state.sweep_for_events(locations=locations)
|
new_state.sweep_for_advancements(locations=locations)
|
||||||
return new_state
|
return new_state
|
||||||
|
|
||||||
|
|
||||||
@@ -212,7 +217,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
f"Unfilled locations:\n"
|
f"Unfilled locations:\n"
|
||||||
f"{', '.join(str(location) for location in locations)}\n"
|
f"{', '.join(str(location) for location in locations)}\n"
|
||||||
f"Already placed {len(placements)}:\n"
|
f"Already placed {len(placements)}:\n"
|
||||||
f"{', '.join(str(place) for place in placements)}")
|
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
||||||
|
|
||||||
item_pool.extend(unplaced_items)
|
item_pool.extend(unplaced_items)
|
||||||
|
|
||||||
@@ -299,7 +304,7 @@ def remaining_fill(multiworld: MultiWorld,
|
|||||||
f"Unfilled locations:\n"
|
f"Unfilled locations:\n"
|
||||||
f"{', '.join(str(location) for location in locations)}\n"
|
f"{', '.join(str(location) for location in locations)}\n"
|
||||||
f"Already placed {len(placements)}:\n"
|
f"Already placed {len(placements)}:\n"
|
||||||
f"{', '.join(str(place) for place in placements)}")
|
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
||||||
|
|
||||||
itempool.extend(unplaced_items)
|
itempool.extend(unplaced_items)
|
||||||
|
|
||||||
@@ -324,8 +329,8 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
|
|||||||
pool.append(location.item)
|
pool.append(location.item)
|
||||||
state.remove(location.item)
|
state.remove(location.item)
|
||||||
location.item = None
|
location.item = None
|
||||||
if location in state.events:
|
if location in state.advancements:
|
||||||
state.events.remove(location)
|
state.advancements.remove(location)
|
||||||
locations.append(location)
|
locations.append(location)
|
||||||
if pool and locations:
|
if pool and locations:
|
||||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||||
@@ -358,7 +363,7 @@ def distribute_early_items(multiworld: MultiWorld,
|
|||||||
early_priority_locations: typing.List[Location] = []
|
early_priority_locations: typing.List[Location] = []
|
||||||
loc_indexes_to_remove: typing.Set[int] = set()
|
loc_indexes_to_remove: typing.Set[int] = set()
|
||||||
base_state = multiworld.state.copy()
|
base_state = multiworld.state.copy()
|
||||||
base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
||||||
for i, loc in enumerate(fill_locations):
|
for i, loc in enumerate(fill_locations):
|
||||||
if loc.can_reach(base_state):
|
if loc.can_reach(base_state):
|
||||||
if loc.progress_type == LocationProgressType.PRIORITY:
|
if loc.progress_type == LocationProgressType.PRIORITY:
|
||||||
@@ -470,28 +475,26 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
nonlocal lock_later
|
nonlocal lock_later
|
||||||
lock_later.append(location)
|
lock_later.append(location)
|
||||||
|
|
||||||
|
single_player = multiworld.players == 1 and not multiworld.groups
|
||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
# "priority fill"
|
# "priority fill"
|
||||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||||
single_player_placement=multiworld.players == 1, swap=False, on_place=mark_for_locking,
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
|
||||||
name="Priority")
|
|
||||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||||
defaultlocations = prioritylocations + defaultlocations
|
defaultlocations = prioritylocations + defaultlocations
|
||||||
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
# "advancement/progression fill"
|
# "advancement/progression fill"
|
||||||
if panic_method == "swap":
|
if panic_method == "swap":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
|
||||||
swap=True,
|
name="Progression", single_player_placement=single_player)
|
||||||
name="Progression", single_player_placement=multiworld.players == 1)
|
|
||||||
elif panic_method == "raise":
|
elif panic_method == "raise":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
||||||
swap=False,
|
name="Progression", single_player_placement=single_player)
|
||||||
name="Progression", single_player_placement=multiworld.players == 1)
|
|
||||||
elif panic_method == "start_inventory":
|
elif panic_method == "start_inventory":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
||||||
swap=False, allow_partial=True,
|
allow_partial=True, name="Progression", single_player_placement=single_player)
|
||||||
name="Progression", single_player_placement=multiworld.players == 1)
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
for item in progitempool:
|
for item in progitempool:
|
||||||
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
||||||
@@ -506,7 +509,8 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
if progitempool:
|
if progitempool:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f"Not enough locations for progression items. "
|
f"Not enough locations for progression items. "
|
||||||
f"There are {len(progitempool)} more progression items than there are available locations."
|
f"There are {len(progitempool)} more progression items than there are available locations.",
|
||||||
|
multiworld=multiworld,
|
||||||
)
|
)
|
||||||
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
||||||
|
|
||||||
@@ -523,7 +527,8 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
if excludedlocations:
|
if excludedlocations:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f"Not enough filler items for excluded locations. "
|
f"Not enough filler items for excluded locations. "
|
||||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
|
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
|
||||||
|
multiworld=multiworld,
|
||||||
)
|
)
|
||||||
|
|
||||||
restitempool = filleritempool + usefulitempool
|
restitempool = filleritempool + usefulitempool
|
||||||
@@ -551,7 +556,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
|||||||
progress_done = False
|
progress_done = False
|
||||||
|
|
||||||
# sweep once to pick up preplaced items
|
# sweep once to pick up preplaced items
|
||||||
multiworld.state.sweep_for_events()
|
multiworld.state.sweep_for_advancements()
|
||||||
|
|
||||||
# fill multiworld from top of itempool while we can
|
# fill multiworld from top of itempool while we can
|
||||||
while not progress_done:
|
while not progress_done:
|
||||||
@@ -589,7 +594,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
|||||||
if candidate_item_to_place is not None:
|
if candidate_item_to_place is not None:
|
||||||
item_to_place = candidate_item_to_place
|
item_to_place = candidate_item_to_place
|
||||||
else:
|
else:
|
||||||
raise FillError('No more progress items left to place.')
|
raise FillError('No more progress items left to place.', multiworld=multiworld)
|
||||||
|
|
||||||
# find item to replace with progress item
|
# find item to replace with progress item
|
||||||
location_list = multiworld.get_reachable_locations()
|
location_list = multiworld.get_reachable_locations()
|
||||||
@@ -646,7 +651,6 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
|
|
||||||
def get_sphere_locations(sphere_state: CollectionState,
|
def get_sphere_locations(sphere_state: CollectionState,
|
||||||
locations: typing.Set[Location]) -> typing.Set[Location]:
|
locations: typing.Set[Location]) -> typing.Set[Location]:
|
||||||
sphere_state.sweep_for_events(key_only=True, locations=locations)
|
|
||||||
return {loc for loc in locations if sphere_state.can_reach(loc)}
|
return {loc for loc in locations if sphere_state.can_reach(loc)}
|
||||||
|
|
||||||
def item_percentage(player: int, num: int) -> float:
|
def item_percentage(player: int, num: int) -> float:
|
||||||
@@ -740,7 +744,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
), items_to_test):
|
), items_to_test):
|
||||||
reducing_state.collect(location.item, True, location)
|
reducing_state.collect(location.item, True, location)
|
||||||
|
|
||||||
reducing_state.sweep_for_events(locations=locations_to_test)
|
reducing_state.sweep_for_advancements(locations=locations_to_test)
|
||||||
|
|
||||||
if multiworld.has_beaten_game(balancing_state):
|
if multiworld.has_beaten_game(balancing_state):
|
||||||
if not multiworld.has_beaten_game(reducing_state):
|
if not multiworld.has_beaten_game(reducing_state):
|
||||||
@@ -823,7 +827,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
warn(warning, force)
|
warn(warning, force)
|
||||||
|
|
||||||
swept_state = multiworld.state.copy()
|
swept_state = multiworld.state.copy()
|
||||||
swept_state.sweep_for_events()
|
swept_state.sweep_for_advancements()
|
||||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||||
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
|
|||||||
36
Generate.py
@@ -43,10 +43,10 @@ def mystery_argparse():
|
|||||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||||
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
|
parser.add_argument("--csv_output", action="store_true",
|
||||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
help="Output rolled player options to csv (made for async multiworld).")
|
||||||
parser.add_argument('--plando', default=defaults.plando_options,
|
parser.add_argument("--plando", default=defaults.plando_options,
|
||||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
help="List of options that can be set manually. Can be combined, for example \"bosses, items\"")
|
||||||
parser.add_argument("--skip_prog_balancing", action="store_true",
|
parser.add_argument("--skip_prog_balancing", action="store_true",
|
||||||
help="Skip progression balancing step during generation.")
|
help="Skip progression balancing step during generation.")
|
||||||
parser.add_argument("--skip_output", action="store_true",
|
parser.add_argument("--skip_output", action="store_true",
|
||||||
@@ -155,6 +155,8 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
erargs.outputpath = args.outputpath
|
erargs.outputpath = args.outputpath
|
||||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||||
erargs.skip_output = args.skip_output
|
erargs.skip_output = args.skip_output
|
||||||
|
erargs.name = {}
|
||||||
|
erargs.csv_output = args.csv_output
|
||||||
|
|
||||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||||
@@ -202,7 +204,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
|
|
||||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||||
erargs.name[player] = f"Player{player}"
|
erargs.name[player] = f"Player{player}"
|
||||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
elif player not in erargs.name: # if name was not specified, generate it from filename
|
||||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||||
|
|
||||||
@@ -215,28 +217,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
||||||
|
|
||||||
if args.yaml_output:
|
|
||||||
import yaml
|
|
||||||
important = {}
|
|
||||||
for option, player_settings in vars(erargs).items():
|
|
||||||
if type(player_settings) == dict:
|
|
||||||
if all(type(value) != list for value in player_settings.values()):
|
|
||||||
if len(player_settings.values()) > 1:
|
|
||||||
important[option] = {player: value for player, value in player_settings.items() if
|
|
||||||
player <= args.yaml_output}
|
|
||||||
else:
|
|
||||||
logging.debug(f"No player settings defined for option '{option}'")
|
|
||||||
|
|
||||||
else:
|
|
||||||
if player_settings != "": # is not empty name
|
|
||||||
important[option] = player_settings
|
|
||||||
else:
|
|
||||||
logging.debug(f"No player settings defined for option '{option}'")
|
|
||||||
if args.outputpath:
|
|
||||||
os.makedirs(args.outputpath, exist_ok=True)
|
|
||||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
|
||||||
yaml.dump(important, f)
|
|
||||||
|
|
||||||
return erargs, seed
|
return erargs, seed
|
||||||
|
|
||||||
|
|
||||||
@@ -511,7 +491,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
continue
|
continue
|
||||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
||||||
if PlandoOptions.items in plando_options:
|
if PlandoOptions.items in plando_options:
|
||||||
ret.plando_items = game_weights.get("plando_items", [])
|
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
||||||
if ret.game == "A Link to the Past":
|
if ret.game == "A Link to the Past":
|
||||||
roll_alttp_settings(ret, game_weights)
|
roll_alttp_settings(ret, game_weights)
|
||||||
|
|
||||||
|
|||||||
9
KH1Client.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
if __name__ == '__main__':
|
||||||
|
import ModuleUpdate
|
||||||
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
import Utils
|
||||||
|
Utils.init_logging("KH1Client", exception_logger="Client")
|
||||||
|
|
||||||
|
from worlds.kh1.Client import launch
|
||||||
|
launch()
|
||||||
109
Launcher.py
@@ -16,10 +16,11 @@ import multiprocessing
|
|||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.parse
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from typing import Callable, Sequence, Union, Optional
|
from typing import Callable, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
import settings
|
import settings
|
||||||
@@ -34,7 +35,9 @@ from Utils import is_frozen, user_path, local_path, init_logging, open_filename,
|
|||||||
|
|
||||||
|
|
||||||
def open_host_yaml():
|
def open_host_yaml():
|
||||||
file = settings.get_settings().filename
|
s = settings.get_settings()
|
||||||
|
file = s.filename
|
||||||
|
s.save()
|
||||||
assert file, "host.yaml missing"
|
assert file, "host.yaml missing"
|
||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('sensible-editor') or which('gedit') or \
|
exe = which('sensible-editor') or which('gedit') or \
|
||||||
@@ -107,7 +110,81 @@ components.extend([
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def identify(path: Union[None, str]):
|
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||||
|
url = urllib.parse.urlparse(path)
|
||||||
|
queries = urllib.parse.parse_qs(url.query)
|
||||||
|
launch_args = (path, *launch_args)
|
||||||
|
client_component = None
|
||||||
|
text_client_component = None
|
||||||
|
if "game" in queries:
|
||||||
|
game = queries["game"][0]
|
||||||
|
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
|
||||||
|
game = "Archipelago"
|
||||||
|
for component in components:
|
||||||
|
if component.supports_uri and component.game_name == game:
|
||||||
|
client_component = component
|
||||||
|
elif component.display_name == "Text Client":
|
||||||
|
text_client_component = component
|
||||||
|
|
||||||
|
from kvui import App, Button, BoxLayout, Label, Clock, Window
|
||||||
|
|
||||||
|
class Popup(App):
|
||||||
|
timer_label: Label
|
||||||
|
remaining_time: Optional[int]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.title = "Connect to Multiworld"
|
||||||
|
self.icon = r"data/icon.png"
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
layout = BoxLayout(orientation="vertical")
|
||||||
|
|
||||||
|
if client_component is None:
|
||||||
|
self.remaining_time = 7
|
||||||
|
label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
|
||||||
|
f"Launching Text Client in 7 seconds...")
|
||||||
|
self.timer_label = Label(text=label_text)
|
||||||
|
layout.add_widget(self.timer_label)
|
||||||
|
Clock.schedule_interval(self.update_label, 1)
|
||||||
|
else:
|
||||||
|
layout.add_widget(Label(text="Select client to open and connect with."))
|
||||||
|
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
|
||||||
|
|
||||||
|
text_client_button = Button(
|
||||||
|
text=text_client_component.display_name,
|
||||||
|
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
||||||
|
)
|
||||||
|
button_row.add_widget(text_client_button)
|
||||||
|
|
||||||
|
game_client_button = Button(
|
||||||
|
text=client_component.display_name,
|
||||||
|
on_release=lambda *args: run_component(client_component, *launch_args)
|
||||||
|
)
|
||||||
|
button_row.add_widget(game_client_button)
|
||||||
|
|
||||||
|
layout.add_widget(button_row)
|
||||||
|
|
||||||
|
return layout
|
||||||
|
|
||||||
|
def update_label(self, dt):
|
||||||
|
if self.remaining_time > 1:
|
||||||
|
# countdown the timer and string replace the number
|
||||||
|
self.remaining_time -= 1
|
||||||
|
self.timer_label.text = self.timer_label.text.replace(
|
||||||
|
str(self.remaining_time + 1), str(self.remaining_time)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# our timer is finished so launch text client and close down
|
||||||
|
run_component(text_client_component, *launch_args)
|
||||||
|
Clock.unschedule(self.update_label)
|
||||||
|
App.get_running_app().stop()
|
||||||
|
Window.close()
|
||||||
|
|
||||||
|
Popup().run()
|
||||||
|
|
||||||
|
|
||||||
|
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
||||||
if path is None:
|
if path is None:
|
||||||
return None, None
|
return None, None
|
||||||
for component in components:
|
for component in components:
|
||||||
@@ -266,7 +343,7 @@ def run_gui():
|
|||||||
if file and component:
|
if file and component:
|
||||||
run_component(component, file)
|
run_component(component, file)
|
||||||
else:
|
else:
|
||||||
logging.warning(f"unable to identify component for {filename}")
|
logging.warning(f"unable to identify component for {file}")
|
||||||
|
|
||||||
def _stop(self, *largs):
|
def _stop(self, *largs):
|
||||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||||
@@ -299,20 +376,24 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
|||||||
elif not args:
|
elif not args:
|
||||||
args = {}
|
args = {}
|
||||||
|
|
||||||
if args.get("Patch|Game|Component", None) is not None:
|
path = args.get("Patch|Game|Component|url", None)
|
||||||
file, component = identify(args["Patch|Game|Component"])
|
if path is not None:
|
||||||
|
if path.startswith("archipelago://"):
|
||||||
|
handle_uri(path, args.get("args", ()))
|
||||||
|
return
|
||||||
|
file, component = identify(path)
|
||||||
if file:
|
if file:
|
||||||
args['file'] = file
|
args['file'] = file
|
||||||
if component:
|
if component:
|
||||||
args['component'] = component
|
args['component'] = component
|
||||||
if not component:
|
if not component:
|
||||||
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
|
logging.warning(f"Could not identify Component responsible for {path}")
|
||||||
|
|
||||||
if args["update_settings"]:
|
if args["update_settings"]:
|
||||||
update_settings()
|
update_settings()
|
||||||
if 'file' in args:
|
if "file" in args:
|
||||||
run_component(args["component"], args["file"], *args["args"])
|
run_component(args["component"], args["file"], *args["args"])
|
||||||
elif 'component' in args:
|
elif "component" in args:
|
||||||
run_component(args["component"], *args["args"])
|
run_component(args["component"], *args["args"])
|
||||||
elif not args["update_settings"]:
|
elif not args["update_settings"]:
|
||||||
run_gui()
|
run_gui()
|
||||||
@@ -322,12 +403,16 @@ if __name__ == '__main__':
|
|||||||
init_logging('Launcher')
|
init_logging('Launcher')
|
||||||
Utils.freeze_support()
|
Utils.freeze_support()
|
||||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||||
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Archipelago Launcher',
|
||||||
|
usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
|
||||||
|
)
|
||||||
run_group = parser.add_argument_group("Run")
|
run_group = parser.add_argument_group("Run")
|
||||||
run_group.add_argument("--update_settings", action="store_true",
|
run_group.add_argument("--update_settings", action="store_true",
|
||||||
help="Update host.yaml and exit.")
|
help="Update host.yaml and exit.")
|
||||||
run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
|
run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?",
|
||||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
help="Pass either a patch file, a generated game, the component name to run, or a url to "
|
||||||
|
"connect with.")
|
||||||
run_group.add_argument("args", nargs="*",
|
run_group.add_argument("args", nargs="*",
|
||||||
help="Arguments to pass to component.")
|
help="Arguments to pass to component.")
|
||||||
main(parser.parse_args())
|
main(parser.parse_args())
|
||||||
|
|||||||
@@ -467,6 +467,8 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||||
self.client = LinksAwakeningClient()
|
self.client = LinksAwakeningClient()
|
||||||
|
self.slot_data = {}
|
||||||
|
|
||||||
if magpie:
|
if magpie:
|
||||||
self.magpie_enabled = True
|
self.magpie_enabled = True
|
||||||
self.magpie = MagpieBridge()
|
self.magpie = MagpieBridge()
|
||||||
@@ -564,6 +566,8 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == "Connected":
|
if cmd == "Connected":
|
||||||
self.game = self.slot_info[self.slot].game
|
self.game = self.slot_info[self.slot].game
|
||||||
|
self.slot_data = args.get("slot_data", {})
|
||||||
|
|
||||||
# TODO - use watcher_event
|
# TODO - use watcher_event
|
||||||
if cmd == "ReceivedItems":
|
if cmd == "ReceivedItems":
|
||||||
for index, item in enumerate(args["items"], start=args["index"]):
|
for index, item in enumerate(args["items"], start=args["index"]):
|
||||||
@@ -628,6 +632,7 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||||
await self.magpie.send_gps(self.client.gps_tracker)
|
await self.magpie.send_gps(self.client.gps_tracker)
|
||||||
|
self.magpie.slot_data = self.slot_data
|
||||||
except Exception:
|
except Exception:
|
||||||
# Don't let magpie errors take out the client
|
# Don't let magpie errors take out the client
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import tkinter as tk
|
|||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from concurrent.futures import as_completed, ThreadPoolExecutor
|
from concurrent.futures import as_completed, ThreadPoolExecutor
|
||||||
from glob import glob
|
from glob import glob
|
||||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
|
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, BOTH, TOP, LabelFrame, \
|
||||||
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
|
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
|
||||||
from tkinter.constants import DISABLED, NORMAL
|
from tkinter.constants import DISABLED, NORMAL
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -29,7 +29,8 @@ from Utils import output_path, local_path, user_path, open_file, get_cert_none_s
|
|||||||
|
|
||||||
|
|
||||||
GAME_ALTTP = "A Link to the Past"
|
GAME_ALTTP = "A Link to the Past"
|
||||||
|
WINDOW_MIN_HEIGHT = 525
|
||||||
|
WINDOW_MIN_WIDTH = 425
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
def __init__(self, sprite_pool):
|
def __init__(self, sprite_pool):
|
||||||
@@ -242,16 +243,17 @@ def adjustGUI():
|
|||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from Utils import __version__ as MWVersion
|
from Utils import __version__ as MWVersion
|
||||||
adjustWindow = Tk()
|
adjustWindow = Tk()
|
||||||
|
adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
|
||||||
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
||||||
set_icon(adjustWindow)
|
set_icon(adjustWindow)
|
||||||
|
|
||||||
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
|
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
|
||||||
|
|
||||||
bottomFrame2 = Frame(adjustWindow)
|
bottomFrame2 = Frame(adjustWindow, padx=8, pady=2)
|
||||||
|
|
||||||
romFrame, romVar = get_rom_frame(adjustWindow)
|
romFrame, romVar = get_rom_frame(adjustWindow)
|
||||||
|
|
||||||
romDialogFrame = Frame(adjustWindow)
|
romDialogFrame = Frame(adjustWindow, padx=8, pady=2)
|
||||||
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
|
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
|
||||||
romVar2 = StringVar()
|
romVar2 = StringVar()
|
||||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||||
@@ -261,9 +263,9 @@ def adjustGUI():
|
|||||||
romVar2.set(rom)
|
romVar2.set(rom)
|
||||||
|
|
||||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||||
romDialogFrame.pack(side=TOP, expand=True, fill=X)
|
romDialogFrame.pack(side=TOP, expand=False, fill=X)
|
||||||
baseRomLabel2.pack(side=LEFT)
|
baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8))
|
||||||
romEntry2.pack(side=LEFT, expand=True, fill=X)
|
romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
|
||||||
romSelectButton2.pack(side=LEFT)
|
romSelectButton2.pack(side=LEFT)
|
||||||
|
|
||||||
def adjustRom():
|
def adjustRom():
|
||||||
@@ -331,12 +333,11 @@ def adjustGUI():
|
|||||||
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
||||||
|
|
||||||
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
|
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
|
||||||
rom_options_frame.pack(side=TOP)
|
rom_options_frame.pack(side=TOP, padx=8, pady=8, fill=BOTH, expand=True)
|
||||||
adjustButton.pack(side=LEFT, padx=(5,5))
|
adjustButton.pack(side=LEFT, padx=(5,5))
|
||||||
|
|
||||||
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
|
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
|
||||||
saveButton.pack(side=LEFT, padx=(5,5))
|
saveButton.pack(side=LEFT, padx=(5,5))
|
||||||
|
|
||||||
bottomFrame2.pack(side=TOP, pady=(5,5))
|
bottomFrame2.pack(side=TOP, pady=(5,5))
|
||||||
|
|
||||||
tkinter_center_window(adjustWindow)
|
tkinter_center_window(adjustWindow)
|
||||||
@@ -576,7 +577,7 @@ class AttachTooltip(object):
|
|||||||
def get_rom_frame(parent=None):
|
def get_rom_frame(parent=None):
|
||||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||||
|
|
||||||
romFrame = Frame(parent)
|
romFrame = Frame(parent, padx=8, pady=8)
|
||||||
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||||
romVar = StringVar(value=adjuster_settings.baserom)
|
romVar = StringVar(value=adjuster_settings.baserom)
|
||||||
romEntry = Entry(romFrame, textvariable=romVar)
|
romEntry = Entry(romFrame, textvariable=romVar)
|
||||||
@@ -596,20 +597,19 @@ def get_rom_frame(parent=None):
|
|||||||
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
|
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
|
||||||
|
|
||||||
baseRomLabel.pack(side=LEFT)
|
baseRomLabel.pack(side=LEFT)
|
||||||
romEntry.pack(side=LEFT, expand=True, fill=X)
|
romEntry.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
|
||||||
romSelectButton.pack(side=LEFT)
|
romSelectButton.pack(side=LEFT)
|
||||||
romFrame.pack(side=TOP, expand=True, fill=X)
|
romFrame.pack(side=TOP, fill=X)
|
||||||
|
|
||||||
return romFrame, romVar
|
return romFrame, romVar
|
||||||
|
|
||||||
def get_rom_options_frame(parent=None):
|
def get_rom_options_frame(parent=None):
|
||||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||||
|
|
||||||
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
romOptionsFrame = LabelFrame(parent, text="Rom options", padx=8, pady=8)
|
||||||
romOptionsFrame.columnconfigure(0, weight=1)
|
|
||||||
romOptionsFrame.columnconfigure(1, weight=1)
|
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
romOptionsFrame.rowconfigure(i, weight=1)
|
romOptionsFrame.rowconfigure(i, weight=0, pad=4)
|
||||||
vars = Namespace()
|
vars = Namespace()
|
||||||
|
|
||||||
vars.MusicVar = IntVar()
|
vars.MusicVar = IntVar()
|
||||||
@@ -660,7 +660,7 @@ def get_rom_options_frame(parent=None):
|
|||||||
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
|
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
|
||||||
|
|
||||||
baseSpriteLabel.pack(side=LEFT)
|
baseSpriteLabel.pack(side=LEFT)
|
||||||
spriteEntry.pack(side=LEFT)
|
spriteEntry.pack(side=LEFT, expand=True, fill=X)
|
||||||
spriteSelectButton.pack(side=LEFT)
|
spriteSelectButton.pack(side=LEFT)
|
||||||
|
|
||||||
oofDialogFrame = Frame(romOptionsFrame)
|
oofDialogFrame = Frame(romOptionsFrame)
|
||||||
|
|||||||
103
Main.py
@@ -11,7 +11,8 @@ from typing import Dict, List, Optional, Set, Tuple, Union
|
|||||||
|
|
||||||
import worlds
|
import worlds
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||||
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
||||||
|
flood_items
|
||||||
from Options import StartInventoryPool
|
from Options import StartInventoryPool
|
||||||
from Utils import __version__, output_path, version_tuple, get_settings
|
from Utils import __version__, output_path, version_tuple, get_settings
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
@@ -45,6 +46,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
multiworld.sprite_pool = args.sprite_pool.copy()
|
multiworld.sprite_pool = args.sprite_pool.copy()
|
||||||
|
|
||||||
multiworld.set_options(args)
|
multiworld.set_options(args)
|
||||||
|
if args.csv_output:
|
||||||
|
from Options import dump_player_options
|
||||||
|
dump_player_options(multiworld)
|
||||||
multiworld.set_item_links()
|
multiworld.set_item_links()
|
||||||
multiworld.state = CollectionState(multiworld)
|
multiworld.state = CollectionState(multiworld)
|
||||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
|
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
|
||||||
@@ -100,7 +104,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
multiworld.early_items[player][item_name] = max(0, early-count)
|
multiworld.early_items[player][item_name] = max(0, early-count)
|
||||||
remaining_count = count-early
|
remaining_count = count-early
|
||||||
if remaining_count > 0:
|
if remaining_count > 0:
|
||||||
local_early = multiworld.early_local_items[player].get(item_name, 0)
|
local_early = multiworld.local_early_items[player].get(item_name, 0)
|
||||||
if local_early:
|
if local_early:
|
||||||
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
|
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
|
||||||
del local_early
|
del local_early
|
||||||
@@ -151,6 +155,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||||
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
|
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
|
||||||
new_items: List[Item] = []
|
new_items: List[Item] = []
|
||||||
|
old_items: List[Item] = []
|
||||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||||
player: getattr(multiworld.worlds[player].options,
|
player: getattr(multiworld.worlds[player].options,
|
||||||
"start_inventory_from_pool",
|
"start_inventory_from_pool",
|
||||||
@@ -169,97 +174,26 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
depletion_pool[item.player][item.name] -= 1
|
depletion_pool[item.player][item.name] -= 1
|
||||||
# quick abort if we have found all items
|
# quick abort if we have found all items
|
||||||
if not target:
|
if not target:
|
||||||
new_items.extend(multiworld.itempool[i+1:])
|
old_items.extend(multiworld.itempool[i+1:])
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
new_items.append(item)
|
old_items.append(item)
|
||||||
|
|
||||||
# leftovers?
|
# leftovers?
|
||||||
if target:
|
if target:
|
||||||
for player, remaining_items in depletion_pool.items():
|
for player, remaining_items in depletion_pool.items():
|
||||||
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
||||||
if remaining_items:
|
if remaining_items:
|
||||||
raise Exception(f"{multiworld.get_player_name(player)}"
|
logger.warning(f"{multiworld.get_player_name(player)}"
|
||||||
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
||||||
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
|
# find all filler we generated for the current player and remove until it matches
|
||||||
multiworld.itempool[:] = new_items
|
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
|
||||||
|
|
||||||
# temporary home for item links, should be moved out of Main
|
multiworld.link_items()
|
||||||
for group_id, group in multiworld.groups.items():
|
|
||||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
|
||||||
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
|
||||||
]:
|
|
||||||
classifications: Dict[str, int] = collections.defaultdict(int)
|
|
||||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
|
||||||
for item in multiworld.itempool:
|
|
||||||
if item.player in counters and item.name in shared_pool:
|
|
||||||
counters[item.player][item.name] += 1
|
|
||||||
classifications[item.name] |= item.classification
|
|
||||||
|
|
||||||
for player in players.copy():
|
|
||||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
|
||||||
players.remove(player)
|
|
||||||
del (counters[player])
|
|
||||||
|
|
||||||
if not players:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
for item in shared_pool:
|
|
||||||
count = min(counters[player][item] for player in players)
|
|
||||||
if count:
|
|
||||||
for player in players:
|
|
||||||
counters[player][item] = count
|
|
||||||
else:
|
|
||||||
for player in players:
|
|
||||||
del (counters[player][item])
|
|
||||||
return counters, classifications
|
|
||||||
|
|
||||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
|
||||||
if not common_item_count:
|
|
||||||
continue
|
|
||||||
|
|
||||||
new_itempool: List[Item] = []
|
|
||||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
|
||||||
for _ in range(item_count):
|
|
||||||
new_item = group["world"].create_item(item_name)
|
|
||||||
# mangle together all original classification bits
|
|
||||||
new_item.classification |= classifications[item_name]
|
|
||||||
new_itempool.append(new_item)
|
|
||||||
|
|
||||||
region = Region("Menu", group_id, multiworld, "ItemLink")
|
|
||||||
multiworld.regions.append(region)
|
|
||||||
locations = region.locations
|
|
||||||
for item in multiworld.itempool:
|
|
||||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
|
||||||
if count:
|
|
||||||
loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}",
|
|
||||||
None, region)
|
|
||||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
|
||||||
state.has(item_name, group_id_, count_)
|
|
||||||
|
|
||||||
locations.append(loc)
|
|
||||||
loc.place_locked_item(item)
|
|
||||||
common_item_count[item.player][item.name] -= 1
|
|
||||||
else:
|
|
||||||
new_itempool.append(item)
|
|
||||||
|
|
||||||
itemcount = len(multiworld.itempool)
|
|
||||||
multiworld.itempool = new_itempool
|
|
||||||
|
|
||||||
while itemcount > len(multiworld.itempool):
|
|
||||||
items_to_add = []
|
|
||||||
for player in group["players"]:
|
|
||||||
if group["link_replacement"]:
|
|
||||||
item_player = group_id
|
|
||||||
else:
|
|
||||||
item_player = player
|
|
||||||
if group["replacement_items"][player]:
|
|
||||||
items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player,
|
|
||||||
group["replacement_items"][player]))
|
|
||||||
else:
|
|
||||||
items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player))
|
|
||||||
multiworld.random.shuffle(items_to_add)
|
|
||||||
multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)])
|
|
||||||
|
|
||||||
if any(multiworld.item_links.values()):
|
if any(multiworld.item_links.values()):
|
||||||
multiworld._all_state = None
|
multiworld._all_state = None
|
||||||
@@ -404,6 +338,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
"seed_name": multiworld.seed_name,
|
"seed_name": multiworld.seed_name,
|
||||||
"spheres": spheres,
|
"spheres": spheres,
|
||||||
"datapackage": data_package,
|
"datapackage": data_package,
|
||||||
|
"race_mode": int(multiworld.is_race),
|
||||||
}
|
}
|
||||||
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
||||||
|
|
||||||
@@ -416,7 +351,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
output_file_futures.append(pool.submit(write_multidata))
|
output_file_futures.append(pool.submit(write_multidata))
|
||||||
if not check_accessibility_task.result():
|
if not check_accessibility_task.result():
|
||||||
if not multiworld.can_beat_game():
|
if not multiworld.can_beat_game():
|
||||||
raise Exception("Game appears as unbeatable. Aborting.")
|
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld)
|
||||||
else:
|
else:
|
||||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||||
|
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ def update(yes: bool = False, force: bool = False) -> None:
|
|||||||
if not update_ran:
|
if not update_ran:
|
||||||
update_ran = True
|
update_ran = True
|
||||||
|
|
||||||
|
install_pkg_resources(yes=yes)
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
if force:
|
if force:
|
||||||
update_command()
|
update_command()
|
||||||
return
|
return
|
||||||
|
|
||||||
install_pkg_resources(yes=yes)
|
|
||||||
import pkg_resources
|
|
||||||
|
|
||||||
prev = "" # if a line ends in \ we store here and merge later
|
prev = "" # if a line ends in \ we store here and merge later
|
||||||
for req_file in requirements_files:
|
for req_file in requirements_files:
|
||||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import math
|
|||||||
import operator
|
import operator
|
||||||
import pickle
|
import pickle
|
||||||
import random
|
import random
|
||||||
|
import shlex
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
@@ -67,6 +68,21 @@ def update_dict(dictionary, entries):
|
|||||||
return dictionary
|
return dictionary
|
||||||
|
|
||||||
|
|
||||||
|
def queue_gc():
|
||||||
|
import gc
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
gc_thread: typing.Optional[Thread] = getattr(queue_gc, "_thread", None)
|
||||||
|
def async_collect():
|
||||||
|
time.sleep(2)
|
||||||
|
setattr(queue_gc, "_thread", None)
|
||||||
|
gc.collect()
|
||||||
|
if not gc_thread:
|
||||||
|
gc_thread = Thread(target=async_collect)
|
||||||
|
setattr(queue_gc, "_thread", gc_thread)
|
||||||
|
gc_thread.start()
|
||||||
|
|
||||||
|
|
||||||
# functions callable on storable data on the server by clients
|
# functions callable on storable data on the server by clients
|
||||||
modify_functions = {
|
modify_functions = {
|
||||||
# generic:
|
# generic:
|
||||||
@@ -169,11 +185,9 @@ class Context:
|
|||||||
slot_info: typing.Dict[int, NetworkSlot]
|
slot_info: typing.Dict[int, NetworkSlot]
|
||||||
generator_version = Version(0, 0, 0)
|
generator_version = Version(0, 0, 0)
|
||||||
checksums: typing.Dict[str, str]
|
checksums: typing.Dict[str, str]
|
||||||
item_names: typing.Dict[str, typing.Dict[int, str]] = (
|
item_names: typing.Dict[str, typing.Dict[int, str]]
|
||||||
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')))
|
|
||||||
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||||
location_names: typing.Dict[str, typing.Dict[int, str]] = (
|
location_names: typing.Dict[str, typing.Dict[int, str]]
|
||||||
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')))
|
|
||||||
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||||
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||||
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||||
@@ -182,7 +196,6 @@ class Context:
|
|||||||
""" each sphere is { player: { location_id, ... } } """
|
""" each sphere is { player: { location_id, ... } } """
|
||||||
logger: logging.Logger
|
logger: logging.Logger
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||||
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
||||||
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
||||||
@@ -253,6 +266,10 @@ class Context:
|
|||||||
self.location_name_groups = {}
|
self.location_name_groups = {}
|
||||||
self.all_item_and_group_names = {}
|
self.all_item_and_group_names = {}
|
||||||
self.all_location_and_group_names = {}
|
self.all_location_and_group_names = {}
|
||||||
|
self.item_names = collections.defaultdict(
|
||||||
|
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})'))
|
||||||
|
self.location_names = collections.defaultdict(
|
||||||
|
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
|
||||||
self.non_hintable_names = collections.defaultdict(frozenset)
|
self.non_hintable_names = collections.defaultdict(frozenset)
|
||||||
|
|
||||||
self._load_game_data()
|
self._load_game_data()
|
||||||
@@ -412,6 +429,8 @@ class Context:
|
|||||||
use_embedded_server_options: bool):
|
use_embedded_server_options: bool):
|
||||||
|
|
||||||
self.read_data = {}
|
self.read_data = {}
|
||||||
|
# there might be a better place to put this.
|
||||||
|
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
|
||||||
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
||||||
if mdata_ver > version_tuple:
|
if mdata_ver > version_tuple:
|
||||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
||||||
@@ -551,6 +570,9 @@ class Context:
|
|||||||
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
||||||
else:
|
else:
|
||||||
self.save_dirty = False
|
self.save_dirty = False
|
||||||
|
if not atexit_save: # if atexit is used, that keeps a reference anyway
|
||||||
|
queue_gc()
|
||||||
|
|
||||||
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
||||||
self.auto_saver_thread.start()
|
self.auto_saver_thread.start()
|
||||||
|
|
||||||
@@ -991,7 +1013,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
|
|||||||
collect_player(ctx, team, group, True)
|
collect_player(ctx, team, group, True)
|
||||||
|
|
||||||
|
|
||||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[typing.Tuple[int, int]]:
|
||||||
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
|
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
|
||||||
|
|
||||||
|
|
||||||
@@ -1132,7 +1154,10 @@ class CommandProcessor(metaclass=CommandMeta):
|
|||||||
if not raw:
|
if not raw:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
command = raw.split()
|
try:
|
||||||
|
command = shlex.split(raw, comments=False)
|
||||||
|
except ValueError: # most likely: "ValueError: No closing quotation"
|
||||||
|
command = raw.split()
|
||||||
basecommand = command[0]
|
basecommand = command[0]
|
||||||
if basecommand[0] == self.marker:
|
if basecommand[0] == self.marker:
|
||||||
method = self.commands.get(basecommand[1:].lower(), None)
|
method = self.commands.get(basecommand[1:].lower(), None)
|
||||||
@@ -1203,6 +1228,10 @@ class CommonCommandProcessor(CommandProcessor):
|
|||||||
timer = int(seconds, 10)
|
timer = int(seconds, 10)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
timer = 10
|
timer = 10
|
||||||
|
else:
|
||||||
|
if timer > 60 * 60:
|
||||||
|
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
||||||
|
|
||||||
async_start(countdown(self.ctx, timer))
|
async_start(countdown(self.ctx, timer))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1350,10 +1379,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
def _cmd_remaining(self) -> bool:
|
def _cmd_remaining(self) -> bool:
|
||||||
"""List remaining items in your game, but not their location or recipient"""
|
"""List remaining items in your game, but not their location or recipient"""
|
||||||
if self.ctx.remaining_mode == "enabled":
|
if self.ctx.remaining_mode == "enabled":
|
||||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||||
if remaining_item_ids:
|
if rest_locations:
|
||||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
|
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
||||||
for item_id in remaining_item_ids))
|
for slot, item_id in rest_locations))
|
||||||
else:
|
else:
|
||||||
self.output("No remaining items found.")
|
self.output("No remaining items found.")
|
||||||
return True
|
return True
|
||||||
@@ -1363,10 +1392,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
return False
|
return False
|
||||||
else: # is goal
|
else: # is goal
|
||||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||||
if remaining_item_ids:
|
if rest_locations:
|
||||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
|
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
||||||
for item_id in remaining_item_ids))
|
for slot, item_id in rest_locations))
|
||||||
else:
|
else:
|
||||||
self.output("No remaining items found.")
|
self.output("No remaining items found.")
|
||||||
return True
|
return True
|
||||||
@@ -2039,6 +2068,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
item_name, usable, response = get_intended_text(item_name, names)
|
item_name, usable, response = get_intended_text(item_name, names)
|
||||||
if usable:
|
if usable:
|
||||||
amount: int = int(amount)
|
amount: int = int(amount)
|
||||||
|
if amount > 100:
|
||||||
|
raise ValueError(f"{amount} is invalid. Maximum is 100.")
|
||||||
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
|
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
|
||||||
send_items_to(self.ctx, team, slot, *new_items)
|
send_items_to(self.ctx, team, slot, *new_items)
|
||||||
|
|
||||||
|
|||||||
12
NetUtils.py
@@ -79,6 +79,7 @@ class NetworkItem(typing.NamedTuple):
|
|||||||
item: int
|
item: int
|
||||||
location: int
|
location: int
|
||||||
player: int
|
player: int
|
||||||
|
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
|
||||||
flags: int = 0
|
flags: int = 0
|
||||||
|
|
||||||
|
|
||||||
@@ -272,7 +273,8 @@ class RawJSONtoTextParser(JSONtoTextParser):
|
|||||||
|
|
||||||
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
|
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
|
||||||
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
|
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
|
||||||
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
|
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47,
|
||||||
|
'plum': 35, 'slateblue': 34, 'salmon': 31,} # convert ui colors to terminal colors
|
||||||
|
|
||||||
|
|
||||||
def color_code(*args):
|
def color_code(*args):
|
||||||
@@ -397,12 +399,12 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
|||||||
location_id not in checked]
|
location_id not in checked]
|
||||||
|
|
||||||
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
||||||
) -> typing.List[int]:
|
) -> typing.List[typing.Tuple[int, int]]:
|
||||||
checked = state[team, slot]
|
checked = state[team, slot]
|
||||||
player_locations = self[slot]
|
player_locations = self[slot]
|
||||||
return sorted([player_locations[location_id][0] for
|
return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for
|
||||||
location_id in player_locations if
|
location_id in player_locations if
|
||||||
location_id not in checked])
|
location_id not in checked])
|
||||||
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||||
|
|||||||
148
Options.py
@@ -8,16 +8,17 @@ import numbers
|
|||||||
import random
|
import random
|
||||||
import typing
|
import typing
|
||||||
import enum
|
import enum
|
||||||
|
from collections import defaultdict
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from schema import And, Optional, Or, Schema
|
from schema import And, Optional, Or, Schema
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from Utils import get_fuzzy_results, is_iterable_except_str
|
from Utils import get_fuzzy_results, is_iterable_except_str, output_path
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from BaseClasses import PlandoOptions
|
from BaseClasses import MultiWorld, PlandoOptions
|
||||||
from worlds.AutoWorld import World
|
from worlds.AutoWorld import World
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
@@ -786,17 +787,22 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
|||||||
verify_location_name: bool = False
|
verify_location_name: bool = False
|
||||||
value: typing.Any
|
value: typing.Any
|
||||||
|
|
||||||
@classmethod
|
def verify_keys(self) -> None:
|
||||||
def verify_keys(cls, data: typing.Iterable[str]) -> None:
|
if self.valid_keys:
|
||||||
if cls.valid_keys:
|
data = set(self.value)
|
||||||
data = set(data)
|
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
|
||||||
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
extra = dataset - self._valid_keys
|
||||||
extra = dataset - cls._valid_keys
|
|
||||||
if extra:
|
if extra:
|
||||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
raise OptionError(
|
||||||
f"Allowed keys: {cls._valid_keys}.")
|
f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
|
||||||
|
f"Allowed keys: {self._valid_keys}."
|
||||||
|
)
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||||
|
try:
|
||||||
|
self.verify_keys()
|
||||||
|
except OptionError as validation_error:
|
||||||
|
raise OptionError(f"Player {player_name} has invalid option keys:\n{validation_error}")
|
||||||
if self.convert_name_groups and self.verify_item_name:
|
if self.convert_name_groups and self.verify_item_name:
|
||||||
new_value = type(self.value)() # empty container of whatever value is
|
new_value = type(self.value)() # empty container of whatever value is
|
||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
@@ -833,7 +839,6 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||||
if type(data) == dict:
|
if type(data) == dict:
|
||||||
cls.verify_keys(data)
|
|
||||||
return cls(data)
|
return cls(data)
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
||||||
@@ -879,7 +884,6 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any):
|
def from_any(cls, data: typing.Any):
|
||||||
if is_iterable_except_str(data):
|
if is_iterable_except_str(data):
|
||||||
cls.verify_keys(data)
|
|
||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
|
|
||||||
@@ -905,7 +909,6 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any):
|
def from_any(cls, data: typing.Any):
|
||||||
if is_iterable_except_str(data):
|
if is_iterable_except_str(data):
|
||||||
cls.verify_keys(data)
|
|
||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
|
|
||||||
@@ -948,6 +951,19 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
self.value = []
|
self.value = []
|
||||||
logging.warning(f"The plando texts module is turned off, "
|
logging.warning(f"The plando texts module is turned off, "
|
||||||
f"so text for {player_name} will be ignored.")
|
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
|
@classmethod
|
||||||
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
|
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
|
||||||
@@ -958,7 +974,19 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
if random.random() < float(text.get("percentage", 100)/100):
|
if random.random() < float(text.get("percentage", 100)/100):
|
||||||
at = text.get("at", None)
|
at = text.get("at", None)
|
||||||
if at is not None:
|
if at is not None:
|
||||||
|
if isinstance(at, dict):
|
||||||
|
if at:
|
||||||
|
at = random.choices(list(at.keys()),
|
||||||
|
weights=list(at.values()), k=1)[0]
|
||||||
|
else:
|
||||||
|
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||||
given_text = text.get("text", [])
|
given_text = text.get("text", [])
|
||||||
|
if isinstance(given_text, dict):
|
||||||
|
if not given_text:
|
||||||
|
given_text = []
|
||||||
|
else:
|
||||||
|
given_text = random.choices(list(given_text.keys()),
|
||||||
|
weights=list(given_text.values()), k=1)
|
||||||
if isinstance(given_text, str):
|
if isinstance(given_text, str):
|
||||||
given_text = [given_text]
|
given_text = [given_text]
|
||||||
texts.append(PlandoText(
|
texts.append(PlandoText(
|
||||||
@@ -966,12 +994,13 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
given_text,
|
given_text,
|
||||||
text.get("percentage", 100)
|
text.get("percentage", 100)
|
||||||
))
|
))
|
||||||
|
else:
|
||||||
|
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||||
elif isinstance(text, PlandoText):
|
elif isinstance(text, PlandoText):
|
||||||
if random.random() < float(text.percentage/100):
|
if random.random() < float(text.percentage/100):
|
||||||
texts.append(text)
|
texts.append(text)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
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)
|
return cls(texts)
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
|
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
|
||||||
@@ -1144,18 +1173,35 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
|
|
||||||
|
|
||||||
class Accessibility(Choice):
|
class Accessibility(Choice):
|
||||||
"""Set rules for reachability of your items/locations.
|
"""
|
||||||
|
Set rules for reachability of your items/locations.
|
||||||
|
|
||||||
|
**Full:** ensure everything can be reached and acquired.
|
||||||
|
|
||||||
- **Locations:** 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.
|
|
||||||
- **Minimal:** ensure what is needed to reach your goal can be acquired.
|
|
||||||
"""
|
"""
|
||||||
display_name = "Accessibility"
|
display_name = "Accessibility"
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
option_locations = 0
|
option_full = 0
|
||||||
option_items = 1
|
|
||||||
option_minimal = 2
|
option_minimal = 2
|
||||||
alias_none = 2
|
alias_none = 2
|
||||||
|
alias_locations = 0
|
||||||
|
alias_items = 0
|
||||||
|
default = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ItemsAccessibility(Accessibility):
|
||||||
|
"""
|
||||||
|
Set rules for reachability of your items/locations.
|
||||||
|
|
||||||
|
**Full:** ensure everything can be reached and acquired.
|
||||||
|
|
||||||
|
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
||||||
|
|
||||||
|
**Items:** ensure all logically relevant items can be acquired. Some items, such as keys, may be self-locking, and
|
||||||
|
some locations may be inaccessible.
|
||||||
|
"""
|
||||||
|
option_items = 1
|
||||||
default = 1
|
default = 1
|
||||||
|
|
||||||
|
|
||||||
@@ -1205,6 +1251,7 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
:param option_names: names of the options to return
|
:param option_names: names of the options to return
|
||||||
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
||||||
"""
|
"""
|
||||||
|
assert option_names, "options.as_dict() was used without any option names."
|
||||||
option_results = {}
|
option_results = {}
|
||||||
for option_name in option_names:
|
for option_name in option_names:
|
||||||
if option_name in type(self).type_hints:
|
if option_name in type(self).type_hints:
|
||||||
@@ -1289,7 +1336,7 @@ class PriorityLocations(LocationSet):
|
|||||||
|
|
||||||
|
|
||||||
class DeathLink(Toggle):
|
class DeathLink(Toggle):
|
||||||
"""When you die, everyone dies. Of course the reverse is true too."""
|
"""When you die, everyone who enabled death link dies. Of course, the reverse is true too."""
|
||||||
display_name = "Death Link"
|
display_name = "Death Link"
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
|
|
||||||
@@ -1488,29 +1535,40 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
f.write(res)
|
f.write(res)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def dump_player_options(multiworld: MultiWorld) -> None:
|
||||||
|
from csv import DictWriter
|
||||||
|
|
||||||
from worlds.alttp.Options import Logic
|
game_players = defaultdict(list)
|
||||||
import argparse
|
for player, game in multiworld.game.items():
|
||||||
|
game_players[game].append(player)
|
||||||
|
game_players = dict(sorted(game_players.items()))
|
||||||
|
|
||||||
map_shuffle = Toggle
|
output = []
|
||||||
compass_shuffle = Toggle
|
per_game_option_names = [
|
||||||
key_shuffle = Toggle
|
getattr(option, "display_name", option_key)
|
||||||
big_key_shuffle = Toggle
|
for option_key, option in PerGameCommonOptions.type_hints.items()
|
||||||
hints = Toggle
|
]
|
||||||
test = argparse.Namespace()
|
all_option_names = per_game_option_names.copy()
|
||||||
test.logic = Logic.from_text("no_logic")
|
for game, players in game_players.items():
|
||||||
test.map_shuffle = map_shuffle.from_text("ON")
|
game_option_names = per_game_option_names.copy()
|
||||||
test.hints = hints.from_text('OFF')
|
for player in players:
|
||||||
try:
|
world = multiworld.worlds[player]
|
||||||
test.logic = Logic.from_text("overworld_glitches_typo")
|
player_output = {
|
||||||
except KeyError as e:
|
"Game": multiworld.game[player],
|
||||||
print(e)
|
"Name": multiworld.get_player_name(player),
|
||||||
try:
|
}
|
||||||
test.logic_owg = Logic.from_text("owg")
|
output.append(player_output)
|
||||||
except KeyError as e:
|
for option_key, option in world.options_dataclass.type_hints.items():
|
||||||
print(e)
|
if issubclass(Removed, option):
|
||||||
if test.map_shuffle:
|
continue
|
||||||
print("map_shuffle is on")
|
display_name = getattr(option, "display_name", option_key)
|
||||||
print(f"Hints are {bool(test.hints)}")
|
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
||||||
print(test)
|
if display_name not in game_option_names:
|
||||||
|
all_option_names.append(display_name)
|
||||||
|
game_option_names.append(display_name)
|
||||||
|
|
||||||
|
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
||||||
|
fields = ["Game", "Name", *all_option_names]
|
||||||
|
writer = DictWriter(file, fields)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(output)
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ Currently, the following games are supported:
|
|||||||
* Aquaria
|
* Aquaria
|
||||||
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
||||||
* A Hat in Time
|
* A Hat in Time
|
||||||
|
* Old School Runescape
|
||||||
|
* Kingdom Hearts 1
|
||||||
|
* Mega Man 2
|
||||||
|
* Yacht Dice
|
||||||
|
|
||||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
|
|||||||
2
Utils.py
@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
|
|||||||
return ".".join(str(item) for item in self)
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.5.0"
|
__version__ = "0.5.1"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
|
|||||||
@@ -267,9 +267,7 @@ class WargrooveContext(CommonContext):
|
|||||||
|
|
||||||
def build(self):
|
def build(self):
|
||||||
container = super().build()
|
container = super().build()
|
||||||
panel = TabbedPanelItem(text="Wargroove")
|
self.add_client_tab("Wargroove", self.build_tracker())
|
||||||
panel.content = self.build_tracker()
|
|
||||||
self.tabs.add_widget(panel)
|
|
||||||
return container
|
return container
|
||||||
|
|
||||||
def build_tracker(self) -> TrackerLayout:
|
def build_tracker(self) -> TrackerLayout:
|
||||||
|
|||||||
10
WebHost.py
@@ -1,3 +1,4 @@
|
|||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import logging
|
import logging
|
||||||
@@ -31,6 +32,15 @@ def get_app() -> "Flask":
|
|||||||
import yaml
|
import yaml
|
||||||
app.config.from_file(configpath, yaml.safe_load)
|
app.config.from_file(configpath, yaml.safe_load)
|
||||||
logging.info(f"Updated config from {configpath}")
|
logging.info(f"Updated config from {configpath}")
|
||||||
|
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--config_override', default=None,
|
||||||
|
help="Path to yaml config file that overrules config.yaml.")
|
||||||
|
args = parser.parse_known_args()[0]
|
||||||
|
if args.config_override:
|
||||||
|
import yaml
|
||||||
|
app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
|
||||||
|
logging.info(f"Updated config from {args.config_override}")
|
||||||
if not app.config["HOST_ADDRESS"]:
|
if not app.config["HOST_ADDRESS"]:
|
||||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
||||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||||
|
|||||||
@@ -1,51 +1,15 @@
|
|||||||
"""API endpoints package."""
|
"""API endpoints package."""
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from flask import Blueprint, abort, url_for
|
from flask import Blueprint
|
||||||
|
|
||||||
import worlds.Files
|
from ..models import Seed
|
||||||
from ..models import Room, Seed
|
|
||||||
|
|
||||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||||
|
|
||||||
# unsorted/misc endpoints
|
|
||||||
|
|
||||||
|
|
||||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||||
return [(slot.player_name, slot.game) for slot in seed.slots]
|
return [(slot.player_name, slot.game) for slot in seed.slots]
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route('/room_status/<suuid:room>')
|
from . import datapackage, generate, room, user # trigger registration
|
||||||
def room_info(room: UUID):
|
|
||||||
room = Room.get(id=room)
|
|
||||||
if room is None:
|
|
||||||
return abort(404)
|
|
||||||
|
|
||||||
def supports_apdeltapatch(game: str):
|
|
||||||
return game in worlds.Files.AutoPatchRegister.patch_types
|
|
||||||
downloads = []
|
|
||||||
for slot in sorted(room.seed.slots):
|
|
||||||
if slot.data and not supports_apdeltapatch(slot.game):
|
|
||||||
slot_download = {
|
|
||||||
"slot": slot.player_id,
|
|
||||||
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
|
|
||||||
}
|
|
||||||
downloads.append(slot_download)
|
|
||||||
elif slot.data:
|
|
||||||
slot_download = {
|
|
||||||
"slot": slot.player_id,
|
|
||||||
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
|
|
||||||
}
|
|
||||||
downloads.append(slot_download)
|
|
||||||
return {
|
|
||||||
"tracker": room.tracker,
|
|
||||||
"players": get_players(room.seed),
|
|
||||||
"last_port": room.last_port,
|
|
||||||
"last_activity": room.last_activity,
|
|
||||||
"timeout": room.timeout,
|
|
||||||
"downloads": downloads,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
from . import generate, user, datapackage # trigger registration
|
|
||||||
|
|||||||
42
WebHostLib/api/room.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import Any, Dict
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from flask import abort, url_for
|
||||||
|
|
||||||
|
import worlds.Files
|
||||||
|
from . import api_endpoints, get_players
|
||||||
|
from ..models import Room
|
||||||
|
|
||||||
|
|
||||||
|
@api_endpoints.route('/room_status/<suuid:room_id>')
|
||||||
|
def room_info(room_id: UUID) -> Dict[str, Any]:
|
||||||
|
room = Room.get(id=room_id)
|
||||||
|
if room is None:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
def supports_apdeltapatch(game: str) -> bool:
|
||||||
|
return game in worlds.Files.AutoPatchRegister.patch_types
|
||||||
|
|
||||||
|
downloads = []
|
||||||
|
for slot in sorted(room.seed.slots):
|
||||||
|
if slot.data and not supports_apdeltapatch(slot.game):
|
||||||
|
slot_download = {
|
||||||
|
"slot": slot.player_id,
|
||||||
|
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
|
||||||
|
}
|
||||||
|
downloads.append(slot_download)
|
||||||
|
elif slot.data:
|
||||||
|
slot_download = {
|
||||||
|
"slot": slot.player_id,
|
||||||
|
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
|
||||||
|
}
|
||||||
|
downloads.append(slot_download)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tracker": room.tracker,
|
||||||
|
"players": get_players(room.seed),
|
||||||
|
"last_port": room.last_port,
|
||||||
|
"last_activity": room.last_activity,
|
||||||
|
"timeout": room.timeout,
|
||||||
|
"downloads": downloads,
|
||||||
|
}
|
||||||
@@ -72,6 +72,14 @@ class WebHostContext(Context):
|
|||||||
self.video = {}
|
self.video = {}
|
||||||
self.tags = ["AP", "WebHost"]
|
self.tags = ["AP", "WebHost"]
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
from Utils import format_SI_prefix
|
||||||
|
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
|
||||||
|
except ImportError:
|
||||||
|
self.logger.debug("Context destroyed")
|
||||||
|
|
||||||
def _load_game_data(self):
|
def _load_game_data(self):
|
||||||
for key, value in self.static_server_data.items():
|
for key, value in self.static_server_data.items():
|
||||||
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
||||||
@@ -249,6 +257,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
ctx = WebHostContext(static_server_data, logger)
|
ctx = WebHostContext(static_server_data, logger)
|
||||||
ctx.load(room_id)
|
ctx.load(room_id)
|
||||||
ctx.init_save()
|
ctx.init_save()
|
||||||
|
assert ctx.server is None
|
||||||
try:
|
try:
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(
|
||||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||||
@@ -279,6 +288,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||||
if ctx.saving:
|
if ctx.saving:
|
||||||
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
|
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
|
||||||
|
assert ctx.shutdown_task is None
|
||||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||||
await ctx.shutdown_task
|
await ctx.shutdown_task
|
||||||
|
|
||||||
@@ -325,7 +335,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
def run(self):
|
def run(self):
|
||||||
while 1:
|
while 1:
|
||||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||||
gc.collect(0)
|
gc.collect()
|
||||||
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||||
self._tasks.append(task)
|
self._tasks.append(task)
|
||||||
task.add_done_callback(self._done)
|
task.add_done_callback(self._done)
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
|
|||||||
elif len(gen_options) > app.config["MAX_ROLL"]:
|
elif len(gen_options) > app.config["MAX_ROLL"]:
|
||||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
|
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
|
||||||
f"If you have a larger group, please generate it yourself and upload it.")
|
f"If you have a larger group, please generate it yourself and upload it.")
|
||||||
|
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
||||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||||
gen = Generation(
|
gen = Generation(
|
||||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||||
@@ -134,6 +135,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
|||||||
{"bosses", "items", "connections", "texts"}))
|
{"bosses", "items", "connections", "texts"}))
|
||||||
erargs.skip_prog_balancing = False
|
erargs.skip_prog_balancing = False
|
||||||
erargs.skip_output = False
|
erargs.skip_output = False
|
||||||
|
erargs.csv_output = False
|
||||||
|
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
|||||||
import jinja2.exceptions
|
import jinja2.exceptions
|
||||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||||
from pony.orm import count, commit, db_session
|
from pony.orm import count, commit, db_session
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
@@ -69,14 +70,28 @@ def tutorial_landing():
|
|||||||
|
|
||||||
@app.route('/faq/<string:lang>/')
|
@app.route('/faq/<string:lang>/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def faq(lang):
|
def faq(lang: str):
|
||||||
return render_template("faq.html", lang=lang)
|
import markdown
|
||||||
|
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
|
||||||
|
document = f.read()
|
||||||
|
return render_template(
|
||||||
|
"markdown_document.html",
|
||||||
|
title="Frequently Asked Questions",
|
||||||
|
html_from_markdown=markdown.markdown(document, extensions=["mdx_breakless_lists"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/glossary/<string:lang>/')
|
@app.route('/glossary/<string:lang>/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def terms(lang):
|
def glossary(lang: str):
|
||||||
return render_template("glossary.html", lang=lang)
|
import markdown
|
||||||
|
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
|
||||||
|
document = f.read()
|
||||||
|
return render_template(
|
||||||
|
"markdown_document.html",
|
||||||
|
title="Glossary",
|
||||||
|
html_from_markdown=markdown.markdown(document, extensions=["mdx_breakless_lists"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/seed/<suuid:seed>')
|
@app.route('/seed/<suuid:seed>')
|
||||||
@@ -132,26 +147,41 @@ def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
|
|||||||
return "Access Denied", 403
|
return "Access Denied", 403
|
||||||
|
|
||||||
|
|
||||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
@app.post("/room/<suuid:room>")
|
||||||
|
def host_room_command(room: UUID):
|
||||||
|
room: Room = Room.get(id=room)
|
||||||
|
if room is None:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
if room.owner == session["_id"]:
|
||||||
|
cmd = request.form["cmd"]
|
||||||
|
if cmd:
|
||||||
|
Command(room=room, commandtext=cmd)
|
||||||
|
commit()
|
||||||
|
return redirect(url_for("host_room", room=room.id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/room/<suuid:room>")
|
||||||
def host_room(room: UUID):
|
def host_room(room: UUID):
|
||||||
room: Room = Room.get(id=room)
|
room: Room = Room.get(id=room)
|
||||||
if room is None:
|
if room is None:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
if request.method == "POST":
|
|
||||||
if room.owner == session["_id"]:
|
|
||||||
cmd = request.form["cmd"]
|
|
||||||
if cmd:
|
|
||||||
Command(room=room, commandtext=cmd)
|
|
||||||
commit()
|
|
||||||
return redirect(url_for("host_room", room=room.id))
|
|
||||||
|
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
# indicate that the page should reload to get the assigned port
|
# indicate that the page should reload to get the assigned port
|
||||||
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
|
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||||
|
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
||||||
with db_session:
|
with db_session:
|
||||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||||
|
|
||||||
def get_log(max_size: int = 1024000) -> str:
|
browser_tokens = "Mozilla", "Chrome", "Safari"
|
||||||
|
automated = ("update" in request.args
|
||||||
|
or "Discordbot" in request.user_agent.string
|
||||||
|
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
||||||
|
|
||||||
|
def get_log(max_size: int = 0 if automated else 1024000) -> str:
|
||||||
|
if max_size == 0:
|
||||||
|
return "…"
|
||||||
try:
|
try:
|
||||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
||||||
raw_size = 0
|
raw_size = 0
|
||||||
|
|||||||
@@ -231,6 +231,13 @@ def generate_yaml(game: str):
|
|||||||
|
|
||||||
del options[key]
|
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
|
# Detect random-* keys and set their options accordingly
|
||||||
for key, val in options.copy().items():
|
for key, val in options.copy().items():
|
||||||
if key.startswith("random-"):
|
if key.startswith("random-"):
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
flask>=3.0.3
|
flask>=3.0.3
|
||||||
werkzeug>=3.0.3
|
werkzeug>=3.0.4
|
||||||
pony>=0.7.17
|
pony>=0.7.19
|
||||||
waitress>=3.0.0
|
waitress>=3.0.0
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.3.0
|
||||||
Flask-Compress>=1.15
|
Flask-Compress>=1.15
|
||||||
Flask-Limiter>=3.7.0
|
Flask-Limiter>=3.8.0
|
||||||
bokeh>=3.1.1; python_version <= '3.8'
|
bokeh>=3.1.1; python_version <= '3.8'
|
||||||
bokeh>=3.4.1; python_version >= '3.9'
|
bokeh>=3.4.3; python_version == '3.9'
|
||||||
|
bokeh>=3.5.2; python_version >= '3.10'
|
||||||
markupsafe>=2.1.5
|
markupsafe>=2.1.5
|
||||||
|
Markdown>=3.7
|
||||||
|
mdx-breakless-lists>=1.0.1
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
const tutorialWrapper = document.getElementById('faq-wrapper');
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
if (ajax.status === 404) {
|
|
||||||
reject("Sorry, the tutorial is not available in that language yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ajax.status !== 200) {
|
|
||||||
reject("Something went wrong while loading the tutorial.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(ajax.responseText);
|
|
||||||
};
|
|
||||||
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
|
|
||||||
`faq_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
|
||||||
ajax.send();
|
|
||||||
}).then((results) => {
|
|
||||||
// Populate page with HTML generated from markdown
|
|
||||||
showdown.setOption('tables', true);
|
|
||||||
showdown.setOption('strikethrough', true);
|
|
||||||
showdown.setOption('literalMidWordUnderscores', true);
|
|
||||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
|
||||||
adjustHeaderWidth();
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
|
||||||
header.setAttribute('id', headerId);
|
|
||||||
header.addEventListener('click', () => {
|
|
||||||
window.location.hash = `#${headerId}`;
|
|
||||||
header.scrollIntoView();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
|
||||||
document.fonts.ready.finally(() => {
|
|
||||||
if (window.location.hash) {
|
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
|
||||||
scrollTarget?.scrollIntoView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
tutorialWrapper.innerHTML =
|
|
||||||
`<h2>This page is out of logic!</h2>
|
|
||||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -77,4 +77,4 @@ There, you will find examples of games in the `worlds` folder:
|
|||||||
You may also find developer documentation in the `docs` folder:
|
You may also find developer documentation in the `docs` folder:
|
||||||
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||||
|
|
||||||
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
|
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
const tutorialWrapper = document.getElementById('glossary-wrapper');
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
if (ajax.status === 404) {
|
|
||||||
reject("Sorry, the glossary page is not available in that language yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ajax.status !== 200) {
|
|
||||||
reject("Something went wrong while loading the glossary.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(ajax.responseText);
|
|
||||||
};
|
|
||||||
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
|
|
||||||
`glossary_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
|
||||||
ajax.send();
|
|
||||||
}).then((results) => {
|
|
||||||
// Populate page with HTML generated from markdown
|
|
||||||
showdown.setOption('tables', true);
|
|
||||||
showdown.setOption('strikethrough', true);
|
|
||||||
showdown.setOption('literalMidWordUnderscores', true);
|
|
||||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
|
||||||
adjustHeaderWidth();
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
|
||||||
header.setAttribute('id', headerId);
|
|
||||||
header.addEventListener('click', () => {
|
|
||||||
window.location.hash = `#${headerId}`;
|
|
||||||
header.scrollIntoView();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
|
||||||
document.fonts.ready.finally(() => {
|
|
||||||
if (window.location.hash) {
|
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
|
||||||
scrollTarget?.scrollIntoView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
tutorialWrapper.innerHTML =
|
|
||||||
`<h2>This page is out of logic!</h2>
|
|
||||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -288,6 +288,11 @@ const applyPresets = (presetName) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
namedRangeSelect.value = trueValue;
|
namedRangeSelect.value = trueValue;
|
||||||
|
// It is also possible for a preset to use an unnamed value. If this happens, set the dropdown to "Custom"
|
||||||
|
if (namedRangeSelect.selectedIndex == -1)
|
||||||
|
{
|
||||||
|
namedRangeSelect.value = "custom";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle options whose presets are "random"
|
// Handle options whose presets are "random"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 119 KiB |
66
WebHostLib/static/static/branding/header-logo-full.svg
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 240 38" style="enable-background:new 0 0 240 38;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#316B84;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M59.72,27.96L53.03,4.21L42.25,4.04l1.42,4.37l1.41-0.26l-7.9,24.22h8.44l-0.56-2.27l-0.81-3.27l8.9-5.7
|
||||||
|
l1.78,11.24h7.97v-4.73L59.72,27.96z M45.62,20.21l3.13-10.84h1.5l2.02,7.44L45.62,20.21z"/>
|
||||||
|
<path class="st0" d="M78.67,27.96V20.4l-4.11-2.5l3.29-3.78l-0.47-7.46l-2.82-2.45H56.65v5.27l3.81-1.11l2.31,13.36l-2.79,0.73
|
||||||
|
L61,26.34l5.06-0.52l0.36-6.15l4.32,0.13l3.16,3.62v8.94l12.89,1.49v-5.34L78.67,27.96z M73.27,13.33l-2.18,1.45h-4.64l-0.42-6.57
|
||||||
|
h5.68l1.55,1.37V13.33z"/>
|
||||||
|
<polygon class="st0" points="84.65,4.21 93.01,4.21 95.75,6.46 96.26,10.9 92.23,12.43 91.77,9.74 88.97,8.28 85.86,9.82
|
||||||
|
83.88,15.02 85.51,20.94 88.49,22.38 91.99,20.59 91.99,18.59 96.26,16.96 95.85,22.85 91.81,26.87 84.17,26.55 80.79,23.58
|
||||||
|
78.87,14.87 80.79,6.94 "/>
|
||||||
|
<polygon class="st0" points="97.62,4.21 103.33,4.21 102.96,21.08 108.7,20.14 108.34,6.42 113.85,3.28 113.9,19.9 115.75,19.71
|
||||||
|
115.27,25.86 113.88,25.86 114.27,32.36 108.7,32.36 108.7,26.39 102.96,26.39 102.96,32.36 91.77,33.85 92.2,28.85 97.88,27.96
|
||||||
|
"/>
|
||||||
|
<polygon class="st0" points="147.43,28.86 147.43,32.36 162.85,32.36 162.48,25.36 159.5,26 158.89,27.68 154.1,27.24
|
||||||
|
154.1,21.51 160.81,20.85 160.81,16.48 153.86,16.54 153.86,9.18 158.62,8.43 159.22,9.77 161.85,10.06 162.59,4 147.43,4
|
||||||
|
147.43,6.54 148.68,7.46 148.68,28.4 "/>
|
||||||
|
<polygon class="st0" points="163.89,9.24 163.89,4 172.31,4 170.35,26.87 179.55,24.74 179.55,32.36 164.51,32.34 164.65,28.71
|
||||||
|
165.73,27.84 165.73,9.59 "/>
|
||||||
|
<path class="st0" d="M193.69,32.36l-0.63-2.51l-2.84-1.89l-4.29-20.14L185.9,4h-11.27l-0.03,3.2l1.87-0.34l-2.79,14.07l-1.37,0.57
|
||||||
|
v2.85l6.29-1.33l0.4-2.7l4.65-0.89l1.69,12.93H193.69z M179.39,15.11l1.65-6.52l0.89,0.25l0.92,5.45L179.39,15.11z"/>
|
||||||
|
<polygon class="st0" points="208.47,21.68 210.62,21.12 210.04,18.15 200.51,17.46 198.87,21.13 203.56,21.9 203.32,23.91
|
||||||
|
200.58,25.19 196.44,23.77 194.48,17.19 196.2,10.02 200.08,8.52 203.31,9.62 202.85,11.75 207.79,13.6 208.83,9.69 204.71,4.21
|
||||||
|
195.57,4.21 191.24,7.36 189.29,16.87 192.06,27.54 199.03,30.53 203.2,29.3 203.09,32.36 209.01,32.36 209.4,29.95 207.38,28.99
|
||||||
|
"/>
|
||||||
|
<path class="st0" d="M230.45,6.26L226.39,4l-8.59-0.01l-4.07,2.86l-2.58,8.9l1.52,11.82l5.61,4.73l7.65,0.01l5.72-4.59l2.47-12.46
|
||||||
|
L230.45,6.26z M228.23,21.75l-3.95,5.45l-2.16,0.43l-4.6-3.46L216,15.72l2.4-7.02l5.14-0.48l2.97,1.79l1.74,5.83L228.23,21.75z"/>
|
||||||
|
<path class="st0" d="M116.13,27.48l-0.24,4.88l12.26,0.09l-0.83-5.01l-2.86-0.48l0.14-17.62l2.45-0.42l-0.14-4.85l-10.92,0.36
|
||||||
|
l0.1,4.6l3.2,0.63l-0.42,17.67L116.13,27.48z"/>
|
||||||
|
<path class="st0" d="M141.34,4.21l-12.88-0.39v4.26l1.95,0.62v25.15l-1.8,1.41l-0.02,2.63h8.23L136,27.96h6.09l4.57-4.46V7.27
|
||||||
|
L141.34,4.21z M141.38,20.51l-2.54,1.89l-3.23,0.16L135.4,9.32h3.88l2.1,1.68V20.51z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M14.14,11.28c0,0.35-0.02,0.71-0.07,1.05c0.38,0.07,0.76,0.11,1.16,0.11s0.79-0.04,1.16-0.11
|
||||||
|
c-0.05-0.34-0.07-0.7-0.07-1.05c0-3.23,1.94-6.02,4.72-7.25C20.17,1.68,17.9,0,15.24,0S10.3,1.68,9.42,4.03
|
||||||
|
C12.2,5.26,14.14,8.04,14.14,11.28z"/>
|
||||||
|
<path class="st0" d="M18.04,11.28c0,0.16,0.01,0.32,0.02,0.48c0.02,0.3,0.06,0.6,0.13,0.88c0.06,0.28,0.15,0.56,0.25,0.83
|
||||||
|
c0.11,0.3,0.24,0.58,0.39,0.85c1.42-1.33,3.33-2.15,5.42-2.15s4.01,0.82,5.42,2.15c0.51-0.9,0.79-1.94,0.79-3.04
|
||||||
|
c0-3.42-2.79-6.22-6.22-6.22c-0.4,0-0.79,0.04-1.16,0.11c-0.28,0.06-0.56,0.13-0.83,0.22c-0.28,0.09-0.56,0.21-0.83,0.35
|
||||||
|
C19.42,6.77,18.04,8.87,18.04,11.28z"/>
|
||||||
|
<path class="st0" d="M6.22,12.16c2.1,0,4.01,0.82,5.42,2.15c0.15-0.27,0.28-0.55,0.39-0.85c0.1-0.27,0.19-0.54,0.25-0.83
|
||||||
|
c0.06-0.28,0.11-0.58,0.13-0.88c0.02-0.15,0.02-0.32,0.02-0.48c0-2.41-1.38-4.51-3.39-5.54C8.77,5.6,8.5,5.49,8.21,5.39
|
||||||
|
c-0.27-0.1-0.55-0.17-0.83-0.22C7,5.1,6.61,5.06,6.22,5.06C2.79,5.06,0,7.85,0,11.28c0,1.1,0.28,2.14,0.79,3.04
|
||||||
|
C2.21,12.98,4.12,12.16,6.22,12.16z"/>
|
||||||
|
<path class="st0" d="M29.21,16.33c-0.18-0.23-0.36-0.44-0.57-0.65c-1.12-1.12-2.67-1.81-4.38-1.81c-1.71,0-3.25,0.69-4.38,1.81
|
||||||
|
c-0.2,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72c-0.2,0.34-0.36,0.71-0.48,1.09c2.83,1.21,4.81,4.02,4.81,7.28
|
||||||
|
c0,0.26-0.01,0.52-0.04,0.78c0.37,0.07,0.75,0.1,1.13,0.1c3.43,0,6.22-2.79,6.22-6.22c0-1.11-0.29-2.14-0.8-3.04
|
||||||
|
C29.54,16.8,29.38,16.56,29.21,16.33z"/>
|
||||||
|
<path class="st0" d="M12.12,18.14c-0.13-0.38-0.28-0.75-0.48-1.09c-0.14-0.26-0.3-0.5-0.47-0.72c-0.17-0.23-0.36-0.44-0.56-0.64
|
||||||
|
c-1.12-1.12-2.67-1.81-4.38-1.81s-3.26,0.69-4.38,1.81c-0.21,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72
|
||||||
|
C0.29,17.94,0,18.98,0,20.08c0,3.43,2.79,6.22,6.22,6.22c0.39,0,0.76-0.03,1.13-0.1c-0.03-0.26-0.04-0.52-0.04-0.78
|
||||||
|
C7.31,22.15,9.29,19.34,12.12,18.14z"/>
|
||||||
|
<path class="st0" d="M18.04,19.87c-0.27-0.14-0.55-0.26-0.84-0.35c-0.27-0.09-0.55-0.17-0.84-0.22c-0.37-0.07-0.75-0.1-1.13-0.1
|
||||||
|
s-0.76,0.03-1.13,0.1c-0.28,0.05-0.57,0.13-0.84,0.22c-0.29,0.1-0.57,0.22-0.84,0.35C10.4,20.9,9.02,23,9.02,25.42
|
||||||
|
c0,0.07,0,0.14,0.01,0.21c0.01,0.31,0.04,0.61,0.1,0.9c0.05,0.28,0.12,0.57,0.21,0.84c0.82,2.48,3.16,4.27,5.9,4.27
|
||||||
|
s5.08-1.79,5.9-4.27c0.09-0.27,0.17-0.55,0.21-0.84c0.06-0.3,0.09-0.6,0.1-0.91c0.01-0.07,0.01-0.14,0.01-0.21
|
||||||
|
C21.45,23,20.07,20.9,18.04,19.87z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 3.3 KiB |
@@ -1,66 +1 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 240 38" style="enable-background:new 0 0 240 38" xml:space="preserve"><style>.st0{fill:#316b84}</style><path class="st0" d="M59.72 27.96 53.03 4.21l-10.78-.17 1.42 4.37 1.41-.26-7.9 24.22h8.44l-.56-2.27-.81-3.27 8.9-5.7 1.78 11.24h7.97v-4.73l-3.18.32zm-14.1-7.75 3.13-10.84h1.5l2.02 7.44-6.65 3.4z"/><path class="st0" d="M78.67 27.96V20.4l-4.11-2.5 3.29-3.78-.47-7.46-2.82-2.45H56.65v5.27l3.81-1.11 2.31 13.36-2.79.73L61 26.34l5.06-.52.36-6.15 4.32.13 3.16 3.62v8.94l12.89 1.49v-5.34l-8.12-.55zm-5.4-14.63-2.18 1.45h-4.64l-.42-6.57h5.68l1.55 1.37v3.75z"/><path class="st0" d="M84.65 4.21h8.36l2.74 2.25.51 4.44-4.03 1.53-.46-2.69-2.8-1.46-3.11 1.54-1.98 5.2 1.63 5.92 2.98 1.44 3.5-1.79v-2l4.27-1.63-.41 5.89-4.04 4.02-7.64-.32-3.38-2.97-1.92-8.71 1.92-7.93z"/><path class="st0" d="M97.62 4.21h5.71l-.37 16.87 5.74-.94-.36-13.72 5.51-3.14.05 16.62 1.85-.19-.48 6.15h-1.39l.39 6.5h-5.57v-5.97h-5.74v5.97l-11.19 1.49.43-5 5.68-.89zm49.81 24.65v3.5h15.42l-.37-7-2.98.64-.61 1.68-4.79-.44v-5.73l6.71-.66v-4.37l-6.95.06V9.18l4.76-.75.6 1.34 2.63.29.74-6.06h-15.16v2.54l1.25.92V28.4zm16.46-19.62V4h8.42l-1.96 22.87 9.2-2.13v7.62l-15.04-.02.14-3.63 1.08-.87V9.59z"/><path class="st0" d="m193.69 32.36-.63-2.51-2.84-1.89-4.29-20.14L185.9 4h-11.27l-.03 3.2 1.87-.34-2.79 14.07-1.37.57v2.85l6.29-1.33.4-2.7 4.65-.89 1.69 12.93h8.35zm-14.3-17.25 1.65-6.52.89.25.92 5.45-3.46.82z"/><path class="st0" d="m208.47 21.68 2.15-.56-.58-2.97-9.53-.69-1.64 3.67 4.69.77-.24 2.01-2.74 1.28-4.14-1.42-1.96-6.58 1.72-7.17 3.88-1.5 3.23 1.1-.46 2.13 4.94 1.85 1.04-3.91-4.12-5.48h-9.14l-4.33 3.15-1.95 9.51 2.77 10.67 6.97 2.99 4.17-1.23-.11 3.06h5.92l.39-2.41-2.02-.96zm21.98-15.42L226.39 4l-8.59-.01-4.07 2.86-2.58 8.9 1.52 11.82 5.61 4.73 7.65.01 5.72-4.59 2.47-12.46-3.67-9zm-2.22 15.49-3.95 5.45-2.16.43-4.6-3.46-1.52-8.45 2.4-7.02 5.14-.48 2.97 1.79 1.74 5.83-.02 5.91zm-112.1 5.73-.24 4.88 12.26.09-.83-5.01-2.86-.48.14-17.62 2.45-.42-.14-4.85-10.92.36.1 4.6 3.2.63-.42 17.67-2.74.15zm25.21-23.27-12.88-.39v4.26l1.95.62v25.15l-1.8 1.41-.02 2.63h8.23l-.82-9.93h6.09l4.57-4.46V7.27l-5.32-3.06zm.04 16.3-2.54 1.89-3.23.16-.21-13.24h3.88l2.1 1.68v9.51zM14.14 11.28c0 .35-.02.71-.07 1.05.38.07.76.11 1.16.11s.79-.04 1.16-.11a7.933 7.933 0 0 1 4.65-8.3C20.17 1.68 17.9 0 15.24 0S10.3 1.68 9.42 4.03a7.922 7.922 0 0 1 4.72 7.25z"/><path class="st0" d="M18.04 11.28c0 .16.01.32.02.48.02.3.06.6.13.88.06.28.15.56.25.83.11.3.24.58.39.85 1.42-1.33 3.33-2.15 5.42-2.15s4.01.82 5.42 2.15c.51-.9.79-1.94.79-3.04 0-3.42-2.79-6.22-6.22-6.22-.4 0-.79.04-1.16.11-.28.06-.56.13-.83.22-.28.09-.56.21-.83.35a6.24 6.24 0 0 0-3.38 5.54zm-11.82.88c2.1 0 4.01.82 5.42 2.15.15-.27.28-.55.39-.85.1-.27.19-.54.25-.83.06-.28.11-.58.13-.88.02-.15.02-.32.02-.48a6.23 6.23 0 0 0-3.39-5.54c-.27-.13-.54-.24-.83-.34-.27-.1-.55-.17-.83-.22a6.42 6.42 0 0 0-1.16-.11 6.227 6.227 0 0 0-5.43 9.26 7.885 7.885 0 0 1 5.43-2.16z"/><path class="st0" d="M29.21 16.33c-.18-.23-.36-.44-.57-.65a6.174 6.174 0 0 0-4.38-1.81 6.192 6.192 0 0 0-4.94 2.45c-.18.23-.34.47-.47.72-.2.34-.36.71-.48 1.09a7.923 7.923 0 0 1 4.77 8.06c.37.07.75.1 1.13.1 3.43 0 6.22-2.79 6.22-6.22 0-1.11-.29-2.14-.8-3.04-.15-.23-.31-.47-.48-.7zm-17.09 1.81c-.13-.38-.28-.75-.48-1.09-.14-.26-.3-.5-.47-.72-.17-.23-.36-.44-.56-.64-1.12-1.12-2.67-1.81-4.38-1.81s-3.26.69-4.38 1.81c-.21.2-.39.42-.56.64-.18.23-.34.47-.47.72-.53.89-.82 1.93-.82 3.03 0 3.43 2.79 6.22 6.22 6.22.39 0 .76-.03 1.13-.1a7.902 7.902 0 0 1 4.77-8.06z"/><path class="st0" d="M18.04 19.87c-.27-.14-.55-.26-.84-.35-.27-.09-.55-.17-.84-.22-.37-.07-.75-.1-1.13-.1s-.76.03-1.13.1c-.28.05-.57.13-.84.22-.29.1-.57.22-.84.35a6.225 6.225 0 0 0-3.4 5.55c0 .07 0 .14.01.21.01.31.04.61.1.9.05.28.12.57.21.84.82 2.48 3.16 4.27 5.9 4.27s5.08-1.79 5.9-4.27c.09-.27.17-.55.21-.84.06-.3.09-.6.1-.91.01-.07.01-.14.01-.21a6.24 6.24 0 0 0-3.42-5.54z"/></svg>
|
||||||
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
|
||||||
viewBox="0 0 240 38" style="enable-background:new 0 0 240 38;" xml:space="preserve">
|
|
||||||
<style type="text/css">
|
|
||||||
.st0{fill:#316B84;}
|
|
||||||
</style>
|
|
||||||
<g>
|
|
||||||
<g>
|
|
||||||
<path class="st0" d="M59.72,27.96L53.03,4.21L42.25,4.04l1.42,4.37l1.41-0.26l-7.9,24.22h8.44l-0.56-2.27l-0.81-3.27l8.9-5.7
|
|
||||||
l1.78,11.24h7.97v-4.73L59.72,27.96z M45.62,20.21l3.13-10.84h1.5l2.02,7.44L45.62,20.21z"/>
|
|
||||||
<path class="st0" d="M78.67,27.96V20.4l-4.11-2.5l3.29-3.78l-0.47-7.46l-2.82-2.45H56.65v5.27l3.81-1.11l2.31,13.36l-2.79,0.73
|
|
||||||
L61,26.34l5.06-0.52l0.36-6.15l4.32,0.13l3.16,3.62v8.94l12.89,1.49v-5.34L78.67,27.96z M73.27,13.33l-2.18,1.45h-4.64l-0.42-6.57
|
|
||||||
h5.68l1.55,1.37V13.33z"/>
|
|
||||||
<polygon class="st0" points="84.65,4.21 93.01,4.21 95.75,6.46 96.26,10.9 92.23,12.43 91.77,9.74 88.97,8.28 85.86,9.82
|
|
||||||
83.88,15.02 85.51,20.94 88.49,22.38 91.99,20.59 91.99,18.59 96.26,16.96 95.85,22.85 91.81,26.87 84.17,26.55 80.79,23.58
|
|
||||||
78.87,14.87 80.79,6.94 "/>
|
|
||||||
<polygon class="st0" points="97.62,4.21 103.33,4.21 102.96,21.08 108.7,20.14 108.34,6.42 113.85,3.28 113.9,19.9 115.75,19.71
|
|
||||||
115.27,25.86 113.88,25.86 114.27,32.36 108.7,32.36 108.7,26.39 102.96,26.39 102.96,32.36 91.77,33.85 92.2,28.85 97.88,27.96
|
|
||||||
"/>
|
|
||||||
<polygon class="st0" points="147.43,28.86 147.43,32.36 162.85,32.36 162.48,25.36 159.5,26 158.89,27.68 154.1,27.24
|
|
||||||
154.1,21.51 160.81,20.85 160.81,16.48 153.86,16.54 153.86,9.18 158.62,8.43 159.22,9.77 161.85,10.06 162.59,4 147.43,4
|
|
||||||
147.43,6.54 148.68,7.46 148.68,28.4 "/>
|
|
||||||
<polygon class="st0" points="163.89,9.24 163.89,4 172.31,4 170.35,26.87 179.55,24.74 179.55,32.36 164.51,32.34 164.65,28.71
|
|
||||||
165.73,27.84 165.73,9.59 "/>
|
|
||||||
<path class="st0" d="M193.69,32.36l-0.63-2.51l-2.84-1.89l-4.29-20.14L185.9,4h-11.27l-0.03,3.2l1.87-0.34l-2.79,14.07l-1.37,0.57
|
|
||||||
v2.85l6.29-1.33l0.4-2.7l4.65-0.89l1.69,12.93H193.69z M179.39,15.11l1.65-6.52l0.89,0.25l0.92,5.45L179.39,15.11z"/>
|
|
||||||
<polygon class="st0" points="208.47,21.68 210.62,21.12 210.04,18.15 200.51,17.46 198.87,21.13 203.56,21.9 203.32,23.91
|
|
||||||
200.58,25.19 196.44,23.77 194.48,17.19 196.2,10.02 200.08,8.52 203.31,9.62 202.85,11.75 207.79,13.6 208.83,9.69 204.71,4.21
|
|
||||||
195.57,4.21 191.24,7.36 189.29,16.87 192.06,27.54 199.03,30.53 203.2,29.3 203.09,32.36 209.01,32.36 209.4,29.95 207.38,28.99
|
|
||||||
"/>
|
|
||||||
<path class="st0" d="M230.45,6.26L226.39,4l-8.59-0.01l-4.07,2.86l-2.58,8.9l1.52,11.82l5.61,4.73l7.65,0.01l5.72-4.59l2.47-12.46
|
|
||||||
L230.45,6.26z M228.23,21.75l-3.95,5.45l-2.16,0.43l-4.6-3.46L216,15.72l2.4-7.02l5.14-0.48l2.97,1.79l1.74,5.83L228.23,21.75z"/>
|
|
||||||
<path class="st0" d="M116.13,27.48l-0.24,4.88l12.26,0.09l-0.83-5.01l-2.86-0.48l0.14-17.62l2.45-0.42l-0.14-4.85l-10.92,0.36
|
|
||||||
l0.1,4.6l3.2,0.63l-0.42,17.67L116.13,27.48z"/>
|
|
||||||
<path class="st0" d="M141.34,4.21l-12.88-0.39v4.26l1.95,0.62v25.15l-1.8,1.41l-0.02,2.63h8.23L136,27.96h6.09l4.57-4.46V7.27
|
|
||||||
L141.34,4.21z M141.38,20.51l-2.54,1.89l-3.23,0.16L135.4,9.32h3.88l2.1,1.68V20.51z"/>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<path class="st0" d="M14.14,11.28c0,0.35-0.02,0.71-0.07,1.05c0.38,0.07,0.76,0.11,1.16,0.11s0.79-0.04,1.16-0.11
|
|
||||||
c-0.05-0.34-0.07-0.7-0.07-1.05c0-3.23,1.94-6.02,4.72-7.25C20.17,1.68,17.9,0,15.24,0S10.3,1.68,9.42,4.03
|
|
||||||
C12.2,5.26,14.14,8.04,14.14,11.28z"/>
|
|
||||||
<path class="st0" d="M18.04,11.28c0,0.16,0.01,0.32,0.02,0.48c0.02,0.3,0.06,0.6,0.13,0.88c0.06,0.28,0.15,0.56,0.25,0.83
|
|
||||||
c0.11,0.3,0.24,0.58,0.39,0.85c1.42-1.33,3.33-2.15,5.42-2.15s4.01,0.82,5.42,2.15c0.51-0.9,0.79-1.94,0.79-3.04
|
|
||||||
c0-3.42-2.79-6.22-6.22-6.22c-0.4,0-0.79,0.04-1.16,0.11c-0.28,0.06-0.56,0.13-0.83,0.22c-0.28,0.09-0.56,0.21-0.83,0.35
|
|
||||||
C19.42,6.77,18.04,8.87,18.04,11.28z"/>
|
|
||||||
<path class="st0" d="M6.22,12.16c2.1,0,4.01,0.82,5.42,2.15c0.15-0.27,0.28-0.55,0.39-0.85c0.1-0.27,0.19-0.54,0.25-0.83
|
|
||||||
c0.06-0.28,0.11-0.58,0.13-0.88c0.02-0.15,0.02-0.32,0.02-0.48c0-2.41-1.38-4.51-3.39-5.54C8.77,5.6,8.5,5.49,8.21,5.39
|
|
||||||
c-0.27-0.1-0.55-0.17-0.83-0.22C7,5.1,6.61,5.06,6.22,5.06C2.79,5.06,0,7.85,0,11.28c0,1.1,0.28,2.14,0.79,3.04
|
|
||||||
C2.21,12.98,4.12,12.16,6.22,12.16z"/>
|
|
||||||
<path class="st0" d="M29.21,16.33c-0.18-0.23-0.36-0.44-0.57-0.65c-1.12-1.12-2.67-1.81-4.38-1.81c-1.71,0-3.25,0.69-4.38,1.81
|
|
||||||
c-0.2,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72c-0.2,0.34-0.36,0.71-0.48,1.09c2.83,1.21,4.81,4.02,4.81,7.28
|
|
||||||
c0,0.26-0.01,0.52-0.04,0.78c0.37,0.07,0.75,0.1,1.13,0.1c3.43,0,6.22-2.79,6.22-6.22c0-1.11-0.29-2.14-0.8-3.04
|
|
||||||
C29.54,16.8,29.38,16.56,29.21,16.33z"/>
|
|
||||||
<path class="st0" d="M12.12,18.14c-0.13-0.38-0.28-0.75-0.48-1.09c-0.14-0.26-0.3-0.5-0.47-0.72c-0.17-0.23-0.36-0.44-0.56-0.64
|
|
||||||
c-1.12-1.12-2.67-1.81-4.38-1.81s-3.26,0.69-4.38,1.81c-0.21,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72
|
|
||||||
C0.29,17.94,0,18.98,0,20.08c0,3.43,2.79,6.22,6.22,6.22c0.39,0,0.76-0.03,1.13-0.1c-0.03-0.26-0.04-0.52-0.04-0.78
|
|
||||||
C7.31,22.15,9.29,19.34,12.12,18.14z"/>
|
|
||||||
<path class="st0" d="M18.04,19.87c-0.27-0.14-0.55-0.26-0.84-0.35c-0.27-0.09-0.55-0.17-0.84-0.22c-0.37-0.07-0.75-0.1-1.13-0.1
|
|
||||||
s-0.76,0.03-1.13,0.1c-0.28,0.05-0.57,0.13-0.84,0.22c-0.29,0.1-0.57,0.22-0.84,0.35C10.4,20.9,9.02,23,9.02,25.42
|
|
||||||
c0,0.07,0,0.14,0.01,0.21c0.01,0.31,0.04,0.61,0.1,0.9c0.05,0.28,0.12,0.57,0.21,0.84c0.82,2.48,3.16,4.27,5.9,4.27
|
|
||||||
s5.08-1.79,5.9-4.27c0.09-0.27,0.17-0.55,0.21-0.84c0.06-0.3,0.09-0.6,0.1-0.91c0.01-0.07,0.01-0.14,0.01-0.21
|
|
||||||
C21.45,23,20.07,20.9,18.04,19.87z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 258 B |
@@ -58,3 +58,28 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loader{
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
margin-left: 5px;
|
||||||
|
width: 40px;
|
||||||
|
aspect-ratio: 4;
|
||||||
|
--_g: no-repeat radial-gradient(circle closest-side,#fff 90%,#fff0);
|
||||||
|
background:
|
||||||
|
var(--_g) 0 50%,
|
||||||
|
var(--_g) 50% 50%,
|
||||||
|
var(--_g) 100% 50%;
|
||||||
|
background-size: calc(100%/3) 100%;
|
||||||
|
animation: l7 1s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader.loading{
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes l7{
|
||||||
|
33%{background-size:calc(100%/3) 0% ,calc(100%/3) 100%,calc(100%/3) 100%}
|
||||||
|
50%{background-size:calc(100%/3) 100%,calc(100%/3) 0 ,calc(100%/3) 100%}
|
||||||
|
66%{background-size:calc(100%/3) 100%,calc(100%/3) 100%,calc(100%/3) 0 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
{% include 'header/grassHeader.html' %}
|
|
||||||
<title>Frequently Asked Questions</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
|
||||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/faq.js") }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<div id="faq-wrapper" data-lang="{{ lang }}" class="markdown">
|
|
||||||
<!-- Content generated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -99,14 +99,18 @@
|
|||||||
{% if hint.finding_player == player %}
|
{% if hint.finding_player == player %}
|
||||||
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
||||||
|
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if hint.receiving_player == player %}
|
{% if hint.receiving_player == player %}
|
||||||
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
||||||
|
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
|
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
{% include 'header/grassHeader.html' %}
|
|
||||||
<title>Glossary</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
|
||||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/glossary.js") }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<div id="glossary-wrapper" data-lang="{{ lang }}" class="markdown">
|
|
||||||
<!-- Content generated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -19,28 +19,30 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
{% include 'header/grassHeader.html' %}
|
{% include 'header/grassHeader.html' %}
|
||||||
<div id="host-room">
|
<div id="host-room">
|
||||||
{% if room.owner == session["_id"] %}
|
<span id="host-room-info">
|
||||||
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
|
{% if room.owner == session["_id"] %}
|
||||||
<br />
|
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
|
||||||
{% endif %}
|
<br />
|
||||||
{% if room.tracker %}
|
{% endif %}
|
||||||
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
|
{% if room.tracker %}
|
||||||
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
|
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
|
||||||
<br />
|
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
|
||||||
{% endif %}
|
<br />
|
||||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
{% endif %}
|
||||||
Should you wish to continue later,
|
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||||
anyone can simply refresh this page and the server will resume.<br>
|
Should you wish to continue later,
|
||||||
{% if room.last_port == -1 %}
|
anyone can simply refresh this page and the server will resume.<br>
|
||||||
There was an error hosting this Room. Another attempt will be made on refreshing this page.
|
{% if room.last_port == -1 %}
|
||||||
The most likely failure reason is that the multiworld is too old to be loaded now.
|
There was an error hosting this Room. Another attempt will be made on refreshing this page.
|
||||||
{% elif room.last_port %}
|
The most likely failure reason is that the multiworld is too old to be loaded now.
|
||||||
You can connect to this room by using <span class="interactive"
|
{% elif room.last_port %}
|
||||||
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
|
You can connect to this room by using <span class="interactive"
|
||||||
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
|
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
|
||||||
</span>
|
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
|
||||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
</span>
|
||||||
{% endif %}
|
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
{{ macros.list_patches_room(room) }}
|
{{ macros.list_patches_room(room) }}
|
||||||
{% if room.owner == session["_id"] %}
|
{% if room.owner == session["_id"] %}
|
||||||
<div style="display: flex; align-items: center;">
|
<div style="display: flex; align-items: center;">
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
<label for="cmd"></label>
|
<label for="cmd"></label>
|
||||||
<input class="form-control" type="text" id="cmd" name="cmd"
|
<input class="form-control" type="text" id="cmd" name="cmd"
|
||||||
placeholder="Server Command. /help to list them, list gets appended to log.">
|
placeholder="Server Command. /help to list them, list gets appended to log.">
|
||||||
|
<span class="loader"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<a href="{{ url_for("display_log", room=room.id) }}">
|
<a href="{{ url_for("display_log", room=room.id) }}">
|
||||||
@@ -62,6 +65,7 @@
|
|||||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||||
let bytesReceived = {{ log_len }};
|
let bytesReceived = {{ log_len }};
|
||||||
let updateLogTimeout;
|
let updateLogTimeout;
|
||||||
|
let updateLogImmediately = false;
|
||||||
let awaitingCommandResponse = false;
|
let awaitingCommandResponse = false;
|
||||||
let logger = document.getElementById("logger");
|
let logger = document.getElementById("logger");
|
||||||
|
|
||||||
@@ -78,29 +82,36 @@
|
|||||||
|
|
||||||
async function updateLog() {
|
async function updateLog() {
|
||||||
try {
|
try {
|
||||||
let res = await fetch(url, {
|
if (!document.hidden) {
|
||||||
headers: {
|
updateLogImmediately = false;
|
||||||
'Range': `bytes=${bytesReceived}-`,
|
let res = await fetch(url, {
|
||||||
}
|
headers: {
|
||||||
});
|
'Range': `bytes=${bytesReceived}-`,
|
||||||
if (res.ok) {
|
}
|
||||||
let text = await res.text();
|
});
|
||||||
if (text.length > 0) {
|
if (res.ok) {
|
||||||
awaitingCommandResponse = false;
|
let text = await res.text();
|
||||||
if (bytesReceived === 0 || res.status !== 206) {
|
if (text.length > 0) {
|
||||||
logger.innerHTML = '';
|
awaitingCommandResponse = false;
|
||||||
}
|
if (bytesReceived === 0 || res.status !== 206) {
|
||||||
if (res.status !== 206) {
|
logger.innerHTML = '';
|
||||||
bytesReceived = 0;
|
}
|
||||||
} else {
|
if (res.status !== 206) {
|
||||||
bytesReceived += new Blob([text]).size;
|
bytesReceived = 0;
|
||||||
}
|
} else {
|
||||||
if (logger.innerHTML.endsWith('…')) {
|
bytesReceived += new Blob([text]).size;
|
||||||
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
|
}
|
||||||
}
|
if (logger.innerHTML.endsWith('…')) {
|
||||||
logger.appendChild(document.createTextNode(text));
|
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
|
||||||
scrollToBottom(logger);
|
}
|
||||||
|
logger.appendChild(document.createTextNode(text));
|
||||||
|
scrollToBottom(logger);
|
||||||
|
let loader = document.getElementById("command-form").getElementsByClassName("loader")[0];
|
||||||
|
loader.classList.remove("loading");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
updateLogImmediately = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -125,20 +136,62 @@
|
|||||||
});
|
});
|
||||||
ev.preventDefault(); // has to happen before first await
|
ev.preventDefault(); // has to happen before first await
|
||||||
form.reset();
|
form.reset();
|
||||||
let res = await req;
|
let loader = form.getElementsByClassName("loader")[0];
|
||||||
if (res.ok || res.type === 'opaqueredirect') {
|
loader.classList.add("loading");
|
||||||
awaitingCommandResponse = true;
|
try {
|
||||||
window.clearTimeout(updateLogTimeout);
|
let res = await req;
|
||||||
updateLogTimeout = window.setTimeout(updateLog, 100);
|
if (res.ok || res.type === 'opaqueredirect') {
|
||||||
} else {
|
awaitingCommandResponse = true;
|
||||||
window.alert(res.statusText);
|
window.clearTimeout(updateLogTimeout);
|
||||||
|
updateLogTimeout = window.setTimeout(updateLog, 100);
|
||||||
|
} else {
|
||||||
|
loader.classList.remove("loading");
|
||||||
|
window.alert(res.statusText);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
loader.classList.remove("loading");
|
||||||
|
window.alert(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("command-form").addEventListener("submit", postForm);
|
document.getElementById("command-form").addEventListener("submit", postForm);
|
||||||
updateLogTimeout = window.setTimeout(updateLog, 1000);
|
updateLogTimeout = window.setTimeout(updateLog, 1000);
|
||||||
logger.scrollTop = logger.scrollHeight;
|
logger.scrollTop = logger.scrollHeight;
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden && updateLogImmediately) {
|
||||||
|
updateLog();
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<script>
|
||||||
|
function updateInfo() {
|
||||||
|
let url = new URL(window.location.href);
|
||||||
|
url.search = "?update";
|
||||||
|
fetch(url)
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP error ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.text()
|
||||||
|
})
|
||||||
|
.then(text => new DOMParser().parseFromString(text, 'text/html'))
|
||||||
|
.then(newDocument => {
|
||||||
|
let el = newDocument.getElementById("host-room-info");
|
||||||
|
document.getElementById("host-room-info").innerHTML = el.innerHTML;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.querySelector("meta[http-equiv='refresh']")) {
|
||||||
|
console.log("Refresh!");
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
for (let i=0; i<3; i++) {
|
||||||
|
window.setTimeout(updateInfo, Math.pow(2, i) * 2000); // 2, 4, 8s
|
||||||
|
}
|
||||||
|
window.stop(); // cancel meta refresh
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
|
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ patch.player_id }}</td>
|
<td>{{ patch.player_id }}</td>
|
||||||
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
|
<td data-tooltip="Connect via Game Client"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}?game={{ patch.game }}&room={{ room.id | suuid }}">{{ patch.player_name }}</a></td>
|
||||||
<td>{{ patch.game }}</td>
|
<td>{{ patch.game }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if patch.data %}
|
{% if patch.data %}
|
||||||
|
|||||||
13
WebHostLib/templates/markdown_document.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% include 'header/grassHeader.html' %}
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="markdown">
|
||||||
|
{{ html_from_markdown | safe}}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
{% macro NamedRange(option_name, option) %}
|
{% macro NamedRange(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<div class="named-range-container">
|
<div class="named-range-container">
|
||||||
<select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
<select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||||
{% for key, val in option.special_range_names.items() %}
|
{% for key, val in option.special_range_names.items() %}
|
||||||
{% if option.default == val %}
|
{% if option.default == val %}
|
||||||
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
|
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
|
||||||
@@ -64,17 +64,17 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
<option value="custom" hidden>Custom</option>
|
<option value="custom" hidden>Custom</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="named-range-wrapper">
|
<div class="named-range-wrapper js-required">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
id="{{ option_name }}"
|
id="{{ option_name }}"
|
||||||
name="{{ option_name }}"
|
name="{{ option_name }}-range"
|
||||||
min="{{ option.range_start }}"
|
min="{{ option.range_start }}"
|
||||||
max="{{ option.range_end }}"
|
max="{{ option.range_end }}"
|
||||||
value="{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}"
|
value="{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}"
|
||||||
{{ "disabled" if option.default == "random" }}
|
{{ "disabled" if option.default == "random" }}
|
||||||
/>
|
/>
|
||||||
<span id="{{ option_name }}-value" class="range-value js-required">
|
<span id="{{ option_name }}-value" class="range-value">
|
||||||
{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}
|
{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}
|
||||||
</span>
|
</span>
|
||||||
{{ RandomizeButton(option_name, option) }}
|
{{ RandomizeButton(option_name, option) }}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<noscript>
|
<noscript>
|
||||||
<style>
|
<style>
|
||||||
.js-required{
|
.js-required{
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
{% extends 'tablepage.html' %}
|
{% extends 'tablepage.html' %}
|
||||||
|
|
||||||
|
{%- macro games(slots) -%}
|
||||||
|
{%- set gameList = [] -%}
|
||||||
|
{%- set maxGamesToShow = 10 -%}
|
||||||
|
|
||||||
|
{%- for slot in (slots|list|sort(attribute="player_id"))[:maxGamesToShow] -%}
|
||||||
|
{% set player = "#" + slot["player_id"]|string + " " + slot["player_name"] + " : " + slot["game"] -%}
|
||||||
|
{% set _ = gameList.append(player) -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
|
{%- if slots|length > maxGamesToShow -%}
|
||||||
|
{% set _ = gameList.append("... and " + (slots|length - maxGamesToShow)|string + " more") -%}
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{{ gameList|join('\n') }}
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<title>User Content</title>
|
<title>User Content</title>
|
||||||
@@ -33,10 +49,12 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url_for("view_seed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
|
<td><a href="{{ url_for("view_seed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
|
||||||
<td><a href="{{ url_for("host_room", room=room.id) }}">{{ room.id|suuid }}</a></td>
|
<td><a href="{{ url_for("host_room", room=room.id) }}">{{ room.id|suuid }}</a></td>
|
||||||
<td>{{ room.seed.slots|length }}</td>
|
<td title="{{ games(room.seed.slots) }}">
|
||||||
|
{{ room.seed.slots|length }}
|
||||||
|
</td>
|
||||||
<td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
|
<td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||||
<td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td>
|
<td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||||
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</td>
|
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -60,16 +78,21 @@
|
|||||||
{% for seed in seeds %}
|
{% for seed in seeds %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url_for("view_seed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
|
<td><a href="{{ url_for("view_seed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
|
||||||
<td>{% if seed.multidata %}{{ seed.slots|length }}{% else %}1{% endif %}
|
<td title="{{ games(seed.slots) }}">
|
||||||
|
{% if seed.multidata %}
|
||||||
|
{{ seed.slots|length }}
|
||||||
|
{% else %}
|
||||||
|
1
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
|
<td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||||
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</td>
|
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
You have no generated any seeds yet!
|
You have not generated any seeds yet!
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
id="{{ option_name }}-{{ key }}"
|
id="{{ option_name }}-{{ key }}"
|
||||||
name="{{ option_name }}||{{ key }}"
|
name="{{ option_name }}||{{ key }}"
|
||||||
value="1"
|
value="1"
|
||||||
checked="{{ "checked" if key in option.default else "" }}"
|
{{ "checked" if key in option.default }}
|
||||||
/>
|
/>
|
||||||
<label for="{{ option_name }}-{{ key }}">
|
<label for="{{ option_name }}-{{ key }}">
|
||||||
{{ key }}
|
{{ key }}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class TrackerData:
|
|||||||
|
|
||||||
# Normal lookup tables as well.
|
# Normal lookup tables as well.
|
||||||
self.item_name_to_id[game] = game_package["item_name_to_id"]
|
self.item_name_to_id[game] = game_package["item_name_to_id"]
|
||||||
self.location_name_to_id[game] = game_package["item_name_to_id"]
|
self.location_name_to_id[game] = game_package["location_name_to_id"]
|
||||||
|
|
||||||
def get_seed_name(self) -> str:
|
def get_seed_name(self) -> str:
|
||||||
"""Retrieves the seed name."""
|
"""Retrieves the seed name."""
|
||||||
|
|||||||
@@ -287,15 +287,15 @@ cdef class LocationStore:
|
|||||||
entry in self.entries[start:start + count] if
|
entry in self.entries[start:start + count] if
|
||||||
entry.location not in checked]
|
entry.location not in checked]
|
||||||
|
|
||||||
def get_remaining(self, state: State, team: int, slot: int) -> List[int]:
|
def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]:
|
||||||
cdef LocationEntry* entry
|
cdef LocationEntry* entry
|
||||||
cdef ap_player_t sender = slot
|
cdef ap_player_t sender = slot
|
||||||
cdef size_t start = self.sender_index[sender].start
|
cdef size_t start = self.sender_index[sender].start
|
||||||
cdef size_t count = self.sender_index[sender].count
|
cdef size_t count = self.sender_index[sender].count
|
||||||
cdef set checked = state[team, slot]
|
cdef set checked = state[team, slot]
|
||||||
return sorted([entry.item for
|
return sorted([(entry.receiver, entry.item) for
|
||||||
entry in self.entries[start:start+count] if
|
entry in self.entries[start:start+count] if
|
||||||
entry.location not in checked])
|
entry.location not in checked])
|
||||||
|
|
||||||
|
|
||||||
@cython.auto_pickle(False)
|
@cython.auto_pickle(False)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
/worlds/clique/ @ThePhar
|
/worlds/clique/ @ThePhar
|
||||||
|
|
||||||
# Dark Souls III
|
# Dark Souls III
|
||||||
/worlds/dark_souls_3/ @Marechal-L
|
/worlds/dark_souls_3/ @Marechal-L @nex3
|
||||||
|
|
||||||
# Donkey Kong Country 3
|
# Donkey Kong Country 3
|
||||||
/worlds/dkc3/ @PoryGone
|
/worlds/dkc3/ @PoryGone
|
||||||
@@ -78,6 +78,9 @@
|
|||||||
# Kirby's Dream Land 3
|
# Kirby's Dream Land 3
|
||||||
/worlds/kdl3/ @Silvris
|
/worlds/kdl3/ @Silvris
|
||||||
|
|
||||||
|
# Kingdom Hearts
|
||||||
|
/worlds/kh1/ @gaithern
|
||||||
|
|
||||||
# Kingdom Hearts 2
|
# Kingdom Hearts 2
|
||||||
/worlds/kh2/ @JaredWeakStrike
|
/worlds/kh2/ @JaredWeakStrike
|
||||||
|
|
||||||
@@ -103,6 +106,9 @@
|
|||||||
# Minecraft
|
# Minecraft
|
||||||
/worlds/minecraft/ @KonoTyran @espeon65536
|
/worlds/minecraft/ @KonoTyran @espeon65536
|
||||||
|
|
||||||
|
# Mega Man 2
|
||||||
|
/worlds/mm2/ @Silvris
|
||||||
|
|
||||||
# MegaMan Battle Network 3
|
# MegaMan Battle Network 3
|
||||||
/worlds/mmbn3/ @digiholic
|
/worlds/mmbn3/ @digiholic
|
||||||
|
|
||||||
@@ -112,8 +118,8 @@
|
|||||||
# Noita
|
# Noita
|
||||||
/worlds/noita/ @ScipioWright @heinermann
|
/worlds/noita/ @ScipioWright @heinermann
|
||||||
|
|
||||||
# Ocarina of Time
|
# Old School Runescape
|
||||||
/worlds/oot/ @espeon65536
|
/worlds/osrs @digiholic
|
||||||
|
|
||||||
# Overcooked! 2
|
# Overcooked! 2
|
||||||
/worlds/overcooked2/ @toasterparty
|
/worlds/overcooked2/ @toasterparty
|
||||||
@@ -193,6 +199,9 @@
|
|||||||
# The Witness
|
# The Witness
|
||||||
/worlds/witness/ @NewSoupVi @blastron
|
/worlds/witness/ @NewSoupVi @blastron
|
||||||
|
|
||||||
|
# Yacht Dice
|
||||||
|
/worlds/yachtdice/ @spinerak
|
||||||
|
|
||||||
# Yoshi's Island
|
# Yoshi's Island
|
||||||
/worlds/yoshisisland/ @PinkSwitch
|
/worlds/yoshisisland/ @PinkSwitch
|
||||||
|
|
||||||
@@ -218,6 +227,9 @@
|
|||||||
# Links Awakening DX
|
# Links Awakening DX
|
||||||
# /worlds/ladx/
|
# /worlds/ladx/
|
||||||
|
|
||||||
|
# Ocarina of Time
|
||||||
|
# /worlds/oot/
|
||||||
|
|
||||||
## Disabled Unmaintained Worlds
|
## Disabled Unmaintained Worlds
|
||||||
|
|
||||||
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
|
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ Additional arguments added to the [Set](#Set) package that triggered this [SetRe
|
|||||||
These packets are sent purely from client to server. They are not accepted by clients.
|
These packets are sent purely from client to server. They are not accepted by clients.
|
||||||
|
|
||||||
* [Connect](#Connect)
|
* [Connect](#Connect)
|
||||||
|
* [ConnectUpdate](#ConnectUpdate)
|
||||||
* [Sync](#Sync)
|
* [Sync](#Sync)
|
||||||
* [LocationChecks](#LocationChecks)
|
* [LocationChecks](#LocationChecks)
|
||||||
* [LocationScouts](#LocationScouts)
|
* [LocationScouts](#LocationScouts)
|
||||||
@@ -395,6 +396,7 @@ Some special keys exist with specific return data, all of them have the prefix `
|
|||||||
| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. |
|
| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. |
|
||||||
| location_name_groups_{game_name} | dict\[str, list\[str\]\] | location_name_groups belonging to the requested game. |
|
| location_name_groups_{game_name} | dict\[str, list\[str\]\] | location_name_groups belonging to the requested game. |
|
||||||
| client_status_{team}_{slot} | [ClientStatus](#ClientStatus) | The current game status of the requested player. |
|
| client_status_{team}_{slot} | [ClientStatus](#ClientStatus) | The current game status of the requested player. |
|
||||||
|
| race_mode | int | 0 if race mode is disabled, and 1 if it's enabled. |
|
||||||
|
|
||||||
### Set
|
### Set
|
||||||
Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package.
|
Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package.
|
||||||
@@ -702,14 +704,18 @@ 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. |
|
| checksum | str | A checksum hash of this game's data. |
|
||||||
|
|
||||||
### Tags
|
### 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 |
|
| Name | Notes |
|
||||||
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|-----------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
| 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 |
|
| 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. |
|
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
||||||
| 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. |
|
| 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.
|
||||||
|
|
||||||
### DeathLink
|
### 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:
|
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ display as `Value1` on the webhost.
|
|||||||
files, and both will resolve as `value1`. This should be used when changing options around, i.e. changing a Toggle to a
|
files, and both will resolve as `value1`. This should be used when changing options around, i.e. changing a Toggle to a
|
||||||
Choice, and defining `alias_true = option_full`.
|
Choice, and defining `alias_true = option_full`.
|
||||||
- All options with a fixed set of possible values (i.e. those which inherit from `Toggle`, `(Text)Choice` or
|
- All options with a fixed set of possible values (i.e. those which inherit from `Toggle`, `(Text)Choice` or
|
||||||
`(Named/Special)Range`) support `random` as a generic option. `random` chooses from any of the available values for that
|
`(Named)Range`) support `random` as a generic option. `random` chooses from any of the available values for that
|
||||||
option, and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`.
|
option, and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`.
|
||||||
However, you can override `from_text` and handle `text == "random"` to customize its behavior or
|
However, you can override `from_text` and handle `text == "random"` to customize its behavior or
|
||||||
implement it for additional option types.
|
implement it for additional option types.
|
||||||
@@ -129,6 +129,23 @@ class Difficulty(Choice):
|
|||||||
default = 1
|
default = 1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Option Visibility
|
||||||
|
Every option has a Visibility IntFlag, defaulting to `all` (`0b1111`). This lets you choose where the option will be
|
||||||
|
displayed. This only impacts where options are displayed, not how they can be used. Hidden options are still valid
|
||||||
|
options in a yaml. The flags are as follows:
|
||||||
|
* `none` (`0b0000`): This option is not shown anywhere
|
||||||
|
* `template` (`0b0001`): This option shows up in template yamls
|
||||||
|
* `simple_ui` (`0b0010`): This option shows up on the options page
|
||||||
|
* `complex_ui` (`0b0100`): This option shows up on the advanced/weighted options page
|
||||||
|
* `spoiler` (`0b1000`): This option shows up in spoiler logs
|
||||||
|
|
||||||
|
```python
|
||||||
|
from Options import Choice, Visibility
|
||||||
|
|
||||||
|
class HiddenChoiceOption(Choice):
|
||||||
|
visibility = Visibility.none
|
||||||
|
```
|
||||||
|
|
||||||
### Option Groups
|
### Option Groups
|
||||||
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
|
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
|
by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use that version. These steps are for developers or platforms without compiled r
|
|||||||
|
|
||||||
What you'll need:
|
What you'll need:
|
||||||
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
|
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
|
||||||
* **Python 3.12 is currently unsupported**
|
* Python 3.12.x is currently the newest supported version
|
||||||
* pip: included in downloads from python.org, separate in many Linux distributions
|
* pip: included in downloads from python.org, separate in many Linux distributions
|
||||||
* Matching C compiler
|
* Matching C compiler
|
||||||
* possibly optional, read operating system specific sections
|
* possibly optional, read operating system specific sections
|
||||||
@@ -31,14 +31,14 @@ After this, you should be able to run the programs.
|
|||||||
|
|
||||||
Recommended steps
|
Recommended steps
|
||||||
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
|
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
|
||||||
* **Python 3.12 is currently unsupported**
|
* [read above](#General) which versions are supported
|
||||||
|
|
||||||
* **Optional**: Download and install Visual Studio Build Tools from
|
* **Optional**: Download and install Visual Studio Build Tools from
|
||||||
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/).
|
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/).
|
||||||
* Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details.
|
* Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details.
|
||||||
Generally, selecting the box for "Desktop Development with C++" will provide what you need.
|
Generally, selecting the box for "Desktop Development with C++" will provide what you need.
|
||||||
* Build tools are not required if all modules are installed pre-compiled. Pre-compiled modules are pinned on
|
* Build tools are not required if all modules are installed pre-compiled. Pre-compiled modules are pinned on
|
||||||
[Discord in #archipelago-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
|
[Discord in #ap-core-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
|
||||||
|
|
||||||
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
|
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
|
||||||
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
|
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
|
||||||
|
|||||||
@@ -303,6 +303,31 @@ generation (entrance randomization).
|
|||||||
An access rule is a function that returns `True` or `False` for a `Location` or `Entrance` based on the current `state`
|
An access rule is a function that returns `True` or `False` for a `Location` or `Entrance` based on the current `state`
|
||||||
(items that have been collected).
|
(items that have been collected).
|
||||||
|
|
||||||
|
The two possible ways to make a [CollectionRule](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10) are:
|
||||||
|
- `def rule(state: CollectionState) -> bool:`
|
||||||
|
- `lambda state: ... boolean expression ...`
|
||||||
|
|
||||||
|
An access rule can be assigned through `set_rule(location, rule)`.
|
||||||
|
|
||||||
|
Access rules usually check for one of two things.
|
||||||
|
- Items that have been collected (e.g. `state.has("Sword", player)`)
|
||||||
|
- Locations, Regions or Entrances that have been reached (e.g. `state.can_reach_region("Boss Room")`)
|
||||||
|
|
||||||
|
Keep in mind that entrances and locations implicitly check for the accessibility of their parent region, so you do not need to check explicitly for it.
|
||||||
|
|
||||||
|
#### An important note on Entrance access rules:
|
||||||
|
When using `state.can_reach` within an entrance access condition, you must also use `multiworld.register_indirect_condition`.
|
||||||
|
|
||||||
|
For efficiency reasons, every time reachable regions are searched, every entrance is only checked once in a somewhat non-deterministic order.
|
||||||
|
This is fine when checking for items using `state.has`, because items do not change during a region sweep.
|
||||||
|
However, `state.can_reach` checks for the very same thing we are updating: Regions.
|
||||||
|
This can lead to non-deterministic behavior and, in the worst case, even generation failures.
|
||||||
|
Even doing `state.can_reach_location` or `state.can_reach_entrance` is problematic, as these functions call `state.can_reach_region` on the respective parent region.
|
||||||
|
|
||||||
|
**Therefore, it is considered unsafe to perform `state.can_reach` from within an access condition for an entrance**, unless you are checking for something that sits in the source region of the entrance.
|
||||||
|
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
|
||||||
|
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
|
||||||
|
|
||||||
### Item Rules
|
### Item Rules
|
||||||
|
|
||||||
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
|
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
|
||||||
@@ -630,7 +655,7 @@ def set_rules(self) -> None:
|
|||||||
|
|
||||||
Custom methods can be defined for your logic rules. The access rule that ultimately gets assigned to the Location or
|
Custom methods can be defined for your logic rules. The access rule that ultimately gets assigned to the Location or
|
||||||
Entrance should be
|
Entrance should be
|
||||||
a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L9).
|
a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10).
|
||||||
Typically, this is done by defining a lambda expression on demand at the relevant bit, typically calling other
|
Typically, this is done by defining a lambda expression on demand at the relevant bit, typically calling other
|
||||||
functions, but this can also be achieved by defining a method with the appropriate format and assigning it directly.
|
functions, but this can also be achieved by defining a method with the appropriate format and assigning it directly.
|
||||||
For an example, see [The Messenger](/worlds/messenger/rules.py).
|
For an example, see [The Messenger](/worlds/messenger/rules.py).
|
||||||
|
|||||||
@@ -26,8 +26,17 @@ Unless these are shared between multiple people, we expect the following from ea
|
|||||||
### Adding a World
|
### Adding a World
|
||||||
|
|
||||||
When we merge your world into the core Archipelago repository, you automatically become world maintainer unless you
|
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). You can define who is allowed to approve changes to your world
|
nominate someone else (i.e. there are multiple devs).
|
||||||
in the [CODEOWNERS](/docs/CODEOWNERS) document.
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
### Getting Voted
|
### Getting Voted
|
||||||
|
|
||||||
@@ -35,7 +44,7 @@ When a world is unmaintained, the [core maintainers](https://github.com/orgs/Arc
|
|||||||
can vote for a new maintainer if there is a candidate.
|
can vote for a new maintainer if there is a candidate.
|
||||||
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
|
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
|
||||||
The time limit is 1 week, but can end early if the majority is reached earlier.
|
The time limit is 1 week, but can end early if the majority is reached earlier.
|
||||||
Voting shall be conducted on Discord in #archipelago-dev.
|
Voting shall be conducted on Discord in #ap-core-dev.
|
||||||
|
|
||||||
## Dropping out
|
## Dropping out
|
||||||
|
|
||||||
@@ -51,7 +60,7 @@ for example when they become unreachable.
|
|||||||
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
|
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
|
||||||
The time limit is 2 weeks, but can end early if the majority is reached earlier AND the world maintainer was pinged and
|
The time limit is 2 weeks, but can end early if the majority is reached earlier AND the world maintainer was pinged and
|
||||||
made their case or was pinged and has been unreachable for more than 2 weeks already.
|
made their case or was pinged and has been unreachable for more than 2 weeks already.
|
||||||
Voting shall be conducted on Discord in #archipelago-dev. Commits that are a direct result of the voting shall include
|
Voting shall be conducted on Discord in #ap-core-dev. Commits that are a direct result of the voting shall include
|
||||||
date, voting members and final result in the commit message.
|
date, voting members and final result in the commit message.
|
||||||
|
|
||||||
## Handling of Unmaintained Worlds
|
## Handling of Unmaintained Worlds
|
||||||
|
|||||||
@@ -186,6 +186,11 @@ 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\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: "{#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: ".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"; 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: "";
|
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
|
||||||
@@ -223,8 +228,8 @@ Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{a
|
|||||||
|
|
||||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
|
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"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
|
||||||
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
|
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoLauncher.exe,0";
|
||||||
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1""";
|
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1""";
|
||||||
|
|
||||||
[Code]
|
[Code]
|
||||||
// See: https://stackoverflow.com/a/51614652/2287576
|
// See: https://stackoverflow.com/a/51614652/2287576
|
||||||
|
|||||||
18
kvui.py
@@ -5,6 +5,8 @@ import typing
|
|||||||
import re
|
import re
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
|
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
import ctypes
|
import ctypes
|
||||||
|
|
||||||
@@ -241,6 +243,9 @@ class ServerLabel(HovererableLabel):
|
|||||||
f"\nYou currently have {ctx.hint_points} points."
|
f"\nYou currently have {ctx.hint_points} points."
|
||||||
elif ctx.hint_cost == 0:
|
elif ctx.hint_cost == 0:
|
||||||
text += "\n!hint is free to use."
|
text += "\n!hint is free to use."
|
||||||
|
if ctx.stored_data and "_read_race_mode" in ctx.stored_data:
|
||||||
|
text += "\nRace mode is enabled." \
|
||||||
|
if ctx.stored_data["_read_race_mode"] else "\nRace mode is disabled."
|
||||||
else:
|
else:
|
||||||
text += f"\nYou are not authenticated yet."
|
text += f"\nYou are not authenticated yet."
|
||||||
|
|
||||||
@@ -534,9 +539,8 @@ class GameManager(App):
|
|||||||
# show Archipelago tab if other logging is present
|
# show Archipelago tab if other logging is present
|
||||||
self.tabs.add_widget(panel)
|
self.tabs.add_widget(panel)
|
||||||
|
|
||||||
hint_panel = TabbedPanelItem(text="Hints")
|
hint_panel = self.add_client_tab("Hints", HintLog(self.json_to_kivy_parser))
|
||||||
self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser)
|
self.log_panels["Hints"] = hint_panel.content
|
||||||
self.tabs.add_widget(hint_panel)
|
|
||||||
|
|
||||||
if len(self.logging_pairs) == 1:
|
if len(self.logging_pairs) == 1:
|
||||||
self.tabs.default_tab_text = "Archipelago"
|
self.tabs.default_tab_text = "Archipelago"
|
||||||
@@ -570,6 +574,14 @@ class GameManager(App):
|
|||||||
|
|
||||||
return self.container
|
return self.container
|
||||||
|
|
||||||
|
def add_client_tab(self, title: str, content: Widget) -> Widget:
|
||||||
|
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content.
|
||||||
|
Returns the new tab widget, with the provided content being placed on the tab as content."""
|
||||||
|
new_tab = TabbedPanelItem(text=title)
|
||||||
|
new_tab.content = content
|
||||||
|
self.tabs.add_widget(new_tab)
|
||||||
|
return new_tab
|
||||||
|
|
||||||
def update_texts(self, dt):
|
def update_texts(self, dt):
|
||||||
if hasattr(self.tabs.content.children[0], "fix_heights"):
|
if hasattr(self.tabs.content.children[0], "fix_heights"):
|
||||||
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
|
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
colorama>=0.4.6
|
colorama>=0.4.6
|
||||||
websockets>=12.0
|
websockets>=13.0.1
|
||||||
PyYAML>=6.0.1
|
PyYAML>=6.0.2
|
||||||
jellyfish>=1.0.3
|
jellyfish>=1.1.0
|
||||||
jinja2>=3.1.4
|
jinja2>=3.1.4
|
||||||
schema>=0.7.7
|
schema>=0.7.7
|
||||||
kivy>=2.3.0
|
kivy>=2.3.0
|
||||||
bsdiff4>=1.2.4
|
bsdiff4>=1.2.4
|
||||||
platformdirs>=4.2.2
|
platformdirs>=4.2.2
|
||||||
certifi>=2024.6.2
|
certifi>=2024.8.30
|
||||||
cython>=3.0.10
|
cython>=3.0.11
|
||||||
cymem>=2.0.8
|
cymem>=2.0.8
|
||||||
orjson>=3.10.3
|
orjson>=3.10.7
|
||||||
typing_extensions>=4.12.1
|
typing_extensions>=4.12.2
|
||||||
|
|||||||
1
setup.py
@@ -66,7 +66,6 @@ non_apworlds: set = {
|
|||||||
"Adventure",
|
"Adventure",
|
||||||
"ArchipIDLE",
|
"ArchipIDLE",
|
||||||
"Archipelago",
|
"Archipelago",
|
||||||
"ChecksFinder",
|
|
||||||
"Clique",
|
"Clique",
|
||||||
"Final Fantasy",
|
"Final Fantasy",
|
||||||
"Lufia II Ancient Cave",
|
"Lufia II Ancient Cave",
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ class TestBase(unittest.TestCase):
|
|||||||
state = CollectionState(self.multiworld)
|
state = CollectionState(self.multiworld)
|
||||||
for item in items:
|
for item in items:
|
||||||
item.classification = ItemClassification.progression
|
item.classification = ItemClassification.progression
|
||||||
state.collect(item, event=True)
|
state.collect(item, prevent_sweep=True)
|
||||||
state.sweep_for_events()
|
state.sweep_for_advancements()
|
||||||
state.update_reachable_regions(1)
|
state.update_reachable_regions(1)
|
||||||
self._state_cache[self.multiworld, tuple(items)] = state
|
self._state_cache[self.multiworld, tuple(items)] = state
|
||||||
return state
|
return state
|
||||||
@@ -221,8 +221,8 @@ class WorldTestBase(unittest.TestCase):
|
|||||||
if isinstance(items, Item):
|
if isinstance(items, Item):
|
||||||
items = (items,)
|
items = (items,)
|
||||||
for item in items:
|
for item in items:
|
||||||
if item.location and item.advancement and item.location in self.multiworld.state.events:
|
if item.location and item.advancement and item.location in self.multiworld.state.advancements:
|
||||||
self.multiworld.state.events.remove(item.location)
|
self.multiworld.state.advancements.remove(item.location)
|
||||||
self.multiworld.state.remove(item)
|
self.multiworld.state.remove(item)
|
||||||
|
|
||||||
def can_reach_location(self, location: str) -> bool:
|
def can_reach_location(self, location: str) -> bool:
|
||||||
@@ -293,13 +293,11 @@ class WorldTestBase(unittest.TestCase):
|
|||||||
if not (self.run_default_tests and self.constructed):
|
if not (self.run_default_tests and self.constructed):
|
||||||
return
|
return
|
||||||
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
|
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
|
||||||
excluded = self.multiworld.worlds[self.player].options.exclude_locations.value
|
|
||||||
state = self.multiworld.get_all_state(False)
|
state = self.multiworld.get_all_state(False)
|
||||||
for location in self.multiworld.get_locations():
|
for location in self.multiworld.get_locations():
|
||||||
if location.name not in excluded:
|
with self.subTest("Location should be reached", location=location.name):
|
||||||
with self.subTest("Location should be reached", location=location.name):
|
reachable = location.can_reach(state)
|
||||||
reachable = location.can_reach(state)
|
self.assertTrue(reachable, f"{location.name} unreachable")
|
||||||
self.assertTrue(reachable, f"{location.name} unreachable")
|
|
||||||
with self.subTest("Beatable"):
|
with self.subTest("Beatable"):
|
||||||
self.multiworld.state = state
|
self.multiworld.state = state
|
||||||
self.assertBeatable(True)
|
self.assertBeatable(True)
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ class TestFillRestrictive(unittest.TestCase):
|
|||||||
player1 = generate_player_data(multiworld, 1, 3, 3)
|
player1 = generate_player_data(multiworld, 1, 3, 3)
|
||||||
player2 = generate_player_data(multiworld, 2, 3, 3)
|
player2 = generate_player_data(multiworld, 2, 3, 3)
|
||||||
|
|
||||||
multiworld.accessibility[player1.id].value = multiworld.accessibility[player1.id].option_minimal
|
multiworld.worlds[player1.id].options.accessibility.value = Accessibility.option_minimal
|
||||||
multiworld.accessibility[player2.id].value = multiworld.accessibility[player2.id].option_locations
|
multiworld.worlds[player2.id].options.accessibility.value = Accessibility.option_full
|
||||||
|
|
||||||
multiworld.completion_condition[player1.id] = lambda state: True
|
multiworld.completion_condition[player1.id] = lambda state: True
|
||||||
multiworld.completion_condition[player2.id] = lambda state: state.has(player2.prog_items[2].name, player2.id)
|
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
|
location_pool = player1.locations[1:] + player2.locations
|
||||||
item_pool = player1.prog_items[:-1] + player2.prog_items
|
item_pool = player1.prog_items[:-1] + player2.prog_items
|
||||||
fill_restrictive(multiworld, multiworld.state, location_pool, item_pool)
|
fill_restrictive(multiworld, multiworld.state, location_pool, item_pool)
|
||||||
multiworld.state.sweep_for_events() # collect everything
|
multiworld.state.sweep_for_advancements() # collect everything
|
||||||
|
|
||||||
# all of player2's locations and items should be accessible (not all of player1's)
|
# all of player2's locations and items should be accessible (not all of player1's)
|
||||||
for item in player2.prog_items:
|
for item in player2.prog_items:
|
||||||
@@ -443,8 +443,8 @@ class TestFillRestrictive(unittest.TestCase):
|
|||||||
item = player1.prog_items[0]
|
item = player1.prog_items[0]
|
||||||
item.code = None
|
item.code = None
|
||||||
location.place_locked_item(item)
|
location.place_locked_item(item)
|
||||||
multiworld.state.sweep_for_events()
|
multiworld.state.sweep_for_advancements()
|
||||||
multiworld.state.sweep_for_events()
|
multiworld.state.sweep_for_advancements()
|
||||||
self.assertTrue(multiworld.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed")
|
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")
|
self.assertEqual(multiworld.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times")
|
||||||
|
|
||||||
|
|||||||