diff --git a/AdventureClient.py b/AdventureClient.py index 06e4d60dad43..7bfbd5ef6bd3 100644 --- a/AdventureClient.py +++ b/AdventureClient.py @@ -112,7 +112,7 @@ def on_package(self, cmd: str, args: dict): if ': !' not in msg: self._set_message(msg, SYSTEM_MESSAGE_ID) elif cmd == "ReceivedItems": - msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}" + msg = f"Received {', '.join([self.item_names.lookup_in_slot(item.item) for item in args['items']])}" self._set_message(msg, SYSTEM_MESSAGE_ID) elif cmd == "Retrieved": if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]: diff --git a/BaseClasses.py b/BaseClasses.py index ada18f1e1d04..88857f803212 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -728,7 +728,7 @@ def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool: return True return False - def has_from_list_exclusive(self, items: Iterable[str], player: int, count: int) -> bool: + def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool: """Returns True if the state contains at least `count` items matching any of the item names from a list. Ignores duplicates of the same item.""" found: int = 0 @@ -743,7 +743,7 @@ 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_exclusive(self, items: Iterable[str], player: int) -> int: + def count_from_list_unique(self, items: Iterable[str], player: int) -> int: """Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item.""" return sum(self.prog_items[player][item_name] > 0 for item_name in items) @@ -758,7 +758,7 @@ def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: return True return False - def has_group_exclusive(self, item_name_group: str, player: int, count: int = 1) -> bool: + def has_group_unique(self, item_name_group: str, player: int, count: int = 1) -> bool: """Returns True if the state contains at least `count` items present in a specified item group. Ignores duplicates of the same item. """ @@ -778,7 +778,7 @@ def count_group(self, item_name_group: str, player: int) -> int: for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group] ) - def count_group_exclusive(self, item_name_group: str, player: int) -> int: + def count_group_unique(self, item_name_group: str, player: int) -> int: """Returns the cumulative count of items from an item group present in state. Ignores duplicates of the same item.""" player_prog_items = self.prog_items[player] diff --git a/CommonClient.py b/CommonClient.py index 63cac098e22a..8af822cba571 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -1,5 +1,6 @@ from __future__ import annotations +import collections import copy import logging import asyncio @@ -8,6 +9,7 @@ import typing import time import functools +import warnings import ModuleUpdate ModuleUpdate.update() @@ -173,10 +175,74 @@ class CommonContext: items_handling: typing.Optional[int] = None want_slot_data: bool = True # should slot_data be retrieved via Connect - # data package - # Contents in flux until connection to server is made, to download correct data for this multiworld. - item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})') - location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})') + class NameLookupDict: + """A specialized dict, with helper methods, for id -> name item/location data package lookups by game.""" + def __init__(self, ctx: CommonContext, lookup_type: typing.Literal["item", "location"]): + self.ctx: CommonContext = ctx + self.lookup_type: typing.Literal["item", "location"] = lookup_type + self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})" + self._archipelago_lookup: typing.Dict[int, str] = {} + self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item) + self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict( + lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item))) + self.warned: bool = False + + # noinspection PyTypeChecker + def __getitem__(self, key: str) -> typing.Mapping[int, str]: + # TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support. + if isinstance(key, int): + if not self.warned: + # Use warnings instead of logger to avoid deprecation message from appearing on user side. + self.warned = True + warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain " + f"backwards compatibility for now. If multiple games share the same id for a " + f"{self.lookup_type}, name could be incorrect. Please use " + f"`{self.lookup_type}_names.lookup_in_game()` or " + f"`{self.lookup_type}_names.lookup_in_slot()` instead.") + return self._flat_store[key] # type: ignore + + return self._game_store[key] + + def __len__(self) -> int: + return len(self._game_store) + + def __iter__(self) -> typing.Iterator[str]: + return iter(self._game_store) + + def __repr__(self) -> str: + return self._game_store.__repr__() + + def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str: + """Returns the name for an item/location id in the context of a specific game or own game if `game` is + omitted. + """ + if game_name is None: + game_name = self.ctx.game + assert game_name is not None, f"Attempted to lookup {self.lookup_type} with no game name available." + + return self._game_store[game_name][code] + + def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str: + """Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is + omitted. + """ + if slot is None: + slot = self.ctx.slot + assert slot is not None, f"Attempted to lookup {self.lookup_type} with no slot info available." + + return self.lookup_in_game(code, self.ctx.slot_info[slot].game) + + def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int]) -> None: + """Overrides existing lookup tables for a particular game.""" + id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item) + id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()}) + self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table) + self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method. + if game == "Archipelago": + # Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage, + # it updates in all chain maps automatically. + self._archipelago_lookup.clear() + self._archipelago_lookup.update(id_to_name_lookup_table) # defaults starting_reconnect_delay: int = 5 @@ -231,7 +297,7 @@ class CommonContext: # message box reporting a loss of connection _messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None - def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None: + def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None: # server state self.server_address = server_address self.username = None @@ -271,6 +337,9 @@ def __init__(self, server_address: typing.Optional[str], password: typing.Option self.exit_event = asyncio.Event() self.watcher_event = asyncio.Event() + self.item_names = self.NameLookupDict(self, "item") + self.location_names = self.NameLookupDict(self, "location") + self.jsontotextparser = JSONtoTextParser(self) self.rawjsontotextparser = RawJSONtoTextParser(self) self.update_data_package(network_data_package) @@ -486,19 +555,17 @@ async def prepare_data_package(self, relevant_games: typing.Set[str], or remote_checksum != cache_checksum: needed_updates.add(game) else: - self.update_game(cached_game) + self.update_game(cached_game, game) if needed_updates: await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates]) - def update_game(self, game_package: dict): - for item_name, item_id in game_package["item_name_to_id"].items(): - self.item_names[item_id] = item_name - for location_name, location_id in game_package["location_name_to_id"].items(): - self.location_names[location_id] = location_name + def update_game(self, game_package: dict, game: str): + self.item_names.update_game(game, game_package["item_name_to_id"]) + self.location_names.update_game(game, game_package["location_name_to_id"]) def update_data_package(self, data_package: dict): for game, game_data in data_package["games"].items(): - self.update_game(game_data) + self.update_game(game_data, game) def consume_network_data_package(self, data_package: dict): self.update_data_package(data_package) diff --git a/Generate.py b/Generate.py index 2bc061b7468b..67988bf8b30d 100644 --- a/Generate.py +++ b/Generate.py @@ -9,6 +9,7 @@ import urllib.request from collections import Counter from typing import Any, Dict, Tuple, Union +from itertools import chain import ModuleUpdate @@ -22,9 +23,7 @@ from settings import get_settings from Utils import parse_yamls, version_tuple, __version__, tuplize_version from worlds.alttp.EntranceRandomizer import parse_arguments -from worlds.alttp.Text import TextTable from worlds.AutoWorld import AutoWorldRegister -from worlds.generic import PlandoConnection from worlds import failed_world_loads @@ -319,18 +318,34 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str logging.debug(f'Applying {new_weights}') cleaned_weights = {} for option in new_weights: - option_name = option.lstrip("+") + option_name = option.lstrip("+-") if option.startswith("+") and option_name in weights: cleaned_value = weights[option_name] new_value = new_weights[option] - if isinstance(new_value, (set, dict)): + if isinstance(new_value, set): cleaned_value.update(new_value) elif isinstance(new_value, list): cleaned_value.extend(new_value) + elif isinstance(new_value, dict): + cleaned_value = dict(Counter(cleaned_value) + Counter(new_value)) else: raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name}," f" received {type(new_value).__name__}.") cleaned_weights[option_name] = cleaned_value + elif option.startswith("-") and option_name in weights: + cleaned_value = weights[option_name] + new_value = new_weights[option] + if isinstance(new_value, set): + cleaned_value.difference_update(new_value) + elif isinstance(new_value, list): + for element in new_value: + cleaned_value.remove(element) + elif isinstance(new_value, dict): + cleaned_value = dict(Counter(cleaned_value) - Counter(new_value)) + else: + raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name}," + f" received {type(new_value).__name__}.") + cleaned_weights[option_name] = cleaned_value else: cleaned_weights[option_name] = new_weights[option] new_options = set(cleaned_weights) - set(weights) @@ -415,7 +430,6 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, player_option = option.from_any(game_weights[option_key]) else: player_option = option.from_any(get_choice(option_key, game_weights)) - del game_weights[option_key] else: player_option = option.from_any(option.default) # call the from_any here to support default "random" setattr(ret, option_key, player_option) @@ -429,9 +443,9 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b if "linked_options" in weights: weights = roll_linked_options(weights) - valid_trigger_names = set() + valid_keys = set() if "triggers" in weights: - weights = roll_triggers(weights, weights["triggers"], valid_trigger_names) + weights = roll_triggers(weights, weights["triggers"], valid_keys) requirements = weights.get("requires", {}) if requirements: @@ -466,12 +480,14 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b world_type = AutoWorldRegister.world_types[ret.game] game_weights = weights[ret.game] - if any(weight.startswith("+") for weight in game_weights) or \ - any(weight.startswith("+") for weight in weights): - raise Exception(f"Merge tag cannot be used outside of trigger contexts.") + for weight in chain(game_weights, weights): + if weight.startswith("+"): + raise Exception(f"Merge tag cannot be used outside of trigger contexts. Found {weight}") + if weight.startswith("-"): + raise Exception(f"Remove tag cannot be used outside of trigger contexts. Found {weight}") if "triggers" in game_weights: - weights = roll_triggers(weights, game_weights["triggers"], valid_trigger_names) + weights = roll_triggers(weights, game_weights["triggers"], valid_keys) game_weights = weights[ret.game] ret.name = get_choice('name', weights) @@ -480,42 +496,20 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b for option_key, option in world_type.options_dataclass.type_hints.items(): handle_option(ret, game_weights, option_key, option, plando_options) + valid_keys.add(option_key) for option_key in game_weights: - if option_key in {"triggers", *valid_trigger_names}: + if option_key in {"triggers", *valid_keys}: 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", []) if ret.game == "A Link to the Past": - roll_alttp_settings(ret, game_weights, plando_options) - if PlandoOptions.connections in plando_options: - ret.plando_connections = [] - options = game_weights.get("plando_connections", []) - for placement in options: - if roll_percentage(get_choice("percentage", placement, 100)): - ret.plando_connections.append(PlandoConnection( - get_choice("entrance", placement), - get_choice("exit", placement), - get_choice("direction", placement, "both") - )) + roll_alttp_settings(ret, game_weights) return ret -def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options): - - ret.plando_texts = {} - if PlandoOptions.texts in plando_options: - tt = TextTable() - tt.removeUnwantedText() - options = weights.get("plando_texts", []) - for placement in options: - if roll_percentage(get_choice_legacy("percentage", placement, 100)): - at = str(get_choice_legacy("at", placement)) - if at not in tt: - raise Exception(f"No text target \"{at}\" found.") - ret.plando_texts[at] = str(get_choice_legacy("text", placement)) - +def roll_alttp_settings(ret: argparse.Namespace, weights): ret.sprite_pool = weights.get('sprite_pool', []) ret.sprite = get_choice_legacy('sprite', weights, "Link") if 'random_sprite_on_event' in weights: diff --git a/Main.py b/Main.py index 8b15a57a69e5..de6b467f93d9 100644 --- a/Main.py +++ b/Main.py @@ -372,6 +372,17 @@ def precollect_hint(location): checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {} + # get spheres -> filter address==None -> skip empty + spheres: List[Dict[int, Set[int]]] = [] + for sphere in multiworld.get_spheres(): + current_sphere: Dict[int, Set[int]] = collections.defaultdict(set) + for sphere_location in sphere: + if type(sphere_location.address) is int: + current_sphere[sphere_location.player].add(sphere_location.address) + + if current_sphere: + spheres.append(dict(current_sphere)) + multidata = { "slot_data": slot_data, "slot_info": slot_info, @@ -386,6 +397,7 @@ def precollect_hint(location): "tags": ["AP"], "minimum_versions": minimum_versions, "seed_name": multiworld.seed_name, + "spheres": spheres, "datapackage": data_package, } AutoWorld.call_all(multiworld, "modify_multidata", multidata) diff --git a/MultiServer.py b/MultiServer.py index e95e44dd7d5c..22375da2b3c5 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -37,7 +37,7 @@ import NetUtils import Utils -from Utils import version_tuple, restricted_loads, Version, async_start +from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ SlotType, LocationStore @@ -168,15 +168,20 @@ class Context: slot_info: typing.Dict[int, NetworkSlot] generator_version = Version(0, 0, 0) checksums: typing.Dict[str, str] - item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})') + item_names: typing.Dict[str, typing.Dict[int, str]] = ( + collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})'))) item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] - location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})') + location_names: typing.Dict[str, typing.Dict[int, str]] = ( + collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))) location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] all_item_and_group_names: typing.Dict[str, typing.Set[str]] all_location_and_group_names: typing.Dict[str, typing.Set[str]] non_hintable_names: typing.Dict[str, typing.Set[str]] + spheres: typing.List[typing.Dict[int, typing.Set[int]]] + """ each sphere is { player: { location_id, ... } } """ logger: logging.Logger + def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, @@ -238,6 +243,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo self.stored_data = {} self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet) self.read_data = {} + self.spheres = [] # init empty to satisfy linter, I suppose self.gamespackage = {} @@ -267,14 +273,21 @@ def _init_game_data(self): if "checksum" in game_package: self.checksums[game_name] = game_package["checksum"] for item_name, item_id in game_package["item_name_to_id"].items(): - self.item_names[item_id] = item_name + self.item_names[game_name][item_id] = item_name for location_name, location_id in game_package["location_name_to_id"].items(): - self.location_names[location_id] = location_name + self.location_names[game_name][location_id] = location_name self.all_item_and_group_names[game_name] = \ set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name]) self.all_location_and_group_names[game_name] = \ set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, [])) + archipelago_item_names = self.item_names["Archipelago"] + archipelago_location_names = self.location_names["Archipelago"] + for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]: + # Add Archipelago items and locations to each data package. + self.item_names[game].update(archipelago_item_names) + self.location_names[game].update(archipelago_location_names) + def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]: return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None @@ -466,6 +479,9 @@ def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.A for game_name, data in self.location_name_groups.items(): self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame] + # sorted access spheres + self.spheres = decoded_obj.get("spheres", []) + # saving def save(self, now=False) -> bool: @@ -624,6 +640,16 @@ def get_rechecked_hints(self, team: int, slot: int): self.recheck_hints(team, slot) return self.hints[team, slot] + def get_sphere(self, player: int, location_id: int) -> int: + """Get sphere of a location, -1 if spheres are not available.""" + if self.spheres: + for i, sphere in enumerate(self.spheres): + if location_id in sphere.get(player, set()): + return i + raise KeyError(f"No Sphere found for location ID {location_id} belonging to player {player}. " + f"Location or player may not exist.") + return -1 + def get_players_package(self): return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()] @@ -766,10 +792,7 @@ async def on_client_connected(ctx: Context, client: Client): for slot, connected_clients in clients.items(): if connected_clients: name = ctx.player_names[team, slot] - players.append( - NetworkPlayer(team, slot, - ctx.name_aliases.get((team, slot), name), name) - ) + players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name)) games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)} games.add("Archipelago") await ctx.send_msgs(client, [{ @@ -784,8 +807,6 @@ async def on_client_connected(ctx: Context, client: Client): 'permissions': get_permissions(ctx), 'hint_cost': ctx.hint_cost, 'location_check_points': ctx.location_check_points, - 'datapackage_versions': {game: game_data["version"] for game, game_data - in ctx.gamespackage.items() if game in games}, 'datapackage_checksums': {game: game_data["checksum"] for game, game_data in ctx.gamespackage.items() if game in games and "checksum" in game_data}, 'seed_name': ctx.seed_name, @@ -989,8 +1010,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi send_items_to(ctx, team, target_player, new_item) ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % ( - team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id], - ctx.player_names[(team, target_player)], ctx.location_names[location])) + team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id], + ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location])) info_text = json_format_send_event(new_item, target_player) ctx.broadcast_team(team, [info_text]) @@ -1044,8 +1065,8 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str: text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \ - f"{ctx.item_names[hint.item]} is " \ - f"at {ctx.location_names[hint.location]} " \ + f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \ + f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \ f"in {ctx.player_names[team, hint.finding_player]}'s World" if hint.entrance: @@ -1074,28 +1095,6 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int): "item": net_item} -def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]: - picks = Utils.get_fuzzy_results(input_text, possible_answers, limit=2) - if len(picks) > 1: - dif = picks[0][1] - picks[1][1] - if picks[0][1] == 100: - return picks[0][0], True, "Perfect Match" - elif picks[0][1] < 75: - return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \ - f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" - elif dif > 5: - return picks[0][0], True, "Close Match" - else: - return picks[0][0], False, f"Too many close matches for '{input_text}', " \ - f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" - else: - if picks[0][1] > 90: - return picks[0][0], True, "Only Option Match" - else: - return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \ - f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" - - class CommandMeta(type): def __new__(cls, name, bases, attrs): commands = attrs["commands"] = {} @@ -1347,7 +1346,7 @@ def _cmd_remaining(self) -> bool: 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[item_id] + self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id] for item_id in remaining_item_ids)) else: self.output("No remaining items found.") @@ -1360,7 +1359,7 @@ def _cmd_remaining(self) -> bool: 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[item_id] + self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id] for item_id in remaining_item_ids)) else: self.output("No remaining items found.") @@ -1378,7 +1377,8 @@ def _cmd_missing(self, filter_text="") -> bool: locations = get_missing_checks(self.ctx, self.client.team, self.client.slot) if locations: - names = [self.ctx.location_names[location] for location in locations] + game = self.ctx.slot_info[self.client.slot].game + names = [self.ctx.location_names[game][location] for location in locations] if filter_text: location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]] if filter_text in location_groups: # location group name @@ -1403,7 +1403,8 @@ def _cmd_checked(self, filter_text="") -> bool: locations = get_checked_checks(self.ctx, self.client.team, self.client.slot) if locations: - names = [self.ctx.location_names[location] for location in locations] + game = self.ctx.slot_info[self.client.slot].game + names = [self.ctx.location_names[game][location] for location in locations] if filter_text: location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]] if filter_text in location_groups: # location group name @@ -1484,10 +1485,10 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: elif input_text.isnumeric(): game = self.ctx.games[self.client.slot] hint_id = int(input_text) - hint_name = self.ctx.item_names[hint_id] \ - if not for_location and hint_id in self.ctx.item_names \ - else self.ctx.location_names[hint_id] \ - if for_location and hint_id in self.ctx.location_names \ + hint_name = self.ctx.item_names[game][hint_id] \ + if not for_location and hint_id in self.ctx.item_names[game] \ + else self.ctx.location_names[game][hint_id] \ + if for_location and hint_id in self.ctx.location_names[game] \ else None if hint_name in self.ctx.non_hintable_names[game]: self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") @@ -1549,6 +1550,9 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: self.ctx.random.shuffle(not_found_hints) # By popular vote, make hints prefer non-local placements not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player)) + # By another popular vote, prefer early sphere + not_found_hints.sort(key=lambda hint: self.ctx.get_sphere(hint.finding_player, hint.location), + reverse=True) hints = found_hints + old_hints while can_pay > 0: @@ -1558,10 +1562,10 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: hints.append(hint) can_pay -= 1 self.ctx.hints_used[self.client.team, self.client.slot] += 1 - points_available = get_client_points(self.ctx, self.client) self.ctx.notify_hints(self.client.team, hints) if not_found_hints: + points_available = get_client_points(self.ctx, self.client) if hints and cost and int((points_available // cost) == 0): self.output( f"There may be more hintables, however, you cannot afford to pay for any more. " diff --git a/NetUtils.py b/NetUtils.py index 8fc3929e60b4..076fdc3ba44f 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -247,7 +247,7 @@ def _handle_item_name(self, node: JSONMessagePart): def _handle_item_id(self, node: JSONMessagePart): item_id = int(node["text"]) - node["text"] = self.ctx.item_names[item_id] + node["text"] = self.ctx.item_names.lookup_in_slot(item_id, node["player"]) return self._handle_item_name(node) def _handle_location_name(self, node: JSONMessagePart): @@ -255,8 +255,8 @@ def _handle_location_name(self, node: JSONMessagePart): return self._handle_color(node) def _handle_location_id(self, node: JSONMessagePart): - item_id = int(node["text"]) - node["text"] = self.ctx.location_names[item_id] + location_id = int(node["text"]) + node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"]) return self._handle_location_name(node) def _handle_entrance_name(self, node: JSONMessagePart): diff --git a/Options.py b/Options.py index 39fd56765615..40a6996d325a 100644 --- a/Options.py +++ b/Options.py @@ -12,6 +12,7 @@ from dataclasses import dataclass from schema import And, Optional, Or, Schema +from typing_extensions import Self from Utils import get_fuzzy_results, is_iterable_except_str @@ -896,6 +897,228 @@ class ItemSet(OptionSet): convert_name_groups = True +class PlandoText(typing.NamedTuple): + at: str + text: typing.List[str] + percentage: int = 100 + + +PlandoTextsFromAnyType = typing.Union[ + typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoText, typing.Any]], typing.Any +] + + +class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys): + default = () + supports_weighting = False + display_name = "Plando Texts" + + def __init__(self, value: typing.Iterable[PlandoText]) -> None: + self.value = list(deepcopy(value)) + super().__init__() + + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: + from BaseClasses import PlandoOptions + if self.value and not (PlandoOptions.texts & plando_options): + # plando is disabled but plando options were given so overwrite the options + self.value = [] + logging.warning(f"The plando texts module is turned off, " + f"so text for {player_name} will be ignored.") + + @classmethod + def from_any(cls, data: PlandoTextsFromAnyType) -> Self: + texts: typing.List[PlandoText] = [] + if isinstance(data, typing.Iterable): + for text in data: + if isinstance(text, typing.Mapping): + if random.random() < float(text.get("percentage", 100)/100): + at = text.get("at", None) + if at is not None: + given_text = text.get("text", []) + if isinstance(given_text, str): + given_text = [given_text] + texts.append(PlandoText( + at, + given_text, + text.get("percentage", 100) + )) + elif isinstance(text, PlandoText): + if random.random() < float(text.percentage/100): + 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)}") + + @classmethod + def get_option_name(cls, value: typing.List[PlandoText]) -> str: + return str({text.at: " ".join(text.text) for text in value}) + + def __iter__(self) -> typing.Iterator[PlandoText]: + yield from self.value + + def __getitem__(self, index: typing.SupportsIndex) -> PlandoText: + return self.value.__getitem__(index) + + def __len__(self) -> int: + return self.value.__len__() + + +class ConnectionsMeta(AssembleOptions): + def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any]): + if name != "PlandoConnections": + assert "entrances" in attrs, f"Please define valid entrances for {name}" + attrs["entrances"] = frozenset((connection.lower() for connection in attrs["entrances"])) + assert "exits" in attrs, f"Please define valid exits for {name}" + attrs["exits"] = frozenset((connection.lower() for connection in attrs["exits"])) + if "__doc__" not in attrs: + attrs["__doc__"] = PlandoConnections.__doc__ + cls = super().__new__(mcs, name, bases, attrs) + return cls + + +class PlandoConnection(typing.NamedTuple): + class Direction: + entrance = "entrance" + exit = "exit" + both = "both" + + entrance: str + exit: str + direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped + percentage: int = 100 + + +PlandoConFromAnyType = typing.Union[ + typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoConnection, typing.Any]], typing.Any +] + + +class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=ConnectionsMeta): + """Generic connections plando. Format is: + - entrance: "Entrance Name" + exit: "Exit Name" + direction: "Direction" + percentage: 100 + Direction must be one of 'entrance', 'exit', or 'both', and defaults to 'both' if omitted. + Percentage is an integer from 1 to 100, and defaults to 100 when omitted.""" + + display_name = "Plando Connections" + + default = () + supports_weighting = False + + entrances: typing.ClassVar[typing.AbstractSet[str]] + exits: typing.ClassVar[typing.AbstractSet[str]] + + duplicate_exits: bool = False + """Whether or not exits should be allowed to be duplicate.""" + + def __init__(self, value: typing.Iterable[PlandoConnection]): + self.value = list(deepcopy(value)) + super(PlandoConnections, self).__init__() + + @classmethod + def validate_entrance_name(cls, entrance: str) -> bool: + return entrance.lower() in cls.entrances + + @classmethod + def validate_exit_name(cls, exit: str) -> bool: + return exit.lower() in cls.exits + + @classmethod + def can_connect(cls, entrance: str, exit: str) -> bool: + """Checks that a given entrance can connect to a given exit. + By default, this will always return true unless overridden.""" + return True + + @classmethod + def validate_plando_connections(cls, connections: typing.Iterable[PlandoConnection]) -> None: + used_entrances: typing.List[str] = [] + used_exits: typing.List[str] = [] + for connection in connections: + entrance = connection.entrance + exit = connection.exit + direction = connection.direction + if direction not in (PlandoConnection.Direction.entrance, + PlandoConnection.Direction.exit, + PlandoConnection.Direction.both): + raise ValueError(f"Unknown direction: {direction}") + if entrance in used_entrances: + raise ValueError(f"Duplicate Entrance {entrance} not allowed.") + if not cls.duplicate_exits and exit in used_exits: + raise ValueError(f"Duplicate Exit {exit} not allowed.") + used_entrances.append(entrance) + used_exits.append(exit) + if not cls.validate_entrance_name(entrance): + raise ValueError(f"{entrance.title()} is not a valid entrance.") + if not cls.validate_exit_name(exit): + raise ValueError(f"{exit.title()} is not a valid exit.") + if not cls.can_connect(entrance, exit): + raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.") + + @classmethod + def from_any(cls, data: PlandoConFromAnyType) -> Self: + if not isinstance(data, typing.Iterable): + raise Exception(f"Cannot create plando connections from non-List value, got {type(data)}.") + + value: typing.List[PlandoConnection] = [] + for connection in data: + if isinstance(connection, typing.Mapping): + percentage = connection.get("percentage", 100) + if random.random() < float(percentage / 100): + entrance = connection.get("entrance", None) + if is_iterable_except_str(entrance): + entrance = random.choice(sorted(entrance)) + exit = connection.get("exit", None) + if is_iterable_except_str(exit): + exit = random.choice(sorted(exit)) + direction = connection.get("direction", "both") + + if not entrance or not exit: + raise Exception("Plando connection must have an entrance and an exit.") + value.append(PlandoConnection( + entrance, + exit, + direction, + percentage + )) + elif isinstance(connection, PlandoConnection): + if random.random() < float(connection.percentage / 100): + value.append(connection) + else: + raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.") + cls.validate_plando_connections(value) + return cls(value) + + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: + from BaseClasses import PlandoOptions + if self.value and not (PlandoOptions.connections & plando_options): + # plando is disabled but plando options were given so overwrite the options + self.value = [] + logging.warning(f"The plando connections module is turned off, " + f"so connections for {player_name} will be ignored.") + + @classmethod + def get_option_name(cls, value: typing.List[PlandoConnection]) -> str: + return ", ".join(["%s %s %s" % (connection.entrance, + "<=>" if connection.direction == PlandoConnection.Direction.both else + "<=" if connection.direction == PlandoConnection.Direction.exit else + "=>", + connection.exit) for connection in value]) + + def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection: + return self.value.__getitem__(index) + + def __iter__(self) -> typing.Iterator[PlandoConnection]: + yield from self.value + + def __len__(self) -> int: + return len(self.value) + + class Accessibility(Choice): """Set rules for reachability of your items/locations. Locations: ensure everything can be reached and acquired. @@ -910,8 +1133,10 @@ class Accessibility(Choice): class ProgressionBalancing(NamedRange): - """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. - A lower setting means more getting stuck. A higher setting means less getting stuck.""" + """ + A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. + A lower setting means more getting stuck. A higher setting means less getting stuck. + """ default = 50 range_start = 0 range_end = 99 @@ -984,7 +1209,7 @@ class LocalItems(ItemSet): class NonLocalItems(ItemSet): """Forces these items to be outside their native world.""" - display_name = "Not Local Items" + display_name = "Non-local Items" class StartInventory(ItemDict): @@ -1047,7 +1272,8 @@ class ItemLinks(OptionList): ]) @staticmethod - def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set: + def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, + allow_item_groups: bool = True) -> typing.Set: pool = set() for item_name in items: if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups): @@ -1130,9 +1356,41 @@ class OptionGroup(typing.NamedTuple): """Name of the group to categorize these options in for display on the WebHost and in generated YAMLS.""" options: typing.List[typing.Type[Option[typing.Any]]] """Options to be in the defined group.""" + start_collapsed: bool = False + """Whether the group will start collapsed on the WebHost options pages.""" -def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True): +item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints, + StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks] +""" +Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group. +If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to +it. +""" + + +def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[ + str, typing.Dict[str, typing.Type[Option[typing.Any]]]]: + """Generates and returns a dictionary for the option groups of a specified world.""" + option_groups = {option: option_group.name + for option_group in world.web.option_groups + for option in option_group.options} + # add a default option group for uncategorized options to get thrown into + ordered_groups = ["Game Options"] + [ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups] + grouped_options = {group: {} for group in ordered_groups} + for option_name, option in world.options_dataclass.type_hints.items(): + if visibility_level & option.visibility: + grouped_options[option_groups.get(option, "Game Options")][option_name] = option + + # if the world doesn't have any ungrouped options, this group will be empty so just remove it + if not grouped_options["Game Options"]: + del grouped_options["Game Options"] + + return grouped_options + + +def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None: import os import yaml @@ -1170,17 +1428,7 @@ def dictify_range(option: Range): for game_name, world in AutoWorldRegister.world_types.items(): if not world.hidden or generate_hidden: - - option_groups = {option: option_group.name - for option_group in world.web.option_groups - for option in option_group.options} - ordered_groups = ["Game Options"] - [ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups] - grouped_options = {group: {} for group in ordered_groups} - for option_name, option in world.options_dataclass.type_hints.items(): - if option.visibility >= Visibility.template: - grouped_options[option_groups.get(option, "Game Options")][option_name] = option - + grouped_options = get_option_groups(world) with open(local_path("data", "options.yaml")) as f: file_data = f.read() res = Template(file_data).render( diff --git a/README.md b/README.md index 4633c99c664d..cebd4f7e7529 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # [Archipelago](https://archipelago.gg) ![Discord Shield](https://discordapp.com/api/guilds/731205301247803413/widget.png?style=shield) | [Install](https://github.com/ArchipelagoMW/Archipelago/releases) -Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, presently, Archipelago is also the randomizer itself. +Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, +presently, Archipelago is also the randomizer itself. Currently, the following games are supported: + * The Legend of Zelda: A Link to the Past * Factorio * Minecraft @@ -77,36 +79,57 @@ windows binaries. ## History -Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here. The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are: +Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here. +The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are: * [bonta0's MultiWorld](https://github.com/Bonta0/ALttPEntranceRandomizer/tree/multiworld_31) * [AmazingAmpharos' Entrance Randomizer](https://github.com/AmazingAmpharos/ALttPEntranceRandomizer) * [VT Web Randomizer](https://github.com/sporchia/alttp_vt_randomizer) * [Dessyreqt's alttprandomizer](https://github.com/Dessyreqt/alttprandomizer) -* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89) and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make the vast majority of Enemizer contributions. +* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89) + and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make up the + vast majority of Enemizer contributions. -We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the path. Just because one person's name may be in a repository title does not mean that only one person made that project happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago but we hope to honor them fairly. +We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the +path. Just because one person's name may be in a repository title does not mean that only one person made that project +happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago, but we hope to honor +them fairly. ### Path to the Archipelago -Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project. + +Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a +long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to +_MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as +"Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository +(as opposed to a 'forked repo') and change the name (which came later) to better reflect our project. ## Running Archipelago -For most people, all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer, or AppImage for Linux-based systems. -If you are a developer or are running on a platform with no compiled releases available, please see our doc on [running Archipelago from source](docs/running%20from%20source.md). +For most people, all you need to do is head over to +the [releases page](https://github.com/ArchipelagoMW/Archipelago/releases), then download and run the appropriate +installer, or AppImage for Linux-based systems. + +If you are a developer or are running on a platform with no compiled releases available, please see our doc on +[running Archipelago from source](docs/running%20from%20source.md). ## Related Repositories -This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present. + +This project makes use of multiple other projects. We wouldn't be here without these other repositories and the +contributions of their developers, past and present. * [z3randomizer](https://github.com/ArchipelagoMW/z3randomizer) * [Enemizer](https://github.com/Ijwu/Enemizer) * [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer) ## Contributing -For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md) + +To contribute to Archipelago, including the WebHost, core program, or by adding a new game, see our +[Contributing guidelines](/docs/contributing.md). ## FAQ -For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/) + +For Frequently asked questions, please see the website's [FAQ Page](https://archipelago.gg/faq/en/). ## Code of Conduct -Please refer to our [code of conduct.](/docs/code_of_conduct.md) + +Please refer to our [code of conduct](/docs/code_of_conduct.md). diff --git a/UndertaleClient.py b/UndertaleClient.py index e1538ce81d2e..cdc21c561ab8 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -247,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): with open(os.path.join(ctx.save_game_folder, filename), "w") as f: toDraw = "" for i in range(20): - if i < len(str(ctx.item_names[l.item])): - toDraw += str(ctx.item_names[l.item])[i] + if i < len(str(ctx.item_names.lookup_in_slot(l.item))): + toDraw += str(ctx.item_names.lookup_in_slot(l.item))[i] else: break f.write(toDraw) diff --git a/Utils.py b/Utils.py index 141b1dc7f8c6..a7fd7f4f334c 100644 --- a/Utils.py +++ b/Utils.py @@ -46,7 +46,7 @@ def as_simple_string(self) -> str: return ".".join(str(item) for item in self) -__version__ = "0.4.6" +__version__ = "0.5.0" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -101,8 +101,7 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[ @functools.wraps(function) def wrap(self: S, arg: T) -> RetType: - cache: Optional[Dict[T, RetType]] = typing.cast(Optional[Dict[T, RetType]], - getattr(self, cache_name, None)) + cache: Optional[Dict[T, RetType]] = getattr(self, cache_name, None) if cache is None: res = function(self, arg) setattr(self, cache_name, {arg: res}) @@ -209,10 +208,11 @@ def output_path(*path: str) -> str: def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: if is_windows: - os.startfile(filename) + os.startfile(filename) # type: ignore else: from shutil import which open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open")) + assert open_command, "Didn't find program for open_file! Please report this together with system details." subprocess.call([open_command, filename]) @@ -300,21 +300,21 @@ def get_options() -> Settings: return get_settings() -def persistent_store(category: str, key: typing.Any, value: typing.Any): +def persistent_store(category: str, key: str, value: typing.Any): path = user_path("_persistent_storage.yaml") - storage: dict = persistent_load() - category = storage.setdefault(category, {}) - category[key] = value + storage = persistent_load() + category_dict = storage.setdefault(category, {}) + category_dict[key] = value with open(path, "wt") as f: f.write(dump(storage, Dumper=Dumper)) -def persistent_load() -> typing.Dict[str, dict]: - storage = getattr(persistent_load, "storage", None) +def persistent_load() -> Dict[str, Dict[str, Any]]: + storage: Union[Dict[str, Dict[str, Any]], None] = getattr(persistent_load, "storage", None) if storage: return storage path = user_path("_persistent_storage.yaml") - storage: dict = {} + storage = {} if os.path.exists(path): try: with open(path, "r") as f: @@ -323,7 +323,7 @@ def persistent_load() -> typing.Dict[str, dict]: logging.debug(f"Could not read store: {e}") if storage is None: storage = {} - persistent_load.storage = storage + setattr(persistent_load, "storage", storage) return storage @@ -365,6 +365,7 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N except Exception as e: logging.debug(f"Could not store data package: {e}") + def get_default_adjuster_settings(game_name: str) -> Namespace: import LttPAdjuster adjuster_settings = Namespace() @@ -383,7 +384,9 @@ def get_adjuster_settings(game_name: str) -> Namespace: default_settings = get_default_adjuster_settings(game_name) # Fill in any arguments from the argparser that we haven't seen before - return Namespace(**vars(adjuster_settings), **{k:v for k,v in vars(default_settings).items() if k not in vars(adjuster_settings)}) + return Namespace(**vars(adjuster_settings), **{ + k: v for k, v in vars(default_settings).items() if k not in vars(adjuster_settings) + }) @cache_argsless @@ -407,13 +410,13 @@ def get_unique_identifier(): class RestrictedUnpickler(pickle.Unpickler): generic_properties_module: Optional[object] - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super(RestrictedUnpickler, self).__init__(*args, **kwargs) self.options_module = importlib.import_module("Options") self.net_utils_module = importlib.import_module("NetUtils") self.generic_properties_module = None - def find_class(self, module, name): + def find_class(self, module: str, name: str) -> type: if module == "builtins" and name in safe_builtins: return getattr(builtins, name) # used by MultiServer -> savegame/multidata @@ -437,7 +440,7 @@ def find_class(self, module, name): raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") -def restricted_loads(s): +def restricted_loads(s: bytes) -> Any: """Helper function analogous to pickle.loads().""" return RestrictedUnpickler(io.BytesIO(s)).load() @@ -455,6 +458,15 @@ class KeyedDefaultDict(collections.defaultdict): """defaultdict variant that uses the missing key as argument to default_factory""" default_factory: typing.Callable[[typing.Any], typing.Any] + def __init__(self, + default_factory: typing.Callable[[Any], Any] = None, + seq: typing.Union[typing.Mapping, typing.Iterable, None] = None, + **kwargs): + if seq is not None: + super().__init__(default_factory, seq, **kwargs) + else: + super().__init__(default_factory, **kwargs) + def __missing__(self, key): self[key] = value = self.default_factory(key) return value @@ -493,7 +505,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri file_handler.setFormatter(logging.Formatter(log_format)) class Filter(logging.Filter): - def __init__(self, filter_name, condition): + def __init__(self, filter_name: str, condition: typing.Callable[[logging.LogRecord], bool]) -> None: super().__init__(filter_name) self.condition = condition @@ -544,7 +556,7 @@ def _cleanup(): ) -def stream_input(stream, queue): +def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"): def queuer(): while 1: try: @@ -572,7 +584,7 @@ class VersionException(Exception): pass -def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str: +def chaining_prefix(index: int, labels: typing.Sequence[str]) -> str: text = "" max_label = len(labels) - 1 while index > max_label: @@ -595,7 +607,7 @@ def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}" -def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \ +def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit: typing.Optional[int] = None) \ -> typing.List[typing.Tuple[str, int]]: import jellyfish @@ -603,21 +615,55 @@ def get_fuzzy_ratio(word1: str, word2: str) -> float: return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower()) / max(len(word1), len(word2))) - limit: int = limit if limit else len(wordlist) + limit = limit if limit else len(word_list) return list( map( lambda container: (container[0], int(container[1]*100)), # convert up to limit to int % sorted( - map(lambda candidate: - (candidate, get_fuzzy_ratio(input_word, candidate)), - wordlist), + map(lambda candidate: (candidate, get_fuzzy_ratio(input_word, candidate)), word_list), key=lambda element: element[1], - reverse=True)[0:limit] + reverse=True + )[0:limit] ) ) -def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \ +def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]: + picks = get_fuzzy_results(input_text, possible_answers, limit=2) + if len(picks) > 1: + dif = picks[0][1] - picks[1][1] + if picks[0][1] == 100: + return picks[0][0], True, "Perfect Match" + elif picks[0][1] < 75: + return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \ + f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" + elif dif > 5: + return picks[0][0], True, "Close Match" + else: + return picks[0][0], False, f"Too many close matches for '{input_text}', " \ + f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" + else: + if picks[0][1] > 90: + return picks[0][0], True, "Only Option Match" + else: + return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \ + f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" + + +def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]: + if "did you mean " in text: + for question in ("Didn't find something that closely matches", + "Too many close matches"): + if text.startswith(question): + name = get_text_between(text, "did you mean '", + "'? (") + return f"!{command} {name}" + elif text.startswith("Missing: "): + return text.replace("Missing: ", "!hint_location ") + return None + + +def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ -> typing.Optional[str]: logging.info(f"Opening file input dialog for {title}.") @@ -734,7 +780,7 @@ def is_kivy_running(): root.update() -def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))): +def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))): """Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning.""" def sorter(element: Union[str, Dict[str, Any]]) -> str: if (not isinstance(element, str)): @@ -788,7 +834,7 @@ class DeprecateDict(dict): log_message: str should_error: bool - def __init__(self, message, error: bool = False) -> None: + def __init__(self, message: str, error: bool = False) -> None: self.log_message = message self.should_error = error super().__init__() diff --git a/WargrooveClient.py b/WargrooveClient.py index 77180502cefc..c5fdeb3532f5 100644 --- a/WargrooveClient.py +++ b/WargrooveClient.py @@ -176,7 +176,7 @@ def on_package(self, cmd: str, args: dict): if not os.path.isfile(path): open(path, 'w').close() # Announcing commander unlocks - item_name = self.item_names[network_item.item] + item_name = self.item_names.lookup_in_slot(network_item.item) if item_name in faction_table.keys(): for commander in faction_table[item_name]: logger.info(f"{commander.name} has been unlocked!") @@ -197,7 +197,7 @@ def on_package(self, cmd: str, args: dict): open(print_path, 'w').close() with open(print_path, 'w') as f: f.write("Received " + - self.item_names[network_item.item] + + self.item_names.lookup_in_slot(network_item.item) + " from " + self.player_names[network_item.player]) f.close() @@ -342,7 +342,7 @@ def update_commander_data(self): faction_items = 0 faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()] for network_item in self.items_received: - if self.item_names[network_item.item] in faction_item_names: + if self.item_names.lookup_in_slot(network_item.item) in faction_item_names: faction_items += 1 starting_groove = (faction_items - 1) * self.starting_groove_multiplier # Must be an integer larger than 0 diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py index cfdbe25ff2fe..22d1f19f6bdf 100644 --- a/WebHostLib/api/__init__.py +++ b/WebHostLib/api/__init__.py @@ -56,15 +56,6 @@ def get_datapackage(): return network_data_package -@api_endpoints.route('/datapackage_version') -@cache.cached() -def get_datapackage_versions(): - from worlds import AutoWorldRegister - - version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()} - return version_package - - @api_endpoints.route('/datapackage_checksum') @cache.cached() def get_datapackage_checksums(): diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 16769b7a760e..3a86cb551d27 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -106,9 +106,9 @@ def load(self, room_id: int): static_gamespackage = self.gamespackage # this is shared across all rooms static_item_name_groups = self.item_name_groups static_location_name_groups = self.location_name_groups - self.gamespackage = {"Archipelago": static_gamespackage["Archipelago"]} # this may be modified by _load - self.item_name_groups = {} - self.location_name_groups = {} + self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load + self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})} + self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})} for game in list(multidata.get("datapackage", {})): game_data = multidata["datapackage"][game] diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index a78560cb0bd3..a12dc0f4ae14 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -6,7 +6,7 @@ import tempfile import zipfile from collections import Counter -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union, Set from flask import flash, redirect, render_template, request, session, url_for from pony.orm import commit, db_session @@ -16,6 +16,7 @@ from Main import main as ERmain from Utils import __version__ from WebHostLib import app +from settings import ServerOptions, GeneratorOptions from worlds.alttp.EntranceRandomizer import parse_arguments from .check import get_yaml_data, roll_options from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID @@ -23,25 +24,22 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]: - plando_options = { - options_source.get("plando_bosses", ""), - options_source.get("plando_items", ""), - options_source.get("plando_connections", ""), - options_source.get("plando_texts", "") - } - plando_options -= {""} + plando_options: Set[str] = set() + for substr in ("bosses", "items", "connections", "texts"): + if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options): + plando_options.add(substr) server_options = { - "hint_cost": int(options_source.get("hint_cost", 10)), - "release_mode": options_source.get("release_mode", "goal"), - "remaining_mode": options_source.get("remaining_mode", "disabled"), - "collect_mode": options_source.get("collect_mode", "disabled"), - "item_cheat": bool(int(options_source.get("item_cheat", 1))), + "hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)), + "release_mode": options_source.get("release_mode", ServerOptions.release_mode), + "remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode), + "collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode), + "item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))), "server_password": options_source.get("server_password", None), } generator_options = { - "spoiler": int(options_source.get("spoiler", 0)), - "race": race + "spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)), + "race": race, } if race: diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 94f173df70cb..53c3a6151b82 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -11,6 +11,7 @@ from Utils import local_path from worlds.AutoWorld import AutoWorldRegister from . import app, cache +from .generate import get_meta def create() -> None: @@ -27,26 +28,21 @@ def get_world_theme(game_name: str) -> str: def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]: - visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui world = AutoWorldRegister.world_types[world_name] if world.hidden or world.web.options_page is False: return redirect("games") + visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui - option_groups = {option: option_group.name - for option_group in world.web.option_groups - for option in option_group.options} - ordered_groups = ["Game Options", *[group.name for group in world.web.option_groups]] - grouped_options = {group: {} for group in ordered_groups} - for option_name, option in world.options_dataclass.type_hints.items(): - # Exclude settings from options pages if their visibility is disabled - if visibility_flag in option.visibility: - grouped_options[option_groups.get(option, "Game Options")][option_name] = option + start_collapsed = {"Game Options": False} + for group in world.web.option_groups: + start_collapsed[group.name] = group.start_collapsed return render_template( template, world_name=world_name, world=world, - option_groups=grouped_options, + option_groups=Options.get_option_groups(world, visibility_level=visibility_flag), + start_collapsed=start_collapsed, issubclass=issubclass, Options=Options, theme=get_world_theme(world_name), @@ -55,7 +51,7 @@ def render_options_page(template: str, world_name: str, is_complex: bool = False def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]: from .generate import start_generation - return start_generation(options, {"plando_options": ["items", "connections", "texts", "bosses"]}) + return start_generation(options, get_meta({})) def send_yaml(player_name: str, formatted_options: dict) -> Response: @@ -80,6 +76,34 @@ def test_ordered(obj): def option_presets(game: str) -> Response: world = AutoWorldRegister.world_types[game] + presets = {} + for preset_name, preset in world.web.options_presets.items(): + presets[preset_name] = {} + for preset_option_name, preset_option in preset.items(): + if preset_option == "random": + presets[preset_name][preset_option_name] = preset_option + continue + + option = world.options_dataclass.type_hints[preset_option_name].from_any(preset_option) + if isinstance(option, Options.NamedRange) and isinstance(preset_option, str): + assert preset_option in option.special_range_names, \ + f"Invalid preset value '{preset_option}' for '{preset_option_name}' in '{preset_name}'. " \ + f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}." + + presets[preset_name][preset_option_name] = option.value + elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)): + presets[preset_name][preset_option_name] = option.value + elif isinstance(preset_option, str): + # Ensure the option value is valid for Choice and Toggle options + assert option.name_lookup[option.value] == preset_option, \ + f"Invalid option value '{preset_option}' for '{preset_option_name}' in preset '{preset_name}'. " \ + f"Values must not be resolved to a different option via option.from_text (or an alias)." + # Use the name of the option + presets[preset_name][preset_option_name] = option.current_key + else: + # Use the name of the option + presets[preset_name][preset_option_name] = option.current_key + class SetEncoder(json.JSONEncoder): def default(self, obj): from collections.abc import Set @@ -87,7 +111,7 @@ def default(self, obj): return list(obj) return json.JSONEncoder.default(self, obj) - json_data = json.dumps(world.web.options_presets, cls=SetEncoder) + json_data = json.dumps(presets, cls=SetEncoder) response = Response(json_data) response.headers["Content-Type"] = "application/json" return response @@ -173,9 +197,9 @@ def generate_yaml(game: str): else: options[key] = val - # Detect and build ItemDict options from their name pattern for key, val in options.copy().items(): key_parts = key.rsplit("||", 2) + # Detect and build ItemDict options from their name pattern if key_parts[-1] == "qty": if key_parts[0] not in options: options[key_parts[0]] = {} @@ -183,6 +207,13 @@ def generate_yaml(game: str): options[key_parts[0]][key_parts[1]] = int(val) del options[key] + # Detect keys which end with -custom, indicating a TextChoice with a possible custom value + elif key_parts[-1].endswith("-custom"): + if val: + options[key_parts[-1][:-7]] = 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/static/assets/trackerCommon.js b/WebHostLib/static/assets/trackerCommon.js index b8e089ece5d3..6324837b2816 100644 --- a/WebHostLib/static/assets/trackerCommon.js +++ b/WebHostLib/static/assets/trackerCommon.js @@ -27,7 +27,7 @@ const adjustTableHeight = () => { * @returns {string} */ const secondsToHours = (seconds) => { - let hours = Math.floor(seconds / 3600); + let hours = Math.floor(seconds / 3600); let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0'); return `${hours}:${minutes}`; }; @@ -38,18 +38,18 @@ window.addEventListener('load', () => { info: false, dom: "t", stateSave: true, - stateSaveCallback: function(settings, data) { + stateSaveCallback: function (settings, data) { delete data.search; localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data)); }, - stateLoadCallback: function(settings) { + stateLoadCallback: function (settings) { return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`)); }, - footerCallback: function(tfoot, data, start, end, display) { + footerCallback: function (tfoot, data, start, end, display) { if (tfoot) { const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x)); Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText = - (activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None'; + (activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None'; } }, columnDefs: [ @@ -123,49 +123,64 @@ window.addEventListener('load', () => { event.preventDefault(); } }); - const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker'); - const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3; + const target_second = parseInt(document.getElementById('tracker-wrapper').getAttribute('data-second')) + 3; + console.log("Target second of refresh: " + target_second); - function getSleepTimeSeconds(){ + function getSleepTimeSeconds() { // -40 % 60 is -40, which is absolutely wrong and should burn var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60; return sleepSeconds || 60; } + let update_on_view = false; const update = () => { - const target = $("
"); - console.log("Updating Tracker..."); - target.load(location.href, function (response, status) { - if (status === "success") { - target.find(".table").each(function (i, new_table) { - const new_trs = $(new_table).find("tbody>tr"); - const footer_tr = $(new_table).find("tfoot>tr"); - const old_table = tables.eq(i); - const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop(); - const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft(); - old_table.clear(); - if (footer_tr.length) { - $(old_table.table).find("tfoot").html(footer_tr); - } - old_table.rows.add(new_trs); - old_table.draw(); - $(old_table.settings()[0].nScrollBody).scrollTop(topscroll); - $(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll); - }); - $("#multi-stream-link").replaceWith(target.find("#multi-stream-link")); - } else { - console.log("Failed to connect to Server, in order to update Table Data."); - console.log(response); - } - }) - setTimeout(update, getSleepTimeSeconds()*1000); + if (document.hidden) { + console.log("Document reporting as not visible, not updating Tracker..."); + update_on_view = true; + } else { + update_on_view = false; + const target = $(""); + console.log("Updating Tracker..."); + target.load(location.href, function (response, status) { + if (status === "success") { + target.find(".table").each(function (i, new_table) { + const new_trs = $(new_table).find("tbody>tr"); + const footer_tr = $(new_table).find("tfoot>tr"); + const old_table = tables.eq(i); + const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop(); + const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft(); + old_table.clear(); + if (footer_tr.length) { + $(old_table.table).find("tfoot").html(footer_tr); + } + old_table.rows.add(new_trs); + old_table.draw(); + $(old_table.settings()[0].nScrollBody).scrollTop(topscroll); + $(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll); + }); + $("#multi-stream-link").replaceWith(target.find("#multi-stream-link")); + } else { + console.log("Failed to connect to Server, in order to update Table Data."); + console.log(response); + } + }) + } + updater = setTimeout(update, getSleepTimeSeconds() * 1000); } - setTimeout(update, getSleepTimeSeconds()*1000); + let updater = setTimeout(update, getSleepTimeSeconds() * 1000); window.addEventListener('resize', () => { adjustTableHeight(); tables.draw(); }); + window.addEventListener('visibilitychange', () => { + if (!document.hidden && update_on_view) { + console.log("Page became visible, tracker should be refreshed."); + clearTimeout(updater); + update(); + } + }); + adjustTableHeight(); }); diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.css b/WebHostLib/static/styles/playerOptions/playerOptions.css index 6165e3a0f622..56c9263d3330 100644 --- a/WebHostLib/static/styles/playerOptions/playerOptions.css +++ b/WebHostLib/static/styles/playerOptions/playerOptions.css @@ -15,7 +15,7 @@ html { border-radius: 8px; padding: 1rem; color: #eeffeb; - word-break: break-all; + word-break: break-word; } #player-options #player-options-header h1 { margin-bottom: 0; diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.scss b/WebHostLib/static/styles/playerOptions/playerOptions.scss index 525b8ef15403..06bde759d263 100644 --- a/WebHostLib/static/styles/playerOptions/playerOptions.scss +++ b/WebHostLib/static/styles/playerOptions/playerOptions.scss @@ -16,7 +16,7 @@ html{ border-radius: 8px; padding: 1rem; color: #eeffeb; - word-break: break-all; + word-break: break-word; #player-options-header{ h1{ diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index 2981c41452f0..2bbfe4ad0169 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -24,7 +24,8 @@Sphere | + {#- Mimicking hint table header for familiarity. #} +Finder | +Receiver | +Item | +Location | +Game | +
---|---|---|---|---|---|
{{ current_sphere }} | +{{ tracker_data.get_player_name(team, player) }} | +{{ tracker_data.get_player_name(team, receiver) }} | +{{ tracker_data.item_id_to_name[receiver_game][item_id] }} | +{{ tracker_data.location_id_to_name[finder_game][location_id] }} | +{{ finder_game }} | +
- | - | - | - | - | - | - |
- | - |
-
-
-
- {{ hookshot_length }}
- |
- - | - | - | - |
- | - | - | - | - | - | - |
-
-
-
- {{ bottle_count if bottle_count > 0 else '' }}
- |
- - | - | - |
-
-
-
- {{ wallet_size }}
- |
- - | - |
-
-
-
- Zelda
- |
-
-
-
-
- Epona
- |
-
-
-
-
- Saria
- |
-
-
-
-
- Sun
- |
-
-
-
-
- Time
- |
-
-
-
-
- Storms
- |
-
-
-
-
- {{ token_count }}
- |
-
-
-
-
- Min
- |
-
-
-
-
- Bol
- |
-
-
-
-
- Ser
- |
-
-
-
-
- Req
- |
-
-
-
-
- Noc
- |
-
-
-
-
- Pre
- |
-
-
-
-
- {{ piece_count if piece_count > 0 else '' }}
- |
-
- | - | - | Items | -
{{ area }} {{'▼' if area != 'Total'}} | -{{ small_key_counts.get(area, '-') }} | -{{ boss_key_counts.get(area, '-') }} | -{{ checks_done[area] }} / {{ checks_in_area[area] }} | -
{{ location }} | -- | - | {{ '✔' if location_info[area][location] else '' }} | -