diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7151ff00c88e..f5eb371567be 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,12 +9,14 @@ on: - 'setup.py' - 'requirements.txt' - '*.iss' + - 'worlds/*/archipelago.json' pull_request: paths: - '.github/workflows/build.yml' - 'setup.py' - 'requirements.txt' - '*.iss' + - 'worlds/*/archipelago.json' workflow_dispatch: env: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000000..cf9ce08faf38 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,154 @@ +name: Build and Publish Docker Images + +on: + push: + paths: + - "**" + - "!docs/**" + - "!deploy/**" + - "!setup.py" + - "!.gitignore" + - "!.github/workflows/**" + - ".github/workflows/docker.yml" + branches: + - "*" + tags: + - "v?[0-9]+.[0-9]+.[0-9]*" + workflow_dispatch: + +env: + REGISTRY: ghcr.io + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + image-name: ${{ steps.image.outputs.name }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + package-name: ${{ steps.package.outputs.name }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set lowercase image name + id: image + run: | + echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT + + - name: Set package name + id: package + run: | + echo "name=$(basename ${GITHUB_REPOSITORY,,})" >> $GITHUB_OUTPUT + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }} + tags: | + type=ref,event=branch,enable={{is_not_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=nightly,enable={{is_default_branch}} + + - name: Compute final tags + id: final-tags + run: | + readarray -t tags <<< "${{ steps.meta.outputs.tags }}" + + if [[ "${{ github.ref_type }}" == "tag" ]]; then + tag="${{ github.ref_name }}" + if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + full_latest="${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:latest" + # Check if latest is already in tags to avoid duplicates + if ! printf '%s\n' "${tags[@]}" | grep -q "^$full_latest$"; then + tags+=("$full_latest") + fi + fi + fi + + # Set multiline output + echo "tags<> $GITHUB_OUTPUT + printf '%s\n' "${tags[@]}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + build: + needs: prepare + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - platform: amd64 + runner: ubuntu-latest + suffix: amd64 + cache-scope: amd64 + - platform: arm64 + runner: ubuntu-24.04-arm + suffix: arm64 + cache-scope: arm64 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute suffixed tags + id: tags + run: | + readarray -t tags <<< "${{ needs.prepare.outputs.tags }}" + suffixed=() + for t in "${tags[@]}"; do + suffixed+=("$t-${{ matrix.suffix }}") + done + echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + platforms: linux/${{ matrix.platform }} + push: true + tags: ${{ steps.tags.outputs.tags }} + labels: ${{ needs.prepare.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.cache-scope }} + cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }} + provenance: false + + manifest: + needs: [prepare, build] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push multi-arch manifest + run: | + readarray -t tag_array <<< "${{ needs.prepare.outputs.tags }}" + + for tag in "${tag_array[@]}"; do + docker manifest create "$tag" \ + "$tag-amd64" \ + "$tag-arm64" + + docker manifest push "$tag" + done diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml index 4a7d4034590a..1675c942bddb 100644 --- a/.github/workflows/label-pull-requests.yml +++ b/.github/workflows/label-pull-requests.yml @@ -12,7 +12,6 @@ env: jobs: labeler: name: 'Apply content-based labels' - if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize' runs-on: ubuntu-latest steps: - uses: actions/labeler@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c5d87b0ba44..147f30942d99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ name: Release on: push: tags: - - '*.*.*' + - 'v?[0-9]+.[0-9]+.[0-9]*' env: ENEMIZER_VERSION: 7.1 diff --git a/.run/Build APWorld.run.xml b/.run/Build APWorld.run.xml new file mode 100644 index 000000000000..db6a305e7bb3 --- /dev/null +++ b/.run/Build APWorld.run.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/BaseClasses.py b/BaseClasses.py index ca717b60f25f..ee2f73ca5106 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -261,6 +261,7 @@ def set_item_links(self): "local_items": set(item_link.get("local_items", [])), "non_local_items": set(item_link.get("non_local_items", [])), "link_replacement": replacement_prio.index(item_link["link_replacement"]), + "skip_if_solo": item_link.get("skip_if_solo", False), } for _name, item_link in item_links.items(): @@ -284,6 +285,8 @@ def set_item_links(self): for group_name, item_link in item_links.items(): game = item_link["game"] + if item_link["skip_if_solo"] and len(item_link["players"]) == 1: + continue group_id, group = self.add_group(group_name, game, set(item_link["players"])) group["item_pool"] = item_link["item_pool"] @@ -1343,8 +1346,7 @@ def get_connecting_entrance(self, is_main_entrance: Callable[[Entrance], bool]) for entrance in self.entrances: # BFS might be better here, trying DFS for now. return entrance.parent_region.get_connecting_entrance(is_main_entrance) - def add_locations(self, locations: Dict[str, Optional[int]], - location_type: Optional[type[Location]] = None) -> None: + def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None: """ Adds locations to the Region object, where location_type is your Location class and locations is a dict of location names to address. @@ -1432,8 +1434,8 @@ def create_er_target(self, name: str) -> Entrance: entrance.connect(self) return entrance - def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], - rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]: + def add_exits(self, exits: Iterable[str] | Mapping[str, str | None], + rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]: """ Connects current region to regions in exit dictionary. Passed region names must exist first. @@ -1441,7 +1443,7 @@ def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], created entrances will be named "self.name -> connecting_region" :param rules: rules for the exits from this region. format is {"connecting_region": rule} """ - if not isinstance(exits, Dict): + if not isinstance(exits, Mapping): exits = dict.fromkeys(exits) return [ self.connect( @@ -1855,6 +1857,9 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None: Utils.__version__, self.multiworld.seed)) outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm) outfile.write('Players: %d\n' % self.multiworld.players) + if self.multiworld.players > 1: + loc_count = len([loc for loc in self.multiworld.get_locations() if not loc.is_event]) + outfile.write('Total Location Count: %d\n' % loc_count) outfile.write(f'Plando Options: {self.multiworld.plando_options}\n') AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile) @@ -1863,6 +1868,9 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None: outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('Game: %s\n' % self.multiworld.game[player]) + loc_count = len([loc for loc in self.multiworld.get_locations(player) if not loc.is_event]) + outfile.write('Location Count: %d\n' % loc_count) + for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items(): write_option(f_option, option) diff --git a/CommonClient.py b/CommonClient.py index bd7113cb6f75..41cc08d1d0a9 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -99,17 +99,6 @@ def _cmd_received(self) -> bool: self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"}) return True - def get_current_datapackage(self) -> dict[str, typing.Any]: - """ - Return datapackage for current game if known. - - :return: The datapackage for the currently registered game. If not found, an empty dictionary will be returned. - """ - if not self.ctx.game: - return {} - checksum = self.ctx.checksums[self.ctx.game] - return Utils.load_data_package_for_checksum(self.ctx.game, checksum) - def _cmd_missing(self, filter_text = "") -> bool: """List all missing location checks, from your local game state. Can be given text, which will be used as filter.""" @@ -119,8 +108,8 @@ def _cmd_missing(self, filter_text = "") -> bool: count = 0 checked_count = 0 - lookup = self.get_current_datapackage().get("location_name_to_id", {}) - for location, location_id in lookup.items(): + lookup = self.ctx.location_names[self.ctx.game] + for location_id, location in lookup.items(): if filter_text and filter_text not in location: continue if location_id < 0: @@ -141,11 +130,10 @@ def _cmd_missing(self, filter_text = "") -> bool: self.output("No missing location checks found.") return True - def output_datapackage_part(self, key: str, name: str) -> bool: + def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool: """ Helper to digest a specific section of this game's datapackage. - :param key: The dictionary key in the datapackage. :param name: Printed to the user as context for the part. :return: Whether the process was successful. @@ -154,23 +142,20 @@ def output_datapackage_part(self, key: str, name: str) -> bool: self.output(f"No game set, cannot determine {name}.") return False - lookup = self.get_current_datapackage().get(key) - if lookup is None: - self.output("datapackage not yet loaded, try again") - return False - + lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names + lookup = lookup[self.ctx.game] self.output(f"{name} for {self.ctx.game}") - for key in lookup: - self.output(key) + for name in lookup.values(): + self.output(name) return True def _cmd_items(self) -> bool: """List all item names for the currently running game.""" - return self.output_datapackage_part("item_name_to_id", "Item Names") + return self.output_datapackage_part("Item Names") def _cmd_locations(self) -> bool: """List all location names for the currently running game.""" - return self.output_datapackage_part("location_name_to_id", "Location Names") + return self.output_datapackage_part("Location Names") def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"], filter_key: str, @@ -871,9 +856,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) server_url = urllib.parse.urlparse(address) if server_url.username: - ctx.username = server_url.username + ctx.username = urllib.parse.unquote(server_url.username) if server_url.password: - ctx.password = server_url.password + ctx.password = urllib.parse.unquote(server_url.password) def reconnect_hint() -> str: return ", type /connect to reconnect" if ctx.server_address else "" diff --git a/Fill.py b/Fill.py index 7a079fbc82db..48ed7253d9d1 100644 --- a/Fill.py +++ b/Fill.py @@ -129,6 +129,10 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati for i, location in enumerate(placements)) for (i, location, unsafe) in swap_attempts: placed_item = location.item + if item_to_place == placed_item: + # The number of allowed swaps is limited, so do not allow a swap of an item with a copy of + # itself. + continue # Unplaceable items can sometimes be swapped infinitely. Limit the # number of times we will swap an individual item to prevent this swap_count = swapped_items[placed_item.player, placed_item.name, unsafe] diff --git a/Generate.py b/Generate.py index f9607e328bc8..1044a9880d88 100644 --- a/Generate.py +++ b/Generate.py @@ -23,7 +23,7 @@ from Utils import parse_yamls, version_tuple, __version__, tuplize_version -def mystery_argparse(): +def mystery_argparse(argv: list[str] | None = None): from settings import get_settings settings = get_settings() defaults = settings.generator @@ -57,7 +57,7 @@ def mystery_argparse(): parser.add_argument("--spoiler_only", action="store_true", help="Skips generation assertion and multidata, outputting only a spoiler log. " "Intended for debugging and testing purposes.") - args = parser.parse_args() + args = parser.parse_args(argv) if args.skip_output and args.spoiler_only: parser.error("Cannot mix --skip_output and --spoiler_only") @@ -166,19 +166,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]: f"A mix is also permitted.") from worlds.AutoWorld import AutoWorldRegister - from worlds.alttp.EntranceRandomizer import parse_arguments - erargs = parse_arguments(['--multi', str(args.multi)]) - erargs.seed = seed - erargs.plando_options = args.plando - erargs.spoiler = args.spoiler - erargs.race = args.race - erargs.outputname = seed_name - erargs.outputpath = args.outputpath - erargs.skip_prog_balancing = args.skip_prog_balancing - erargs.skip_output = args.skip_output - erargs.spoiler_only = args.spoiler_only - erargs.name = {} - erargs.csv_output = args.csv_output + args.outputname = seed_name + args.sprite = dict.fromkeys(range(1, args.multi+1), None) + args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None) + args.name = {} settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \ {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None) @@ -205,7 +196,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]: for player in range(1, args.multi + 1): player_path_cache[player] = player_files.get(player, args.weights_file_path) name_counter = Counter() - erargs.player_options = {} + args.player_options = {} player = 1 while player <= args.multi: @@ -218,21 +209,21 @@ def main(args=None) -> tuple[argparse.Namespace, int]: for k, v in vars(settingsObject).items(): if v is not None: try: - getattr(erargs, k)[player] = v + getattr(args, k)[player] = v except AttributeError: - setattr(erargs, k, {player: v}) + setattr(args, k, {player: v}) except Exception as e: raise Exception(f"Error setting {k} to {v} for player {player}") from e # name was not specified - if player not in erargs.name: + if player not in args.name: if path == args.weights_file_path: # weights file, so we need to make the name unique - erargs.name[player] = f"Player{player}" + args.name[player] = f"Player{player}" else: # use the filename - erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0] - erargs.name[player] = handle_name(erargs.name[player], player, name_counter) + args.name[player] = os.path.splitext(os.path.split(path)[-1])[0] + args.name[player] = handle_name(args.name[player], player, name_counter) player += 1 except Exception as e: @@ -240,10 +231,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]: else: raise RuntimeError(f'No weights specified for player {player}') - if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name): - raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}") + if len(set(name.lower() for name in args.name.values())) != len(args.name): + raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}") - return erargs, seed + return args, seed def read_weights_yamls(path) -> tuple[Any, ...]: @@ -495,7 +486,22 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b if required_plando_options: raise Exception(f"Settings reports required plando module {str(required_plando_options)}, " f"which is not enabled.") - + games = requirements.get("game", {}) + for game, version in games.items(): + if game not in AutoWorldRegister.world_types: + continue + if not version: + raise Exception(f"Invalid version for game {game}: {version}.") + if isinstance(version, str): + version = {"min": version} + if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version: + raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, " + f"however world is of version " + f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.") + if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version: + raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, " + f"however world is of version " + f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.") ret = argparse.Namespace() for option_key in Options.PerGameCommonOptions.type_hints: if option_key in weights and option_key not in Options.CommonOptions.type_hints: diff --git a/KH1Client.py b/KH1Client.py deleted file mode 100644 index 4c3ed501901b..000000000000 --- a/KH1Client.py +++ /dev/null @@ -1,9 +0,0 @@ -if __name__ == '__main__': - import ModuleUpdate - ModuleUpdate.update() - - import Utils - Utils.init_logging("KH1Client", exception_logger="Client") - - from worlds.kh1.Client import launch - launch() diff --git a/KH2Client.py b/KH2Client.py deleted file mode 100644 index 69e4adf8bf7c..000000000000 --- a/KH2Client.py +++ /dev/null @@ -1,8 +0,0 @@ -import ModuleUpdate -import Utils -from worlds.kh2.Client import launch -ModuleUpdate.update() - -if __name__ == '__main__': - Utils.init_logging("KH2Client", exception_logger="Client") - launch() diff --git a/Main.py b/Main.py index bc2787579fac..892baa8d4fa5 100644 --- a/Main.py +++ b/Main.py @@ -37,7 +37,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) logger = logging.getLogger() multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) - multiworld.plando_options = args.plando_options + multiworld.plando_options = args.plando multiworld.game = args.game.copy() multiworld.player_name = args.name.copy() multiworld.sprite = args.sprite.copy() @@ -54,12 +54,17 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:") longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) - item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) - location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) + world_classes = AutoWorld.AutoWorldRegister.world_types.values() + + version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes) + item_count = len(str(max(len(cls.item_names) for cls in world_classes))) + location_count = len(str(max(len(cls.location_names) for cls in world_classes))) for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): if not cls.hidden and len(cls.item_names) > 0: - logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | " + logger.info(f" {name:{longest_name}}: " + f"v{cls.world_version.as_simple_string():{version_count}} | " + f"Items: {len(cls.item_names):{item_count}} | " f"Locations: {len(cls.location_names):{location_count}}") del item_count, location_count diff --git a/MultiServer.py b/MultiServer.py index 11a9e394c6b6..1de44caddc9d 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -32,7 +32,7 @@ import colorama import websockets -from websockets.extensions.permessage_deflate import PerMessageDeflate +from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory try: # ponyorm is a requirement for webhost, not default server, so may not be importable from pony.orm.dbapiprovider import OperationalError @@ -50,6 +50,15 @@ min_client_version = Version(0, 5, 0) colorama.just_fix_windows_console() +no_version = Version(0, 0, 0) +assert isinstance(no_version, tuple) # assert immutable + +server_per_message_deflate_factory = ServerPerMessageDeflateFactory( + server_max_window_bits=11, + client_max_window_bits=11, + compress_settings={"memLevel": 4}, +) + def remove_from_list(container, value): try: @@ -125,8 +134,31 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int: class Client(Endpoint): - version = Version(0, 0, 0) - tags: typing.List[str] + __slots__ = ( + "__weakref__", + "version", + "auth", + "team", + "slot", + "send_index", + "tags", + "messageprocessor", + "ctx", + "remote_items", + "remote_start_inventory", + "no_items", + "no_locations", + "no_text", + ) + + version: Version + auth: bool + team: int | None + slot: int | None + send_index: int + tags: list[str] + messageprocessor: ClientMessageProcessor + ctx: weakref.ref[Context] remote_items: bool remote_start_inventory: bool no_items: bool @@ -135,6 +167,7 @@ class Client(Endpoint): def __init__(self, socket: "ServerConnection", ctx: Context) -> None: super().__init__(socket) + self.version = no_version self.auth = False self.team = None self.slot = None @@ -142,6 +175,11 @@ def __init__(self, socket: "ServerConnection", ctx: Context) -> None: self.tags = [] self.messageprocessor = client_message_processor(ctx, self) self.ctx = weakref.ref(ctx) + self.remote_items = False + self.remote_start_inventory = False + self.no_items = False + self.no_locations = False + self.no_text = False @property def items_handling(self): @@ -179,6 +217,7 @@ class Context: "release_mode": str, "remaining_mode": str, "collect_mode": str, + "countdown_mode": str, "item_cheat": bool, "compatibility": int} # team -> slot id -> list of clients authenticated to slot. @@ -208,8 +247,8 @@ class Context: 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, - log_network: bool = False, logger: logging.Logger = logging.getLogger()): + countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, + compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()): self.logger = logger super(Context, self).__init__() self.slot_info = {} @@ -242,6 +281,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo self.release_mode: str = release_mode self.remaining_mode: str = remaining_mode self.collect_mode: str = collect_mode + self.countdown_mode: str = countdown_mode self.item_cheat = item_cheat self.exit_event = asyncio.Event() self.client_activity_timers: typing.Dict[ @@ -627,6 +667,7 @@ def get_save(self) -> dict: "server_password": self.server_password, "password": self.password, "release_mode": self.release_mode, "remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode, + "countdown_mode": self.countdown_mode, "item_cheat": self.item_cheat, "compatibility": self.compatibility} } @@ -661,6 +702,7 @@ def set_save(self, savedata: dict): self.release_mode = savedata["game_options"]["release_mode"] self.remaining_mode = savedata["game_options"]["remaining_mode"] self.collect_mode = savedata["game_options"]["collect_mode"] + self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode) self.item_cheat = savedata["game_options"]["item_cheat"] self.compatibility = savedata["game_options"]["compatibility"] @@ -1135,8 +1177,13 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi ctx.save() -def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \ - -> typing.List[Hint]: +def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], + status: HintStatus | None = None) -> typing.List[Hint]: + """ + Collect a new hint for a given item id or name, with a given status. + If status is None (which is the default value), an automatic status will be determined from the item's quality. + """ + hints = [] slots: typing.Set[int] = {slot} for group_id, group in ctx.groups.items(): @@ -1152,25 +1199,39 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st else: found = location_id in ctx.location_checks[team, finding_player] entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") - new_status = auto_status + + hint_status = status # Assign again because we're in a for loop if found: - new_status = HintStatus.HINT_FOUND - elif item_flags & ItemClassification.trap: - new_status = HintStatus.HINT_AVOID - hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance, - item_flags, new_status)) + hint_status = HintStatus.HINT_FOUND + elif hint_status is None: + if item_flags & ItemClassification.trap: + hint_status = HintStatus.HINT_AVOID + else: + hint_status = HintStatus.HINT_PRIORITY + + hints.append( + Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status) + ) return hints -def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \ - -> typing.List[Hint]: +def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, + status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]: + """ + Collect a new hint for a given location name, with a given status (defaults to "unspecified"). + If None is passed for the status, then an automatic status will be determined from the item's quality. + """ seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location] - return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status) + return collect_hint_location_id(ctx, team, slot, seeked_location, status) -def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \ - -> typing.List[Hint]: +def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, + status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]: + """ + Collect a new hint for a given location id, with a given status (defaults to "unspecified"). + If None is passed for the status, then an automatic status will be determined from the item's quality. + """ prev_hint = ctx.get_hint(team, slot, seeked_location) if prev_hint: return [prev_hint] @@ -1180,13 +1241,16 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location found = seeked_location in ctx.location_checks[team, slot] entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "") - new_status = auto_status + if found: - new_status = HintStatus.HINT_FOUND - elif item_flags & ItemClassification.trap: - new_status = HintStatus.HINT_AVOID - return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, - new_status)] + status = HintStatus.HINT_FOUND + elif status is None: + if item_flags & ItemClassification.trap: + status = HintStatus.HINT_AVOID + else: + status = HintStatus.HINT_PRIORITY + + return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, status)] return [] @@ -1300,7 +1364,8 @@ def get_help_text(self) -> str: argname += "=" + parameter.default argtext += argname argtext += " " - s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n" + doctext = '\n '.join(inspect.getdoc(method).split('\n')) + s += f"{self.marker}{command} {argtext}\n {doctext}\n" return s def _cmd_help(self): @@ -1329,19 +1394,6 @@ def _error_parsing_command(self, exception: Exception): class CommonCommandProcessor(CommandProcessor): ctx: Context - def _cmd_countdown(self, seconds: str = "10") -> bool: - """Start a countdown in seconds""" - try: - timer = int(seconds, 10) - except ValueError: - timer = 10 - else: - if timer > 60 * 60: - raise ValueError(f"{timer} is invalid. Maximum is 1 hour.") - - async_start(countdown(self.ctx, timer)) - return True - def _cmd_options(self): """List all current options. Warning: lists password.""" self.output("Current options:") @@ -1483,6 +1535,23 @@ def _cmd_collect(self) -> bool: " You can ask the server admin for a /collect") return False + def _cmd_countdown(self, seconds: str = "10") -> bool: + """Start a countdown in seconds""" + if self.ctx.countdown_mode == "disabled" or \ + self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30: + self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown") + return False + try: + timer = int(seconds, 10) + except ValueError: + timer = 10 + else: + if timer > 60 * 60: + raise ValueError(f"{timer} is invalid. Maximum is 1 hour.") + + async_start(countdown(self.ctx, timer)) + return True + def _cmd_remaining(self) -> bool: """List remaining items in your game, but not their location or recipient""" if self.ctx.remaining_mode == "enabled": @@ -1610,7 +1679,6 @@ def _cmd_getitem(self, item_name: str) -> bool: def get_hints(self, input_text: str, for_location: bool = False) -> bool: points_available = get_client_points(self.ctx, self.client) cost = self.ctx.get_hint_cost(self.client.slot) - auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY if not input_text: hints = {hint.re_check(self.ctx, self.client.team) for hint in self.ctx.hints[self.client.team, self.client.slot]} @@ -1636,9 +1704,9 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") hints = [] elif not for_location: - hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status) + hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id) else: - hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status) + hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id) else: game = self.ctx.games[self.client.slot] @@ -1658,16 +1726,18 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: hints = [] for item_name in self.ctx.item_name_groups[game][hint_name]: if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID - hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status)) + hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name)) elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name - hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status) + hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name) elif hint_name in self.ctx.location_name_groups[game]: # location group name hints = [] for loc_name in self.ctx.location_name_groups[game][hint_name]: if loc_name in self.ctx.location_names_for_game(game): - hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status)) + hints.extend( + collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name) + ) else: # location name - hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status) + hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name) else: self.output(response) @@ -1945,8 +2015,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): target_item, target_player, flags = ctx.locations[client.slot][location] if create_as_hint: - hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location, - HintStatus.HINT_UNSPECIFIED)) + hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location)) locs.append(NetworkItem(target_item, location, target_player, flags)) ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True) if locs and create_as_hint: @@ -1961,6 +2030,16 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): if not locations: await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments", "text": "CreateHints: No locations specified.", "original_cmd": cmd}]) + return + + try: + status = HintStatus(status) + except ValueError as err: + await ctx.send_msgs(client, + [{"cmd": "InvalidPacket", "type": "arguments", + "text": f"Unknown Status: {err}", + "original_cmd": cmd}]) + return hints = [] @@ -2228,6 +2307,19 @@ def _cmd_collect(self, player_name: str) -> bool: self.output(f"Could not find player {player_name} to collect") return False + def _cmd_countdown(self, seconds: str = "10") -> bool: + """Start a countdown in seconds""" + try: + timer = int(seconds, 10) + except ValueError: + timer = 10 + else: + if timer > 60 * 60: + raise ValueError(f"{timer} is invalid. Maximum is 1 hour.") + + async_start(countdown(self.ctx, timer)) + return True + @mark_raw def _cmd_release(self, player_name: str) -> bool: """Send out the remaining items from a player to their intended recipients.""" @@ -2349,9 +2441,9 @@ def _cmd_hint(self, player_name: str, *item_name: str) -> bool: hints = [] for item_name_from_group in self.ctx.item_name_groups[game][item]: if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID - hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY)) + hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group)) else: # item name or id - hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY) + hints = collect_hints(self.ctx, team, slot, item) if hints: self.ctx.notify_hints(team, hints) @@ -2385,17 +2477,14 @@ def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool: if usable: if isinstance(location, int): - hints = collect_hint_location_id(self.ctx, team, slot, location, - HintStatus.HINT_UNSPECIFIED) + hints = collect_hint_location_id(self.ctx, team, slot, location) elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]: hints = [] for loc_name_from_group in self.ctx.location_name_groups[game][location]: if loc_name_from_group in self.ctx.location_names_for_game(game): - hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group, - HintStatus.HINT_UNSPECIFIED)) + hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group)) else: - hints = collect_hint_location_name(self.ctx, team, slot, location, - HintStatus.HINT_UNSPECIFIED) + hints = collect_hint_location_name(self.ctx, team, slot, location) if hints: self.ctx.notify_hints(team, hints) else: @@ -2423,6 +2512,11 @@ def value_type(input_text: str): elif value_type == str and option_name.endswith("password"): def value_type(input_text: str): return None if input_text.lower() in {"null", "none", '""', "''"} else input_text + elif option_name == "countdown_mode": + valid_values = {"enabled", "disabled", "auto"} + if option_value.lower() not in valid_values: + self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}") + return False elif value_type == str and option_name.endswith("mode"): valid_values = {"goal", "enabled", "disabled"} valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else []) @@ -2510,6 +2604,13 @@ def parse_args() -> argparse.Namespace: goal: !collect can be used after goal completion auto-enabled: !collect is available and automatically triggered on goal completion ''') + parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?', + choices=['enabled', 'disabled', "auto"], help='''\ + Select !countdown Accessibility. (default: %(default)s) + enabled: !countdown is always available + disabled: !countdown is never available + auto: !countdown is available for rooms with less than 30 players + ''') parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?', choices=['enabled', 'disabled', "goal"], help='''\ Select !remaining Accessibility. (default: %(default)s) @@ -2575,7 +2676,7 @@ async def main(args: argparse.Namespace): ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points, args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode, - args.remaining_mode, + args.countdown_mode, args.remaining_mode, args.auto_shutdown, args.compatibility, args.log_network) data_filename = args.multidata @@ -2610,7 +2711,13 @@ async def main(args: argparse.Namespace): ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None - ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context) + ctx.server = websockets.serve( + functools.partial(server, ctx=ctx), + host=ctx.host, + port=ctx.port, + ssl=ssl_context, + extensions=[server_per_message_deflate_factory], + ) ip = args.host if args.host else Utils.get_public_ipv4() logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port, 'No password' if not ctx.password else 'Password: %s' % ctx.password)) diff --git a/NetUtils.py b/NetUtils.py index 45279183f631..f61dbf9fcb0f 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -174,6 +174,8 @@ def _object_hook(o: typing.Any) -> typing.Any: class Endpoint: + __slots__ = ("socket",) + socket: "ServerConnection" def __init__(self, socket): diff --git a/Options.py b/Options.py index 47d6c2d38708..d4e42fc02d8c 100644 --- a/Options.py +++ b/Options.py @@ -1380,7 +1380,7 @@ class NonLocalItems(ItemSet): class StartInventory(ItemDict): - """Start with these items.""" + """Start with the specified amount of these items. Example: "Bomb: 1" """ verify_item_name = True display_name = "Start Inventory" rich_text_doc = True @@ -1388,7 +1388,7 @@ class StartInventory(ItemDict): class StartInventoryPool(StartInventory): - """Start with these items and don't place them in the world. + """Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1" The game decides what the replacement items will be. """ @@ -1446,6 +1446,7 @@ class ItemLinks(OptionList): Optional("local_items"): [And(str, len)], Optional("non_local_items"): [And(str, len)], Optional("link_replacement"): Or(None, bool), + Optional("skip_if_solo"): Or(None, bool), } ]) @@ -1473,8 +1474,10 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P super(ItemLinks, self).verify(world, player_name, plando_options) existing_links = set() for link in self.value: + link["name"] = link["name"].strip()[:16].strip() if link["name"] in existing_links: - raise Exception(f"You cannot have more than one link named {link['name']}.") + raise Exception(f"Item link names are limited to their first 16 characters and must be unique. " + f"You have more than one link named '{link['name']}'.") existing_links.add(link["name"]) pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world) @@ -1752,7 +1755,10 @@ def yaml_dump_scalar(scalar) -> str: res = template.render( option_groups=option_groups, - __version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar, + __version__=__version__, + game=game_name, + world_version=world.world_version.as_simple_string(), + yaml_dump=yaml_dump_scalar, dictify_range=dictify_range, cleandoc=cleandoc, ) diff --git a/README.md b/README.md index 4a0aa614ffec..fa87190565dd 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ Currently, the following games are supported: * Meritous * Super Metroid/Link to the Past combo randomizer (SMZ3) * ChecksFinder -* ArchipIDLE * Hollow Knight * The Witness * Sonic Adventure 2: Battle @@ -82,6 +81,7 @@ Currently, the following games are supported: * shapez * Paint * Celeste (Open World) +* Choo-Choo Charles For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/SNIClient.py b/SNIClient.py index d8bc05841f77..38fabcaab2e2 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -18,7 +18,7 @@ from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser import Utils -from settings import Settings +import settings from Utils import async_start from MultiServer import mark_raw if typing.TYPE_CHECKING: @@ -286,7 +286,7 @@ class SNESState(enum.IntEnum): def launch_sni() -> None: - sni_path = Settings.sni_options.sni_path + sni_path = settings.get_settings().sni_options.sni_path if not os.path.isdir(sni_path): sni_path = Utils.local_path(sni_path) @@ -669,7 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None: async def run_game(romfile: str) -> None: - auto_start = Settings.sni_options.snes_rom_start + auto_start = settings.get_settings().sni_options.snes_rom_start if auto_start is True: import webbrowser webbrowser.open(romfile) diff --git a/Utils.py b/Utils.py index e73edd7137f2..e79e54182d88 100644 --- a/Utils.py +++ b/Utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import concurrent.futures import json import typing import builtins @@ -35,7 +36,7 @@ def tuplize_version(version: str) -> Version: - return Version(*(int(piece, 10) for piece in version.split("."))) + return Version(*(int(piece) for piece in version.split("."))) class Version(typing.NamedTuple): @@ -322,11 +323,13 @@ def get_options() -> Settings: return get_settings() -def persistent_store(category: str, key: str, value: typing.Any): - path = user_path("_persistent_storage.yaml") +def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False): storage = persistent_load() + if not force_store and category in storage and key in storage[category] and storage[category][key] == value: + return # no changes necessary category_dict = storage.setdefault(category, {}) category_dict[key] = value + path = user_path("_persistent_storage.yaml") with open(path, "wt") as f: f.write(dump(storage, Dumper=Dumper)) @@ -475,7 +478,7 @@ def find_class(self, module: str, name: str) -> type: mod = importlib.import_module(module) obj = getattr(mod, name) if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, - self.options_module.PlandoText)): + self.options_module.PlandoItem, self.options_module.PlandoText)): return obj # Forbid everything else. raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") @@ -718,13 +721,22 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]: + """ + Parses the response text from `get_intended_text` to find the suggested input and autocomplete the command in + arguments with it. + + :param text: The response text from `get_intended_text`. + :param command: The command to which the input text should be added. Must contain the prefix used by the command + (`!` or `/`). + :return: The command with the suggested input text appended, or None if no suggestion was found. + """ 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}" + return f"{command} {name}" elif text.startswith("Missing: "): return text.replace("Missing: ", "!hint_location ") return None @@ -1127,3 +1139,40 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any] if isinstance(obj, str): return False return isinstance(obj, typing.Iterable) + + +class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor): + """ + ThreadPoolExecutor that uses daemonic threads that do not keep the program alive. + NOTE: use this with caution because killed threads will not properly clean up. + """ + + def _adjust_thread_count(self): + # see upstream ThreadPoolExecutor for details + import threading + import weakref + from concurrent.futures.thread import _worker + + if self._idle_semaphore.acquire(timeout=0): + return + + def weakref_cb(_, q=self._work_queue): + q.put(None) + + num_threads = len(self._threads) + if num_threads < self._max_workers: + thread_name = f"{self._thread_name_prefix or self}_{num_threads}" + t = threading.Thread( + name=thread_name, + target=_worker, + args=( + weakref.ref(self, weakref_cb), + self._work_queue, + self._initializer, + self._initargs, + ), + daemon=True, + ) + t.start() + self._threads.add(t) + # NOTE: don't add to _threads_queues so we don't block on shutdown diff --git a/WebHost.py b/WebHost.py index 946eaa116f01..db465be61beb 100644 --- a/WebHost.py +++ b/WebHost.py @@ -99,16 +99,23 @@ def copy_tutorials_files_to_static() -> None: multiprocessing.set_start_method('spawn') logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) - from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.autolauncher import autohost, autogen, stop from WebHostLib.options import create as create_options_files try: + from WebHostLib.lttpsprites import update_sprites_lttp update_sprites_lttp() except Exception as e: logging.exception(e) logging.warning("Could not update LttP sprites.") app = get_app() + from worlds import AutoWorldRegister + # Update to only valid WebHost worlds + invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items() + if not hasattr(world.web, "tutorials")} + if invalid_worlds: + logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}") + AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds} create_options_files() copy_tutorials_files_to_static() if app.config["SELFLAUNCH"]: diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 74086cb8842b..e4c2ab83c76c 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -1,6 +1,7 @@ import base64 import os import socket +import typing import uuid from flask import Flask @@ -61,20 +62,21 @@ Compress(app) -def to_python(value): +def to_python(value: str) -> uuid.UUID: return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '==')) -def to_url(value): +def to_url(value: uuid.UUID) -> str: return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') class B64UUIDConverter(BaseConverter): - def to_python(self, value): + def to_python(self, value: str) -> uuid.UUID: return to_python(value) - def to_url(self, value): + def to_url(self, value: typing.Any) -> str: + assert isinstance(value, uuid.UUID) return to_url(value) @@ -84,7 +86,7 @@ def to_url(self, value): app.jinja_env.filters["title_sorted"] = title_sorted -def register(): +def register() -> None: """Import submodules, triggering their registering on flask routing. Note: initializes worlds subsystem.""" import importlib diff --git a/WebHostLib/api/tracker.py b/WebHostLib/api/tracker.py index 4ea3a2339233..36692af42652 100644 --- a/WebHostLib/api/tracker.py +++ b/WebHostLib/api/tracker.py @@ -11,6 +11,53 @@ from WebHostLib.tracker import TrackerData +class PlayerAlias(TypedDict): + team: int + player: int + alias: str | None + + +class PlayerItemsReceived(TypedDict): + team: int + player: int + items: list[NetworkItem] + + +class PlayerChecksDone(TypedDict): + team: int + player: int + locations: list[int] + + +class TeamTotalChecks(TypedDict): + team: int + checks_done: int + + +class PlayerHints(TypedDict): + team: int + player: int + hints: list[Hint] + + +class PlayerTimer(TypedDict): + team: int + player: int + time: datetime | None + + +class PlayerStatus(TypedDict): + team: int + player: int + status: ClientStatus + + +class PlayerLocationsTotal(TypedDict): + team: int + player: int + total_locations: int + + @api_endpoints.route("/tracker/") @cache.memoize(timeout=60) def tracker_data(tracker: UUID) -> dict[str, Any]: @@ -29,122 +76,77 @@ def tracker_data(tracker: UUID) -> dict[str, Any]: all_players: dict[int, list[int]] = tracker_data.get_all_players() - class PlayerAlias(TypedDict): - player: int - name: str | None - - player_aliases: list[dict[str, int | list[PlayerAlias]]] = [] + player_aliases: list[PlayerAlias] = [] """Slot aliases of all players.""" for team, players in all_players.items(): - team_player_aliases: list[PlayerAlias] = [] - team_aliases = {"team": team, "players": team_player_aliases} - player_aliases.append(team_aliases) for player in players: - team_player_aliases.append({"player": player, "alias": tracker_data.get_player_alias(team, player)}) - - class PlayerItemsReceived(TypedDict): - player: int - items: list[NetworkItem] + player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)}) - player_items_received: list[dict[str, int | list[PlayerItemsReceived]]] = [] + player_items_received: list[PlayerItemsReceived] = [] """Items received by each player.""" for team, players in all_players.items(): - player_received_items: list[PlayerItemsReceived] = [] - team_items_received = {"team": team, "players": player_received_items} - player_items_received.append(team_items_received) for player in players: - player_received_items.append( - {"player": player, "items": tracker_data.get_player_received_items(team, player)}) + player_items_received.append( + {"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)}) - class PlayerChecksDone(TypedDict): - player: int - locations: list[int] - - player_checks_done: list[dict[str, int | list[PlayerChecksDone]]] = [] + player_checks_done: list[PlayerChecksDone] = [] """ID of all locations checked by each player.""" for team, players in all_players.items(): - per_player_checks: list[PlayerChecksDone] = [] - team_checks_done = {"team": team, "players": per_player_checks} - player_checks_done.append(team_checks_done) for player in players: - per_player_checks.append( - {"player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))}) + player_checks_done.append( + {"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))}) - total_checks_done: list[dict[str, int]] = [ + total_checks_done: list[TeamTotalChecks] = [ {"team": team, "checks_done": checks_done} for team, checks_done in tracker_data.get_team_locations_checked_count().items() ] """Total number of locations checked for the entire multiworld per team.""" - class PlayerHints(TypedDict): - player: int - hints: list[Hint] - - hints: list[dict[str, int | list[PlayerHints]]] = [] + hints: list[PlayerHints] = [] """Hints that all players have used or received.""" for team, players in tracker_data.get_all_slots().items(): - per_player_hints: list[PlayerHints] = [] - team_hints = {"team": team, "players": per_player_hints} - hints.append(team_hints) for player in players: player_hints = sorted(tracker_data.get_player_hints(team, player)) - per_player_hints.append({"player": player, "hints": player_hints}) - slot_info = tracker_data.get_slot_info(team, player) + hints.append({"team": team, "player": player, "hints": player_hints}) + slot_info = tracker_data.get_slot_info(player) # this assumes groups are always after players if slot_info.type != SlotType.group: continue for member in slot_info.group_members: - team_hints[member]["hints"] += player_hints - - class PlayerTimer(TypedDict): - player: int - time: datetime | None + hints[member - 1]["hints"] += player_hints - activity_timers: list[dict[str, int | list[PlayerTimer]]] = [] + activity_timers: list[PlayerTimer] = [] """Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made.""" for team, players in all_players.items(): - player_timers: list[PlayerTimer] = [] - team_timers = {"team": team, "players": player_timers} - activity_timers.append(team_timers) for player in players: - player_timers.append({"player": player, "time": None}) + activity_timers.append({"team": team, "player": player, "time": None}) - client_activity_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get("client_activity_timers", ()) - for (team, player), timestamp in client_activity_timers: - # use index since we can rely on order - # FIX: key is "players" (not "player_timers") - activity_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc) + for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []): + for entry in activity_timers: + if entry["team"] == team and entry["player"] == player: + entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc) + break - - connection_timers: list[dict[str, int | list[PlayerTimer]]] = [] + connection_timers: list[PlayerTimer] = [] """Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made.""" for team, players in all_players.items(): - player_timers: list[PlayerTimer] = [] - team_connection_timers = {"team": team, "players": player_timers} - connection_timers.append(team_connection_timers) for player in players: - player_timers.append({"player": player, "time": None}) - - client_connection_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get( - "client_connection_timers", ()) - for (team, player), timestamp in client_connection_timers: - connection_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc) + connection_timers.append({"team": team, "player": player, "time": None}) - class PlayerStatus(TypedDict): - player: int - status: ClientStatus + for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []): + # find the matching entry + for entry in connection_timers: + if entry["team"] == team and entry["player"] == player: + entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc) + break - player_status: list[dict[str, int | list[PlayerStatus]]] = [] + player_status: list[PlayerStatus] = [] """The current client status for each player.""" for team, players in all_players.items(): - player_statuses: list[PlayerStatus] = [] - team_status = {"team": team, "players": player_statuses} - player_status.append(team_status) for player in players: - player_statuses.append({"player": player, "status": tracker_data.get_player_client_status(team, player)}) + player_status.append({"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)}) return { - **get_static_tracker_data(room), "aliases": player_aliases, "player_items_received": player_items_received, "player_checks_done": player_checks_done, @@ -153,80 +155,87 @@ class PlayerStatus(TypedDict): "activity_timers": activity_timers, "connection_timers": connection_timers, "player_status": player_status, - "datapackage": tracker_data._multidata["datapackage"], } -@cache.memoize() -def get_static_tracker_data(room: Room) -> dict[str, Any]: - """ - Builds and caches the static data for this active session tracker, so that it doesn't need to be recalculated. + +class PlayerGroups(TypedDict): + slot: int + name: str + members: list[int] + + +class PlayerSlotData(TypedDict): + player: int + slot_data: dict[str, Any] + + +@api_endpoints.route("/static_tracker/") +@cache.memoize(timeout=300) +def static_tracker_data(tracker: UUID) -> dict[str, Any]: """ + Outputs json data to /api/static_tracker/. + + :param tracker: UUID of current session tracker. + :return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value. + """ + room: Room | None = Room.get(tracker=tracker) + if not room: + abort(404) tracker_data = TrackerData(room) all_players: dict[int, list[int]] = tracker_data.get_all_players() - class PlayerGroups(TypedDict): - slot: int - name: str - members: list[int] - - groups: list[dict[str, int | list[PlayerGroups]]] = [] + groups: list[PlayerGroups] = [] """The Slot ID of groups and the IDs of the group's members.""" for team, players in tracker_data.get_all_slots().items(): - groups_in_team: list[PlayerGroups] = [] - team_groups = {"team": team, "groups": groups_in_team} - groups.append(team_groups) for player in players: - slot_info = tracker_data.get_slot_info(team, player) + slot_info = tracker_data.get_slot_info(player) if slot_info.type != SlotType.group or not slot_info.group_members: continue - groups_in_team.append( + groups.append( { "slot": player, "name": slot_info.name, "members": list(slot_info.group_members), }) - class PlayerName(TypedDict): - player: int - name: str + break - player_names: list[dict[str, str | list[PlayerName]]] = [] - """Slot names of all players.""" + player_locations_total: list[PlayerLocationsTotal] = [] for team, players in all_players.items(): - per_team_player_names: list[PlayerName] = [] - team_names = {"team": team, "players": per_team_player_names} - player_names.append(team_names) for player in players: - per_team_player_names.append({"player": player, "name": tracker_data.get_player_name(team, player)}) + player_locations_total.append( + {"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))}) - class PlayerGame(TypedDict): - player: int - game: str + return { + "groups": groups, + "datapackage": tracker_data._multidata["datapackage"], + "player_locations_total": player_locations_total, + } - games: list[dict[str, int | list[PlayerGame]]] = [] - """The game each player is playing.""" - for team, players in all_players.items(): - player_games: list[PlayerGame] = [] - team_games = {"team": team, "players": player_games} - games.append(team_games) - for player in players: - player_games.append({"player": player, "game": tracker_data.get_player_game(team, player)}) +# It should be exceedingly rare that slot data is needed, so it's separated out. +@api_endpoints.route("/slot_data_tracker/") +@cache.memoize(timeout=300) +def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]: + """ + Outputs json data to /api/slot_data_tracker/. + + :param tracker: UUID of current session tracker. + + :return: Slot data for all players in the room. Typing completely arbitrary per game. + """ + room: Room | None = Room.get(tracker=tracker) + if not room: + abort(404) + tracker_data = TrackerData(room) - class PlayerSlotData(TypedDict): - player: int - slot_data: dict[str, Any] + all_players: dict[int, list[int]] = tracker_data.get_all_players() - slot_data: list[dict[str, int | list[PlayerSlotData]]] = [] + slot_data: list[PlayerSlotData] = [] """Slot data for each player.""" for team, players in all_players.items(): - player_slot_data: list[PlayerSlotData] = [] - team_slot_data = {"team": team, "players": player_slot_data} - slot_data.append(team_slot_data) for player in players: - player_slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(team, player)}) + slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)}) + break - return { - "groups": groups, - "slot_data": slot_data, - } + return slot_data diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 719963e37508..96ffbe9e9540 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -17,7 +17,7 @@ _stop_event = Event() -def stop(): +def stop() -> None: """Stops previously launched threads""" global _stop_event stop_event = _stop_event @@ -36,25 +36,39 @@ def handle_generation_failure(result: BaseException): logging.exception(e) -def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None: +def _mp_gen_game( + gen_options: dict, + meta: dict[str, Any] | None = None, + owner=None, + sid=None, + timeout: int|None = None, +) -> PrimaryKey | None: from setproctitle import setproctitle setproctitle(f"Generator ({sid})") - res = gen_game(gen_options, meta=meta, owner=owner, sid=sid) - setproctitle(f"Generator (idle)") - return res + try: + return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout) + finally: + setproctitle(f"Generator (idle)") -def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): +def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None: try: meta = json.loads(generation.meta) options = restricted_loads(generation.options) logging.info(f"Generating {generation.id} for {len(options)} players") - pool.apply_async(_mp_gen_game, (options,), - {"meta": meta, - "sid": generation.id, - "owner": generation.owner}, - handle_generation_success, handle_generation_failure) + pool.apply_async( + _mp_gen_game, + (options,), + { + "meta": meta, + "sid": generation.id, + "owner": generation.owner, + "timeout": timeout, + }, + handle_generation_success, + handle_generation_failure, + ) except Exception as e: generation.state = STATE_ERROR commit() @@ -135,6 +149,7 @@ def keep_running(): with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator, initargs=(config,), maxtasksperchild=10) as generator_pool: + job_time = config["JOB_TIME"] with db_session: to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) @@ -145,7 +160,7 @@ def keep_running(): if sid: generation.delete() else: - launch_generator(generator_pool, generation) + launch_generator(generator_pool, generation, timeout=job_time) commit() select(generation for generation in Generation if generation.state == STATE_ERROR).delete() @@ -157,7 +172,7 @@ def keep_running(): generation for generation in Generation if generation.state == STATE_QUEUED).for_update() for generation in to_start: - launch_generator(generator_pool, generation) + launch_generator(generator_pool, generation, timeout=job_time) except AlreadyRunningException: logging.info("Autogen reports as already running, not starting another.") diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 156c12523d9e..14ae291982bb 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -19,7 +19,10 @@ import Utils -from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert +from MultiServer import ( + Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert, + server_per_message_deflate_factory, +) from Utils import restricted_loads, cache_argsless from .locker import Locker from .models import Command, GameDataPackage, Room, db @@ -97,6 +100,7 @@ def listen_to_db_commands(self): self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext) command.delete() commit() + del commands time.sleep(5) @db_session @@ -146,13 +150,13 @@ def load(self, room_id: int): self.location_name_groups = static_location_name_groups return self._load(multidata, game_data_packages, True) - @db_session def init_save(self, enabled: bool = True): self.saving = enabled if self.saving: - savegame_data = Room.get(id=self.room_id).multisave - if savegame_data: - self.set_save(restricted_loads(Room.get(id=self.room_id).multisave)) + with db_session: + savegame_data = Room.get(id=self.room_id).multisave + if savegame_data: + self.set_save(restricted_loads(Room.get(id=self.room_id).multisave)) self._start_async_saving(atexit_save=False) threading.Thread(target=self.listen_to_db_commands, daemon=True).start() @@ -282,8 +286,12 @@ async def start_room(room_id): assert ctx.server is None try: ctx.server = websockets.serve( - functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context()) - + functools.partial(server, ctx=ctx), + ctx.host, + ctx.port, + ssl=get_ssl_context(), + extensions=[server_per_message_deflate_factory], + ) await ctx.server except OSError: # likely port in use ctx.server = websockets.serve( @@ -304,6 +312,7 @@ async def start_room(room_id): with db_session: room = Room.get(id=ctx.room_id) room.last_port = port + del room else: ctx.logger.exception("Could not determine port. Likely hosting failure.") with db_session: @@ -322,6 +331,7 @@ async def start_room(room_id): with db_session: room = Room.get(id=room_id) room.last_port = -1 + del room logger.exception(e) raise else: @@ -333,11 +343,12 @@ async def start_room(room_id): ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup ctx.exit_event.set() # make sure the saving thread stops at some point # NOTE: async saving should probably be an async task and could be merged with shutdown_task - with (db_session): + with db_session: # ensure the Room does not spin up again on its own, minute of safety buffer room = Room.get(id=room_id) room.last_activity = datetime.datetime.utcnow() - \ datetime.timedelta(minutes=1, seconds=room.timeout) + del room logging.info(f"Shutting down room {room_id} on {name}.") finally: await asyncio.sleep(5) diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index 02f5a0379aa2..f80663ff43f1 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -12,12 +12,11 @@ from pony.orm import commit, db_session from BaseClasses import get_seed, seeddigits -from Generate import PlandoOptions, handle_name +from Generate import PlandoOptions, handle_name, mystery_argparse from Main import main as ERmain -from Utils import __version__, restricted_dumps +from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor 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 from .upload import upload_zip_to_db @@ -34,6 +33,7 @@ def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | "release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)), "remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)), "collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)), + "countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)), "item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))), "server_password": str(options_source.get("server_password", None)), } @@ -73,6 +73,10 @@ def generate(race=False): return render_template("generate.html", race=race, version=__version__) +def format_exception(e: BaseException) -> str: + return f"{e.__class__.__name__}: {e}" + + def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): results, gen_options = roll_options(options, set(meta["plando_options"])) @@ -93,7 +97,9 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): except PicklingError as e: from .autolauncher import handle_generation_failure handle_generation_failure(e) - return render_template("seedError.html", seed_error=("PicklingError: " + str(e))) + meta["error"] = format_exception(e) + details = json.dumps(meta, indent=4).strip() + return render_template("seedError.html", seed_error=meta["error"], details=details) commit() @@ -101,16 +107,18 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): else: try: seed_id = gen_game({name: vars(options) for name, options in gen_options.items()}, - meta=meta, owner=session["_id"].int) + meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"]) except BaseException as e: from .autolauncher import handle_generation_failure handle_generation_failure(e) - return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e))) + meta["error"] = format_exception(e) + details = json.dumps(meta, indent=4).strip() + return render_template("seedError.html", seed_error=meta["error"], details=details) return redirect(url_for("view_seed", seed=seed_id)) -def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None): +def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None): if meta is None: meta = {} @@ -129,43 +137,47 @@ def task(): seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)) - erargs = parse_arguments(['--multi', str(playercount)]) - erargs.seed = seed - erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery - erargs.spoiler = meta["generator_options"].get("spoiler", 0) - erargs.race = race - erargs.outputname = seedname - erargs.outputpath = target.name - erargs.teams = 1 - erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options", - {"bosses", "items", "connections", "texts"})) - erargs.skip_prog_balancing = False - erargs.skip_output = False - erargs.spoiler_only = False - erargs.csv_output = False + args = mystery_argparse([]) # Just to set up the Namespace with defaults + args.multi = playercount + args.seed = seed + args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery + args.spoiler = meta["generator_options"].get("spoiler", 0) + args.race = race + args.outputname = seedname + args.outputpath = target.name + args.teams = 1 + args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options", + {"bosses", "items", "connections", "texts"})) + args.skip_prog_balancing = False + args.skip_output = False + args.spoiler_only = False + args.csv_output = False + args.sprite = dict.fromkeys(range(1, args.multi+1), None) + args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None) name_counter = Counter() for player, (playerfile, settings) in enumerate(gen_options.items(), 1): for k, v in settings.items(): if v is not None: - if hasattr(erargs, k): - getattr(erargs, k)[player] = v + if hasattr(args, k): + getattr(args, k)[player] = v else: - setattr(erargs, k, {player: v}) + setattr(args, k, {player: v}) - if not erargs.name[player]: - erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0] - erargs.name[player] = handle_name(erargs.name[player], player, name_counter) - if len(set(erargs.name.values())) != len(erargs.name): - raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}") - ERmain(erargs, seed, baked_server_options=meta["server_options"]) + if not args.name[player]: + args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0] + args.name[player] = handle_name(args.name[player], player, name_counter) + if len(set(args.name.values())) != len(args.name): + raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}") + ERmain(args, seed, baked_server_options=meta["server_options"]) return upload_to_db(target.name, sid, owner, race) - thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) + + thread_pool = DaemonThreadPoolExecutor(max_workers=1) thread = thread_pool.submit(task) try: - return thread.result(app.config["JOB_TIME"]) + return thread.result(timeout) except concurrent.futures.TimeoutError as e: if sid: with db_session: @@ -173,11 +185,14 @@ def task(): if gen is not None: gen.state = STATE_ERROR meta = json.loads(gen.meta) - meta["error"] = ( - "Allowed time for Generation exceeded, please consider generating locally instead. " + - e.__class__.__name__ + ": " + str(e)) + meta["error"] = ("Allowed time for Generation exceeded, " + + "please consider generating locally instead. " + + format_exception(e)) gen.meta = json.dumps(meta) commit() + except (KeyboardInterrupt, SystemExit): + # don't update db, retry next time + raise except BaseException as e: if sid: with db_session: @@ -185,10 +200,15 @@ def task(): if gen is not None: gen.state = STATE_ERROR meta = json.loads(gen.meta) - meta["error"] = (e.__class__.__name__ + ": " + str(e)) + meta["error"] = format_exception(e) gen.meta = json.dumps(meta) commit() raise + finally: + # free resources claimed by thread pool, if possible + # NOTE: Timeout depends on the process being killed at some point + # since we can't actually cancel a running gen at the moment. + thread_pool.shutdown(wait=False, cancel_futures=True) @app.route('/wait/') @@ -202,7 +222,9 @@ def wait_seed(seed: UUID): if not generation: return "Generation not found." elif generation.state == STATE_ERROR: - return render_template("seedError.html", seed_error=generation.meta) + meta = json.loads(generation.meta) + details = json.dumps(meta, indent=4).strip() + return render_template("seedError.html", seed_error=meta["error"], details=details) return render_template("waitSeed.html", seed_id=seed_id) diff --git a/WebHostLib/lttpsprites.py b/WebHostLib/lttpsprites.py index 9d780b13e12a..3bf596db4804 100644 --- a/WebHostLib/lttpsprites.py +++ b/WebHostLib/lttpsprites.py @@ -3,10 +3,10 @@ import json from Utils import local_path, user_path -from worlds.alttp.Rom import Sprite def update_sprites_lttp(): + from worlds.alttp.Rom import Sprite from tkinter import Tk from LttPAdjuster import get_image_for_sprite from LttPAdjuster import BackgroundTaskProgress diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index c57a6386127f..b56b11dd6f47 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -260,7 +260,10 @@ def host_room(room: UUID): # indicate that the page should reload to get the assigned port should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)) or room.last_activity < now - datetime.timedelta(seconds=room.timeout)) - with db_session: + + if now - room.last_activity > datetime.timedelta(minutes=1): + # we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error + # due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction" room.last_activity = now # will trigger a spinup, if it's not already running browser_tokens = "Mozilla", "Chrome", "Safari" @@ -268,9 +271,9 @@ def host_room(room: UUID): or "Discordbot" in request.user_agent.string or not any(browser_token in request.user_agent.string for browser_token in browser_tokens)) - def get_log(max_size: int = 0 if automated else 1024000) -> str: + def get_log(max_size: int = 0 if automated else 1024000) -> Tuple[str, int]: if max_size == 0: - return "…" + return "…", 0 try: with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log: raw_size = 0 @@ -281,9 +284,9 @@ def get_log(max_size: int = 0 if automated else 1024000) -> str: break raw_size += len(block) fragments.append(block.decode("utf-8")) - return "".join(fragments) + return "".join(fragments), raw_size except FileNotFoundError: - return "" + return "", 0 return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 3c63fa8c7fb9..c2f1619f9ff0 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str: lines = text.splitlines() text = lines[0] + "\n" + dedent("\n".join(lines[1:])) - return publish_parts(text, writer_name='html', settings=None, settings_overrides={ + return publish_parts(text, writer='html', settings=None, settings_overrides={ 'raw_enable': False, 'file_insertion_enabled': False, 'output_encoding': 'unicode' @@ -231,7 +231,7 @@ def generate_yaml(game: str): if key_parts[-1] == "qty": if key_parts[0] not in options: options[key_parts[0]] = {} - if val != "0": + if val and val != "0": options[key_parts[0]][key_parts[1]] = int(val) del options[key] diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index f64ed085c982..e7181bd9a791 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -4,7 +4,8 @@ pony>=0.7.19; python_version <= '3.12' pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13' waitress>=3.0.2 Flask-Caching>=2.3.0 -Flask-Compress>=1.17 +Flask-Compress>=1.17; python_version >= '3.12' +Flask-Compress==1.18; python_version <= '3.11' # 3.11's pkg_resources can't resolve the new "backports.zstd" dependency Flask-Limiter>=3.12 bokeh>=3.6.3 markupsafe>=3.0.2 diff --git a/WebHostLib/static/assets/faq/en.md b/WebHostLib/static/assets/faq/en.md index 96e526612be6..588750065666 100644 --- a/WebHostLib/static/assets/faq/en.md +++ b/WebHostLib/static/assets/faq/en.md @@ -66,7 +66,7 @@ is to ensure items necessary to complete the game will be accessible to the play rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are comfortable exploiting certain glitches in the game. -## I want to add a game to the Archipelago randomizer. How do I do that? +## I want to develop a game implementation for Archipelago. How do I do that? The best way to get started is to take a look at our code on GitHub: [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago). @@ -77,4 +77,5 @@ There, you will find examples of games in the `worlds` folder: You may also find developer documentation in the `docs` folder: [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs). -If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord. +If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev** +channel on our Discord. diff --git a/WebHostLib/static/styles/themes/ocean-island.css b/WebHostLib/static/styles/themes/ocean-island.css index 2b45fb9d167c..3216e5e3e2df 100644 --- a/WebHostLib/static/styles/themes/ocean-island.css +++ b/WebHostLib/static/styles/themes/ocean-island.css @@ -72,3 +72,13 @@ code{ padding-right: 0.25rem; color: #000000; } + +code.grassy { + background-color: #b5e9a4; + border: 1px solid #2a6c2f; + white-space: preserve; + text-align: left; + display: block; + font-size: 14px; + line-height: 20px; +} diff --git a/WebHostLib/static/styles/waitSeed.css b/WebHostLib/static/styles/waitSeed.css index 85d281b20dff..0b4e4c328c34 100644 --- a/WebHostLib/static/styles/waitSeed.css +++ b/WebHostLib/static/styles/waitSeed.css @@ -13,3 +13,7 @@ min-height: 360px; text-align: center; } + +h2, h4 { + color: #ffffff; +} diff --git a/WebHostLib/templates/genericTracker.html b/WebHostLib/templates/genericTracker.html index b92097ceea08..2598aa12194b 100644 --- a/WebHostLib/templates/genericTracker.html +++ b/WebHostLib/templates/genericTracker.html @@ -98,7 +98,7 @@ {% if hint.finding_player == player %} {{ player_names_with_alias[(team, hint.finding_player)] }} - {% elif get_slot_info(team, hint.finding_player).type == 2 %} + {% elif get_slot_info(hint.finding_player).type == 2 %} {{ player_names_with_alias[(team, hint.finding_player)] }} {% else %} @@ -109,7 +109,7 @@ {% if hint.receiving_player == player %} {{ player_names_with_alias[(team, hint.receiving_player)] }} - {% elif get_slot_info(team, hint.receiving_player).type == 2 %} + {% elif get_slot_info(hint.receiving_player).type == 2 %} {{ player_names_with_alias[(team, hint.receiving_player)] }} {% else %} diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index c5996d181ee0..10ff5e84470a 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -58,8 +58,7 @@ Open Log File... - {% set log = get_log() -%} - {%- set log_len = log | length - 1 if log.endswith("…") else log | length -%} + {% set log, log_len = get_log() -%}
{{ log }}