diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000000..537a05f68b67
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+worlds/blasphemous/region_data.py linguist-generated=true
diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml
index 3ad29b007772..9a3a6d11217f 100644
--- a/.github/workflows/unittests.yml
+++ b/.github/workflows/unittests.yml
@@ -37,12 +37,13 @@ jobs:
- {version: '3.9'}
- {version: '3.10'}
- {version: '3.11'}
+ - {version: '3.12'}
include:
- python: {version: '3.8'} # win7 compat
os: windows-latest
- - python: {version: '3.11'} # current
+ - python: {version: '3.12'} # current
os: windows-latest
- - python: {version: '3.11'} # current
+ - python: {version: '3.12'} # current
os: macos-latest
steps:
@@ -70,7 +71,7 @@ jobs:
os:
- ubuntu-latest
python:
- - {version: '3.11'} # current
+ - {version: '3.12'} # current
steps:
- uses: actions/checkout@v4
diff --git a/.gitignore b/.gitignore
index 5686f43de380..791f7b1bb7fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -150,7 +150,7 @@ venv/
ENV/
env.bak/
venv.bak/
-.code-workspace
+*.code-workspace
shell.nix
# Spyder project settings
diff --git a/BaseClasses.py b/BaseClasses.py
index 88857f803212..b40b872f0c8c 100644
--- a/BaseClasses.py
+++ b/BaseClasses.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-import copy
+import collections
import itertools
import functools
import logging
@@ -11,8 +11,10 @@
from collections import Counter, deque
from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
-from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
- TypedDict, Union, Type, ClassVar
+from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
+ Optional, Protocol, Set, Tuple, Union, Type)
+
+from typing_extensions import NotRequired, TypedDict
import NetUtils
import Options
@@ -22,16 +24,16 @@
from worlds import AutoWorld
-class Group(TypedDict, total=False):
+class Group(TypedDict):
name: str
game: str
world: "AutoWorld.World"
- players: Set[int]
- item_pool: Set[str]
- replacement_items: Dict[int, Optional[str]]
- local_items: Set[str]
- non_local_items: Set[str]
- link_replacement: bool
+ players: AbstractSet[int]
+ item_pool: NotRequired[Set[str]]
+ replacement_items: NotRequired[Dict[int, Optional[str]]]
+ local_items: NotRequired[Set[str]]
+ non_local_items: NotRequired[Set[str]]
+ link_replacement: NotRequired[bool]
class ThreadBarrierProxy:
@@ -48,6 +50,11 @@ def __getattr__(self, name: str) -> Any:
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
+class HasNameAndPlayer(Protocol):
+ name: str
+ player: int
+
+
class MultiWorld():
debug_types = False
player_name: Dict[int, str]
@@ -63,7 +70,6 @@ class MultiWorld():
state: CollectionState
plando_options: PlandoOptions
- accessibility: Dict[int, Options.Accessibility]
early_items: Dict[int, Dict[str, int]]
local_early_items: Dict[int, Dict[str, int]]
local_items: Dict[int, Options.LocalItems]
@@ -157,7 +163,7 @@ def __init__(self, players: int):
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
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
set_player_attr('plando_items', [])
set_player_attr('plando_texts', {})
@@ -166,13 +172,13 @@ def set_player_attr(attr, val):
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
- "world's random object instead (usually self.random)")
+ "world's random object instead (usually self.random)")
self.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]:
return self.player_ids + tuple(self.groups)
- def add_group(self, name: str, game: str, players: 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.
If a group of this name already exists, the set of players is extended instead of creating a new one."""
from worlds import AutoWorld
@@ -196,7 +202,7 @@ def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tu
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"]}
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
@@ -259,7 +265,7 @@ def set_item_links(self):
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
}
- for name, item_link in item_links.items():
+ for _name, item_link in item_links.items():
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
pool = set()
local_items = set()
@@ -288,6 +294,86 @@ def set_item_links(self):
group["non_local_items"] = item_link["non_local_items"]
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
+ def link_items(self) -> None:
+ """Called to link together items in the itempool related to the registered item link groups."""
+ from worlds import AutoWorld
+
+ for group_id, group in self.groups.items():
+ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
+ Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
+ ]:
+ classifications: Dict[str, int] = collections.defaultdict(int)
+ counters = {player: {name: 0 for name in shared_pool} for player in players}
+ for item in self.itempool:
+ if item.player in counters and item.name in shared_pool:
+ counters[item.player][item.name] += 1
+ classifications[item.name] |= item.classification
+
+ for player in players.copy():
+ if all([counters[player][item] == 0 for item in shared_pool]):
+ players.remove(player)
+ del (counters[player])
+
+ if not players:
+ return None, None
+
+ for item in shared_pool:
+ count = min(counters[player][item] for player in players)
+ if count:
+ for player in players:
+ counters[player][item] = count
+ else:
+ for player in players:
+ del (counters[player][item])
+ return counters, classifications
+
+ common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
+ if not common_item_count:
+ continue
+
+ new_itempool: List[Item] = []
+ for item_name, item_count in next(iter(common_item_count.values())).items():
+ for _ in range(item_count):
+ new_item = group["world"].create_item(item_name)
+ # mangle together all original classification bits
+ new_item.classification |= classifications[item_name]
+ new_itempool.append(new_item)
+
+ region = Region("Menu", group_id, self, "ItemLink")
+ self.regions.append(region)
+ locations = region.locations
+ for item in self.itempool:
+ count = common_item_count.get(item.player, {}).get(item.name, 0)
+ if count:
+ loc = Location(group_id, f"Item Link: {item.name} -> {self.player_name[item.player]} {count}",
+ None, region)
+ loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
+ state.has(item_name, group_id_, count_)
+
+ locations.append(loc)
+ loc.place_locked_item(item)
+ common_item_count[item.player][item.name] -= 1
+ else:
+ new_itempool.append(item)
+
+ itemcount = len(self.itempool)
+ self.itempool = new_itempool
+
+ while itemcount > len(self.itempool):
+ items_to_add = []
+ for player in group["players"]:
+ if group["link_replacement"]:
+ item_player = group_id
+ else:
+ item_player = player
+ if group["replacement_items"][player]:
+ items_to_add.append(AutoWorld.call_single(self, "create_item", item_player,
+ group["replacement_items"][player]))
+ else:
+ items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player))
+ self.random.shuffle(items_to_add)
+ self.itempool.extend(items_to_add[:itemcount - len(self.itempool)])
+
def secure(self):
self.random = ThreadBarrierProxy(secrets.SystemRandom())
self.is_race = True
@@ -309,7 +395,7 @@ def get_game_worlds(self, game_name: str):
return tuple(world for player, world in self.worlds.items() if
player not in self.groups and self.game[player] == game_name)
- def get_name_string_for_object(self, obj) -> 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)})'
def get_player_name(self, player: int) -> str:
@@ -351,7 +437,7 @@ def get_all_state(self, use_cache: bool) -> CollectionState:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
- ret.sweep_for_events()
+ ret.sweep_for_advancements()
if use_cache:
self._all_state = ret
@@ -360,7 +446,7 @@ def get_all_state(self, use_cache: bool) -> CollectionState:
def get_items(self) -> List[Item]:
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:
player_groups = self.get_player_groups(player)
return [location for location in self.get_locations() if
@@ -369,7 +455,7 @@ def find_item_locations(self, item, player: int, resolve_group_locations: bool =
return [location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player]
- def find_item(self, item, player: int) -> Location:
+ def find_item(self, item: str, player: int) -> Location:
return next(location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player)
@@ -462,9 +548,9 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> boo
return True
state = starting_state.copy()
else:
- if self.has_beaten_game(self.state):
- return True
state = CollectionState(self)
+ if self.has_beaten_game(state):
+ return True
prog_locations = {location for location in self.get_locations() if location.item
and location.item.advancement and location not in state.locations_checked}
@@ -523,26 +609,21 @@ def fulfills_accessibility(self, state: Optional[CollectionState] = None):
players: Dict[str, Set[int]] = {
"minimal": set(),
"items": set(),
- "locations": set()
+ "full": set()
}
- for player, access in self.accessibility.items():
- players[access.current_key].add(player)
+ for player, world in self.worlds.items():
+ players[world.options.accessibility.current_key].add(player)
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"""
- if location.player in players["locations"] or (location.item and location.item.player not in
- players["minimal"]):
- return True
- return False
+ return location.player in players["full"] or \
+ (location.item and location.item.player not in players["minimal"])
- def location_relevant(location: Location):
+ def location_relevant(location: Location) -> bool:
"""Determine if this location is relevant to sweep."""
- if location.progress_type != LocationProgressType.EXCLUDED \
- and (location.player in players["locations"] or location.advancement):
- return True
- return False
+ return location.player in players["full"] or location.advancement
def all_done() -> bool:
"""Check if all access rules are fulfilled"""
@@ -587,7 +668,7 @@ class CollectionState():
multiworld: MultiWorld
reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]]
- events: Set[Location]
+ advancements: Set[Location]
path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location]
stale: Dict[int, bool]
@@ -599,7 +680,7 @@ def __init__(self, parent: MultiWorld):
self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
- self.events = set()
+ self.advancements = set()
self.path = {}
self.locations_checked = set()
self.stale = {player: True for player in parent.get_all_ids()}
@@ -611,17 +692,25 @@ def __init__(self, parent: MultiWorld):
def update_reachable_regions(self, player: int):
self.stale[player] = False
+ world: AutoWorld.World = self.multiworld.worlds[player]
reachable_regions = self.reachable_regions[player]
- blocked_connections = 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
if start not in reachable_regions:
reachable_regions.add(start)
- blocked_connections.update(start.exits)
+ self.blocked_connections[player].update(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
while queue:
connection = queue.popleft()
@@ -641,16 +730,39 @@ def update_reachable_regions(self, player: int):
if new_entrance in blocked_connections and new_entrance not in queue:
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:
ret = CollectionState(self.multiworld)
- ret.prog_items = copy.deepcopy(self.prog_items)
- ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
- self.reachable_regions}
- ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
- self.blocked_connections}
- ret.events = copy.copy(self.events)
- ret.path = copy.copy(self.path)
- ret.locations_checked = copy.copy(self.locations_checked)
+ ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()}
+ ret.reachable_regions = {player: region_set.copy() for player, region_set in
+ self.reachable_regions.items()}
+ ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in
+ self.blocked_connections.items()}
+ ret.advancements = self.advancements.copy()
+ ret.path = self.path.copy()
+ ret.locations_checked = self.locations_checked.copy()
for function in self.additional_copy_functions:
ret = function(self, ret)
return ret
@@ -680,20 +792,25 @@ def can_reach_entrance(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)
- 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:
locations = self.multiworld.get_filled_locations()
- reachable_events = True
- # since the loop has a good chance to run more than once, only filter the events once
- locations = {location for location in locations if location.advancement and location not in self.events and
- not key_only or getattr(location.item, "locked_dungeon_item", False)}
- while reachable_events:
- reachable_events = {location for location in locations if location.can_reach(self)}
- locations -= reachable_events
- for event in reachable_events:
- self.events.add(event)
- assert isinstance(event.item, Item), "tried to collect Event with no Item"
- self.collect(event.item, True, event)
+ reachable_advancements = True
+ # since the loop has a good chance to run more than once, only filter the advancements once
+ locations = {location for location in locations if location.advancement and location not in self.advancements}
+
+ while reachable_advancements:
+ reachable_advancements = {location for location in locations if location.can_reach(self)}
+ locations -= reachable_advancements
+ for advancement in reachable_advancements:
+ self.advancements.add(advancement)
+ assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
+ self.collect(advancement.item, True, advancement)
# item name related
def has(self, item: str, player: int, count: int = 1) -> bool:
@@ -727,7 +844,7 @@ def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool:
if found >= count:
return True
return False
-
+
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
Ignores duplicates of the same item."""
@@ -742,7 +859,7 @@ def has_from_list_unique(self, items: Iterable[str], player: int, count: int) ->
def count_from_list(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state."""
return sum(self.prog_items[player][item_name] for item_name in items)
-
+
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
@@ -788,20 +905,16 @@ def count_group_unique(self, item_name_group: str, player: int) -> int:
)
# 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:
self.locations_checked.add(location)
changed = self.multiworld.worlds[item.player].collect(self, item)
- if not changed and event:
- self.prog_items[item.player][item.name] += 1
- changed = True
-
self.stale[item.player] = True
- if changed and not event:
- self.sweep_for_events()
+ if changed and not prevent_sweep:
+ self.sweep_for_advancements()
return changed
@@ -825,7 +938,7 @@ class Entrance:
addresses = 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.parent_region = parent
self.player = player
@@ -845,9 +958,6 @@ def connect(self, region: Region, addresses: Any = None, target: Any = None) ->
region.entrances.append(self)
def __repr__(self):
- return self.__str__()
-
- def __str__(self):
multiworld = self.parent_region.multiworld if self.parent_region else None
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
@@ -973,7 +1083,7 @@ def add_locations(self, locations: Dict[str, Optional[int]],
self.locations.append(location_type(self.player, location, address, self))
def connect(self, connecting_region: Region, name: Optional[str] = None,
- rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
+ rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
"""
Connects this Region to another Region, placing the provided rule on the connection.
@@ -1013,9 +1123,6 @@ def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
rules[connecting_region] if rules and connecting_region in rules else None)
def __repr__(self):
- return self.__str__()
-
- def __str__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
@@ -1034,9 +1141,9 @@ class Location:
locked: bool = False
show_in_spoiler: bool = True
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)
- item_rule = staticmethod(lambda item: True)
+ item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
item: Optional[Item] = None
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
@@ -1045,16 +1152,20 @@ def __init__(self, player: int, name: str = '', address: Optional[int] = None, p
self.address = address
self.parent_region = parent
- def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
- return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
- or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
- and self.item_rule(item)
- and (not check_access or self.can_reach(state))))
+ def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool:
+ return ((
+ self.always_allow(state, item)
+ and item.name not in state.multiworld.worlds[item.player].options.non_local_items
+ ) or (
+ (self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
+ and self.item_rule(item)
+ and (not check_access or self.can_reach(state))
+ ))
def can_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"
- 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):
if self.item:
@@ -1064,9 +1175,6 @@ def place_locked_item(self, item: Item):
self.locked = True
def __repr__(self):
- return self.__str__()
-
- def __str__(self):
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
@@ -1088,7 +1196,7 @@ def is_event(self) -> bool:
@property
def native_item(self) -> bool:
"""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
def hint_text(self) -> str:
@@ -1099,7 +1207,7 @@ class ItemClassification(IntFlag):
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
progression = 0b0001 # Item that is logically relevant
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
- trap = 0b0100 # detrimental or entirely useless (nothing) item
+ trap = 0b0100 # detrimental item
skip_balancing = 0b1000 # should technically never occur on its own
# Item that is logically relevant, but progression balancing should not touch.
# Typically currency or other counted items.
@@ -1171,9 +1279,6 @@ def __hash__(self) -> int:
return hash((self.name, self.player))
def __repr__(self) -> str:
- return self.__str__()
-
- def __str__(self) -> str:
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
return self.location.parent_region.multiworld.get_name_string_for_object(self)
return f"{self.name} (Player {self.player})"
@@ -1251,9 +1356,9 @@ def create_playthrough(self, create_paths: bool = True) -> None:
# in the second phase, we cull each sphere such that the game is still beatable,
# reducing each range of influence to the bare minimum required inside it
- restore_later = {}
+ restore_later: Dict[Location, Item] = {}
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
- to_delete = set()
+ to_delete: Set[Location] = set()
for location in sphere:
# we remove the item at location and check if game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
@@ -1271,7 +1376,7 @@ def create_playthrough(self, create_paths: bool = True) -> None:
sphere -= to_delete
# 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):
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
multiworld.precollected_items[item.player].remove(item)
@@ -1291,8 +1396,6 @@ def create_playthrough(self, create_paths: bool = True) -> None:
state = CollectionState(multiworld)
collection_spheres = []
while required_locations:
- state.sweep_for_events(key_only=True)
-
sphere = set(filter(state.can_reach, required_locations))
for location in sphere:
@@ -1354,7 +1457,7 @@ def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, st
# Maybe move the big bomb over to the Event system instead?
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
for (_, exit_path) in path):
- if multiworld.mode[player] != 'inverted':
+ if multiworld.worlds[player].options.mode != 'inverted':
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
get_path(state, multiworld.get_region('Big Bomb Shop', player))
else:
@@ -1426,9 +1529,9 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
if self.paths:
outfile.write('\n\nPaths:\n\n')
- path_listings = []
+ path_listings: List[str] = []
for location, path in sorted(self.paths.items()):
- path_lines = []
+ path_lines: List[str] = []
for region, exit in path:
if exit is not None:
path_lines.append("{} -> {}".format(region, exit))
diff --git a/CommonClient.py b/CommonClient.py
index f8d1fcb7a221..122de476feca 100644
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -61,6 +61,7 @@ def _cmd_connect(self, address: str = "") -> bool:
if address:
self.ctx.server_address = None
self.ctx.username = None
+ self.ctx.password = None
elif not self.ctx.server_address:
self.output("Please specify an address.")
return False
@@ -251,7 +252,7 @@ def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int])
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
- ui = None
+ ui: typing.Optional["kvui.GameManager"] = None
ui_task: typing.Optional["asyncio.Task[None]"] = None
input_task: typing.Optional["asyncio.Task[None]"] = None
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
@@ -514,6 +515,7 @@ def update_permissions(self, permissions: typing.Dict[str, int]):
async def shutdown(self):
self.server_address = ""
self.username = None
+ self.password = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed:
await self.server.socket.close()
@@ -660,17 +662,19 @@ def handle_connection_loss(self, msg: str) -> None:
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
- def run_gui(self):
- """Import kivy UI system and start running it as self.ui_task."""
+ def make_gui(self) -> type:
+ """To return the Kivy App class needed for run_gui so it can be overridden before being built"""
from kvui import GameManager
class TextManager(GameManager):
- logging_pairs = [
- ("Client", "Archipelago")
- ]
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")
def run_cli(self):
@@ -992,7 +996,7 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser
-def run_as_textclient():
+def run_as_textclient(*args):
class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry
tags = CommonContext.tags | {"TextOnly"}
@@ -1031,7 +1035,7 @@ async def main(args):
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("url", nargs="?", help="Archipelago connection url")
- args = parser.parse_args()
+ args = parser.parse_args(args)
if args.url:
url = urllib.parse.urlparse(args.url)
@@ -1049,4 +1053,4 @@ async def main(args):
if __name__ == '__main__':
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
diff --git a/Fill.py b/Fill.py
index 4967ff073601..e2fcff00358e 100644
--- a/Fill.py
+++ b/Fill.py
@@ -12,7 +12,12 @@
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:
@@ -24,7 +29,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
new_state = base_state.copy()
for item in itempool:
new_state.collect(item, True)
- new_state.sweep_for_events(locations=locations)
+ new_state.sweep_for_advancements(locations=locations)
return new_state
@@ -212,7 +217,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n"
- f"{', '.join(str(place) for place in placements)}")
+ f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
item_pool.extend(unplaced_items)
@@ -299,7 +304,7 @@ def remaining_fill(multiworld: MultiWorld,
f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n"
- f"{', '.join(str(place) for place in placements)}")
+ f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
itempool.extend(unplaced_items)
@@ -324,8 +329,8 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
pool.append(location.item)
state.remove(location.item)
location.item = None
- if location in state.events:
- state.events.remove(location)
+ if location in state.advancements:
+ state.advancements.remove(location)
locations.append(location)
if pool and locations:
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] = []
loc_indexes_to_remove: typing.Set[int] = set()
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):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
@@ -506,7 +511,8 @@ def mark_for_locking(location: Location):
if progitempool:
raise FillError(
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)
@@ -523,7 +529,8 @@ def mark_for_locking(location: Location):
if excludedlocations:
raise FillError(
f"Not enough filler items for excluded locations. "
- f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
+ f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
+ multiworld=multiworld,
)
restitempool = filleritempool + usefulitempool
@@ -551,7 +558,7 @@ def flood_items(multiworld: MultiWorld) -> None:
progress_done = False
# 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
while not progress_done:
@@ -589,7 +596,7 @@ def flood_items(multiworld: MultiWorld) -> None:
if candidate_item_to_place is not None:
item_to_place = candidate_item_to_place
else:
- raise FillError('No more progress items left to place.')
+ raise FillError('No more progress items left to place.', multiworld=multiworld)
# find item to replace with progress item
location_list = multiworld.get_reachable_locations()
@@ -646,7 +653,6 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
- sphere_state.sweep_for_events(key_only=True, locations=locations)
return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float:
@@ -740,7 +746,7 @@ def item_percentage(player: int, num: int) -> float:
), items_to_test):
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 not multiworld.has_beaten_game(reducing_state):
@@ -823,7 +829,7 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
warn(warning, force)
swept_state = multiworld.state.copy()
- swept_state.sweep_for_events()
+ swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
diff --git a/Generate.py b/Generate.py
index d7dd6523e7f1..6220c0eb8188 100644
--- a/Generate.py
+++ b/Generate.py
@@ -511,7 +511,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
continue
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
if PlandoOptions.items in plando_options:
- ret.plando_items = game_weights.get("plando_items", [])
+ ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
if ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights)
diff --git a/KH1Client.py b/KH1Client.py
new file mode 100644
index 000000000000..4c3ed501901b
--- /dev/null
+++ b/KH1Client.py
@@ -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()
diff --git a/Launcher.py b/Launcher.py
index e4b65be93a68..42f93547cc9d 100644
--- a/Launcher.py
+++ b/Launcher.py
@@ -16,10 +16,11 @@
import shlex
import subprocess
import sys
+import urllib.parse
import webbrowser
from os.path import isfile
from shutil import which
-from typing import Callable, Sequence, Union, Optional
+from typing import Callable, Optional, Sequence, Tuple, Union
import Utils
import settings
@@ -107,7 +108,81 @@ def update_settings():
])
-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:
return None, None
for component in components:
@@ -266,7 +341,7 @@ def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None
if file and component:
run_component(component, file)
else:
- logging.warning(f"unable to identify component for {filename}")
+ logging.warning(f"unable to identify component for {file}")
def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
@@ -299,20 +374,24 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
elif not args:
args = {}
- if args.get("Patch|Game|Component", None) is not None:
- file, component = identify(args["Patch|Game|Component"])
+ path = args.get("Patch|Game|Component|url", None)
+ if path is not None:
+ if path.startswith("archipelago://"):
+ handle_uri(path, args.get("args", ()))
+ return
+ file, component = identify(path)
if file:
args['file'] = file
if component:
args['component'] = 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"]:
update_settings()
- if 'file' in args:
+ if "file" in args:
run_component(args["component"], args["file"], *args["args"])
- elif 'component' in args:
+ elif "component" in args:
run_component(args["component"], *args["args"])
elif not args["update_settings"]:
run_gui()
@@ -322,12 +401,16 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
init_logging('Launcher')
Utils.freeze_support()
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.add_argument("--update_settings", action="store_true",
help="Update host.yaml and exit.")
- run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
- help="Pass either a patch file, a generated game or the name of a component to run.")
+ run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?",
+ 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="*",
help="Arguments to pass to component.")
main(parser.parse_args())
diff --git a/LttPAdjuster.py b/LttPAdjuster.py
index 9c5bd102440b..7e33a3d5efe8 100644
--- a/LttPAdjuster.py
+++ b/LttPAdjuster.py
@@ -14,7 +14,7 @@
from argparse import Namespace
from concurrent.futures import as_completed, ThreadPoolExecutor
from glob import glob
-from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, 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
from tkinter.constants import DISABLED, NORMAL
from urllib.parse import urlparse
@@ -29,7 +29,8 @@
GAME_ALTTP = "A Link to the Past"
-
+WINDOW_MIN_HEIGHT = 525
+WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object):
def __init__(self, sprite_pool):
@@ -242,16 +243,17 @@ def adjustGUI():
from argparse import Namespace
from Utils import __version__ as MWVersion
adjustWindow = Tk()
+ adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
set_icon(adjustWindow)
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
- bottomFrame2 = Frame(adjustWindow)
+ bottomFrame2 = Frame(adjustWindow, padx=8, pady=2)
romFrame, romVar = get_rom_frame(adjustWindow)
- romDialogFrame = Frame(adjustWindow)
+ romDialogFrame = Frame(adjustWindow, padx=8, pady=2)
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
romVar2 = StringVar()
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
@@ -261,9 +263,9 @@ def RomSelect2():
romVar2.set(rom)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
- romDialogFrame.pack(side=TOP, expand=True, fill=X)
- baseRomLabel2.pack(side=LEFT)
- romEntry2.pack(side=LEFT, expand=True, fill=X)
+ romDialogFrame.pack(side=TOP, expand=False, fill=X)
+ baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8))
+ romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
romSelectButton2.pack(side=LEFT)
def adjustRom():
@@ -331,12 +333,11 @@ def saveGUISettings():
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
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))
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
saveButton.pack(side=LEFT, padx=(5,5))
-
bottomFrame2.pack(side=TOP, pady=(5,5))
tkinter_center_window(adjustWindow)
@@ -576,7 +577,7 @@ def hide(self):
def get_rom_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
- romFrame = Frame(parent)
+ romFrame = Frame(parent, padx=8, pady=8)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
romVar = StringVar(value=adjuster_settings.baserom)
romEntry = Entry(romFrame, textvariable=romVar)
@@ -596,20 +597,19 @@ def RomSelect():
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
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)
- romFrame.pack(side=TOP, expand=True, fill=X)
+ romFrame.pack(side=TOP, fill=X)
return romFrame, romVar
def get_rom_options_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
- romOptionsFrame = LabelFrame(parent, text="Rom options")
- romOptionsFrame.columnconfigure(0, weight=1)
- romOptionsFrame.columnconfigure(1, weight=1)
+ romOptionsFrame = LabelFrame(parent, text="Rom options", padx=8, pady=8)
+
for i in range(5):
- romOptionsFrame.rowconfigure(i, weight=1)
+ romOptionsFrame.rowconfigure(i, weight=0, pad=4)
vars = Namespace()
vars.MusicVar = IntVar()
@@ -660,7 +660,7 @@ def SpriteSelect():
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
baseSpriteLabel.pack(side=LEFT)
- spriteEntry.pack(side=LEFT)
+ spriteEntry.pack(side=LEFT, expand=True, fill=X)
spriteSelectButton.pack(side=LEFT)
oofDialogFrame = Frame(romOptionsFrame)
diff --git a/Main.py b/Main.py
index de6b467f93d9..c931e22145a5 100644
--- a/Main.py
+++ b/Main.py
@@ -11,7 +11,8 @@
import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
-from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
+from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
+ flood_items
from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple, get_settings
from settings import get_settings
@@ -100,7 +101,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
multiworld.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early
if remaining_count > 0:
- local_early = multiworld.early_local_items[player].get(item_name, 0)
+ local_early = multiworld.local_early_items[player].get(item_name, 0)
if local_early:
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early
@@ -124,14 +125,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in multiworld.player_ids:
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
+ world_excluded_locations = set()
for location_name in multiworld.worlds[player].options.priority_locations.value:
try:
location = multiworld.get_location(location_name, player)
- except KeyError as e: # failed to find the given location. Check if it's a legitimate location
- if location_name not in multiworld.worlds[player].location_name_to_id:
- raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
- else:
+ except KeyError:
+ continue
+
+ if location.progress_type != LocationProgressType.EXCLUDED:
location.progress_type = LocationProgressType.PRIORITY
+ else:
+ logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.")
+ world_excluded_locations.add(location_name)
+ multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
# Set local and non-local item rules.
if multiworld.players > 1:
@@ -146,6 +152,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.
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
new_items: List[Item] = []
+ old_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options,
"start_inventory_from_pool",
@@ -164,97 +171,26 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items
if not target:
- new_items.extend(multiworld.itempool[i+1:])
+ old_items.extend(multiworld.itempool[i+1:])
break
else:
- new_items.append(item)
+ old_items.append(item)
# leftovers?
if target:
for player, remaining_items in depletion_pool.items():
remaining_items = {name: count for name, count in remaining_items.items() if count}
if remaining_items:
- 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}")
- assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
- multiworld.itempool[:] = new_items
-
- # temporary home for item links, should be moved out of Main
- for group_id, group in multiworld.groups.items():
- def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
- Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
- ]:
- classifications: Dict[str, int] = collections.defaultdict(int)
- counters = {player: {name: 0 for name in shared_pool} for player in players}
- for item in multiworld.itempool:
- if item.player in counters and item.name in shared_pool:
- counters[item.player][item.name] += 1
- classifications[item.name] |= item.classification
-
- for player in players.copy():
- if all([counters[player][item] == 0 for item in shared_pool]):
- players.remove(player)
- del (counters[player])
-
- if not players:
- return None, None
-
- for item in shared_pool:
- count = min(counters[player][item] for player in players)
- if count:
- for player in players:
- counters[player][item] = count
- else:
- for player in players:
- del (counters[player][item])
- return counters, classifications
-
- common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
- if not common_item_count:
- continue
-
- new_itempool: List[Item] = []
- for item_name, item_count in next(iter(common_item_count.values())).items():
- for _ in range(item_count):
- new_item = group["world"].create_item(item_name)
- # mangle together all original classification bits
- new_item.classification |= classifications[item_name]
- new_itempool.append(new_item)
-
- region = Region("Menu", group_id, multiworld, "ItemLink")
- multiworld.regions.append(region)
- locations = region.locations
- for item in multiworld.itempool:
- count = common_item_count.get(item.player, {}).get(item.name, 0)
- if count:
- loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}",
- None, region)
- loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
- state.has(item_name, group_id_, count_)
-
- locations.append(loc)
- loc.place_locked_item(item)
- common_item_count[item.player][item.name] -= 1
- else:
- new_itempool.append(item)
-
- itemcount = len(multiworld.itempool)
- multiworld.itempool = new_itempool
+ # find all filler we generated for the current player and remove until it matches
+ removables = [item for item in new_items if item.player == player]
+ for _ in range(sum(remaining_items.values())):
+ new_items.remove(removables.pop())
+ assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change."
+ multiworld.itempool[:] = new_items + old_items
- 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)])
+ multiworld.link_items()
if any(multiworld.item_links.values()):
multiworld._all_state = None
@@ -411,7 +347,7 @@ def precollect_hint(location):
output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result():
if not multiworld.can_beat_game():
- raise Exception("Game appears as unbeatable. Aborting.")
+ raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld)
else:
logger.warning("Location Accessibility requirements not fulfilled.")
diff --git a/ModuleUpdate.py b/ModuleUpdate.py
index ed041bef4604..f49182bb7863 100644
--- a/ModuleUpdate.py
+++ b/ModuleUpdate.py
@@ -75,13 +75,13 @@ def update(yes: bool = False, force: bool = False) -> None:
if not update_ran:
update_ran = True
+ install_pkg_resources(yes=yes)
+ import pkg_resources
+
if force:
update_command()
return
- install_pkg_resources(yes=yes)
- import pkg_resources
-
prev = "" # if a line ends in \ we store here and merge later
for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
diff --git a/MultiServer.py b/MultiServer.py
index f59855fca6a4..e0b137fd68ce 100644
--- a/MultiServer.py
+++ b/MultiServer.py
@@ -67,6 +67,21 @@ def update_dict(dictionary, entries):
return dictionary
+def queue_gc():
+ import gc
+ from threading import Thread
+
+ gc_thread: typing.Optional[Thread] = getattr(queue_gc, "_thread", None)
+ def async_collect():
+ time.sleep(2)
+ setattr(queue_gc, "_thread", None)
+ gc.collect()
+ if not gc_thread:
+ gc_thread = Thread(target=async_collect)
+ setattr(queue_gc, "_thread", gc_thread)
+ gc_thread.start()
+
+
# functions callable on storable data on the server by clients
modify_functions = {
# generic:
@@ -551,6 +566,9 @@ def get_datetime_second():
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
else:
self.save_dirty = False
+ if not atexit_save: # if atexit is used, that keeps a reference anyway
+ queue_gc()
+
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start()
@@ -991,7 +1009,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
collect_player(ctx, team, group, True)
-def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[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)
@@ -1203,6 +1221,10 @@ def _cmd_countdown(self, seconds: str = "10") -> bool:
timer = int(seconds, 10)
except ValueError:
timer = 10
+ else:
+ if timer > 60 * 60:
+ raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
+
async_start(countdown(self.ctx, timer))
return True
@@ -1350,10 +1372,10 @@ def _cmd_collect(self) -> bool:
def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled":
- remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
- if remaining_item_ids:
- self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
- for item_id in remaining_item_ids))
+ rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
+ if rest_locations:
+ self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
+ for slot, item_id in rest_locations))
else:
self.output("No remaining items found.")
return True
@@ -1363,10 +1385,10 @@ def _cmd_remaining(self) -> bool:
return False
else: # is 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)
- if remaining_item_ids:
- self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
- for item_id in remaining_item_ids))
+ rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
+ if rest_locations:
+ self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
+ for slot, item_id in rest_locations))
else:
self.output("No remaining items found.")
return True
@@ -2039,6 +2061,8 @@ def _cmd_send_multiple(self, amount: typing.Union[int, str], player_name: str, *
item_name, usable, response = get_intended_text(item_name, names)
if usable:
amount: int = int(amount)
+ if amount > 100:
+ raise ValueError(f"{amount} is invalid. Maximum is 100.")
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
send_items_to(self.ctx, team, slot, *new_items)
diff --git a/NetUtils.py b/NetUtils.py
index f8d698c74fcc..c451fa3f8460 100644
--- a/NetUtils.py
+++ b/NetUtils.py
@@ -79,6 +79,7 @@ class NetworkItem(typing.NamedTuple):
item: int
location: int
player: int
+ """ Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
flags: int = 0
@@ -397,12 +398,12 @@ def get_missing(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]
location_id not in checked]
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
- ) -> typing.List[int]:
+ ) -> typing.List[typing.Tuple[int, int]]:
checked = state[team, slot]
player_locations = self[slot]
- return sorted([player_locations[location_id][0] for
- location_id in player_locations if
- location_id not in checked])
+ return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for
+ location_id in player_locations if
+ location_id not in checked])
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
diff --git a/Options.py b/Options.py
index b5fb25ea34a0..ecde6275f1ea 100644
--- a/Options.py
+++ b/Options.py
@@ -786,17 +786,22 @@ class VerifyKeys(metaclass=FreezeValidKeys):
verify_location_name: bool = False
value: typing.Any
- @classmethod
- def verify_keys(cls, data: typing.Iterable[str]) -> None:
- if cls.valid_keys:
- data = set(data)
- dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
- extra = dataset - cls._valid_keys
+ def verify_keys(self) -> None:
+ if self.valid_keys:
+ data = set(self.value)
+ dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
+ extra = dataset - self._valid_keys
if extra:
- raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
- f"Allowed keys: {cls._valid_keys}.")
+ raise OptionError(
+ 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:
+ try:
+ self.verify_keys()
+ except OptionError as validation_error:
+ raise OptionError(f"Player {player_name} has invalid option keys:\n{validation_error}")
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
@@ -833,7 +838,6 @@ def __init__(self, value: typing.Dict[str, typing.Any]):
@classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
if type(data) == dict:
- cls.verify_keys(data)
return cls(data)
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
@@ -879,7 +883,6 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
- cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -905,7 +908,6 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
- cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -948,6 +950,19 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
self.value = []
logging.warning(f"The plando texts module is turned off, "
f"so text for {player_name} will be ignored.")
+ else:
+ super().verify(world, player_name, plando_options)
+
+ def verify_keys(self) -> None:
+ if self.valid_keys:
+ data = set(text.at for text in self)
+ dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
+ extra = dataset - self._valid_keys
+ if extra:
+ raise OptionError(
+ f"Invalid \"at\" placement {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
+ f"Allowed placements: {self._valid_keys}."
+ )
@classmethod
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
@@ -971,7 +986,6 @@ def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
texts.append(text)
else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
- cls.verify_keys([text.at for text in texts])
return cls(texts)
else:
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
@@ -1144,18 +1158,35 @@ def __len__(self) -> int:
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.
- - **Items:** ensure all logically relevant items can be acquired.
- - **Minimal:** ensure what is needed to reach your goal can be acquired.
+ **Minimal:** ensure what is needed to reach your goal can be acquired.
"""
display_name = "Accessibility"
rich_text_doc = True
- option_locations = 0
- option_items = 1
+ option_full = 0
option_minimal = 2
alias_none = 2
+ alias_locations = 0
+ alias_items = 0
+ default = 0
+
+
+class ItemsAccessibility(Accessibility):
+ """
+ Set rules for reachability of your items/locations.
+
+ **Full:** ensure everything can be reached and acquired.
+
+ **Minimal:** ensure what is needed to reach your goal can be acquired.
+
+ **Items:** ensure all logically relevant items can be acquired. Some items, such as keys, may be self-locking, and
+ some locations may be inaccessible.
+ """
+ option_items = 1
default = 1
@@ -1205,6 +1236,7 @@ def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str,
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
"""
+ assert option_names, "options.as_dict() was used without any option names."
option_results = {}
for option_name in option_names:
if option_name in type(self).type_hints:
@@ -1486,31 +1518,3 @@ def yaml_dump_scalar(scalar) -> str:
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res)
-
-
-if __name__ == "__main__":
-
- from worlds.alttp.Options import Logic
- import argparse
-
- map_shuffle = Toggle
- compass_shuffle = Toggle
- key_shuffle = Toggle
- big_key_shuffle = Toggle
- hints = Toggle
- test = argparse.Namespace()
- test.logic = Logic.from_text("no_logic")
- test.map_shuffle = map_shuffle.from_text("ON")
- test.hints = hints.from_text('OFF')
- try:
- test.logic = Logic.from_text("overworld_glitches_typo")
- except KeyError as e:
- print(e)
- try:
- test.logic_owg = Logic.from_text("owg")
- except KeyError as e:
- print(e)
- if test.map_shuffle:
- print("map_shuffle is on")
- print(f"Hints are {bool(test.hints)}")
- print(test)
diff --git a/README.md b/README.md
index cebd4f7e7529..0e57bce53b51 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,10 @@ Currently, the following games are supported:
* Aquaria
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
* A Hat in Time
+* Old School Runescape
+* Kingdom Hearts 1
+* Mega Man 2
+* Yacht Dice
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
diff --git a/UndertaleClient.py b/UndertaleClient.py
index 415d7e7f21a3..dfacee148abc 100644
--- a/UndertaleClient.py
+++ b/UndertaleClient.py
@@ -29,7 +29,7 @@ def _cmd_resync(self):
def _cmd_patch(self):
"""Patch the game. Only use this command if /auto_patch fails."""
if isinstance(self.ctx, UndertaleContext):
- os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
+ os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
self.ctx.patch_game()
self.output("Patched.")
@@ -43,7 +43,7 @@ def _cmd_savepath(self, directory: str):
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext):
- os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
+ os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None
@@ -62,7 +62,7 @@ def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll":
shutil.copy(os.path.join(tempInstall, file_name),
- os.path.join(os.getcwd(), "Undertale", file_name))
+ Utils.user_path("Undertale", file_name))
self.ctx.patch_game()
self.output("Patching successful!")
@@ -111,12 +111,12 @@ def __init__(self, server_address, password):
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def patch_game(self):
- with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f:
+ with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
- with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f:
+ with open(Utils.user_path("Undertale", "data.win"), "wb") as f:
f.write(patchedFile)
- os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
- with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites",
+ os.makedirs(name=Utils.user_path("Undertale", "Custom Sprites"), exist_ok=True)
+ with open(os.path.expandvars(Utils.user_path("Undertale", "Custom Sprites",
"Which Character.txt")), "w") as f:
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
"line other than this one.\n", "frisk"])
diff --git a/WebHost.py b/WebHost.py
index 08ef3c430795..e597de24763d 100644
--- a/WebHost.py
+++ b/WebHost.py
@@ -1,3 +1,4 @@
+import argparse
import os
import multiprocessing
import logging
@@ -31,6 +32,15 @@ def get_app() -> "Flask":
import yaml
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
+ # inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--config_override', default=None,
+ help="Path to yaml config file that overrules config.yaml.")
+ args = parser.parse_known_args()[0]
+ if args.config_override:
+ import yaml
+ app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
+ logging.info(f"Updated config from {args.config_override}")
if not app.config["HOST_ADDRESS"]:
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py
index 9f70165b61e5..a2eef108b0a1 100644
--- a/WebHostLib/customserver.py
+++ b/WebHostLib/customserver.py
@@ -72,6 +72,14 @@ def __init__(self, static_server_data: dict, logger: logging.Logger):
self.video = {}
self.tags = ["AP", "WebHost"]
+ def __del__(self):
+ try:
+ import psutil
+ from Utils import format_SI_prefix
+ self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
+ except ImportError:
+ self.logger.debug("Context destroyed")
+
def _load_game_data(self):
for key, value in self.static_server_data.items():
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
@@ -249,6 +257,7 @@ async def start_room(room_id):
ctx = WebHostContext(static_server_data, logger)
ctx.load(room_id)
ctx.init_save()
+ assert ctx.server is None
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
@@ -279,6 +288,7 @@ async def start_room(room_id):
ctx.auto_shutdown = Room.get(id=room_id).timeout
if ctx.saving:
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
+ assert ctx.shutdown_task is None
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
@@ -325,10 +335,12 @@ def _done(self, task: asyncio.Future):
def run(self):
while 1:
next_room = rooms_to_run.get(block=True, timeout=None)
+ gc.collect()
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task)
task.add_done_callback(self._done)
logging.info(f"Starting room {next_room} on {name}.")
+ del task # delete reference to task object
starter = Starter()
starter.daemon = True
diff --git a/WebHostLib/options.py b/WebHostLib/options.py
index 33339daa1983..15b7bd61ceee 100644
--- a/WebHostLib/options.py
+++ b/WebHostLib/options.py
@@ -231,6 +231,13 @@ def generate_yaml(game: str):
del options[key]
+ # Detect keys which end with -range, indicating a NamedRange with a possible custom value
+ elif key_parts[-1].endswith("-range"):
+ if options[key_parts[-1][:-6]] == "custom":
+ options[key_parts[-1][:-6]] = val
+
+ del options[key]
+
# Detect random-* keys and set their options accordingly
for key, val in options.copy().items():
if key.startswith("random-"):
diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt
index 3452c9d416db..c593cd63df7e 100644
--- a/WebHostLib/requirements.txt
+++ b/WebHostLib/requirements.txt
@@ -1,10 +1,11 @@
flask>=3.0.3
-werkzeug>=3.0.3
-pony>=0.7.17
+werkzeug>=3.0.4
+pony>=0.7.19
waitress>=3.0.0
Flask-Caching>=2.3.0
Flask-Compress>=1.15
-Flask-Limiter>=3.7.0
+Flask-Limiter>=3.8.0
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
diff --git a/WebHostLib/static/assets/faq/faq_en.md b/WebHostLib/static/assets/faq/faq_en.md
index fb1ccd2d6f4a..e64535b42d03 100644
--- a/WebHostLib/static/assets/faq/faq_en.md
+++ b/WebHostLib/static/assets/faq/faq_en.md
@@ -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:
[/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.
diff --git a/WebHostLib/templates/macros.html b/WebHostLib/templates/macros.html
index 7bbb894de090..6b2a4b0ed784 100644
--- a/WebHostLib/templates/macros.html
+++ b/WebHostLib/templates/macros.html
@@ -22,7 +22,7 @@
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
{{ patch.player_id }} |
- {{ patch.player_name }} |
+ {{ patch.player_name }} |
{{ patch.game }} |
{% if patch.data %}
diff --git a/WebHostLib/templates/playerOptions/macros.html b/WebHostLib/templates/playerOptions/macros.html
index 415739b861a1..30a4fc78dff3 100644
--- a/WebHostLib/templates/playerOptions/macros.html
+++ b/WebHostLib/templates/playerOptions/macros.html
@@ -54,7 +54,7 @@
{% macro NamedRange(option_name, option) %}
{{ OptionTitle(option_name, option) }}
- |