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/BaseClasses.py b/BaseClasses.py index 1c7dad7f3b9e..29264f34ab0f 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()} @@ -643,14 +724,14 @@ def update_reachable_regions(self, player: int): 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 @@ -681,19 +762,24 @@ def can_reach_region(self, spot: str, player: int) -> bool: return self.multiworld.get_region(spot, player).can_reach(self) def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None: + Utils.deprecate("sweep_for_events has been renamed to sweep_for_advancements. The functionality is the same. " + "Please switch over to sweep_for_advancements.") + return self.sweep_for_advancements(locations) + + def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None: 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} - - 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 +813,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 +828,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 +874,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 +907,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 +927,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 +1052,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 +1092,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 +1110,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 +1121,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 +1144,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 +1165,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: @@ -1171,9 +1248,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 +1325,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 +1345,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) @@ -1352,7 +1426,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: @@ -1424,9 +1498,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 d0ca33e9144a..070e92e6ed9f 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -252,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 diff --git a/Fill.py b/Fill.py index 5185bbb60ee4..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() @@ -739,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): @@ -822,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 c55abd9061d7..414073e12a58 100644 --- a/Launcher.py +++ b/Launcher.py @@ -266,7 +266,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. 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 56b3a6545db2..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 @@ -151,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", @@ -169,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 @@ -416,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/MultiServer.py b/MultiServer.py index f59855fca6a4..b7c0e0f74555 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -991,7 +991,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) @@ -1350,10 +1350,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 +1363,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 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/WebHostLib/templates/weightedOptions/macros.html b/WebHostLib/templates/weightedOptions/macros.html index a1d319697154..68d3968a178a 100644 --- a/WebHostLib/templates/weightedOptions/macros.html +++ b/WebHostLib/templates/weightedOptions/macros.html @@ -138,7 +138,7 @@ id="{{ option_name }}-{{ key }}" name="{{ option_name }}||{{ key }}" value="1" - checked="{{ "checked" if key in option.default else "" }}" + {{ "checked" if key in option.default }} />