diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 60dbd15b9b..c51f155049 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ name: Build on: workflow_dispatch env: - SNI_VERSION: v0.0.84 + SNI_VERSION: v0.0.88 ENEMIZER_VERSION: 7.1 APPIMAGETOOL_VERSION: 13 @@ -78,9 +78,10 @@ jobs: - name: Build run: | # pygobject is an optional dependency for kivy that's not in requirements - "${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools + # charset-normalizer was somehow incomplete in the github runner "${{ env.PYTHON }}" -m venv venv source venv/bin/activate + "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer pip install -r requirements.txt python setup.py build_exe --yes bdist_appimage --yes echo -e "setup.py build output:\n `ls build`" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23f018caf2..e9559f7856 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: - '*.*.*' env: - SNI_VERSION: v0.0.84 + SNI_VERSION: v0.0.88 ENEMIZER_VERSION: 7.1 APPIMAGETOOL_VERSION: 13 @@ -65,9 +65,10 @@ jobs: - name: Build run: | # pygobject is an optional dependency for kivy that's not in requirements - "${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools + # charset-normalizer was somehow incomplete in the github runner "${{ env.PYTHON }}" -m venv venv source venv/bin/activate + "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer pip install -r requirements.txt python setup.py build --yes bdist_appimage --yes echo -e "setup.py build output:\n `ls build`" diff --git a/.gitignore b/.gitignore index c009036324..e269202db9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ Output Logs/ /Archipelago.zip /setup.ini /installdelete.iss +/data/user.kv # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/BaseClasses.py b/BaseClasses.py index 1d3b7ee922..e30dbd3296 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -29,6 +29,20 @@ class Group(TypedDict, total=False): link_replacement: bool +class ThreadBarrierProxy(): + """Passes through getattr while passthrough is True""" + def __init__(self, obj: Any): + self.passthrough = True + self.obj = obj + + def __getattr__(self, item): + if self.passthrough: + return getattr(self.obj, item) + else: + raise RuntimeError("You are in a threaded context and global random state was removed for your safety. " + "Please use multiworld.per_slot_randoms[player] or randomize ahead of output.") + + class MultiWorld(): debug_types = False player_name: Dict[int, str] @@ -62,6 +76,9 @@ class MultiWorld(): game: Dict[int, str] + random: random.Random + per_slot_randoms: Dict[int, random.Random] + class AttributeProxy(): def __init__(self, rule): self.rule = rule @@ -70,7 +87,8 @@ class MultiWorld(): return self.rule(player) def __init__(self, players: int): - self.random = random.Random() # world-local random state is saved for multiple generations running concurrently + # world-local random state is saved for multiple generations running concurrently + self.random = ThreadBarrierProxy(random.Random()) self.players = players self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids} self.glitch_triforce = False @@ -161,7 +179,7 @@ class MultiWorld(): set_player_attr('completion_condition', lambda state: True) self.custom_data = {} self.worlds = {} - self.slot_seeds = {} + self.per_slot_randoms = {} self.plando_options = PlandoOptions.none def get_all_ids(self) -> Tuple[int, ...]: @@ -207,8 +225,8 @@ class MultiWorld(): else: self.random.seed(self.seed) self.seed_name = name if name else str(self.seed) - self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in - range(1, self.players + 1)} + self.per_slot_randoms = {player: random.Random(self.random.getrandbits(64)) for player in + range(1, self.players + 1)} def set_options(self, args: Namespace) -> None: for option_key in Options.common_options: @@ -292,7 +310,7 @@ class MultiWorld(): self.state = CollectionState(self) def secure(self): - self.random = secrets.SystemRandom() + self.random = ThreadBarrierProxy(secrets.SystemRandom()) self.is_race = True @functools.cached_property @@ -932,24 +950,9 @@ class CollectionState(): self.stale[item.player] = True -@unique -class RegionType(IntEnum): - Generic = 0 - LightWorld = 1 - DarkWorld = 2 - Cave = 3 # Also includes Houses - Dungeon = 4 - - @property - def is_indoors(self) -> bool: - """Shorthand for checking if Cave or Dungeon""" - return self in (RegionType.Cave, RegionType.Dungeon) - - class Region: name: str - type: RegionType - hint_text: str + _hint_text: str player: int multiworld: Optional[MultiWorld] entrances: List[Entrance] @@ -963,14 +966,13 @@ class Region: is_light_world: bool = False is_dark_world: bool = False - def __init__(self, name: str, type_: RegionType, hint: str, player: int, world: Optional[MultiWorld] = None): + def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None): self.name = name - self.type = type_ self.entrances = [] self.exits = [] self.locations = [] - self.multiworld = world - self.hint_text = hint + self.multiworld = multiworld + self._hint_text = hint self.player = player def can_reach(self, state: CollectionState) -> bool: @@ -986,6 +988,10 @@ class Region: return True return False + @property + def hint_text(self) -> str: + return self._hint_text if self._hint_text else self.name + def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance: for entrance in self.entrances: if is_main_entrance(entrance): @@ -1272,6 +1278,7 @@ class Spoiler(): [('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)]) def parse_data(self): + from worlds.alttp.SubClasses import LTTPRegionType self.medallions = OrderedDict() for player in self.multiworld.get_game_players("A Link to the Past"): self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \ @@ -1281,23 +1288,31 @@ class Spoiler(): self.locations = OrderedDict() listed_locations = set() + lw_locations = [] + dw_locations = [] + cave_locations = [] + for loc in self.multiworld.get_locations(): + if loc.game == "A Link to the Past": + if loc not in listed_locations and loc.parent_region and \ + loc.parent_region.type == LTTPRegionType.LightWorld and loc.show_in_spoiler: + lw_locations.append(loc) + elif loc not in listed_locations and loc.parent_region and \ + loc.parent_region.type == LTTPRegionType.DarkWorld and loc.show_in_spoiler: + dw_locations.append(loc) + elif loc not in listed_locations and loc.parent_region and \ + loc.parent_region.type == LTTPRegionType.Cave and loc.show_in_spoiler: + cave_locations.append(loc) - lw_locations = [loc for loc in self.multiworld.get_locations() if - loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler] self.locations['Light World'] = OrderedDict( [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in lw_locations]) listed_locations.update(lw_locations) - dw_locations = [loc for loc in self.multiworld.get_locations() if - loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler] self.locations['Dark World'] = OrderedDict( [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in dw_locations]) listed_locations.update(dw_locations) - cave_locations = [loc for loc in self.multiworld.get_locations() if - loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler] self.locations['Caves'] = OrderedDict( [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in cave_locations]) diff --git a/CommonClient.py b/CommonClient.py index 2fb0ef8ede..92f8d76a66 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -341,6 +341,11 @@ class CommonContext: return self.slot in self.slot_info[slot].group_members return False + def is_echoed_chat(self, print_json_packet: dict) -> bool: + return print_json_packet.get("type", "") == "Chat" \ + and print_json_packet.get("team", None) == self.team \ + and print_json_packet.get("slot", None) == self.slot + def is_uninteresting_item_send(self, print_json_packet: dict) -> bool: """Helper function for filtering out ItemSend prints that do not concern the local player.""" return print_json_packet.get("type", "") == "ItemSend" \ diff --git a/FactorioClient.py b/FactorioClient.py index 5abce65491..9c294c1016 100644 --- a/FactorioClient.py +++ b/FactorioClient.py @@ -109,9 +109,10 @@ class FactorioContext(CommonContext): def on_print_json(self, args: dict): if self.rcon_client: - if not self.filter_item_sends or not self.is_uninteresting_item_send(args): + if (not self.filter_item_sends or not self.is_uninteresting_item_send(args)) \ + and not self.is_echoed_chat(args): text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) - if not text.startswith(self.player_names[self.slot] + ":"): + if not text.startswith(self.player_names[self.slot] + ":"): # TODO: Remove string heuristic in the future. self.print_to_game(text) super(FactorioContext, self).on_print_json(args) diff --git a/LttPAdjuster.py b/LttPAdjuster.py index a2cc2eeba5..205a76813a 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -35,7 +35,7 @@ class AdjusterWorld(object): def __init__(self, sprite_pool): import random self.sprite_pool = {1: sprite_pool} - self.slot_seeds = {1: random} + self.per_slot_randoms = {1: random} class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): diff --git a/Main.py b/Main.py index 7d14431824..9bb2cfef9e 100644 --- a/Main.py +++ b/Main.py @@ -9,11 +9,12 @@ import tempfile import zipfile from typing import Dict, List, Tuple, Optional, Set -from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location +from BaseClasses import Item, MultiWorld, CollectionState, Region, LocationProgressType, Location import worlds +from worlds.alttp.SubClasses import LTTPRegionType from worlds.alttp.Regions import is_main_entrance from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned -from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots +from worlds.alttp.Shops import FillDisabledShopSlots from Utils import output_path, get_options, __version__, version_tuple from worlds.generic.Rules import locality_rules, exclusion_rules from worlds import AutoWorld @@ -191,7 +192,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No new_item.classification |= classifications[item_name] new_itempool.append(new_item) - region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world) + region = Region("Menu", group_id, world, "ItemLink") world.regions.append(region) locations = region.locations = [] for item in world.itempool: @@ -251,6 +252,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No balance_multiworld_progression(world) logger.info(f'Beginning output...') + + # we're about to output using multithreading, so we're removing the global random state to prevent accidental use + world.random.passthrough = False + outfilebase = 'AP_' + world.seed_name output = tempfile.TemporaryDirectory() @@ -286,13 +291,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No 'Inverted Ganons Tower': 'Ganons Tower'} \ .get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) checks_in_area[location.player][dungeonname].append(location.address) - elif location.parent_region.type == RegionType.LightWorld: + elif location.parent_region.type == LTTPRegionType.LightWorld: checks_in_area[location.player]["Light World"].append(location.address) - elif location.parent_region.type == RegionType.DarkWorld: + elif location.parent_region.type == LTTPRegionType.DarkWorld: checks_in_area[location.player]["Dark World"].append(location.address) - elif main_entrance.parent_region.type == RegionType.LightWorld: + elif main_entrance.parent_region.type == LTTPRegionType.LightWorld: checks_in_area[location.player]["Light World"].append(location.address) - elif main_entrance.parent_region.type == RegionType.DarkWorld: + elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld: checks_in_area[location.player]["Dark World"].append(location.address) checks_in_area[location.player]["Total"] += 1 @@ -361,8 +366,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No multidata = { "slot_data": slot_data, "slot_info": slot_info, - "names": names, # TODO: remove around 0.2.5 in favor of slot_info - "games": games, # TODO: remove around 0.2.5 in favor of slot_info + "names": names, # TODO: remove after 0.3.9 "connect_names": {name: (0, player) for player, name in world.player_name.items()}, "locations": locations_data, "checks_in_area": checks_in_area, diff --git a/ModuleUpdate.py b/ModuleUpdate.py index 2b6aed1f39..a8258ce17e 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -2,6 +2,7 @@ import os import sys import subprocess import pkg_resources +import warnings local_dir = os.path.dirname(__file__) requirements_files = {os.path.join(local_dir, 'requirements.txt')} @@ -39,6 +40,8 @@ def update(yes=False, force=False): path = os.path.join(os.path.dirname(__file__), req_file) with open(path) as requirementsfile: for line in requirementsfile: + if not line or line[0] == "#": + continue # ignore comments if line.startswith(("https://", "git+https://")): # extract name and version for url rest = line.split('/')[-1] @@ -46,8 +49,10 @@ def update(yes=False, force=False): if "#egg=" in rest: # from egg info rest, egg = rest.split("#egg=", 1) - egg = egg.split(";", 1)[0] + egg = egg.split(";", 1)[0].rstrip() if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")): + warnings.warn(f"Specifying version as #egg={egg} will become unavailable in pip 25.0. " + "Use name @ url#version instead.", DeprecationWarning) line = egg else: egg = "" @@ -58,16 +63,27 @@ def update(yes=False, force=False): rest = rest.replace(".zip", "-").replace(".tar.gz", "-") name, version, _ = rest.split("-", 2) line = f'{egg or name}=={version}' + elif "@" in line and "#" in line: + # PEP 508 does not allow us to specify a version, so we use custom syntax + # name @ url#version ; marker + name, rest = line.split("@", 1) + version = rest.split("#", 1)[1].split(";", 1)[0].rstrip() + line = f"{name.rstrip()}=={version}" + if ";" in rest: # keep marker + line += rest[rest.find(";"):] requirements = pkg_resources.parse_requirements(line) - for requirement in requirements: - requirement = str(requirement) + for requirement in map(str, requirements): try: pkg_resources.require(requirement) except pkg_resources.ResolutionError: if not yes: import traceback traceback.print_exc() - input(f'Requirement {requirement} is not satisfied, press enter to install it') + try: + input(f"\nRequirement {requirement} is not satisfied, press enter to install it") + except KeyboardInterrupt: + print("\nAborting") + sys.exit(1) update_command() return diff --git a/MultiServer.py b/MultiServer.py index 132c73dd8c..f69ce6bc64 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -41,7 +41,6 @@ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, Networ SlotType min_client_version = Version(0, 1, 6) -print_command_compatability_threshold = Version(0, 3, 5) # Remove backwards compatibility around 0.3.7 colorama.init() @@ -159,6 +158,7 @@ class Context: stored_data: typing.Dict[str, object] read_data: typing.Dict[str, object] stored_data_notification_clients: typing.Dict[str, typing.Set[Client]] + slot_info: typing.Dict[int, NetworkSlot] item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})') item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] @@ -171,7 +171,7 @@ class Context: remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, log_network: bool = False): super(Context, self).__init__() - self.slot_info: typing.Dict[int, NetworkSlot] = {} + self.slot_info = {} self.log_network = log_network self.endpoints = [] self.clients = {} @@ -310,6 +310,10 @@ class Context: endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth) async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) + def broadcast_text_all(self, text: str, additional_arguments: dict = {}): + logging.info("Notice (all): %s" % text) + self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}]) + def broadcast_team(self, team: int, msgs: typing.List[dict]): msgs = self.dumper(msgs) endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values())) @@ -326,29 +330,18 @@ class Context: self.clients[endpoint.team][endpoint.slot].remove(endpoint) await on_client_disconnected(self, endpoint) - # text - - def notify_all(self, text: str): - logging.info("Notice (all): %s" % text) - broadcast_text_all(self, text) - - def notify_client(self, client: Client, text: str): + def notify_client(self, client: Client, text: str, additional_arguments: dict = {}): if not client.auth: return logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text)) - if client.version >= print_command_compatability_threshold: - async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}])) - else: - async_start(self.send_msgs(client, [{"cmd": "Print", "text": text}])) + async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}])) - def notify_client_multiple(self, client: Client, texts: typing.List[str]): + def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}): if not client.auth: return - if client.version >= print_command_compatability_threshold: - async_start(self.send_msgs(client, - [{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts])) - else: - async_start(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts])) + async_start(self.send_msgs(client, + [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments} + for text in texts])) # loading @@ -387,15 +380,25 @@ class Context: for player, version in clients_ver.items(): self.minimum_client_versions[player] = max(Utils.Version(*version), min_client_version) - self.clients = {} - for team, names in enumerate(decoded_obj['names']): - self.clients[team] = {} - for player, name in enumerate(names, 1): - self.clients[team][player] = [] - self.player_names[team, player] = name - self.player_name_lookup[name] = team, player - self.read_data[f"hints_{team}_{player}"] = lambda local_team=team, local_player=player: \ - list(self.get_rechecked_hints(local_team, local_player)) + self.slot_info = decoded_obj["slot_info"] + self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()} + self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items() + if slot_info.type == SlotType.group} + self.allow_collect = {slot: slot_info.allow_collect if type(slot_info.allow_collect) is bool else True + for slot, slot_info in self.slot_info.items()} + + self.clients = {0: {}} + slot_info: NetworkSlot + slot_id: int + + team_0 = self.clients[0] + for slot_id, slot_info in self.slot_info.items(): + team_0[slot_id] = [] + self.player_names[0, slot_id] = slot_info.name + self.player_name_lookup[slot_info.name] = 0, slot_id + self.read_data[f"hints_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \ + list(self.get_rechecked_hints(local_team, local_player)) + self.seed_name = decoded_obj["seed_name"] self.random.seed(self.seed_name) self.connect_names = decoded_obj['connect_names'] @@ -410,32 +413,9 @@ class Context: for slot, item_codes in decoded_obj["precollected_items"].items(): self.start_inventory[slot] = [NetworkItem(item_code, -2, 0) for item_code in item_codes] - for team in range(len(decoded_obj['names'])): - for slot, hints in decoded_obj["precollected_hints"].items(): - self.hints[team, slot].update(hints) - if "slot_info" in decoded_obj: - self.slot_info = decoded_obj["slot_info"] - self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()} - self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items() - if slot_info.type == SlotType.group} - self.allow_collect = {slot: slot_info.allow_collect if type(slot_info.allow_collect) is bool else True - for slot, slot_info in self.slot_info.items()} - else: - self.games = decoded_obj["games"] - self.groups = {} - self.slot_info = { - slot: NetworkSlot( - self.player_names[0, slot], - self.games[slot], - SlotType(int(bool(locations)))) - for slot, locations in self.locations.items() - } - # locations may need converting - for slot, locations in self.locations.items(): - self.allow_collect[slot] = True - for location, item_data in locations.items(): - if len(item_data) < 3: - locations[location] = (*item_data, 0) + for slot, hints in decoded_obj["precollected_hints"].items(): + self.hints[0, slot].update(hints) + # declare slots that aren't players as done for slot, slot_info in self.slot_info.items(): if slot_info.type.always_goal: @@ -578,7 +558,7 @@ class Context: self.location_check_points = savedata["game_options"]["location_check_points"] self.server_password = savedata["game_options"]["server_password"] self.password = savedata["game_options"]["password"] - self.release_mode = savedata["game_options"]["release_mode"] + self.release_mode = savedata["game_options"].get("release_mode", savedata["game_options"].get("forfeit_mode", "goal")) self.remaining_mode = savedata["game_options"]["remaining_mode"] self.collect_mode = savedata["game_options"]["collect_mode"] self.item_cheat = savedata["game_options"]["item_cheat"] @@ -689,7 +669,7 @@ class Context: def on_goal_achieved(self, client: Client): finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \ f' has completed their goal.' - self.notify_all(finished_msg) + self.broadcast_text_all(finished_msg, {"type": "Goal", "team": client.team, "slot": client.slot}) if "auto" in self.collect_mode: collect_player(self, client.team, client.slot) if "auto" in self.release_mode: @@ -782,55 +762,42 @@ async def on_client_joined(ctx: Context, client: Client): update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED) version_str = '.'.join(str(x) for x in client.version) verb = "tracking" if "Tracker" in client.tags else "playing" - ctx.notify_all( + ctx.broadcast_text_all( f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) " f"{verb} {ctx.games[client.slot]} has joined. " - f"Client({version_str}), {client.tags}).") + f"Client({version_str}), {client.tags}).", + {"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags}) ctx.notify_client(client, "Now that you are connected, " "you can use !help to list commands to run via the server. " "If your client supports it, " - "you may have additional local commands you can list with /help.") + "you may have additional local commands you can list with /help.", + {"type": "Tutorial"}) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) async def on_client_left(ctx: Context, client: Client): update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN) - ctx.notify_all( - "%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1)) + ctx.broadcast_text_all( + "%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1), + {"type": "Part", "team": client.team, "slot": client.slot}) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) async def countdown(ctx: Context, timer: int): - broadcast_countdown(ctx, timer, f"[Server]: Starting countdown of {timer}s") + ctx.broadcast_text_all(f"[Server]: Starting countdown of {timer}s", {"type": "Countdown", "countdown": timer}) if ctx.countdown_timer: ctx.countdown_timer = timer # timer is already running, set it to a different time else: ctx.countdown_timer = timer while ctx.countdown_timer > 0: - broadcast_countdown(ctx, ctx.countdown_timer, f"[Server]: {ctx.countdown_timer}") + ctx.broadcast_text_all(f"[Server]: {ctx.countdown_timer}", + {"type": "Countdown", "countdown": ctx.countdown_timer}) ctx.countdown_timer -= 1 await asyncio.sleep(1) - broadcast_countdown(ctx, 0, f"[Server]: GO") + ctx.broadcast_text_all(f"[Server]: GO", {"type": "Countdown", "countdown": 0}) ctx.countdown_timer = 0 -def broadcast_text_all(ctx: Context, text: str, additional_arguments: dict = {}): - old_clients, new_clients = [], [] - - for teams in ctx.clients.values(): - for clients in teams.values(): - for client in clients: - new_clients.append(client) if client.version >= print_command_compatability_threshold \ - else old_clients.append(client) - - ctx.broadcast(old_clients, [{"cmd": "Print", "text": text }]) - ctx.broadcast(new_clients, [{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}]) - - -def broadcast_countdown(ctx: Context, timer: int, message: str): - broadcast_text_all(ctx, message, {"type": "Countdown", "countdown": timer}) - - def get_players_string(ctx: Context): auth_clients = {(c.team, c.slot) for c in ctx.endpoints if c.auth} @@ -898,7 +865,9 @@ def update_checked_locations(ctx: Context, team: int, slot: int): def release_player(ctx: Context, team: int, slot: int): """register any locations that are in the multidata""" all_locations = set(ctx.locations[slot]) - ctx.notify_all("%s (Team #%d) has released all remaining items from their world." % (ctx.player_names[(team, slot)], team + 1)) + ctx.broadcast_text_all("%s (Team #%d) has released all remaining items from their world." + % (ctx.player_names[(team, slot)], team + 1), + {"type": "Release", "team": team, "slot": slot}) register_location_checks(ctx, team, slot, all_locations) update_checked_locations(ctx, team, slot) @@ -913,7 +882,9 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False): if values[1] == slot: all_locations[source_slot].add(location_id) - ctx.notify_all("%s (Team #%d) has collected their items from other worlds." % (ctx.player_names[(team, slot)], team + 1)) + ctx.broadcast_text_all("%s (Team #%d) has collected their items from other worlds." + % (ctx.player_names[(team, slot)], team + 1), + {"type": "Collect", "team": team, "slot": slot}) for source_player, location_ids in all_locations.items(): register_location_checks(ctx, team, source_player, location_ids, count_activity=False) update_checked_locations(ctx, team, source_player) @@ -1183,11 +1154,15 @@ class ClientMessageProcessor(CommonCommandProcessor): def __call__(self, raw: str) -> typing.Optional[bool]: if not raw.startswith("!admin"): - self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + raw) + self.ctx.broadcast_text_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + raw, + {"type": "Chat", "team": self.client.team, "slot": self.client.slot, "message": raw}) return super(ClientMessageProcessor, self).__call__(raw) - def output(self, text): - self.ctx.notify_client(self.client, text) + def output(self, text: str): + self.ctx.notify_client(self.client, text, {"type": "CommandResult"}) + + def output_multiple(self, texts: typing.List[str]): + self.ctx.notify_client_multiple(self.client, texts, {"type": "CommandResult"}) def default(self, raw: str): pass # default is client sending just text @@ -1210,9 +1185,8 @@ class ClientMessageProcessor(CommonCommandProcessor): # disallow others from knowing what the new remote administration password is. "!admin /option server_password"): output = f"!admin /option server_password {('*' * random.randint(4, 16))}" - # Otherwise notify the others what is happening. - self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team, - self.client.slot) + ': ' + output) + self.ctx.broadcast_text_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + output, + {"type": "Chat", "team": self.client.team, "slot": self.client.slot, "message": output}) if not self.ctx.server_password: self.output("Sorry, Remote administration is disabled") @@ -1249,7 +1223,7 @@ class ClientMessageProcessor(CommonCommandProcessor): def _cmd_players(self) -> bool: """Get information about connected and missing players.""" if len(self.ctx.player_names) < 10: - self.ctx.notify_all(get_players_string(self.ctx)) + self.ctx.broadcast_text_all(get_players_string(self.ctx), {"type": "CommandResult"}) else: self.output(get_players_string(self.ctx)) return True @@ -1338,7 +1312,7 @@ class ClientMessageProcessor(CommonCommandProcessor): if locations: texts = [f'Missing: {self.ctx.location_names[location]}' for location in locations] texts.append(f"Found {len(locations)} missing location checks") - self.ctx.notify_client_multiple(self.client, texts) + self.output_multiple(texts) else: self.output("No missing location checks found.") return True @@ -1351,7 +1325,7 @@ class ClientMessageProcessor(CommonCommandProcessor): if locations: texts = [f'Checked: {self.ctx.location_names[location]}' for location in locations] texts.append(f"Found {len(locations)} done location checks") - self.ctx.notify_client_multiple(self.client, texts) + self.output_multiple(texts) else: self.output("No done location checks found.") return True @@ -1387,9 +1361,10 @@ class ClientMessageProcessor(CommonCommandProcessor): new_item = NetworkItem(names[item_name], -1, self.client.slot) get_received_items(self.ctx, self.client.team, self.client.slot, False).append(new_item) get_received_items(self.ctx, self.client.team, self.client.slot, True).append(new_item) - self.ctx.notify_all( + self.ctx.broadcast_text_all( 'Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team, - self.client.slot)) + self.client.slot), + {"type": "ItemCheat", "team": self.client.team, "receiving": self.client.slot, "item": new_item}) send_new_items(self.ctx) return True else: @@ -1688,9 +1663,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): client.tags = args["tags"] if set(old_tags) != set(client.tags): client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags - ctx.notify_all( + ctx.broadcast_text_all( f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags " - f"from {old_tags} to {client.tags}.") + f"from {old_tags} to {client.tags}.", + {"type": "TagsChanged", "team": client.team, "slot": client.slot, "tags": client.tags}) elif cmd == 'Sync': start_inventory = get_start_inventory(ctx, client.slot, client.remote_start_inventory) @@ -1808,11 +1784,11 @@ class ServerCommandProcessor(CommonCommandProcessor): def output(self, text: str): if self.client: - self.ctx.notify_client(self.client, text) + self.ctx.notify_client(self.client, text, {"type": "AdminCommandResult"}) super(ServerCommandProcessor, self).output(text) def default(self, raw: str): - self.ctx.notify_all('[Server]: ' + raw) + self.ctx.broadcast_text_all('[Server]: ' + raw, {"type": "ServerChat", "message": raw}) def _cmd_save(self) -> bool: """Save current state to multidata""" @@ -1953,7 +1929,7 @@ class ServerCommandProcessor(CommonCommandProcessor): send_items_to(self.ctx, team, slot, *new_items) send_new_items(self.ctx) - self.ctx.notify_all( + self.ctx.broadcast_text_all( 'Cheat console: sending ' + ('' if amount == 1 else f'{amount} of ') + f'"{item_name}" to {self.ctx.get_aliased_name(team, slot)}') return True diff --git a/OoTAdjuster.py b/OoTAdjuster.py index c2df9b074e..f449113d22 100644 --- a/OoTAdjuster.py +++ b/OoTAdjuster.py @@ -197,7 +197,7 @@ def set_icon(window): def adjust(args): # Create a fake world and OOTWorld to use as a base world = MultiWorld(1) - world.slot_seeds = {1: random} + world.per_slot_randoms = {1: random} ootworld = OOTWorld(world, 1) # Set options in the fake OOTWorld for name, option in chain(cosmetic_options.items(), sfx_options.items()): diff --git a/Options.py b/Options.py index 7a9eb8275a..c01168965b 100644 --- a/Options.py +++ b/Options.py @@ -79,9 +79,6 @@ class AssembleOptions(abc.ABCMeta): return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs) - @abc.abstractclassmethod - def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ... - T = typing.TypeVar('T') @@ -129,8 +126,9 @@ class Option(typing.Generic[T], metaclass=AssembleOptions): return bool(self.value) @classmethod + @abc.abstractmethod def from_any(cls, data: typing.Any) -> Option[T]: - raise NotImplementedError + ... if typing.TYPE_CHECKING: from Generate import PlandoOptions @@ -168,7 +166,7 @@ class FreeText(Option): return value -class NumericOption(Option[int], numbers.Integral): +class NumericOption(Option[int], numbers.Integral, abc.ABC): default = 0 # note: some of the `typing.Any`` here is a result of unresolved issue in python standards # `int` is not a `numbers.Integral` according to the official typestubs diff --git a/SNIClient.py b/SNIClient.py index 623bc17554..8d402b1d5f 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -56,7 +56,9 @@ class SNIClientCommandProcessor(ClientCommandProcessor): """Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices; and a SNES device number if more than one SNES is detected. Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """ - + if self.ctx.snes_state in {SNESState.SNES_ATTACHED, SNESState.SNES_CONNECTED, SNESState.SNES_CONNECTING}: + self.output("Already connected to SNES. Disconnecting first.") + self._cmd_snes_close() return self.connect_to_snes(snes_options) def connect_to_snes(self, snes_options: str = "") -> bool: @@ -84,7 +86,7 @@ class SNIClientCommandProcessor(ClientCommandProcessor): """Close connection to a currently connected snes""" self.ctx.snes_reconnect_address = None self.ctx.cancel_snes_autoreconnect() - if self.ctx.snes_socket is not None and not self.ctx.snes_socket.closed: + if self.ctx.snes_socket and not self.ctx.snes_socket.closed: async_start(self.ctx.snes_socket.close()) return True else: @@ -442,7 +444,8 @@ async def snes_connect(ctx: SNIContext, address: str, deviceIndex: int = -1) -> recv_task = asyncio.create_task(snes_recv_loop(ctx)) except Exception as e: - if recv_task is not None: + ctx.snes_state = SNESState.SNES_DISCONNECTED + if task_alive(recv_task): if not ctx.snes_socket.closed: await ctx.snes_socket.close() else: @@ -450,15 +453,9 @@ async def snes_connect(ctx: SNIContext, address: str, deviceIndex: int = -1) -> if not ctx.snes_socket.closed: await ctx.snes_socket.close() ctx.snes_socket = None - ctx.snes_state = SNESState.SNES_DISCONNECTED - if not ctx.snes_reconnect_address: - snes_logger.error("Error connecting to snes (%s)" % e) - else: - snes_logger.error(f"Error connecting to snes, retrying in {_global_snes_reconnect_delay} seconds") - assert ctx.snes_autoreconnect_task is None - ctx.snes_autoreconnect_task = asyncio.create_task(snes_autoreconnect(ctx), name="snes auto-reconnect") + snes_logger.error(f"Error connecting to snes ({e}), retrying in {_global_snes_reconnect_delay} seconds") + ctx.snes_autoreconnect_task = asyncio.create_task(snes_autoreconnect(ctx), name="snes auto-reconnect") _global_snes_reconnect_delay *= 2 - else: _global_snes_reconnect_delay = ctx.starting_reconnect_delay snes_logger.info(f"Attached to {device}") @@ -471,10 +468,17 @@ async def snes_disconnect(ctx: SNIContext) -> None: ctx.snes_socket = None +def task_alive(task: typing.Optional[asyncio.Task]) -> bool: + if task: + return not task.done() + return False + + async def snes_autoreconnect(ctx: SNIContext) -> None: await asyncio.sleep(_global_snes_reconnect_delay) - if ctx.snes_reconnect_address and not ctx.snes_socket and not ctx.snes_connect_task: - ctx.snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_reconnect_address), name="SNES Connect") + if not ctx.snes_socket and not task_alive(ctx.snes_connect_task): + address = ctx.snes_reconnect_address if ctx.snes_reconnect_address else ctx.snes_address + ctx.snes_connect_task = asyncio.create_task(snes_connect(ctx, address), name="SNES Connect") async def snes_recv_loop(ctx: SNIContext) -> None: diff --git a/Utils.py b/Utils.py index 48ec934656..010cc3e5d3 100644 --- a/Utils.py +++ b/Utils.py @@ -12,7 +12,7 @@ import io import collections import importlib import logging -from typing import BinaryIO, ClassVar, Coroutine, Optional, Set +from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union from yaml import load, load_all, dump, SafeLoader @@ -38,7 +38,7 @@ class Version(typing.NamedTuple): build: int -__version__ = "0.3.8" +__version__ = "0.3.9" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -662,7 +662,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None: def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))): """Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning.""" - def sorter(element: str) -> str: + def sorter(element: Union[str, Dict[str, Any]]) -> str: + if (not isinstance(element, str)): + element = element["title"] + parts = element.split(maxsplit=1) if parts[0].lower() in ignore: return parts[1].lower() diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index aa5467d628..bf9f4e2fd7 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -1,5 +1,6 @@ import datetime import os +from typing import List, Dict, Union import jinja2.exceptions from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory @@ -163,8 +164,9 @@ def get_datapackage(): @app.route('/index') @app.route('/sitemap') def get_sitemap(): - available_games = [] + available_games: List[Dict[str, Union[str, bool]]] = [] for game, world in AutoWorldRegister.world_types.items(): if not world.hidden: - available_games.append(game) + has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page + available_games.append({ 'title': game, 'has_settings': has_settings }) return render_template("siteMap.html", games=available_games) diff --git a/WebHostLib/static/assets/faq/faq_en.md b/WebHostLib/static/assets/faq/faq_en.md index 339a2a15ea..74f423df1f 100644 --- a/WebHostLib/static/assets/faq/faq_en.md +++ b/WebHostLib/static/assets/faq/faq_en.md @@ -20,7 +20,7 @@ comfortable exploiting certain glitches in the game. ## What is a multi-world? While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a -two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each player's +two player multi-world, players A and B each get their own randomized version of a game, called a world. In each player's game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the item will be sent to player B's world over the internet. diff --git a/WebHostLib/static/assets/tracker.js b/WebHostLib/static/assets/tracker.js index 22f6f72f1c..23e7f979a5 100644 --- a/WebHostLib/static/assets/tracker.js +++ b/WebHostLib/static/assets/tracker.js @@ -18,7 +18,8 @@ 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) { @@ -70,10 +71,30 @@ window.addEventListener('load', () => { // the tbody and render two separate tables. }); - document.getElementById('search').addEventListener('keyup', (event) => { - tables.search(event.target.value); - console.info(tables.search()); + const searchBox = document.getElementById("search"); + searchBox.value = tables.search(); + searchBox.focus(); + searchBox.select(); + const doSearch = () => { + tables.search(searchBox.value); tables.draw(); + }; + searchBox.addEventListener("keyup", doSearch); + window.addEventListener("keydown", (event) => { + if (!event.ctrlKey && !event.altKey && event.key.length === 1 && document.activeElement !== searchBox) { + searchBox.focus(); + searchBox.select(); + } + if (!event.ctrlKey && !event.altKey && !event.shiftKey && event.key === "Escape") { + if (searchBox.value !== "") { + searchBox.value = ""; + doSearch(); + } + searchBox.blur(); + if (!document.getElementById("tables-container")) + window.scroll(0, 0); + event.preventDefault(); + } }); const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker'); const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3; diff --git a/WebHostLib/static/styles/timespinnerTracker.css b/WebHostLib/static/styles/timespinnerTracker.css index e4e47f9e61..007c6a19ba 100644 --- a/WebHostLib/static/styles/timespinnerTracker.css +++ b/WebHostLib/static/styles/timespinnerTracker.css @@ -9,19 +9,54 @@ border-top-left-radius: 4px; border-top-right-radius: 4px; padding: 3px 3px 10px; - width: 384px; + width: 374px; background-color: #8d60a7; -} -#inventory-table td{ - width: 40px; - height: 40px; - text-align: center; - vertical-align: middle; + display: grid; + grid-template-rows: repeat(5, 48px); +} + +#inventory-table img{ + display: block; +} + +#inventory-table div.table-row{ + display: grid; + grid-template-columns: repeat(5, 1fr); +} + +#inventory-table div.C1{ + grid-column: 1; + place-content: center; + place-items: center; + display: flex; +} +#inventory-table div.C2{ + grid-column: 2; + place-content: center; + place-items: center; + display: flex; +} +#inventory-table div.C3{ + grid-column: 3; + place-content: center; + place-items: center; + display: flex; +} +#inventory-table div.C4{ + grid-column: 4; + place-content: center; + place-items: center; + display: flex; +} +#inventory-table div.C5{ + grid-column: 5; + place-content: center; + place-items: center; + display: flex; } #inventory-table img{ - height: 100%; max-width: 40px; max-height: 40px; filter: grayscale(100%) contrast(75%) brightness(30%); @@ -31,11 +66,70 @@ filter: none; } -#inventory-table div.counted-item { +#inventory-table img.acquired.purple{ /*00FFFF*/ + filter: hue-rotate(270deg) saturate(6) brightness(0.8); +} +#inventory-table img.acquired.cyan{ /*FF00FF*/ + filter: hue-rotate(138deg) saturate(10) brightness(0.8); +} +#inventory-table img.acquired.green{ /*32CD32*/ + filter: hue-rotate(84deg) saturate(10) brightness(0.7); +} + +#inventory-table div.image-stack{ + display: grid; + position: relative; + grid-template-columns: 1fr; + grid-template-rows: 1fr; +} + +#inventory-table div.image-stack div.stack-back{ + grid-column: 1; + grid-row: 1; +} + +#inventory-table div.image-stack div.stack-front{ + grid-column: 1; + grid-row: 1; + display: grid; + grid-template-columns: 20px 20px; + grid-template-rows: 20px 20px; +} + +#inventory-table div.image-stack div.stack-top-left{ + grid-column: 1; + grid-row: 1; + z-index: 1; +} + +#inventory-table div.image-stack div.stack-top-right{ + grid-column: 2; + grid-row: 1; + z-index: 1; +} + +#inventory-table div.image-stack div.stack-bottum-left{ + grid-column: 1; + grid-row: 2; + z-index: 1; +} + +#inventory-table div.image-stack div.stack-bottum-right{ + grid-column: 2; + grid-row: 2; + z-index: 1; +} + +#inventory-table div.image-stack div.stack-front img{ + width: 20px; + height: 20px; +} + +#inventory-table div.counted-item{ position: relative; } -#inventory-table div.item-count { +#inventory-table div.item-count{ position: absolute; color: white; font-family: "Minecraftia", monospace; @@ -69,16 +163,16 @@ line-height: 20px; } -#location-table td.counter { +#location-table td.counter{ text-align: right; font-size: 14px; } -#location-table td.toggle-arrow { +#location-table td.toggle-arrow{ text-align: right; } -#location-table tr#Total-header { +#location-table tr#Total-header{ font-weight: bold; } @@ -88,14 +182,14 @@ max-height: 30px; } -#location-table tbody.locations { +#location-table tbody.locations{ font-size: 12px; } -#location-table td.location-name { +#location-table td.location-name{ padding-left: 16px; } -.hide { +.hide{ display: none; } diff --git a/WebHostLib/templates/siteMap.html b/WebHostLib/templates/siteMap.html index 6d1816d5fa..1cfb71f8df 100644 --- a/WebHostLib/templates/siteMap.html +++ b/WebHostLib/templates/siteMap.html @@ -29,17 +29,30 @@
  • Glossary
  • +

    Tutorials

    + +

    Game Info Pages

    Game Settings Pages

    diff --git a/WebHostLib/templates/timespinnerTracker.html b/WebHostLib/templates/timespinnerTracker.html index 82565316ab..f02ec6daab 100644 --- a/WebHostLib/templates/timespinnerTracker.html +++ b/WebHostLib/templates/timespinnerTracker.html @@ -8,79 +8,94 @@
    - - - - - - - - - - - - - - - - - - - - - {% if 'DownloadableItems' in options %} - - {% else %} - - {% endif %} - - - {% if 'DownloadableItems' in options %} - - {% else %} - - {% endif %} - - {% if 'EyeSpy' in options %} - - {% else %} - - {% endif %} - - - - - {% if 'GyreArchives' in options %} - - - {% else %} - - - {% endif %} - +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + {% if 'UnchainedKeys' in options %} + {% if 'EnterSandman' in options %} +
    + +
    + {% endif %} +
    + +
    +
    + +
    + {% endif %} +
    +
    +
    +
    +
    +
    +
    +
    +
    + {% if 'DownloadableItems' in options %} +
    + {% endif %} +
    +
    + {% if 'DownloadableItems' in options %} +
    + {% endif %} +
    + {% if 'EyeSpy' in options %} +
    + {% endif %} +
    +
    +
    +
    + {% if 'GyreArchives' in options %} +
    +
    + {% endif %} +
    {% if 'Djinn Inferno' in acquired_items %} -
    + {% elif 'Pyro Ring' in acquired_items %} - + {% elif 'Fire Orb' in acquired_items %} - + {% elif 'Infernal Flames' in acquired_items %} - + {% else %} - + {% endif %} - + +
    {% if 'Royal Ring' in acquired_items %} -
    + {% elif 'Plasma Geyser' in acquired_items %} - + {% elif 'Plasma Orb' in acquired_items %} - + {% else %} - + {% endif %} - -
    +
    + + + {% for area in checks_done %} diff --git a/WebHostLib/templates/tracker.html b/WebHostLib/templates/tracker.html index 0f6fe12ffb..96148e3454 100644 --- a/WebHostLib/templates/tracker.html +++ b/WebHostLib/templates/tracker.html @@ -98,6 +98,7 @@ {%- endif -%} {%- endfor -%} + @@ -140,6 +141,7 @@ {%- endif -%} {%- endfor -%} + {%- if activity_timers[(team, player)] -%} {%- else -%} diff --git a/WebHostLib/tracker.py b/WebHostLib/tracker.py index 68a40a323a..c0b83e4daf 100644 --- a/WebHostLib/tracker.py +++ b/WebHostLib/tracker.py @@ -1232,6 +1232,11 @@ def getTracker(tracker: UUID): for playernumber in range(1, len(team) + 1) if playernumber not in groups} for teamnumber, team in enumerate(names)} + percent_total_checks_done = {teamnumber: {playernumber: 0 + for playernumber in range(1, len(team) + 1) if playernumber not in groups} + for teamnumber, team in enumerate(names)} + + hints = {team: set() for team in range(len(names))} if room.multisave: multisave = restricted_loads(room.multisave) @@ -1259,6 +1264,7 @@ def getTracker(tracker: UUID): attribute_item(inventory, team, recipient, item) checks_done[team][player][player_location_to_area[player][location]] += 1 checks_done[team][player]["Total"] += 1 + percent_total_checks_done[team][player] = int(checks_done[team][player]["Total"] / seed_checks_in_area[player]["Total"] * 100) if seed_checks_in_area[player]["Total"] else 100 for (team, player), game_state in multisave.get("client_game_state", {}).items(): if player in groups: @@ -1303,8 +1309,8 @@ def getTracker(tracker: UUID): return render_template("tracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name, lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names, tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons, - multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas, - checks_in_area=seed_checks_in_area, activity_timers=activity_timers, + multi_items=multi_items, checks_done=checks_done, percent_total_checks_done=percent_total_checks_done, + ordered_areas=ordered_areas, checks_in_area=seed_checks_in_area, activity_timers=activity_timers, key_locations=group_key_locations, small_key_ids=small_key_ids, big_key_ids=big_key_ids, video=video, big_key_locations=group_big_key_locations, hints=hints, long_player_names=long_player_names) diff --git a/data/client.kv b/data/client.kv index 5948a3ebf6..f0e3616900 100644 --- a/data/client.kv +++ b/data/client.kv @@ -1,4 +1,21 @@ - +: + # Hex-format RGB colors used in clients. Resets after an update/install. + # To avoid, you can copy the TextColors section into a new "user.kv" next to this file + # and it will read from there instead. + black: "000000" + red: "EE0000" + green: "00FF7F" # typically a location + yellow: "FAFAD2" # typically other slots/players + blue: "6495ED" # typically extra info (such as entrance) + magenta: "EE00EE" # typically your slot/player + cyan: "00EEEE" # typically regular item + slateblue: "6D8BE8" # typically useful item + plum: "AF99EF" # typically progression item + salmon: "FA8072" # typically trap item + white: "FFFFFF" # not used, if you want to change the generic text color change color in Label +
    {{ area }}% Last
    Activity
    {% if inventory[team][player][big_key_ids[area]] %}✔️{% endif %}{{ percent_total_checks_done[team][player] }}{{ activity_timers[(team, player)].total_seconds() }}