From add7a6a848cf2d1652a74f6dab786c3216a4e850 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Wed, 22 Jul 2026 20:47:03 -0400 Subject: [PATCH] feat(gems): add multiplayer gem game with custom content packs - implement FastAPI and HTMX lobby and game interfaces - support up to four human and AI-controlled players - add configurable rules, victory conditions, and expansion modules - support validated JSON cards, patrons, objectives, and outposts --- .gitignore | 3 + overlays/default.nix | 3 + python/gems/README.md | 54 ++ python/gems/__init__.py | 1 + python/gems/ai/__init__.py | 5 + python/gems/ai/runner.py | 68 ++ python/gems/config.py | 25 + python/gems/content.py | 55 ++ python/gems/domain/__init__.py | 1 + python/gems/domain/engine.py | 903 ++++++++++++++++++ python/gems/domain/legal_actions.py | 159 +++ python/gems/domain/models.py | 394 ++++++++ python/gems/domain/requirements.py | 58 ++ python/gems/events.py | 35 + python/gems/main.py | 91 ++ python/gems/persistence.py | 255 +++++ python/gems/rooms.py | 375 ++++++++ python/gems/routes/__init__.py | 5 + python/gems/routes/app.py | 626 ++++++++++++ python/gems/security.py | 40 + python/gems/static/gems.css | 1 + python/gems/static/overrides.css | 18 + python/gems/static/vendor/HTMX-LICENSE | 13 + python/gems/static/vendor/SSE-LICENSE | 14 + python/gems/static/vendor/htmx.min.js | 1 + python/gems/static/vendor/sse.min.js | 1 + python/gems/templates/base.html | 18 + python/gems/templates/home.html | 22 + python/gems/templates/join.html | 14 + .../gems/templates/partials/room_state.html | 211 ++++ python/gems/templates/room.html | 17 + python/gems/web.py | 11 + systems/jeeves/services/gems.nix | 52 + systems/jeeves/web_services/acme.nix | 1 + systems/jeeves/web_services/haproxy.cfg | 22 +- tests/gems/__init__.py | 1 + tests/gems/conftest.py | 76 ++ tests/gems/test_base_rules.py | 117 +++ tests/gems/test_content.py | 45 + tests/gems/test_modules.py | 60 ++ tests/gems/test_web.py | 291 ++++++ 41 files changed, 4161 insertions(+), 1 deletion(-) create mode 100644 python/gems/README.md create mode 100644 python/gems/__init__.py create mode 100644 python/gems/ai/__init__.py create mode 100644 python/gems/ai/runner.py create mode 100644 python/gems/config.py create mode 100644 python/gems/content.py create mode 100644 python/gems/domain/__init__.py create mode 100644 python/gems/domain/engine.py create mode 100644 python/gems/domain/legal_actions.py create mode 100644 python/gems/domain/models.py create mode 100644 python/gems/domain/requirements.py create mode 100644 python/gems/events.py create mode 100644 python/gems/main.py create mode 100644 python/gems/persistence.py create mode 100644 python/gems/rooms.py create mode 100644 python/gems/routes/__init__.py create mode 100644 python/gems/routes/app.py create mode 100644 python/gems/security.py create mode 100644 python/gems/static/gems.css create mode 100644 python/gems/static/overrides.css create mode 100644 python/gems/static/vendor/HTMX-LICENSE create mode 100644 python/gems/static/vendor/SSE-LICENSE create mode 100644 python/gems/static/vendor/htmx.min.js create mode 100644 python/gems/static/vendor/sse.min.js create mode 100644 python/gems/templates/base.html create mode 100644 python/gems/templates/home.html create mode 100644 python/gems/templates/join.html create mode 100644 python/gems/templates/partials/room_state.html create mode 100644 python/gems/templates/room.html create mode 100644 python/gems/web.py create mode 100644 systems/jeeves/services/gems.nix create mode 100644 tests/gems/__init__.py create mode 100644 tests/gems/conftest.py create mode 100644 tests/gems/test_base_rules.py create mode 100644 tests/gems/test_content.py create mode 100644 tests/gems/test_modules.py create mode 100644 tests/gems/test_web.py diff --git a/.gitignore b/.gitignore index cf7b0f4..80e6b29 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,6 @@ frontend/node_modules/ # data from testing llms data/* .ebook_search_bm25 + +# gems data +.gems diff --git a/overlays/default.nix b/overlays/default.nix index 8de9684..867e26b 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -24,6 +24,7 @@ fastapi fastapi-cli httpx + jinja2 mypy pgvector psycopg @@ -34,11 +35,13 @@ pytest-mock pytest-xdist python-multipart + pydantic-settings ruff sqlalchemy tenacity tinytuya typer + uvicorn websockets ] ); diff --git a/python/gems/README.md b/python/gems/README.md new file mode 100644 index 0000000..83646d8 --- /dev/null +++ b/python/gems/README.md @@ -0,0 +1,54 @@ +# Gems + +Gems is a server-rendered, turn-based resource-engine game for one to four human or AI players. It uses FastAPI, +Jinja, HTMX, server-sent events, and SQLite. + +The application deliberately contains no playable card deck, patron/governor set, objective set, official artwork, +or copied rulebook text. A room host must upload a content pack they are entitled to use before starting a game. + +## Run locally + +```shell +uv run gems --host 127.0.0.1 --port 8082 +``` + +The default database and installation key are created under `.gems/`. The following environment variables override +runtime behavior: + +- `GEMS_DATABASE_PATH` +- `GEMS_KEY_PATH` +- `GEMS_PUBLIC_ORIGIN` +- `GEMS_SECURE_COOKIES` +- `GEMS_HOST` +- `GEMS_PORT` + +## Content packs + +The current schema is available from a running server at `/schemas/content-pack-v1.json`. A pack defines exactly +five normal resources, one wild resource, cards, and optional patrons, objectives, and outposts. `patrons` is the +canonical field name; `governors` is accepted as an input alias. + +Cards may use only the built-in, bounded effect vocabulary: + +- `none` +- `virtual_wild` +- `copy_bonus` +- `copy_and_claim` +- `multi_bonus` +- `claim_free` +- an optional discard-cards alternate cost + +Unknown fields, resource references, executable expressions, HTML, artwork URLs, and files larger than 512 KiB are +rejected. The normalized pack is private to its room and becomes immutable when play starts. + +## Neutral module mapping + +Gems calls the four optional mechanics Objectives, Outposts, Eastern Decks, and Fortifications. Lobby presets combine +these mechanics into the familiar base, objective-race, objective-plus-outpost, eastern-plus-fortification, and +all-module configurations. Component identities and values always come from the uploaded pack. + +## Jeeves + +The NixOS module runs one Uvicorn worker on `127.0.0.1:8002`, stores state in +`/zfs/media/services/gems`, and publishes it through HAProxy at `https://gems.tmmworkshop.com`. The DNS record must +point to Jeeves before ACME can issue the certificate. diff --git a/python/gems/__init__.py b/python/gems/__init__.py new file mode 100644 index 0000000..ca1ae5f --- /dev/null +++ b/python/gems/__init__.py @@ -0,0 +1 @@ +"""Gems multiplayer card-engine game.""" diff --git a/python/gems/ai/__init__.py b/python/gems/ai/__init__.py new file mode 100644 index 0000000..a72128c --- /dev/null +++ b/python/gems/ai/__init__.py @@ -0,0 +1,5 @@ +"""AI controllers for Gems.""" + +from .runner import choose_ai_command + +__all__ = ["choose_ai_command"] diff --git a/python/gems/ai/runner.py b/python/gems/ai/runner.py new file mode 100644 index 0000000..1251290 --- /dev/null +++ b/python/gems/ai/runner.py @@ -0,0 +1,68 @@ +"""Deterministic AI command selection at three difficulty levels.""" + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +from python.gems.domain.engine import RuleError, apply_command, score +from python.gems.domain.legal_actions import legal_commands + +if TYPE_CHECKING: + from python.gems.domain.models import ContentPack, GameCommand, GameSettings, GameState + + +def choose_ai_command( + state: GameState, + pack: ContentPack, + settings: GameSettings, + seat: int, + difficulty: str, +) -> GameCommand: + """Choose from legal commands using only public/current-player information.""" + actions = legal_commands(state, pack, settings, seat) + if not actions: + error = "AI has no legal command" + raise RuntimeError(error) + rng = random.Random(f"{state.seed}:{state.revision}:{seat}:{difficulty}") # noqa: S311 - deterministic AI + if difficulty == "easy": + return rng.choice(actions) + + ranked = sorted(actions, key=lambda item: _heuristic(item, state, pack), reverse=True) + if difficulty == "medium": + return ranked[0] + + # Bounded deterministic rollout: examine at most the twelve strongest actions. + best = ranked[0] + best_value = float("-inf") + for candidate in ranked[:12]: + try: + future = apply_command(state, candidate, pack, settings, actor_seat=seat) + except RuleError: + continue + value = score(future.players[seat], pack) * 100 + _heuristic(candidate, state, pack) + value += rng.random() * 0.001 + if value > best_value: + best, best_value = candidate, value + return best + + +def _heuristic(command: GameCommand, state: GameState, pack: ContentPack) -> float: + player = state.players[state.current_seat] + value = 0.0 + if command.type == "purchase": + card = pack.card(str(command.payload.get("card_id"))) + value += 40 + card.points * 25 + (8 if card.bonus_resource else 0) + value -= sum(command.payload.get("payment", {}).values()) + elif command.type == "take_distinct": + value += 10 + len(command.payload.get("resources", [])) + elif command.type == "reserve": + value += 6 + elif command.type == "take_double": + value += 4 + elif command.type == "choose": + value += 20 + elif command.type == "decline": + value -= 2 + value += score(player, pack) * 0.01 + return value diff --git a/python/gems/config.py b/python/gems/config.py new file mode 100644 index 0000000..ed9e501 --- /dev/null +++ b/python/gems/config.py @@ -0,0 +1,25 @@ +"""Runtime configuration loaded from environment variables.""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class GemsConfig(BaseSettings): + """Configure persistence, public URLs, cookies, and the HTTP server.""" + + model_config = SettingsConfigDict(env_prefix="GEMS_") + + database_path: Path = Path(".gems/gems.sqlite3") + key_path: Path = Path(".gems/instance.key") + public_origin: str = "http://127.0.0.1:8082" + secure_cookies: bool = False + host: str = "127.0.0.1" + port: int = 8082 + + +def load_config() -> GemsConfig: + """Load Gems configuration from defaults and environment variables.""" + return GemsConfig() diff --git a/python/gems/content.py b/python/gems/content.py new file mode 100644 index 0000000..dbf190d --- /dev/null +++ b/python/gems/content.py @@ -0,0 +1,55 @@ +"""Content-pack parsing, normalization, and JSON Schema publication.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import Any + +from pydantic import ValidationError + +from .domain.models import ContentPack + +MAX_PACK_BYTES = 512 * 1024 + + +@dataclass(frozen=True, slots=True) +class ParsedPack: + """Validated content plus its canonical representation.""" + + pack: ContentPack + canonical_json: str + digest: str + + +class ContentPackError(ValueError): + """A safe, user-visible content validation error.""" + + +def parse_content_pack(raw: bytes | str) -> ParsedPack: + """Validate a content pack and return stable canonical JSON.""" + data = raw.encode() if isinstance(raw, str) else raw + if len(data) > MAX_PACK_BYTES: + error = "Content pack exceeds the 512 KiB limit" + raise ContentPackError(error) + try: + decoded: Any = json.loads(data) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + error = f"Invalid JSON: {exc}" + raise ContentPackError(error) from exc + try: + pack = ContentPack.model_validate(decoded) + except ValidationError as exc: + messages = [] + for issue in exc.errors(include_url=False): + path = ".".join(str(part) for part in issue["loc"]) + messages.append(f"{path or '$'}: {issue['msg']}") + raise ContentPackError("\n".join(messages)) from exc + canonical = json.dumps(pack.model_dump(mode="json", by_alias=False), sort_keys=True, separators=(",", ":")) + return ParsedPack(pack=pack, canonical_json=canonical, digest=hashlib.sha256(canonical.encode()).hexdigest()) + + +def content_pack_schema() -> dict[str, Any]: + """Return the authoritative version-one JSON Schema.""" + return ContentPack.model_json_schema(by_alias=False) diff --git a/python/gems/domain/__init__.py b/python/gems/domain/__init__.py new file mode 100644 index 0000000..c02e172 --- /dev/null +++ b/python/gems/domain/__init__.py @@ -0,0 +1 @@ +"""Domain models and rules for Gems.""" diff --git a/python/gems/domain/engine.py b/python/gems/domain/engine.py new file mode 100644 index 0000000..dfdc5f8 --- /dev/null +++ b/python/gems/domain/engine.py @@ -0,0 +1,903 @@ +"""Deterministic, server-authoritative Gems rule engine.""" + +from __future__ import annotations + +import random +from typing import Any + +from .models import ( + CardDefinition, + CardEffectKind, + ContentPack, + GameCommand, + GameSettings, + GameState, + OutpostDefinition, + OutpostPower, + OwnedCard, + PendingChoice, + PlayerState, +) +from .requirements import requirements_met + +MAX_PLAYERS = 4 +TWO_PLAYER_COUNT = 2 +THREE_PLAYER_COUNT = 3 +DISTINCT_TAKE_COUNT = 3 +DOUBLE_TAKE_MINIMUM = 4 +BLIND_RESERVE_DRAW_COUNT = 2 + + +class RuleError(ValueError): + """A command was not legal for the current state.""" + + +def deck_key(deck: str, tier: int) -> str: + """Build the state key for a named deck and tier.""" + return f"{deck}:{tier}" + + +def new_game( + room_code: str, + names: list[str], + pack: ContentPack, + settings: GameSettings, + *, + seed: int, +) -> GameState: + """Create and deal a deterministic game.""" + if not 1 <= len(names) <= MAX_PLAYERS: + error = "Games require one to four seats" + raise RuleError(error) + if settings.first_player_mode == "selected" and settings.first_player_seat >= len(names): + error = "The selected first player is no longer in the room" + raise RuleError(error) + _validate_startable(pack, settings) + rng = random.Random(seed) # noqa: S311 - deterministic seeded shuffle + first_seat = settings.first_player_seat if settings.first_player_mode == "selected" else rng.randrange(len(names)) + normal_count = 4 if len(names) <= TWO_PLAYER_COUNT else 5 if len(names) == THREE_PLAYER_COUNT else 7 + supply = dict.fromkeys(pack.resource_ids, normal_count) + supply[pack.wild_resource.id] = 5 + players = [ + PlayerState( + seat=index, + name=name, + tokens=dict.fromkeys((*pack.resource_ids, pack.wild_resource.id), 0), + fortifications_available=settings.fortifications_per_player if settings.modules.fortifications else 0, + ) + for index, name in enumerate(names) + ] + decks: dict[str, list[str]] = {} + markets: dict[str, list[str]] = {} + for source in ("base", "eastern"): + if source == "eastern" and not settings.modules.eastern_decks: + continue + market_size = settings.base_market_size if source == "base" else settings.eastern_market_size + for tier in (1, 2, 3): + key = deck_key(source, tier) + cards = [card.id for card in pack.cards if card.deck == source and card.tier == tier] + rng.shuffle(cards) + markets[key] = [cards.pop() for _ in range(min(market_size, len(cards)))] + decks[key] = cards + patrons = [patron.id for patron in pack.patrons] + rng.shuffle(patrons) + patrons = patrons[: min(len(patrons), len(players) + 1)] + objectives = [objective.id for objective in pack.objectives] + rng.shuffle(objectives) + objectives = objectives[: settings.objective_count] if settings.modules.objectives else [] + return GameState( + room_code=room_code, + seed=seed, + players=players, + supply=supply, + decks=decks, + markets=markets, + available_patrons=[] if settings.modules.objectives else patrons, + available_objectives=objectives, + current_seat=first_seat, + first_seat=first_seat, + ) + + +def _validate_startable(pack: ContentPack, settings: GameSettings) -> None: + for tier in (1, 2, 3): + if not any(card.deck == "base" and card.tier == tier for card in pack.cards): + error = f"The base deck has no tier-{tier} cards" + raise RuleError(error) + if settings.modules.eastern_decks and not any( + card.deck == "eastern" and card.tier == tier for card in pack.cards + ): + error = f"The eastern deck has no tier-{tier} cards" + raise RuleError(error) + if settings.modules.objectives and not pack.objectives: + error = "The Objectives module needs objective definitions" + raise RuleError(error) + if settings.modules.outposts and not pack.outposts: + error = "The Outposts module needs outpost definitions" + raise RuleError(error) + + +def card_map(pack: ContentPack) -> dict[str, CardDefinition]: + """Index all cards in a content pack by identifier.""" + return {card.id: card for card in pack.cards} + + +def bonuses(player: PlayerState, pack: ContentPack) -> dict[str, int]: + """Calculate effective bonuses after copies, multiples, and discards.""" + cards = card_map(pack) + result = dict.fromkeys(pack.resource_ids, 0) + for owned in player.cards: + definition = cards[owned.card_id] + resource = owned.copied_resource or definition.bonus_resource + if resource is not None: + result[resource] += definition.effect.amount if definition.effect.kind == CardEffectKind.MULTI_BONUS else 1 + return result + + +def score(player: PlayerState, pack: ContentPack) -> int: + """Calculate a player's score from cards, patrons, and outposts.""" + cards = card_map(pack) + patrons = {patron.id: patron for patron in pack.patrons} + outposts = {outpost.id: outpost for outpost in pack.outposts} + value = sum(cards[item.card_id].points for item in player.cards) + value += sum(patrons[item].points for item in player.patrons) + for item in player.outposts: + definition = outposts[item] + if definition.power == OutpostPower.POINTS_PER_OUTPOST: + value += definition.value * len(player.outposts) + return value + + +def affordable_payments(player: PlayerState, card: CardDefinition, pack: ContentPack, *, double_wild: bool) -> bool: + """Report whether a player can cover a card's standard cost.""" + discount = bonuses(player, pack) + shortage = sum( + max(0, amount - discount.get(resource, 0) - player.tokens.get(resource, 0)) + for resource, amount in card.cost.items() + ) + wild_value = 2 if double_wild else 1 + return shortage <= player.tokens.get(pack.wild_resource.id, 0) * wild_value + + +def visible_cards(state: GameState) -> set[str]: + """Return the identifiers of cards currently visible in markets.""" + return {card for market in state.markets.values() for card in market} + + +def apply_command( + state: GameState, + command: GameCommand, + pack: ContentPack, + settings: GameSettings, + *, + actor_seat: int, +) -> GameState: + """Validate and apply one command, returning a new state snapshot.""" + if state.finished: + error = "The game is finished" + raise RuleError(error) + if command.expected_revision != state.revision: + error = "The board changed; refresh and try again" + raise RuleError(error) + if actor_seat != state.current_seat: + error = "It is not your turn" + raise RuleError(error) + if state.pending and state.pending.seat != actor_seat: + error = "Another player must resolve the pending choice" + raise RuleError(error) + + result = state.model_copy(deep=True) + if result.pending: + _apply_pending(result, command, pack, settings) + elif command.type == "take_distinct": + _take_distinct(result, command.payload, pack, settings) + elif command.type == "take_double": + _take_double(result, command.payload, pack, settings) + elif command.type == "reserve": + _reserve(result, command.payload, pack, settings) + elif command.type == "purchase": + _purchase(result, command.payload, pack, settings, is_conquest=False) + else: + error = "Choose a normal turn action" + raise RuleError(error) + result.revision += 1 + return result + + +def _take_distinct(state: GameState, payload: dict[str, Any], pack: ContentPack, settings: GameSettings) -> None: + colors = payload.get("resources") + if not isinstance(colors, list) or len(colors) != len(set(colors)): + error = "Choose distinct resources" + raise RuleError(error) + available = [resource for resource in pack.resource_ids if state.supply.get(resource, 0) > 0] + required = DISTINCT_TAKE_COUNT if len(available) >= DISTINCT_TAKE_COUNT else None + if required is not None and len(colors) != required: + error = "Take exactly three different resources when possible" + raise RuleError(error) + if required is None and not 1 <= len(colors) <= len(available): + error = "Choose one or more available resources" + raise RuleError(error) + if not set(colors) <= set(available): + error = "A selected resource is unavailable" + raise RuleError(error) + player = state.players[state.current_seat] + for resource in colors: + state.supply[resource] -= 1 + player.tokens[resource] += 1 + state.log.append(f"{player.name} took {len(colors)} different resources") + _after_standard_action(state, pack, settings, action="take_distinct") + + +def _take_double(state: GameState, payload: dict[str, Any], pack: ContentPack, settings: GameSettings) -> None: + resource = payload.get("resource") + if resource not in pack.resource_ids or state.supply.get(resource, 0) < DOUBLE_TAKE_MINIMUM: + error = "That resource cannot be taken twice" + raise RuleError(error) + player = state.players[state.current_seat] + state.supply[resource] -= 2 + player.tokens[resource] += 2 + state.log.append(f"{player.name} took two {resource} resources") + extra = _owned_outpost(player, pack, OutpostPower.RESOURCE_AFTER_DOUBLE) + if settings.modules.outposts and extra: + options = [item for item in pack.resource_ids if item != resource and state.supply.get(item, 0) > 0] + if options: + state.pending = PendingChoice( + kind="resource", seat=player.seat, options=options, context={"next": "after_action"} + ) + return + _after_standard_action(state, pack, settings, action="take_double") + + +def _reserve(state: GameState, payload: dict[str, Any], pack: ContentPack, settings: GameSettings) -> None: + player = state.players[state.current_seat] + if len(player.reserved) >= settings.reserve_limit: + error = "Your reserve is full" + raise RuleError(error) + card_id = payload.get("card_id") + source = payload.get("deck") + drawn: list[str] + if card_id: + if card_id not in visible_cards(state): + error = "That card is not visible" + raise RuleError(error) + _assert_not_blocked(state, card_id, player.seat) + _remove_visible(state, card_id) + drawn = [card_id] + elif isinstance(source, str) and source in state.decks: + if not state.decks[source]: + error = "That deck is empty" + raise RuleError(error) + draw_count = 1 + post = _owned_outpost(player, pack, OutpostPower.BLIND_RESERVE_TWO) + if post and (not source.startswith("eastern:") or settings.interactions.outpost_reserve_applies_eastern): + draw_count = min(BLIND_RESERVE_DRAW_COUNT, len(state.decks[source])) + drawn = [state.decks[source].pop() for _ in range(draw_count)] + else: + error = "Choose a visible card or deck" + raise RuleError(error) + if len(drawn) == BLIND_RESERVE_DRAW_COUNT: + state.pending = PendingChoice(kind="reserve_keep", seat=player.seat, options=drawn, context={"deck": source}) + return + _finish_reserve(state, drawn[0], pack, settings) + + +def _finish_reserve(state: GameState, card_id: str, pack: ContentPack, settings: GameSettings) -> None: + player = state.players[state.current_seat] + player.reserved.append(card_id) + _refill_markets(state, settings) + wild = pack.wild_resource.id + if state.supply[wild] > 0: + state.supply[wild] -= 1 + player.tokens[wild] += 1 + state.log.append(f"{player.name} reserved a card") + _after_standard_action(state, pack, settings, action="reserve") + + +def _purchase( + state: GameState, + payload: dict[str, Any], + pack: ContentPack, + settings: GameSettings, + *, + is_conquest: bool, +) -> None: + player = state.players[state.current_seat] + card_id = payload.get("card_id") + if not isinstance(card_id, str): + error = "Choose a card" + raise RuleError(error) + from_reserve = card_id in player.reserved + if not from_reserve: + if card_id not in visible_cards(state): + error = "That card is not available" + raise RuleError(error) + _assert_not_blocked(state, card_id, player.seat) + if is_conquest and state.fortifications.get(card_id, {}).get(player.seat, 0) < settings.fortifications_per_player: + error = "All your fortifications must occupy the conquest card" + raise RuleError(error) + card = pack.card(card_id) + _pay_for_purchase(state, player, card, payload, pack, settings) + if from_reserve: + player.reserved.remove(card_id) + else: + _remove_visible(state, card_id) + _return_fortifications(state, card_id) + owned = OwnedCard(card_id=card_id) + player.cards.append(owned) + player.purchased_card_count += 1 + state.turn_purchase_count += 1 + state.log.append(f"{player.name} purchased {card.label}") + if card.effect.kind in {CardEffectKind.COPY_BONUS, CardEffectKind.COPY_AND_CLAIM}: + # Do not allow the new copy card to satisfy its own target requirement. + prior = player.cards.pop() + options = sorted(resource for resource, amount in bonuses(player, pack).items() if amount) + player.cards.append(prior) + if not options: + error = "This card requires an existing bonus to copy" + raise RuleError(error) + state.pending = PendingChoice( + kind="copy_bonus", + seat=player.seat, + options=options, + context={"card_id": card_id, "is_conquest": is_conquest}, + ) + return + if card.effect.kind == CardEffectKind.CLAIM_FREE: + _queue_free_card(state, card, pack, settings, is_conquest=is_conquest) + return + _after_purchase(state, pack, settings, is_conquest=is_conquest) + + +def _pay_for_purchase( + state: GameState, + player: PlayerState, + card: CardDefinition, + payload: dict[str, Any], + pack: ContentPack, + settings: GameSettings, +) -> None: + if card.alternate_cost: + discarded = payload.get("discard_cards", []) + _pay_alternate(player, card, discarded, pack) + if not settings.interactions.retain_outposts_after_discard: + _reconcile_outposts(player, pack) + else: + payment = payload.get("payment", {}) + virtual_cards = payload.get("virtual_wild_cards", []) + _pay_tokens(state, player, card, payment, virtual_cards, pack, settings) + + +def _pay_tokens( # noqa: C901 - payment validation mirrors the rule sequence + state: GameState, + player: PlayerState, + card: CardDefinition, + payment: object, + virtual_cards: object, + pack: ContentPack, + settings: GameSettings, +) -> None: + if not isinstance(payment, dict) or any(not isinstance(value, int) or value < 0 for value in payment.values()): + error = "Provide a valid payment" + raise RuleError(error) + allowed = {*pack.resource_ids, pack.wild_resource.id} + if not set(payment) <= allowed: + error = "Payment contains an unknown resource" + raise RuleError(error) + if any(payment.get(resource, 0) > player.tokens.get(resource, 0) for resource in allowed): + error = "Payment uses resources you do not have" + raise RuleError(error) + discount = bonuses(player, pack) + if not isinstance(virtual_cards, list) or len(virtual_cards) != len(set(virtual_cards)): + error = "Virtual-wild selections must be unique" + raise RuleError(error) + owned_by_id = {item.card_id: item for item in player.cards} + virtual_value = 0 + for card_id in virtual_cards: + owned = owned_by_id.get(card_id) + if owned is None: + error = "You do not own a selected virtual-wild card" + raise RuleError(error) + definition = pack.card(card_id) + if definition.effect.kind != CardEffectKind.VIRTUAL_WILD: + error = "A selected card does not grant virtual wild resources" + raise RuleError(error) + virtual_value += definition.effect.amount + wild = payment.get(pack.wild_resource.id, 0) + double = bool(_owned_outpost(player, pack, OutpostPower.DOUBLE_WILD)) + wild_value = 2 if double else 1 + virtual_multiplier = 2 if double and settings.interactions.virtual_wild_can_double else 1 + remaining_wild_value = wild * wild_value + virtual_value * virtual_multiplier + for resource in pack.resource_ids: + due = max(0, card.cost.get(resource, 0) - discount.get(resource, 0)) + normal = payment.get(resource, 0) + if normal > due: + error = "Payment overpays a normal resource" + raise RuleError(error) + remaining_wild_value -= due - normal + if remaining_wild_value < 0: + error = "Payment does not exactly cover the card cost" + raise RuleError(error) + for resource, amount in payment.items(): + player.tokens[resource] -= amount + state.supply[resource] += amount + if virtual_cards: + player.cards = [item for item in player.cards if item.card_id not in virtual_cards] + + +def _pay_alternate(player: PlayerState, card: CardDefinition, selected: object, pack: ContentPack) -> None: + if not isinstance(selected, list) or not card.alternate_cost or len(selected) != card.alternate_cost.count: + error = "Choose the required owned cards to discard" + raise RuleError(error) + if len(selected) != len(set(selected)): + error = "A card can only be discarded once" + raise RuleError(error) + owned_by_id = {item.card_id: item for item in player.cards} + for card_id in selected: + owned = owned_by_id.get(card_id) + if owned is None: + error = "You do not own a selected discard" + raise RuleError(error) + definition = pack.card(card_id) + effective = owned.copied_resource or definition.bonus_resource + if effective != card.alternate_cost.discard_resource: + error = "A selected discard has the wrong color" + raise RuleError(error) + # Copy cards of the effective color must be discarded first. + matching_copies = [ + item.card_id for item in player.cards if item.copied_resource == card.alternate_cost.discard_resource + ] + if any(item not in selected for item in matching_copies[: min(len(matching_copies), len(selected))]): + error = "Copied cards of this color must be discarded first" + raise RuleError(error) + player.cards = [item for item in player.cards if item.card_id not in selected] + + +def _apply_pending( # noqa: C901, PLR0911, PLR0912, PLR0915 - explicit pending-choice state machine + state: GameState, command: GameCommand, pack: ContentPack, settings: GameSettings +) -> None: + pending = state.pending + if pending is None or command.type not in {"choose", "decline", "purchase"}: + error = "Resolve the pending choice" + raise RuleError(error) + player = state.players[state.current_seat] + choice = command.payload.get("choice") + if pending.kind == "reserve_keep": + if choice not in pending.options: + error = "Choose one of the drawn cards" + raise RuleError(error) + other = next(item for item in pending.options if item != choice) + state.decks[pending.context["deck"]].insert(0, other) + state.pending = None + _finish_reserve(state, choice, pack, settings) + return + if pending.kind == "copy_bonus": + if choice not in pending.options: + error = "Choose an existing bonus" + raise RuleError(error) + card_id = pending.context["card_id"] + owned = next(item for item in reversed(player.cards) if item.card_id == card_id) + owned.copied_resource = choice + definition = pack.card(card_id) + state.pending = None + if definition.effect.kind == CardEffectKind.COPY_AND_CLAIM: + _queue_free_card(state, definition, pack, settings, is_conquest=bool(pending.context.get("is_conquest"))) + else: + _after_purchase( + state, + pack, + settings, + is_conquest=bool(pending.context.get("is_conquest")), + chained=bool(pending.context.get("chained")), + ) + return + if pending.kind == "free_card": + if choice not in pending.options: + error = "Choose an eligible free card" + raise RuleError(error) + if settings.interactions.fortifications_block_free_claim: + _assert_not_blocked(state, choice, player.seat) + _remove_visible(state, choice) + _return_fortifications(state, choice) + claimed = pack.card(choice) + player.cards.append(OwnedCard(card_id=choice)) + state.turn_chain_free_count += 1 + state.log.append(f"{player.name} claimed {claimed.label} without payment") + state.pending = None + if claimed.effect.kind in {CardEffectKind.COPY_BONUS, CardEffectKind.COPY_AND_CLAIM}: + prior = player.cards.pop() + options = sorted(resource for resource, amount in bonuses(player, pack).items() if amount) + player.cards.append(prior) + if options: + state.pending = PendingChoice( + kind="copy_bonus", + seat=player.seat, + options=options, + context={ + "card_id": choice, + "is_conquest": pending.context.get("is_conquest", False), + "chained": True, + }, + ) + return + _after_purchase(state, pack, settings, is_conquest=bool(pending.context.get("is_conquest")), chained=True) + return + if pending.kind == "resource": + if choice not in pending.options or state.supply.get(str(choice), 0) <= 0: + error = "Choose an available resource" + raise RuleError(error) + state.supply[str(choice)] -= 1 + player.tokens[str(choice)] += 1 + context = pending.context + state.pending = None + remaining = int(context.get("remaining", 1)) + if context.get("next") == "after_purchase" and remaining > 1: + options = [item for item in pack.resource_ids if state.supply.get(item, 0) > 0] + if options: + state.pending = PendingChoice( + kind="resource", + seat=player.seat, + options=options, + context={**context, "remaining": remaining - 1}, + ) + return + if context.get("next") == "after_purchase": + _after_purchase(state, pack, settings, is_conquest=bool(context.get("is_conquest")), skip_resource=True) + else: + _after_standard_action(state, pack, settings, action="take_double") + return + if pending.kind == "discard_tokens": + discard = command.payload.get("tokens", {}) + allowed = {*pack.resource_ids, pack.wild_resource.id} + if not isinstance(discard, dict) or not set(discard) <= allowed or sum(discard.values()) != pending.amount: + error = "Discard exactly the required number of resources" + raise RuleError(error) + for resource, amount in discard.items(): + if not isinstance(amount, int) or amount <= 0 or amount > player.tokens.get(resource, 0): + error = "Invalid token discard" + raise RuleError(error) + for resource, amount in discard.items(): + player.tokens[resource] -= amount + state.supply[resource] += amount + state.pending = None + _end_checks(state, pack, settings) + return + if pending.kind == "patron": + if choice not in pending.options: + error = "Choose an eligible patron" + raise RuleError(error) + player.patrons.append(choice) + state.available_patrons.remove(choice) + state.pending = None + _check_outposts_or_objectives(state, pack, settings) + return + if pending.kind == "outpost": + if choice not in pending.options: + error = "Choose an eligible outpost" + raise RuleError(error) + player.outposts.append(choice) + state.pending = None + _check_objectives(state, pack, settings) + return + if pending.kind == "fortification": + target = command.payload.get("card_id") + mode = command.payload.get("mode") + if target not in visible_cards(state): + error = "Choose a visible card" + raise RuleError(error) + if ( + target in state.markets.get(deck_key("eastern", pack.card(target).tier), []) + and not settings.interactions.fortifications_on_eastern + ): + error = "Fortifications cannot occupy eastern cards with this setting" + raise RuleError(error) + if mode == "place": + occupants = state.fortifications.get(target, {}) + if any(seat != player.seat and count for seat, count in occupants.items()): + error = "An opponent occupies that card" + raise RuleError(error) + source = command.payload.get("from_card") + if source: + if state.fortifications.get(source, {}).get(player.seat, 0) <= 0: + error = "You have no fortification there" + raise RuleError(error) + state.fortifications[source][player.seat] -= 1 + elif player.fortifications_available > 0: + player.fortifications_available -= 1 + else: + error = "Move one of your placed fortifications" + raise RuleError(error) + state.fortifications.setdefault(target, {})[player.seat] = occupants.get(player.seat, 0) + 1 + elif mode == "remove": + occupants = state.fortifications.get(target, {}) + opponents = [(seat, count) for seat, count in occupants.items() if seat != player.seat and count == 1] + if len(opponents) != 1: + error = "Choose a card with exactly one opposing fortification" + raise RuleError(error) + seat, _ = opponents[0] + occupants[seat] = 0 + state.players[seat].fortifications_available += 1 + else: + error = "Choose place or remove" + raise RuleError(error) + remaining = int(pending.context.get("remaining", 1)) + state.pending = None + if remaining > 1 and _queue_fortification(state, pack, settings, remaining=remaining - 1): + return + _after_fortification(state, pack, settings) + return + if pending.kind == "conquest": + if command.type == "decline": + state.pending = None + _enforce_token_limit_or_checks(state, pack, settings) + return + if command.type != "purchase": + error = "Purchase the conquest card or decline" + raise RuleError(error) + state.pending = None + _purchase(state, command.payload, pack, settings, is_conquest=True) + return + error = "Unsupported pending choice" + raise RuleError(error) + + +def _queue_free_card( + state: GameState, card: CardDefinition, pack: ContentPack, settings: GameSettings, *, is_conquest: bool +) -> None: + options = [] + for card_id in visible_cards(state): + candidate = pack.card(card_id) + if candidate.tier != card.effect.target_tier: + continue + if settings.interactions.fortifications_block_free_claim: + occupants = state.fortifications.get(card_id, {}) + if any(seat != state.current_seat and count for seat, count in occupants.items()): + continue + options.append(card_id) + if options: + state.pending = PendingChoice( + kind="free_card", seat=state.current_seat, options=sorted(options), context={"is_conquest": is_conquest} + ) + else: + _after_purchase(state, pack, settings, is_conquest=is_conquest) + + +def _after_purchase( + state: GameState, + pack: ContentPack, + settings: GameSettings, + *, + is_conquest: bool, + chained: bool = False, + skip_resource: bool = False, +) -> None: + player = state.players[state.current_seat] + _refill_markets(state, settings) + resource_post = _owned_outpost(player, pack, OutpostPower.RESOURCE_AFTER_PURCHASE) + triggers = not is_conquest or settings.interactions.purchase_resource_on_conquest + if settings.modules.outposts and resource_post and triggers and not skip_resource: + options = [item for item in pack.resource_ids if state.supply.get(item, 0) > 0] + if options: + remaining = 1 + if chained and not settings.interactions.chained_claim_is_not_purchase: + remaining += state.turn_chain_free_count + state.pending = PendingChoice( + kind="resource", + seat=player.seat, + options=options, + context={"next": "after_purchase", "is_conquest": is_conquest, "remaining": remaining}, + ) + return + if settings.modules.fortifications: + remaining = 1 + if chained and not settings.interactions.one_fortification_decision_per_purchase_chain: + remaining += state.turn_chain_free_count + if _queue_fortification(state, pack, settings, remaining=remaining): + return + _after_fortification(state, pack, settings) + + +def _after_fortification(state: GameState, pack: ContentPack, settings: GameSettings) -> None: + player = state.players[state.current_seat] + conquest = [ + card_id + for card_id, occupants in state.fortifications.items() + if occupants.get(player.seat, 0) >= settings.fortifications_per_player and card_id in visible_cards(state) + ] + if settings.modules.fortifications and conquest: + state.turn_chain_free_count = 0 + state.pending = PendingChoice(kind="conquest", seat=player.seat, options=conquest) + return + state.turn_chain_free_count = 0 + _enforce_token_limit_or_checks(state, pack, settings) + + +def _queue_fortification( + state: GameState, + pack: ContentPack, + settings: GameSettings, + *, + remaining: int, +) -> bool: + player = state.players[state.current_seat] + options = [ + card_id + for card_id in visible_cards(state) + if settings.interactions.fortifications_on_eastern or pack.card(card_id).deck != "eastern" + ] + can_act = False + for card_id in options: + occupants = state.fortifications.get(card_id, {}) + can_place = not any(seat != player.seat and count for seat, count in occupants.items()) + can_remove = any(seat != player.seat and count == 1 for seat, count in occupants.items()) + if can_place or can_remove: + can_act = True + break + if not can_act: + return False + state.pending = PendingChoice( + kind="fortification", + seat=player.seat, + options=sorted(options), + context={"remaining": remaining}, + ) + return True + + +def _after_standard_action(state: GameState, pack: ContentPack, settings: GameSettings, *, action: str) -> None: + del action + if settings.modules.fortifications and state.turn_purchase_count: + _after_purchase(state, pack, settings, is_conquest=False) + else: + _enforce_token_limit_or_checks(state, pack, settings) + + +def _enforce_token_limit_or_checks(state: GameState, pack: ContentPack, settings: GameSettings) -> None: + player = state.players[state.current_seat] + excess = sum(player.tokens.values()) - settings.token_limit + if excess > 0: + state.pending = PendingChoice(kind="discard_tokens", seat=player.seat, amount=excess) + return + _end_checks(state, pack, settings) + + +def _end_checks(state: GameState, pack: ContentPack, settings: GameSettings) -> None: + player = state.players[state.current_seat] + bonus = bonuses(player, pack) + eligible_patrons = [ + patron.id + for patron in pack.patrons + if patron.id in state.available_patrons and requirements_met(patron.requirements, bonus) + ] + if eligible_patrons: + state.pending = PendingChoice(kind="patron", seat=player.seat, options=eligible_patrons) + return + _check_outposts_or_objectives(state, pack, settings) + + +def _check_outposts_or_objectives(state: GameState, pack: ContentPack, settings: GameSettings) -> None: + if settings.modules.outposts and settings.interactions.outposts_before_objectives and _queue_outpost(state, pack): + return + _check_objectives(state, pack, settings) + + +def _queue_outpost(state: GameState, pack: ContentPack) -> bool: + player = state.players[state.current_seat] + bonus = bonuses(player, pack) + choices = [ + outpost.id + for outpost in pack.outposts + if outpost.id not in player.outposts and requirements_met(outpost.requirements, bonus) + ] + if choices: + state.pending = PendingChoice(kind="outpost", seat=player.seat, options=choices) + return True + return False + + +def _check_objectives(state: GameState, pack: ContentPack, settings: GameSettings) -> None: + player = state.players[state.current_seat] + if settings.modules.objectives: + bonus = bonuses(player, pack) + choices = [ + objective.id + for objective in pack.objectives + if objective.id in state.available_objectives + and score(player, pack) >= objective.minimum_score + and requirements_met(objective.requirements, bonus) + ] + if choices: + player.objective_met = choices[0] + if player.seat not in state.objective_qualifiers: + state.objective_qualifiers.append(player.seat) + if ( + settings.modules.outposts + and not settings.interactions.outposts_before_objectives + and _queue_outpost(state, pack) + ): + return + _finish_turn(state, pack, settings) + + +def _finish_turn(state: GameState, pack: ContentPack, settings: GameSettings) -> None: + player = state.players[state.current_seat] + has_score = score(player, pack) >= settings.target_score + has_objective = player.objective_met is not None + triggered = { + "score": has_score, + "objective": has_objective, + "either": has_score or has_objective, + "both": has_score and has_objective, + }[settings.victory_condition] + if triggered and state.finish_at_seat is None: + state.finish_at_seat = (state.first_seat - 1) % len(state.players) + if state.finish_at_seat == state.current_seat: + candidates = ( + state.objective_qualifiers if settings.victory_condition == "objective" else list(range(len(state.players))) + ) + if not candidates: + candidates = list(range(len(state.players))) + best_score = max(score(state.players[seat], pack) for seat in candidates) + candidates = [seat for seat in candidates if score(state.players[seat], pack) == best_score] + fewest = min(state.players[seat].purchased_card_count for seat in candidates) + state.winners = [seat for seat in candidates if state.players[seat].purchased_card_count == fewest] + state.finished = True + state.pending = None + return + state.current_seat = (state.current_seat + 1) % len(state.players) + if state.current_seat == state.first_seat: + state.round_number += 1 + state.turn_purchase_count = 0 + state.turn_chain_free_count = 0 + + +def _remove_visible(state: GameState, card_id: str) -> None: + for market in state.markets.values(): + if card_id in market: + market.remove(card_id) + return + error = "Card is not visible" + raise RuleError(error) + + +def _refill_markets(state: GameState, settings: GameSettings) -> None: + for key, market in state.markets.items(): + desired = settings.eastern_market_size if key.startswith("eastern:") else settings.base_market_size + deck = state.decks[key] + while len(market) < desired and deck: + market.append(deck.pop()) + + +def _assert_not_blocked(state: GameState, card_id: str, seat: int) -> None: + occupants = state.fortifications.get(card_id, {}) + if any(owner != seat and count > 0 for owner, count in occupants.items()): + error = "An opponent's fortification protects that card" + raise RuleError(error) + + +def _return_fortifications(state: GameState, card_id: str) -> None: + for seat, count in state.fortifications.pop(card_id, {}).items(): + state.players[seat].fortifications_available += count + + +def _owned_outpost(player: PlayerState, pack: ContentPack, power: OutpostPower) -> OutpostDefinition | None: + definitions = {item.id: item for item in pack.outposts} + return next((definitions[item] for item in player.outposts if definitions[item].power == power), None) + + +def _reconcile_outposts(player: PlayerState, pack: ContentPack) -> None: + definitions = {item.id: item for item in pack.outposts} + bonus = bonuses(player, pack) + player.outposts = [item for item in player.outposts if requirements_met(definitions[item].requirements, bonus)] + + +def public_state(state: GameState, viewer_seat: int | None) -> dict[str, Any]: + """Serialize state while redacting other players' reserved cards and deck order.""" + data = state.model_dump(mode="json") + for player in data["players"]: + if player["seat"] != viewer_seat: + player["reserved"] = [None] * len(player["reserved"]) + data["decks"] = {key: len(value) for key, value in state.decks.items()} + if state.pending and state.pending.seat != viewer_seat: + data["pending"]["options"] = [] + data["pending"]["context"] = {} + return data diff --git a/python/gems/domain/legal_actions.py b/python/gems/domain/legal_actions.py new file mode 100644 index 0000000..d2b8500 --- /dev/null +++ b/python/gems/domain/legal_actions.py @@ -0,0 +1,159 @@ +"""Legal command generation used by bots and server-rendered controls.""" + +from __future__ import annotations + +import itertools +import math +import uuid + +from .engine import bonuses, visible_cards +from .models import ContentPack, GameCommand, GameSettings, GameState, OutpostPower + +DISTINCT_TAKE_COUNT = 3 +DOUBLE_TAKE_MINIMUM = 4 + + +def command(state: GameState, type_: str, payload: dict | None = None) -> GameCommand: + """Create a command targeting the state's current revision.""" + return GameCommand( + command_id=uuid.uuid4().hex, + expected_revision=state.revision, + type=type_, # type: ignore[arg-type] + payload=payload or {}, + ) + + +def legal_commands( # noqa: C901, PLR0911, PLR0912 - mirrors pending and normal action branches + state: GameState, pack: ContentPack, settings: GameSettings, seat: int +) -> list[GameCommand]: + """Enumerate meaningful legal commands without revealing hidden information.""" + if state.finished or seat != state.current_seat: + return [] + player = state.players[seat] + if state.pending: + pending = state.pending + if pending.seat != seat: + return [] + if pending.kind == "discard_tokens": + available = [item for item, amount in player.tokens.items() for _ in range(amount)] + payload: dict[str, int] = {} + for item in available[: pending.amount]: + payload[item] = payload.get(item, 0) + 1 + return [command(state, "choose", {"tokens": payload})] + if pending.kind == "fortification": + pending_results = [] + for target in pending.options: + occupants = state.fortifications.get(target, {}) + if not any(owner != seat and count for owner, count in occupants.items()): + if player.fortifications_available: + pending_results.append(command(state, "choose", {"card_id": target, "mode": "place"})) + for source, source_occupants in state.fortifications.items(): + if source_occupants.get(seat, 0): + pending_results.append( + command(state, "choose", {"card_id": target, "from_card": source, "mode": "place"}) + ) + if any(owner != seat and count == 1 for owner, count in occupants.items()): + pending_results.append(command(state, "choose", {"card_id": target, "mode": "remove"})) + return pending_results + if pending.kind == "conquest": + conquest_results = [command(state, "decline")] + for card_id in pending.options: + payment = default_payment(state, pack, settings, card_id, seat) + if payment is not None: + conquest_results.append(command(state, "purchase", {"card_id": card_id, **payment})) + return conquest_results + return [command(state, "choose", {"choice": item}) for item in pending.options] + + results: list[GameCommand] = [] + available = [resource for resource in pack.resource_ids if state.supply.get(resource, 0)] + take_size = DISTINCT_TAKE_COUNT if len(available) >= DISTINCT_TAKE_COUNT else 1 + sizes = [take_size] if len(available) >= DISTINCT_TAKE_COUNT else list(range(1, len(available) + 1)) + for size in sizes: + results.extend( + command(state, "take_distinct", {"resources": list(items)}) + for items in itertools.combinations(available, size) + ) + results.extend( + command(state, "take_double", {"resource": resource}) + for resource in pack.resource_ids + if state.supply.get(resource, 0) >= DOUBLE_TAKE_MINIMUM + ) + if len(player.reserved) < settings.reserve_limit: + results.extend( + command(state, "reserve", {"card_id": card_id}) + for card_id in visible_cards(state) + if not _blocked(state, card_id, seat) + ) + results.extend(command(state, "reserve", {"deck": key}) for key, cards in state.decks.items() if cards) + for card_id in [*visible_cards(state), *player.reserved]: + if card_id not in player.reserved and _blocked(state, card_id, seat): + continue + payment = default_payment(state, pack, settings, card_id, seat) + if payment is not None: + results.append(command(state, "purchase", {"card_id": card_id, **payment})) + return results + + +def default_payment( # noqa: C901, PLR0912 - ordered payment rules are intentionally explicit + state: GameState, + pack: ContentPack, + settings: GameSettings, + card_id: str, + seat: int, +) -> dict | None: + """Return one legal colored-first payment, or None when unaffordable.""" + player = state.players[seat] + card = pack.card(card_id) + if card.alternate_cost: + matching = [] + definitions = {item.id: item for item in pack.cards} + for owned in player.cards: + resource = owned.copied_resource or definitions[owned.card_id].bonus_resource + if resource == card.alternate_cost.discard_resource: + matching.append(owned.card_id) + copies = [ + item for item in matching if next(owned for owned in player.cards if owned.card_id == item).copied_resource + ] + ordered = [*copies, *(item for item in matching if item not in copies)] + if len(ordered) < card.alternate_cost.count: + return None + return {"discard_cards": ordered[: card.alternate_cost.count]} + discount = bonuses(player, pack) + payment: dict[str, int] = {} + shortage = 0 + for resource in pack.resource_ids: + due = max(0, card.cost.get(resource, 0) - discount.get(resource, 0)) + amount = min(due, player.tokens.get(resource, 0)) + if amount: + payment[resource] = amount + shortage += due - amount + double_wild = any( + outpost.power == OutpostPower.DOUBLE_WILD and outpost.id in player.outposts for outpost in pack.outposts + ) + wild_value = 2 if double_wild else 1 + virtual_multiplier = 2 if double_wild and settings.interactions.virtual_wild_can_double else 1 + virtual_cards: list[str] = [] + virtual_total = 0 + if shortage > player.tokens.get(pack.wild_resource.id, 0) * wild_value: + for owned in player.cards: + definition = pack.card(owned.card_id) + if definition.effect.kind.value != "virtual_wild": + continue + virtual_cards.append(owned.card_id) + virtual_total += definition.effect.amount * virtual_multiplier + if virtual_total + player.tokens.get(pack.wild_resource.id, 0) * wild_value >= shortage: + break + remaining = max(0, shortage - virtual_total) + wild_needed = math.ceil(remaining / wild_value) + if wild_needed > player.tokens.get(pack.wild_resource.id, 0) or virtual_total + wild_needed * wild_value < shortage: + return None + if wild_needed: + payment[pack.wild_resource.id] = wild_needed + result: dict[str, object] = {"payment": payment} + if virtual_cards: + result["virtual_wild_cards"] = virtual_cards + return result + + +def _blocked(state: GameState, card_id: str, seat: int) -> bool: + return any(owner != seat and count for owner, count in state.fortifications.get(card_id, {}).items()) diff --git a/python/gems/domain/models.py b/python/gems/domain/models.py new file mode 100644 index 0000000..07f41fd --- /dev/null +++ b/python/gems/domain/models.py @@ -0,0 +1,394 @@ +"""Typed content, configuration, commands, and game state.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class StrictModel(BaseModel): + """Base model that rejects misspelled fields.""" + + model_config = ConfigDict(extra="forbid") + + +Slug = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,47}$")] +ShortText = Annotated[str, Field(min_length=1, max_length=80)] +TOKEN_DARK_INK_THRESHOLD = 0.82 +STANDARD_RESOURCE_SYMBOLS = { + "onyx": "O", + "sapphire": "S", + "emerald": "E", + "ruby": "R", + "diamond": "D", + "gold": "G", +} + + +class ResourceDefinition(StrictModel): + """A normal or wild resource rendered by the UI.""" + + id: Slug + label: ShortText + symbol: Annotated[str, Field(min_length=1, max_length=4)] + color: Annotated[str, Field(pattern=r"^#[0-9a-fA-F]{6}$")] + + @model_validator(mode="after") + def normalize_standard_symbol(self) -> ResourceDefinition: + """Store the conventional symbol for a standard gem label.""" + self.symbol = STANDARD_RESOURCE_SYMBOLS.get(self.label.casefold(), self.symbol) + return self + + @property + def ink_color(self) -> str: + """Return readable token lettering for the configured background.""" + red, green, blue = (int(self.color[index : index + 2], 16) for index in (1, 3, 5)) + perceived_brightness = (299 * red + 587 * green + 114 * blue) / 255_000 + return "#111111" if perceived_brightness >= TOKEN_DARK_INK_THRESHOLD else "#ffffff" + + +class RequirementKind(StrEnum): + """Identify how a requirement selects resource colors.""" + + COLOR = "color" + ANY_COLOR = "any_color" + + +class Requirement(StrictModel): + """A fixed-color or same-color bonus requirement.""" + + id: Slug + kind: RequirementKind + count: Annotated[int, Field(ge=1, le=99)] + resource: Slug | None = None + exclude: list[Slug] = Field(default_factory=list) + distinct_from: list[Slug] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_shape(self) -> Requirement: + """Validate fields that depend on the requirement kind.""" + if self.kind == RequirementKind.COLOR and self.resource is None: + error = "color requirements need a resource" + raise ValueError(error) + if self.kind == RequirementKind.ANY_COLOR and self.resource is not None: + error = "any_color requirements cannot name a resource" + raise ValueError(error) + return self + + +class CardEffectKind(StrEnum): + """Identify a supported server-side card effect.""" + + NONE = "none" + VIRTUAL_WILD = "virtual_wild" + COPY_BONUS = "copy_bonus" + COPY_AND_CLAIM = "copy_and_claim" + MULTI_BONUS = "multi_bonus" + CLAIM_FREE = "claim_free" + + +class CardEffect(StrictModel): + """A bounded built-in card effect; uploaded code is never evaluated.""" + + kind: CardEffectKind = CardEffectKind.NONE + amount: Annotated[int, Field(ge=1, le=10)] = 1 + target_tier: Annotated[int, Field(ge=1, le=3)] | None = None + + +class AlternateCost(StrictModel): + """Purchase a card by discarding owned cards of one effective color.""" + + discard_resource: Slug + count: Annotated[int, Field(ge=1, le=10)] + + +class CardDefinition(StrictModel): + """A card supplied by a user-owned content pack.""" + + id: Slug + label: ShortText + deck: Literal["base", "eastern"] = "base" + tier: Annotated[int, Field(ge=1, le=3)] + points: Annotated[int, Field(ge=0, le=99)] = 0 + bonus_resource: Slug | None = None + cost: dict[Slug, Annotated[int, Field(ge=0, le=99)]] = Field(default_factory=dict) + effect: CardEffect = Field(default_factory=CardEffect) + alternate_cost: AlternateCost | None = None + + @model_validator(mode="after") + def validate_effect(self) -> CardDefinition: + """Validate fields that depend on the selected card effect.""" + effect = self.effect + if effect.kind in {CardEffectKind.CLAIM_FREE, CardEffectKind.COPY_AND_CLAIM}: + if effect.target_tier is None: + error = "claim effects require target_tier" + raise ValueError(error) + if effect.target_tier >= self.tier: + error = "free-card effects must target a lower tier" + raise ValueError(error) + elif effect.target_tier is not None: + error = "target_tier is only valid for claim effects" + raise ValueError(error) + if effect.kind == CardEffectKind.MULTI_BONUS and self.bonus_resource is None: + error = "multi_bonus cards require bonus_resource" + raise ValueError(error) + return self + + +class PatronDefinition(StrictModel): + """Define a patron claimed by meeting permanent-bonus requirements.""" + + id: Slug + label: ShortText + points: Annotated[int, Field(ge=0, le=99)] + requirements: list[Requirement] + + +class ObjectiveDefinition(StrictModel): + """Define an optional objective-based victory condition.""" + + id: Slug + label: ShortText + minimum_score: Annotated[int, Field(ge=0, le=999)] + requirements: list[Requirement] + + +class OutpostPower(StrEnum): + """Identify a built-in outpost ability.""" + + RESOURCE_AFTER_PURCHASE = "resource_after_purchase" + RESOURCE_AFTER_DOUBLE = "resource_after_double" + DOUBLE_WILD = "double_wild" + POINTS_PER_OUTPOST = "points_per_outpost" + BLIND_RESERVE_TWO = "blind_reserve_two" + + +class OutpostDefinition(StrictModel): + """Define an outpost and the bonuses required to claim it.""" + + id: Slug + label: ShortText + requirements: list[Requirement] + power: OutpostPower + value: Annotated[int, Field(ge=1, le=10)] = 1 + + +class PackMetadata(StrictModel): + """Describe the identity and authorship of a content pack.""" + + id: Slug + name: ShortText + version: Annotated[str, Field(min_length=1, max_length=32)] + author: Annotated[str, Field(max_length=80)] = "" + + +class ContentPack(StrictModel): + """Versioned user-provided game content.""" + + schema_version: Literal[1] + metadata: PackMetadata + resources: Annotated[list[ResourceDefinition], Field(min_length=5, max_length=5)] + wild_resource: ResourceDefinition + cards: list[CardDefinition] + patrons: list[PatronDefinition] = Field(default_factory=list) + objectives: list[ObjectiveDefinition] = Field(default_factory=list) + outposts: list[OutpostDefinition] = Field(default_factory=list) + + @model_validator(mode="before") + @classmethod + def normalize_patrons(cls, data: object) -> object: + """Normalize the legacy governors key to the neutral patrons key.""" + if isinstance(data, dict): + if "patrons" in data and "governors" in data: + error = "use patrons or governors, not both" + raise ValueError(error) + if "governors" in data: + data = dict(data) + data["patrons"] = data.pop("governors") + return data + + @model_validator(mode="after") + def validate_references(self) -> ContentPack: # noqa: C901, PLR0912 - validates each reference family + """Validate identifiers and references across the complete pack.""" + resources = {item.id for item in self.resources} + if self.wild_resource.id in resources: + error = "wild resource ID must be distinct" + raise ValueError(error) + id_collections = [ + [item.id for item in self.resources], + [item.id for item in self.cards], + [item.id for item in self.patrons], + [item.id for item in self.objectives], + [item.id for item in self.outposts], + ] + for ids in id_collections: + if len(ids) != len(set(ids)): + error = "IDs must be unique within each content collection" + raise ValueError(error) + req_ids: set[str] + for card in self.cards: + if card.bonus_resource is not None and card.bonus_resource not in resources: + error = f"card {card.id} has an unknown bonus resource" + raise ValueError(error) + if not set(card.cost) <= resources: + error = f"card {card.id} has an unknown cost resource" + raise ValueError(error) + if card.alternate_cost and card.alternate_cost.discard_resource not in resources: + error = f"card {card.id} has an unknown alternate-cost resource" + raise ValueError(error) + requirement_owners: list[PatronDefinition | ObjectiveDefinition | OutpostDefinition] = [ + *self.patrons, + *self.objectives, + *self.outposts, + ] + for owner in requirement_owners: + req_ids = {requirement.id for requirement in owner.requirements} + if len(req_ids) != len(owner.requirements): + error = f"{owner.id} has duplicate requirement IDs" + raise ValueError(error) + for requirement in owner.requirements: + if requirement.resource and requirement.resource not in resources: + error = f"{owner.id} references an unknown resource" + raise ValueError(error) + if not set(requirement.exclude) <= resources: + error = f"{owner.id} excludes an unknown resource" + raise ValueError(error) + if not set(requirement.distinct_from) <= req_ids: + error = f"{owner.id} references an unknown requirement" + raise ValueError(error) + return self + + @property + def resource_ids(self) -> tuple[str, ...]: + """Return normal resource identifiers in display order.""" + return tuple(resource.id for resource in self.resources) + + def card(self, card_id: str) -> CardDefinition: + """Return a card definition by identifier.""" + return next(card for card in self.cards if card.id == card_id) + + +class Modules(StrictModel): + """Select optional rule modules for a game.""" + + objectives: bool = False + outposts: bool = False + eastern_decks: bool = False + fortifications: bool = False + + +class Interactions(StrictModel): + """Configure interactions between optional rule modules.""" + + outpost_reserve_applies_eastern: bool = True + virtual_wild_can_double: bool = True + retain_outposts_after_discard: bool = True + outposts_before_objectives: bool = True + purchase_resource_on_conquest: bool = True + chained_claim_is_not_purchase: bool = True + fortifications_on_eastern: bool = True + fortifications_block_free_claim: bool = True + one_fortification_decision_per_purchase_chain: bool = True + + +class GameSettings(StrictModel): + """Configure win conditions, limits, markets, and rule modules.""" + + target_score: Annotated[int, Field(ge=1, le=999)] = 15 + victory_condition: Literal["score", "objective", "either", "both"] = "score" + first_player_mode: Literal["random", "selected"] = "random" + first_player_seat: Annotated[int, Field(ge=0, le=3)] = 0 + token_limit: Annotated[int, Field(ge=1, le=99)] = 10 + reserve_limit: Annotated[int, Field(ge=0, le=20)] = 3 + base_market_size: Annotated[int, Field(ge=1, le=10)] = 4 + eastern_market_size: Annotated[int, Field(ge=1, le=10)] = 2 + objective_count: Annotated[int, Field(ge=1, le=10)] = 3 + fortifications_per_player: Annotated[int, Field(ge=1, le=10)] = 3 + modules: Modules = Field(default_factory=Modules) + interactions: Interactions = Field(default_factory=Interactions) + + +class OwnedCard(StrictModel): + """Track an owned card and any copied resource assignment.""" + + card_id: Slug + copied_resource: Slug | None = None + + +class PlayerState(StrictModel): + """Store the mutable state belonging to one player.""" + + seat: int + name: ShortText + tokens: dict[str, int] + cards: list[OwnedCard] = Field(default_factory=list) + reserved: list[Slug] = Field(default_factory=list) + patrons: list[Slug] = Field(default_factory=list) + outposts: list[Slug] = Field(default_factory=list) + objective_met: Slug | None = None + fortifications_available: int = 0 + purchased_card_count: int = 0 + + +class PendingChoice(StrictModel): + """Describe a choice that must be resolved before play continues.""" + + kind: Literal[ + "discard_tokens", + "copy_bonus", + "free_card", + "reserve_keep", + "resource", + "fortification", + "conquest", + "patron", + "outpost", + ] + seat: int + options: list[str] = Field(default_factory=list) + amount: int = 1 + context: dict[str, Any] = Field(default_factory=dict) + + +class GameState(StrictModel): + """Store a complete authoritative game snapshot.""" + + room_code: str + seed: int + revision: int = 0 + players: list[PlayerState] + supply: dict[str, int] + decks: dict[str, list[Slug]] + markets: dict[str, list[Slug]] + available_patrons: list[Slug] + available_objectives: list[Slug] + fortifications: dict[Slug, dict[int, int]] = Field(default_factory=dict) + current_seat: int = 0 + first_seat: int = 0 + round_number: int = 1 + pending: PendingChoice | None = None + finish_at_seat: int | None = None + objective_qualifiers: list[int] = Field(default_factory=list) + winners: list[int] = Field(default_factory=list) + finished: bool = False + turn_purchase_count: int = 0 + turn_chain_free_count: int = 0 + log: list[str] = Field(default_factory=list) + + +class GameCommand(StrictModel): + """Represent one revision-bound action submitted to the engine.""" + + command_id: Annotated[str, Field(min_length=8, max_length=64)] + expected_revision: Annotated[int, Field(ge=0)] + type: Literal[ + "take_distinct", + "take_double", + "reserve", + "purchase", + "choose", + "decline", + ] + payload: dict[str, Any] = Field(default_factory=dict) diff --git a/python/gems/domain/requirements.py b/python/gems/domain/requirements.py new file mode 100644 index 0000000..533e860 --- /dev/null +++ b/python/gems/domain/requirements.py @@ -0,0 +1,58 @@ +"""Requirement evaluation shared by patrons, objectives, outposts, and AI.""" + +from __future__ import annotations + +from .models import Requirement, RequirementKind + + +def requirements_met(requirements: list[Requirement], bonuses: dict[str, int]) -> bool: + """Return whether one assignment satisfies all fixed and wildcard clauses.""" + chosen: dict[str, str] = {} + + def visit(index: int) -> bool: + if index == len(requirements): + return True + requirement = requirements[index] + if requirement.kind == RequirementKind.COLOR: + if bonuses.get(requirement.resource or "", 0) < requirement.count: + return False + chosen[requirement.id] = requirement.resource or "" + return visit(index + 1) + + forbidden = set(requirement.exclude) + forbidden.update(chosen[item] for item in requirement.distinct_from if item in chosen) + for resource, amount in bonuses.items(): + if resource in forbidden or amount < requirement.count: + continue + chosen[requirement.id] = resource + if visit(index + 1): + return True + chosen.pop(requirement.id, None) + return False + + fixed = [item for item in requirements if item.kind == RequirementKind.COLOR] + flexible = [item for item in requirements if item.kind == RequirementKind.ANY_COLOR] + return visit_ordered([*fixed, *flexible], bonuses, chosen) + + +def visit_ordered(requirements: list[Requirement], bonuses: dict[str, int], chosen: dict[str, str]) -> bool: + """Backtracking evaluator kept separate for straightforward unit testing.""" + if not requirements: + return True + requirement, *rest = requirements + if requirement.kind == RequirementKind.COLOR: + resource = requirement.resource or "" + if bonuses.get(resource, 0) < requirement.count: + return False + chosen[requirement.id] = resource + return visit_ordered(rest, bonuses, chosen) + forbidden = set(requirement.exclude) + forbidden.update(chosen[item] for item in requirement.distinct_from if item in chosen) + for resource, amount in bonuses.items(): + if resource in forbidden or amount < requirement.count: + continue + chosen[requirement.id] = resource + if visit_ordered(rest, bonuses, chosen): + return True + chosen.pop(requirement.id, None) + return False diff --git a/python/gems/events.py b/python/gems/events.py new file mode 100644 index 0000000..a817432 --- /dev/null +++ b/python/gems/events.py @@ -0,0 +1,35 @@ +"""In-process revision notifications for SSE clients.""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections import defaultdict + + +class EventBroker: + """Fan out room revisions; clients always reload the latest snapshot.""" + + def __init__(self) -> None: + """Initialize an empty subscription registry.""" + self._queues: dict[str, set[asyncio.Queue[int]]] = defaultdict(set) + + def subscribe(self, room_code: str) -> asyncio.Queue[int]: + """Subscribe a bounded notification queue to a room.""" + queue: asyncio.Queue[int] = asyncio.Queue(maxsize=1) + self._queues[room_code].add(queue) + return queue + + def unsubscribe(self, room_code: str, queue: asyncio.Queue[int]) -> None: + """Remove a room notification queue from the registry.""" + self._queues[room_code].discard(queue) + if not self._queues[room_code]: + self._queues.pop(room_code, None) + + def publish(self, room_code: str, revision: int) -> None: + """Publish the newest room revision to every subscriber.""" + for queue in tuple(self._queues.get(room_code, ())): + if queue.full(): + with contextlib.suppress(asyncio.QueueEmpty): + queue.get_nowait() + queue.put_nowait(revision) diff --git a/python/gems/main.py b/python/gems/main.py new file mode 100644 index 0000000..8908166 --- /dev/null +++ b/python/gems/main.py @@ -0,0 +1,91 @@ +"""FastAPI entry point for Gems.""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager, suppress +from typing import TYPE_CHECKING, Annotated + +import typer +import uvicorn +from fastapi import FastAPI, Request, Response +from fastapi.staticfiles import StaticFiles + +from .config import load_config +from .events import EventBroker +from .persistence import Repository +from .rooms import RoomService +from .routes import router +from .security import CredentialService +from .web import STATIC_DIR + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from starlette.middleware.base import RequestResponseEndpoint + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Initialize and close application-scoped services.""" + config = app.state.config + repository = Repository(config.database_path) + app.state.repository = repository + app.state.rooms = RoomService(repository, CredentialService(config.key_path), EventBroker()) + repository.cleanup() + cleanup_task = asyncio.create_task(_cleanup_rooms(repository)) + try: + yield + finally: + cleanup_task.cancel() + with suppress(asyncio.CancelledError): + await cleanup_task + repository.close() + + +async def _cleanup_rooms(repository: Repository) -> None: + while True: + await asyncio.sleep(3600) + repository.cleanup() + + +def create_app() -> FastAPI: + """Create an isolated application instance.""" + app = FastAPI(title="Gems", docs_url=None, redoc_url=None, lifespan=lifespan) + app.state.config = load_config() + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + app.include_router(router) + + @app.middleware("http") + async def security_headers(request: Request, call_next: RequestResponseEndpoint) -> Response: + response = await call_next(request) + response.headers.setdefault("Referrer-Policy", "no-referrer") + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("X-Robots-Tag", "noindex, nofollow") + response.headers.setdefault( + "Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; " + "img-src 'self' data:; connect-src 'self'", + ) + if request.url.path.startswith("/rooms/"): + response.headers.setdefault("Cache-Control", "no-store") + return response + + return app + + +app = create_app() + + +def serve( + host: Annotated[str | None, typer.Option()] = None, + port: Annotated[int | None, typer.Option()] = None, +) -> None: + """Run the Gems ASGI application with Uvicorn.""" + config = load_config() + uvicorn.run("python.gems.main:app", host=host or config.host, port=port or config.port, workers=1) + + +if __name__ == "__main__": + typer.run(serve) diff --git a/python/gems/persistence.py b/python/gems/persistence.py new file mode 100644 index 0000000..b1a0f99 --- /dev/null +++ b/python/gems/persistence.py @@ -0,0 +1,255 @@ +"""SQLite persistence for rooms, memberships, snapshots, and events.""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from .domain.models import ContentPack, GameSettings, GameState + +if TYPE_CHECKING: + from pathlib import Path + + +@dataclass(slots=True) +class Member: + """Represent a human or AI seat persisted for a room.""" + + room_code: str + seat: int + name: str + controller: str + difficulty: str | None + credential_hash: str | None + is_host: bool + ready: bool + last_seen: str + + +@dataclass(slots=True) +class Room: + """Represent persisted room metadata and its current snapshot.""" + + code: str + status: str + settings: GameSettings + pack: ContentPack | None + pack_digest: str | None + state: GameState | None + revision: int + created_at: str + updated_at: str + + +class Repository: + """Small transactional repository designed for one application worker.""" + + def __init__(self, path: Path) -> None: + """Open the SQLite database and initialize its schema.""" + path.parent.mkdir(parents=True, exist_ok=True) + self._connection = sqlite3.connect(path, check_same_thread=False) + self._connection.row_factory = sqlite3.Row + self._lock = threading.RLock() + with self._connection: + self._connection.execute("PRAGMA journal_mode=WAL") + self._connection.execute("PRAGMA foreign_keys=ON") + self._connection.execute("PRAGMA busy_timeout=5000") + self._connection.executescript( + """ + CREATE TABLE IF NOT EXISTS schema_version(version INTEGER NOT NULL); + INSERT INTO schema_version(version) SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version); + CREATE TABLE IF NOT EXISTS rooms( + code TEXT PRIMARY KEY, status TEXT NOT NULL, settings_json TEXT NOT NULL, + pack_json TEXT, pack_digest TEXT, state_json TEXT, revision INTEGER NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS members( + room_code TEXT NOT NULL REFERENCES rooms(code) ON DELETE CASCADE, + seat INTEGER NOT NULL, name TEXT NOT NULL, controller TEXT NOT NULL, + difficulty TEXT, credential_hash TEXT, is_host INTEGER NOT NULL, + ready INTEGER NOT NULL, last_seen TEXT NOT NULL, + PRIMARY KEY(room_code, seat), UNIQUE(room_code, credential_hash) + ); + CREATE TABLE IF NOT EXISTS room_events( + room_code TEXT NOT NULL REFERENCES rooms(code) ON DELETE CASCADE, + revision INTEGER NOT NULL, command_id TEXT NOT NULL, actor_seat INTEGER, + event_type TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL, + PRIMARY KEY(room_code, command_id) + ); + """ + ) + + def room_exists(self, code: str) -> bool: + """Report whether a room code exists.""" + return self._connection.execute("SELECT 1 FROM rooms WHERE code=?", (code,)).fetchone() is not None + + def create_room(self, room: Room, host: Member) -> None: + """Persist a new room and its host in one transaction.""" + with self._lock, self._connection: + self._connection.execute( + "INSERT INTO rooms VALUES(?,?,?,?,?,?,?,?,?)", + ( + room.code, + room.status, + room.settings.model_dump_json(), + None, + None, + None, + room.revision, + room.created_at, + room.updated_at, + ), + ) + self._insert_member(host) + + def _insert_member(self, member: Member) -> None: + self._connection.execute( + "INSERT INTO members VALUES(?,?,?,?,?,?,?,?,?)", + ( + member.room_code, + member.seat, + member.name, + member.controller, + member.difficulty, + member.credential_hash, + int(member.is_host), + int(member.ready), + member.last_seen, + ), + ) + + def add_member(self, member: Member) -> None: + """Add a member and refresh the room's activity timestamp.""" + with self._lock, self._connection: + self._insert_member(member) + self.touch(member.room_code) + + def update_member(self, member: Member) -> None: + """Persist all mutable fields for an existing member.""" + with self._lock, self._connection: + self._connection.execute( + """UPDATE members SET name=?,controller=?,difficulty=?,credential_hash=?,is_host=?,ready=?,last_seen=? + WHERE room_code=? AND seat=?""", + ( + member.name, + member.controller, + member.difficulty, + member.credential_hash, + int(member.is_host), + int(member.ready), + member.last_seen, + member.room_code, + member.seat, + ), + ) + + def remove_member(self, code: str, seat: int) -> None: + """Remove a member from a room seat.""" + with self._lock, self._connection: + self._connection.execute("DELETE FROM members WHERE room_code=? AND seat=?", (code, seat)) + + def get_room(self, code: str) -> Room | None: + """Load a room by code, including its typed JSON fields.""" + row = self._connection.execute("SELECT * FROM rooms WHERE code=?", (code.upper(),)).fetchone() + if row is None: + return None + return Room( + code=row["code"], + status=row["status"], + settings=GameSettings.model_validate_json(row["settings_json"]), + pack=ContentPack.model_validate_json(row["pack_json"]) if row["pack_json"] else None, + pack_digest=row["pack_digest"], + state=GameState.model_validate_json(row["state_json"]) if row["state_json"] else None, + revision=row["revision"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + def members(self, code: str) -> list[Member]: + """Load all room members ordered by seat.""" + rows = self._connection.execute("SELECT * FROM members WHERE room_code=? ORDER BY seat", (code,)).fetchall() + return [ + Member( + room_code=row["room_code"], + seat=row["seat"], + name=row["name"], + controller=row["controller"], + difficulty=row["difficulty"], + credential_hash=row["credential_hash"], + is_host=bool(row["is_host"]), + ready=bool(row["ready"]), + last_seen=row["last_seen"], + ) + for row in rows + ] + + def member_for_credential(self, code: str, credential_hash: str) -> Member | None: + """Find the room member associated with a credential digest.""" + return next((member for member in self.members(code) if member.credential_hash == credential_hash), None) + + def save_lobby(self, room: Room) -> None: + """Persist mutable lobby configuration and revision data.""" + with self._lock, self._connection: + self._connection.execute( + """UPDATE rooms SET settings_json=?,pack_json=?,pack_digest=?,revision=?,updated_at=? WHERE code=?""", + ( + room.settings.model_dump_json(), + room.pack.model_dump_json() if room.pack else None, + room.pack_digest, + room.revision, + room.updated_at, + room.code, + ), + ) + + def save_state(self, room: Room, command_id: str, actor_seat: int | None, payload: dict) -> bool: + """Persist a game snapshot and its idempotent command event.""" + now = utc_now() + with self._lock, self._connection: + existing = self._connection.execute( + "SELECT 1 FROM room_events WHERE room_code=? AND command_id=?", (room.code, command_id) + ).fetchone() + if existing: + return False + self._connection.execute( + "UPDATE rooms SET status=?,state_json=?,revision=?,updated_at=? WHERE code=?", + (room.status, room.state.model_dump_json() if room.state else None, room.revision, now, room.code), + ) + self._connection.execute( + "INSERT INTO room_events VALUES(?,?,?,?,?,?,?)", + (room.code, room.revision, command_id, actor_seat, "command", json.dumps(payload), now), + ) + return True + + def has_command(self, code: str, command_id: str) -> bool: + """Report whether a command was already persisted for a room.""" + row = self._connection.execute( + "SELECT 1 FROM room_events WHERE room_code=? AND command_id=?", + (code, command_id), + ).fetchone() + return row is not None + + def touch(self, code: str) -> None: + """Refresh a room's activity timestamp.""" + with self._connection: + self._connection.execute("UPDATE rooms SET updated_at=? WHERE code=?", (utc_now(), code)) + + def cleanup(self, days: int = 30) -> int: + """Delete rooms inactive for the requested number of days.""" + cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat() + with self._lock, self._connection: + cursor = self._connection.execute("DELETE FROM rooms WHERE updated_at < ?", (cutoff,)) + return cursor.rowcount + + def close(self) -> None: + """Close the underlying SQLite connection.""" + self._connection.close() + + +def utc_now() -> str: + """Return the current UTC time as an ISO 8601 string.""" + return datetime.now(UTC).isoformat() diff --git a/python/gems/rooms.py b/python/gems/rooms.py new file mode 100644 index 0000000..eee1d7d --- /dev/null +++ b/python/gems/rooms.py @@ -0,0 +1,375 @@ +"""Application service for anonymous rooms and AI turns.""" + +from __future__ import annotations + +import asyncio +import secrets +from collections import defaultdict +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from .ai import choose_ai_command +from .domain.engine import RuleError, apply_command, new_game +from .domain.models import GameCommand, GameSettings +from .persistence import Member, Repository, Room, utc_now + +if TYPE_CHECKING: + from .content import ParsedPack + from .events import EventBroker + from .security import CredentialService + +ROOM_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" +MAX_PLAYERS = 4 +OFFLINE_AFTER_SECONDS = 30 +DISPLAY_NAME_MAX_LENGTH = 32 +CONTROL_CHARACTER_LIMIT = 32 + + +class RoomError(ValueError): + """Safe user-facing room operation error.""" + + +class RoomService: + """Coordinates persistence, authorization, per-room locking, and bots.""" + + def __init__(self, repository: Repository, credentials: CredentialService, broker: EventBroker) -> None: + """Initialize the service with persistence, security, and events.""" + self.repository = repository + self.credentials = credentials + self.broker = broker + self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + self._ai_tasks: dict[str, asyncio.Task[None]] = {} + + def create(self, host_name: str, credential: str | None = None) -> tuple[Room, str]: + """Create a lobby and assign its first human member as host.""" + name = validate_name(host_name) + code = self._new_code() + credential = credential or self.credentials.issue() + now = utc_now() + room = Room( + code=code, + status="lobby", + settings=GameSettings(), + pack=None, + pack_digest=None, + state=None, + revision=0, + created_at=now, + updated_at=now, + ) + host = Member( + room_code=code, + seat=0, + name=name, + controller="human", + difficulty=None, + credential_hash=self.credentials.digest(credential), + is_host=True, + ready=False, + last_seen=now, + ) + self.repository.create_room(room, host) + return room, credential + + def join(self, code: str, name: str, credential: str | None = None) -> tuple[Room, str]: + """Join an available lobby or restore an existing membership.""" + room = self.require_room(code) + credential = credential or self.credentials.issue() + existing = self.repository.member_for_credential(room.code, self.credentials.digest(credential)) + if existing is not None: + return room, credential + if room.status != "lobby": + error = "This room is no longer accepting players" + raise RoomError(error) + members = self.repository.members(room.code) + if len(members) >= MAX_PLAYERS: + error = "This room is full" + raise RoomError(error) + clean_name = validate_name(name) + if any(member.name.casefold() == clean_name.casefold() for member in members): + error = "That display name is already in use" + raise RoomError(error) + seat = next(item for item in range(MAX_PLAYERS) if item not in {member.seat for member in members}) + member = Member( + room_code=room.code, + seat=seat, + name=clean_name, + controller="human", + difficulty=None, + credential_hash=self.credentials.digest(credential), + is_host=False, + ready=False, + last_seen=utc_now(), + ) + self.repository.add_member(member) + self._bump_lobby(room) + return room, credential + + def authenticate(self, code: str, credential: str | None) -> tuple[Room, Member]: + """Authenticate a room member and refresh their presence timestamp.""" + room = self.require_room(code) + if not credential: + error = "Join the room to continue" + raise RoomError(error) + member = self.repository.member_for_credential(room.code, self.credentials.digest(credential)) + if member is None: + error = "Join the room to continue" + raise RoomError(error) + member.last_seen = utc_now() + self.repository.update_member(member) + return room, member + + def require_room(self, code: str) -> Room: + """Load a room or raise a safe user-facing error.""" + room = self.repository.get_room(code.upper()) + if room is None: + error = "Room not found" + raise RoomError(error) + return room + + def set_pack(self, room: Room, actor: Member, parsed: ParsedPack) -> Room: + """Assign a validated content pack to a lobby.""" + self._require_host_lobby(room, actor) + room.pack = parsed.pack + room.pack_digest = parsed.digest + self._bump_lobby(room) + return room + + def set_settings(self, room: Room, actor: Member, settings: GameSettings) -> Room: + """Replace the editable rules for a lobby.""" + self._require_host_lobby(room, actor) + room.settings = settings + self._bump_lobby(room) + return room + + def set_ready(self, room: Room, actor: Member, *, ready: bool) -> None: + """Update a human lobby member's readiness.""" + if room.status != "lobby" or actor.controller != "human": + error = "Readiness can only change in the lobby" + raise RoomError(error) + actor.ready = ready + actor.last_seen = utc_now() + self.repository.update_member(actor) + self._bump_lobby(room) + + def add_ai(self, room: Room, actor: Member, difficulty: str) -> None: + """Add an AI-controlled seat to a lobby.""" + self._require_host_lobby(room, actor) + if difficulty not in {"easy", "medium", "hard"}: + error = "Choose easy, medium, or hard AI" + raise RoomError(error) + members = self.repository.members(room.code) + if len(members) >= MAX_PLAYERS: + error = "This room is full" + raise RoomError(error) + seat = next(item for item in range(MAX_PLAYERS) if item not in {member.seat for member in members}) + number = 1 + sum(member.controller == "ai" for member in members) + self.repository.add_member( + Member( + room_code=room.code, + seat=seat, + name=f"Bot {number}", + controller="ai", + difficulty=difficulty, + credential_hash=None, + is_host=False, + ready=True, + last_seen=utc_now(), + ) + ) + self._bump_lobby(room) + + def remove_seat(self, room: Room, actor: Member, seat: int) -> None: + """Remove a non-host seat from a lobby.""" + self._require_host_lobby(room, actor) + target = next((member for member in self.repository.members(room.code) if member.seat == seat), None) + if target is None or target.is_host: + error = "That seat cannot be removed" + raise RoomError(error) + self.repository.remove_member(room.code, seat) + self._bump_lobby(room) + + def transfer_host(self, room: Room, actor: Member, seat: int) -> None: + """Transfer lobby ownership to another human player.""" + self._require_host_lobby(room, actor) + target = next((member for member in self.repository.members(room.code) if member.seat == seat), None) + if target is None or target.controller != "human" or target.seat == actor.seat: + error = "Choose another human player" + raise RoomError(error) + actor.is_host = False + target.is_host = True + self.repository.update_member(actor) + self.repository.update_member(target) + self._bump_lobby(room) + + async def start(self, room: Room, actor: Member) -> Room: + """Validate the lobby and start its first game.""" + self._require_host_lobby(room, actor) + if room.pack is None: + error = "Upload a valid content pack first" + raise RoomError(error) + members = self.repository.members(room.code) + humans = [member for member in members if member.controller == "human"] + if not humans or any(not member.ready for member in humans): + error = "Every human player must be ready" + raise RoomError(error) + ordered = sorted(members, key=lambda member: member.seat) + # Compress lobby seat gaps so engine seats always index the players list. + for new_seat, member in enumerate(ordered): + if member.seat != new_seat: + self.repository.remove_member(room.code, member.seat) + member.seat = new_seat + self.repository.add_member(member) + try: + state = new_game( + room.code, [member.name for member in ordered], room.pack, room.settings, seed=secrets.randbits(63) + ) + except RuleError as exc: + raise RoomError(str(exc)) from exc + room.state = state + room.status = "playing" + room.revision = state.revision + self.repository.save_state(room, f"start-{secrets.token_hex(8)}", actor.seat, {"type": "start"}) + self.broker.publish(room.code, room.revision) + self.schedule_ai(room.code) + return room + + async def play_again(self, room: Room, actor: Member) -> Room: + """Start a fresh game in a finished room while preserving its table setup.""" + if not actor.is_host or room.status != "finished": + error = "Only the host can replay a finished game" + raise RoomError(error) + async with self._locks[room.code]: + room = self.require_room(room.code) + if room.status != "finished" or room.pack is None: + error = "This game is not ready for a replay" + raise RoomError(error) + members = sorted(self.repository.members(room.code), key=lambda member: member.seat) + try: + room.state = new_game( + room.code, + [member.name for member in members], + room.pack, + room.settings, + seed=secrets.randbits(63), + ) + except RuleError as exc: + raise RoomError(str(exc)) from exc + room.status = "playing" + room.revision = room.state.revision + self.repository.save_state( + room, + f"replay-{secrets.token_hex(8)}", + actor.seat, + {"type": "play_again"}, + ) + self.broker.publish(room.code, room.revision) + self.schedule_ai(room.code) + return room + + async def submit(self, room: Room, actor: Member, command: GameCommand) -> Room: + """Apply and persist one idempotent human game command.""" + if room.status != "playing" or room.state is None or room.pack is None: + error = "This game is not active" + raise RoomError(error) + async with self._locks[room.code]: + room = self.require_room(room.code) + if self.repository.has_command(room.code, command.command_id): + return room + if room.state is None or room.pack is None: + error = "This game is not active" + raise RoomError(error) + try: + room.state = apply_command(room.state, command, room.pack, room.settings, actor_seat=actor.seat) + except RuleError as exc: + raise RoomError(str(exc)) from exc + room.revision = room.state.revision + room.status = "finished" if room.state.finished else "playing" + room.state.log = room.state.log[-200:] + self.repository.save_state(room, command.command_id, actor.seat, command.model_dump(mode="json")) + self.broker.publish(room.code, room.revision) + self.schedule_ai(room.code) + return room + + def replace_with_ai(self, room: Room, actor: Member, seat: int, difficulty: str) -> None: + """Replace a disconnected human player with an AI controller.""" + if not actor.is_host or room.status != "playing": + error = "Only the host can replace a player during a game" + raise RoomError(error) + target = next((item for item in self.repository.members(room.code) if item.seat == seat), None) + if target is None or target.controller != "human" or target.is_host: + error = "That player cannot be replaced" + raise RoomError(error) + last_seen = datetime.fromisoformat(target.last_seen) + if (datetime.now(UTC) - last_seen).total_seconds() < OFFLINE_AFTER_SECONDS: + error = "That player is still connected" + raise RoomError(error) + target.controller = "ai" + target.difficulty = difficulty + target.credential_hash = None + target.ready = True + self.repository.update_member(target) + self.broker.publish(room.code, room.revision) + self.schedule_ai(room.code) + + def schedule_ai(self, code: str) -> None: + """Schedule an AI runner when a room does not already have one.""" + task = self._ai_tasks.get(code) + if task is None or task.done(): + self._ai_tasks[code] = asyncio.create_task(self._run_ai(code)) + + async def _run_ai(self, code: str) -> None: + while True: + room = self.require_room(code) + if room.status != "playing" or room.state is None or room.pack is None: + return + member = next( + (item for item in self.repository.members(code) if item.seat == room.state.current_seat), None + ) + if member is None or member.controller != "ai": + return + await asyncio.sleep(0.35) + async with self._locks[code]: + room = self.require_room(code) + if room.state is None or room.pack is None or room.state.current_seat != member.seat: + continue + chosen = choose_ai_command( + room.state, room.pack, room.settings, member.seat, member.difficulty or "medium" + ) + try: + room.state = apply_command(room.state, chosen, room.pack, room.settings, actor_seat=member.seat) + except RuleError: + return + room.revision = room.state.revision + room.status = "finished" if room.state.finished else "playing" + room.state.log = room.state.log[-200:] + self.repository.save_state(room, chosen.command_id, member.seat, chosen.model_dump(mode="json")) + self.broker.publish(code, room.revision) + + def _new_code(self) -> str: + while True: + code = "".join(secrets.choice(ROOM_ALPHABET) for _ in range(8)) + if not self.repository.room_exists(code): + return code + + def _require_host_lobby(self, room: Room, actor: Member) -> None: + if not actor.is_host or room.status != "lobby": + error = "Only the host can change this lobby" + raise RoomError(error) + + def _bump_lobby(self, room: Room) -> None: + room.revision += 1 + room.updated_at = utc_now() + self.repository.save_lobby(room) + self.broker.publish(room.code, room.revision) + + +def validate_name(name: str) -> str: + """Normalize and validate a player display name.""" + clean = " ".join(name.split()) + if not 1 <= len(clean) <= DISPLAY_NAME_MAX_LENGTH or any( + ord(character) < CONTROL_CHARACTER_LIMIT for character in clean + ): + error = "Display names must be 1-32 plain-text characters" + raise RoomError(error) + return clean diff --git a/python/gems/routes/__init__.py b/python/gems/routes/__init__.py new file mode 100644 index 0000000..fe429b8 --- /dev/null +++ b/python/gems/routes/__init__.py @@ -0,0 +1,5 @@ +"""HTTP routes for Gems.""" + +from .app import router + +__all__ = ["router"] diff --git a/python/gems/routes/app.py b/python/gems/routes/app.py new file mode 100644 index 0000000..8bcfe93 --- /dev/null +++ b/python/gems/routes/app.py @@ -0,0 +1,626 @@ +"""FastAPI pages, HTMX mutations, and SSE stream.""" + +from __future__ import annotations + +import asyncio +import html +from collections.abc import AsyncIterator, Callable, Mapping +from datetime import UTC, datetime +from typing import Annotated, Literal + +from fastapi import APIRouter, Form, Request, Response, UploadFile +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse +from pydantic import ValidationError + +from python.gems.content import ContentPackError, content_pack_schema, parse_content_pack +from python.gems.domain.engine import bonuses, public_state, score +from python.gems.domain.legal_actions import command as build_command +from python.gems.domain.legal_actions import legal_commands +from python.gems.domain.models import GameCommand, GameSettings, Interactions, Modules +from python.gems.persistence import Member, Room +from python.gems.rooms import RoomError, RoomService +from python.gems.web import templates + +router = APIRouter() +COOKIE_NAME = "gems_credential" +DISPLAY_NAME_COOKIE = "gems_display_name" +DISPLAY_NAME_MAX_AGE = 365 * 24 * 60 * 60 +DISPLAY_NAME_MAX_LENGTH = 32 +OFFLINE_AFTER_SECONDS = 30 +RoomOperation = Callable[[Room, Member], None] + + +def service(request: Request) -> RoomService: + """Return the application-scoped room service.""" + return request.app.state.rooms + + +def _request_origin(request: Request) -> str: + return f"{request.url.scheme}://{request.url.netloc}".rstrip("/") + + +def _public_origin(request: Request) -> str: + """Prefer the current address when the untouched local default is configured.""" + configured = request.app.state.config.public_origin.rstrip("/") + if configured == "http://127.0.0.1:8082": + return _request_origin(request) + return configured + + +def _context(request: Request, room: Room, member: Member, *, error: str | None = None) -> dict[str, object]: + members = service(request).repository.members(room.code) + humans = [item for item in members if item.controller == "human"] + all_humans_ready = bool(humans) and all(item.ready for item in humans) + csrf = service(request).credentials.csrf(request.cookies.get(COOKIE_NAME, ""), room.code) + context: dict[str, object] = { + "request": request, + "room": room, + "member": member, + "members": members, + "csrf": csrf, + "error": error, + "public_origin": _public_origin(request), + "all_humans_ready": all_humans_ready, + "can_start": room.pack is not None and all_humans_ready, + "offline_seats": { + item.seat + for item in members + if item.controller == "human" + and (datetime.now(UTC) - datetime.fromisoformat(item.last_seen)).total_seconds() >= OFFLINE_AFTER_SECONDS + }, + } + if room.state and room.pack: + context["state"] = public_state(room.state, member.seat) + context["pack"] = room.pack + context["cards"] = {card.id: card for card in room.pack.cards} + context["resources"] = {resource.id: resource for resource in [*room.pack.resources, room.pack.wild_resource]} + context["normal_resource_ids"] = room.pack.resource_ids + context["all_resource_ids"] = (*room.pack.resource_ids, room.pack.wild_resource.id) + context["member_by_seat"] = {item.seat: item for item in members} + context["bonus_counts"] = {player.seat: bonuses(player, room.pack) for player in room.state.players} + context["patrons"] = {item.id: item for item in room.pack.patrons} + context["objectives"] = {item.id: item for item in room.pack.objectives} + context["outposts"] = {item.id: item for item in room.pack.outposts} + commands = ( + legal_commands(room.state, room.pack, room.settings, member.seat) if member.controller == "human" else [] + ) + context["actions"] = [(action_label(item, room), item.model_dump_json()) for item in commands] + context["double_resource_ids"] = { + str(item.payload["resource"]) + for item in commands + if item.type == "take_double" and item.payload.get("resource") + } + card_actions: dict[str, dict[str, str]] = {} + deck_reserve_actions: dict[str, str] = {} + for item in commands: + card_id = item.payload.get("card_id") + if item.type in {"purchase", "reserve"} and card_id: + card_actions.setdefault(str(card_id), {})[item.type] = item.model_dump_json() + elif item.type == "reserve" and item.payload.get("deck"): + deck_reserve_actions[str(item.payload["deck"])] = item.model_dump_json() + context["card_actions"] = card_actions + context["deck_reserve_actions"] = deck_reserve_actions + context["scores"] = {player.seat: score(player, room.pack) for player in room.state.players} + return context + + +def action_label(command: GameCommand, room: Room) -> str: + """Build a human-readable label for a legal game command.""" + payload = command.payload + if command.type == "take_distinct": + label = "Take " + ", ".join(payload["resources"]) + elif command.type == "take_double": + label = f"Take two {payload['resource']}" + elif command.type == "reserve": + if payload.get("card_id") and room.pack: + label = f"Reserve {room.pack.card(payload['card_id']).label}" + else: + label = f"Reserve from {payload.get('deck', 'deck')}" + elif command.type == "purchase" and room.pack: + label = f"Purchase {room.pack.card(payload['card_id']).label}" + elif command.type == "choose" and isinstance(payload.get("tokens"), dict) and room.pack: + resources = {item.id: item for item in [*room.pack.resources, room.pack.wild_resource]} + discarded = payload["tokens"] + label = "Discard " + ", ".join( + f"{amount} {resources[str(resource)].label}" for resource, amount in discarded.items() + ) + elif command.type == "decline": + label = "Decline" + else: + choice = payload.get("choice") or payload.get("card_id") or "selection" + label = f"Choose {choice}" + return label + + +def _selected_gem_command(room: Room, seat: int, selected: list[str]) -> GameCommand: + if room.state is None or room.pack is None: + error = "The game has not started" + raise RoomError(error) + if not selected: + error = "Select gem piles before taking gems" + raise RoomError(error) + if len(selected) != len(set(selected)) or not set(selected) <= set(room.pack.resource_ids): + error = "Choose each normal gem pile at most once" + raise RoomError(error) + commands = legal_commands(room.state, room.pack, room.settings, seat) + chosen = next( + ( + item + for item in commands + if item.type == "take_double" and len(selected) == 1 and item.payload.get("resource") == selected[0] + ), + None, + ) + if chosen is None: + chosen = next( + ( + item + for item in commands + if item.type == "take_distinct" + and set(item.payload.get("resources", [])) == set(selected) + and len(item.payload.get("resources", [])) == len(selected) + ), + None, + ) + if chosen is None: + error = "Choose three different available gems, or one pile with at least four gems to take a pair" + raise RoomError(error) + return chosen + + +def _double_gem_command(room: Room, seat: int, resource: str) -> GameCommand: + if room.state is None or room.pack is None: + error = "The game has not started" + raise RoomError(error) + chosen = next( + ( + item + for item in legal_commands(room.state, room.pack, room.settings, seat) + if item.type == "take_double" and item.payload.get("resource") == resource + ), + None, + ) + if chosen is None: + error = "That gem pair is unavailable; a pile needs at least four gems" + raise RoomError(error) + return chosen + + +def _discard_token_command(room: Room, seat: int, submitted: Mapping[str, object]) -> GameCommand: + if room.state is None or room.pack is None: + error = "The game has not started" + raise RoomError(error) + pending = room.state.pending + if pending is None or pending.kind != "discard_tokens" or pending.seat != seat: + error = "There are no excess tokens to discard" + raise RoomError(error) + discard: dict[str, int] = {} + for resource in (*room.pack.resource_ids, room.pack.wild_resource.id): + value = submitted.get(resource, "0") + try: + amount = int(value) if isinstance(value, str) else 0 + except ValueError as exc: + error = "Token discard amounts must be whole numbers" + raise RoomError(error) from exc + if amount < 0: + error = "Token discard amounts cannot be negative" + raise RoomError(error) + if amount: + discard[resource] = amount + return build_command(room.state, "choose", {"tokens": discard}) + + +def _authenticate(request: Request, code: str) -> tuple[Room, Member]: + return service(request).authenticate(code, request.cookies.get(COOKIE_NAME)) + + +def _saved_display_name(request: Request) -> str: + name = request.cookies.get(DISPLAY_NAME_COOKIE, "").strip() + return name if len(name) <= DISPLAY_NAME_MAX_LENGTH else "" + + +def _set_identity_cookies(response: Response, request: Request, credential: str, name: str) -> None: + cookie_options = {"httponly": True, "secure": request.app.state.config.secure_cookies, "samesite": "lax"} + response.set_cookie(COOKIE_NAME, credential, **cookie_options) + response.set_cookie(DISPLAY_NAME_COOKIE, name.strip(), max_age=DISPLAY_NAME_MAX_AGE, **cookie_options) + + +def _check_csrf(request: Request, room_code: str, csrf: str) -> None: + credential = request.cookies.get(COOKIE_NAME, "") + if not service(request).credentials.valid_csrf(credential, room_code, csrf): + error = "Your form expired; reload and try again" + raise RoomError(error) + origin = request.headers.get("origin") + allowed_origins = { + request.app.state.config.public_origin.rstrip("/"), + _request_origin(request), + } + if origin and origin.rstrip("/") not in allowed_origins: + error = "Request origin was rejected" + raise RoomError(error) + + +@router.get("/", response_class=HTMLResponse) +def home(request: Request) -> Response: + """Render the room creation and join page.""" + return templates.TemplateResponse(request, "home.html", {"display_name": _saved_display_name(request)}) + + +@router.post("/rooms") +def create_room(request: Request, name: Annotated[str, Form()]) -> Response: + """Create a room and redirect its host to the lobby.""" + try: + room, credential = service(request).create(name, request.cookies.get(COOKIE_NAME)) + except RoomError as exc: + return templates.TemplateResponse( + request, "home.html", {"error": str(exc), "display_name": name}, status_code=422 + ) + response = RedirectResponse(f"/rooms/{room.code}", status_code=303) + _set_identity_cookies(response, request, credential, name) + return response + + +@router.post("/join") +def join_code(code: Annotated[str, Form()]) -> Response: + """Normalize a room code and redirect to its join page.""" + return RedirectResponse(f"/join/{code.strip().upper()}", status_code=303) + + +@router.get("/join/{code}", response_class=HTMLResponse) +def join_page(request: Request, code: str) -> Response: + """Render the display-name form for an existing room.""" + try: + room = service(request).require_room(code) + except RoomError: + return templates.TemplateResponse( + request, + "join.html", + {"code": code, "error": "Room not found", "display_name": _saved_display_name(request)}, + status_code=404, + ) + return templates.TemplateResponse( + request, "join.html", {"code": room.code, "display_name": _saved_display_name(request)} + ) + + +@router.post("/join/{code}") +def join_room(request: Request, code: str, name: Annotated[str, Form()]) -> Response: + """Join a room and persist the member's browser identity.""" + try: + room, credential = service(request).join(code, name, request.cookies.get(COOKIE_NAME)) + except RoomError as exc: + return templates.TemplateResponse( + request, "join.html", {"code": code, "error": str(exc), "display_name": name}, status_code=422 + ) + response = RedirectResponse(f"/rooms/{room.code}", status_code=303) + _set_identity_cookies(response, request, credential, name) + return response + + +@router.get("/rooms/{code}", response_class=HTMLResponse) +def room_page(request: Request, code: str) -> Response: + """Render the authenticated lobby or game table.""" + try: + room, member = _authenticate(request, code) + except RoomError: + return RedirectResponse(f"/join/{code}", status_code=303) + return templates.TemplateResponse(request, "room.html", _context(request, room, member)) + + +def _partial(request: Request, code: str, operation: RoomOperation, *, error_status: int = 422) -> Response: + try: + room, member = _authenticate(request, code) + operation(room, member) + room = service(request).require_room(code) + return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member)) + except (RoomError, ValidationError, ContentPackError) as exc: + try: + room, member = _authenticate(request, code) + return templates.TemplateResponse( + request, + "partials/room_state.html", + _context(request, room, member, error=str(exc)), + status_code=error_status, + ) + except RoomError: + return HTMLResponse(html.escape(str(exc)), status_code=error_status) + + +def _csrf_partial(request: Request, code: str, csrf: str, operation: RoomOperation) -> Response: + def authorized(room: Room, member: Member) -> None: + _check_csrf(request, room.code, csrf) + operation(room, member) + + return _partial(request, code, authorized) + + +@router.post("/rooms/{code}/pack", response_class=HTMLResponse) +async def upload_pack(request: Request, code: str, csrf: Annotated[str, Form()], pack_file: UploadFile) -> Response: + """Validate and activate an uploaded JSON content pack.""" + raw = await pack_file.read(512 * 1024 + 1) + + def operation(room: Room, member: Member) -> None: + _check_csrf(request, room.code, csrf) + service(request).set_pack(room, member, parse_content_pack(raw)) + + return _partial(request, code, operation) + + +@router.post("/rooms/{code}/settings/preset", response_class=HTMLResponse) +def apply_preset( + request: Request, + code: str, + csrf: Annotated[str, Form()], + preset: Annotated[str, Form()], +) -> Response: + """Apply a built-in rules preset to a lobby.""" + presets: dict[str, tuple[Modules, Literal["score", "objective", "either", "both"]]] = { + "classic": (Modules(), "score"), + "objectives": (Modules(objectives=True), "objective"), + "objectives_outposts": (Modules(objectives=True, outposts=True), "objective"), + "eastern_fortifications": (Modules(eastern_decks=True, fortifications=True), "score"), + "all": (Modules(objectives=True, outposts=True, eastern_decks=True, fortifications=True), "objective"), + } + + def operation(room: Room, member: Member) -> None: + _check_csrf(request, room.code, csrf) + if preset not in presets: + error = "Unknown rules preset" + raise RoomError(error) + modules, victory = presets[preset] + settings = room.settings.model_copy(deep=True) + settings.modules = modules + settings.victory_condition = victory + settings.interactions = Interactions() + service(request).set_settings(room, member, settings) + + return _partial(request, code, operation) + + +@router.get("/rooms/{code}/pack") +def download_pack(request: Request, code: str) -> Response: + """Download the room's active content pack as JSON.""" + room, _ = _authenticate(request, code) + if room.pack is None: + return JSONResponse({"detail": "No pack uploaded"}, status_code=404) + return JSONResponse( + room.pack.model_dump(mode="json"), + headers={"Content-Disposition": f'attachment; filename="{room.pack.metadata.id}.json"'}, + ) + + +@router.post("/rooms/{code}/settings", response_class=HTMLResponse) +async def update_settings(request: Request, code: str) -> Response: + """Validate and save custom lobby rules.""" + form = await request.form() + + def checked(name: str) -> bool: + return name in form + + def value(name: str, default: str) -> str: + submitted = form.get(name) + return submitted if isinstance(submitted, str) else default + + def operation(room: Room, member: Member) -> None: + _check_csrf(request, room.code, value("csrf", "")) + settings = GameSettings.model_validate( + { + "target_score": value("target_score", "15"), + "victory_condition": value("victory_condition", "score"), + "first_player_mode": value("first_player_mode", "random"), + "first_player_seat": value("first_player_seat", "0"), + "token_limit": value("token_limit", "10"), + "reserve_limit": value("reserve_limit", "3"), + "base_market_size": value("base_market_size", "4"), + "eastern_market_size": value("eastern_market_size", "2"), + "objective_count": value("objective_count", "3"), + "fortifications_per_player": value("fortifications_per_player", "3"), + "modules": Modules( + objectives=checked("objectives"), + outposts=checked("outposts"), + eastern_decks=checked("eastern_decks"), + fortifications=checked("fortifications"), + ), + "interactions": Interactions(**{field: checked(field) for field in Interactions.model_fields}), + } + ) + service(request).set_settings(room, member, settings) + + return _partial(request, code, operation) + + +@router.post("/rooms/{code}/ready", response_class=HTMLResponse) +def ready( + request: Request, + code: str, + csrf: Annotated[str, Form()], + ready_value: Annotated[bool, Form(alias="ready")], +) -> Response: + """Update the authenticated human player's ready state.""" + return _csrf_partial( + request, code, csrf, lambda room, member: service(request).set_ready(room, member, ready=ready_value) + ) + + +@router.post("/rooms/{code}/seats/ai", response_class=HTMLResponse) +def add_ai(request: Request, code: str, csrf: Annotated[str, Form()], difficulty: Annotated[str, Form()]) -> Response: + """Add an AI-controlled player to a lobby.""" + return _csrf_partial(request, code, csrf, lambda room, member: service(request).add_ai(room, member, difficulty)) + + +@router.post("/rooms/{code}/seats/{seat}/remove", response_class=HTMLResponse) +def remove_seat(request: Request, code: str, seat: int, csrf: Annotated[str, Form()]) -> Response: + """Remove a non-host player from a lobby.""" + return _csrf_partial(request, code, csrf, lambda room, member: service(request).remove_seat(room, member, seat)) + + +@router.post("/rooms/{code}/seats/{seat}/make-host", response_class=HTMLResponse) +def transfer_host(request: Request, code: str, seat: int, csrf: Annotated[str, Form()]) -> Response: + """Transfer lobby ownership to another human player.""" + return _csrf_partial(request, code, csrf, lambda room, member: service(request).transfer_host(room, member, seat)) + + +@router.post("/rooms/{code}/seats/{seat}/replace-with-ai", response_class=HTMLResponse) +def replace_with_ai( + request: Request, + code: str, + seat: int, + csrf: Annotated[str, Form()], + difficulty: Annotated[str, Form()] = "medium", +) -> Response: + """Replace a disconnected player with an AI controller.""" + return _csrf_partial( + request, + code, + csrf, + lambda room, member: service(request).replace_with_ai(room, member, seat, difficulty), + ) + + +@router.post("/rooms/{code}/start", response_class=HTMLResponse) +async def start_game(request: Request, code: str, csrf: Annotated[str, Form()]) -> Response: + """Start a game after validating the lobby's readiness.""" + try: + room, member = _authenticate(request, code) + _check_csrf(request, room.code, csrf) + await service(request).start(room, member) + room = service(request).require_room(code) + return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member)) + except RoomError as exc: + room, member = _authenticate(request, code) + return templates.TemplateResponse( + request, "partials/room_state.html", _context(request, room, member, error=str(exc)), status_code=422 + ) + + +@router.post("/rooms/{code}/play-again", response_class=HTMLResponse) +async def play_again(request: Request, code: str, csrf: Annotated[str, Form()]) -> Response: + """Start a fresh game with the finished room's current setup.""" + try: + room, member = _authenticate(request, code) + _check_csrf(request, room.code, csrf) + room = await service(request).play_again(room, member) + return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member)) + except RoomError as exc: + room, member = _authenticate(request, code) + return templates.TemplateResponse( + request, "partials/room_state.html", _context(request, room, member, error=str(exc)), status_code=422 + ) + + +@router.post("/rooms/{code}/commands", response_class=HTMLResponse) +async def game_command( + request: Request, code: str, csrf: Annotated[str, Form()], command_json: Annotated[str, Form()] +) -> Response: + """Validate and submit one serialized game command.""" + try: + room, member = _authenticate(request, code) + _check_csrf(request, room.code, csrf) + command = GameCommand.model_validate_json(command_json) + room = await service(request).submit(room, member, command) + return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member)) + except (RoomError, ValidationError) as exc: + room, member = _authenticate(request, code) + return templates.TemplateResponse( + request, "partials/room_state.html", _context(request, room, member, error=str(exc)), status_code=422 + ) + + +@router.post("/rooms/{code}/take-gems", response_class=HTMLResponse) +async def take_gems(request: Request, code: str) -> Response: + """Translate the visual gem selection into a legal command.""" + form = await request.form() + try: + room, member = _authenticate(request, code) + _check_csrf(request, room.code, str(form.get("csrf", ""))) + pair_resource = form.get("pair_resource") + if pair_resource is not None: + chosen = _double_gem_command(room, member.seat, str(pair_resource)) + else: + selected = [str(item) for item in form.getlist("resources")] + chosen = _selected_gem_command(room, member.seat, selected) + room = await service(request).submit(room, member, chosen) + return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member)) + except RoomError as exc: + room, member = _authenticate(request, code) + return templates.TemplateResponse( + request, "partials/room_state.html", _context(request, room, member, error=str(exc)), status_code=422 + ) + + +@router.post("/rooms/{code}/discard-tokens", response_class=HTMLResponse) +async def discard_tokens(request: Request, code: str) -> Response: + """Return a player's selected excess tokens to the supply.""" + form = await request.form() + try: + room, member = _authenticate(request, code) + _check_csrf(request, room.code, str(form.get("csrf", ""))) + submitted = { + resource: form.get(f"token_{resource}", "0") + for resource in ((*room.pack.resource_ids, room.pack.wild_resource.id) if room.pack else ()) + } + chosen = _discard_token_command(room, member.seat, submitted) + room = await service(request).submit(room, member, chosen) + return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member)) + except RoomError as exc: + room, member = _authenticate(request, code) + return templates.TemplateResponse( + request, "partials/room_state.html", _context(request, room, member, error=str(exc)), status_code=422 + ) + + +@router.get("/rooms/{code}/events") +async def room_events(request: Request, code: str) -> Response: + """Stream room revisions to an authenticated browser over SSE.""" + try: + room, member = _authenticate(request, code) + except RoomError: + return HTMLResponse("Unauthorized", status_code=401) + queue = service(request).broker.subscribe(room.code) + stream_member = member + + async def stream() -> AsyncIterator[str]: + current_member = stream_member + try: + # Comments keep the SSE connection alive without replacing in-progress form input. + yield ": connected\n\n" + while True: + try: + await asyncio.wait_for(queue.get(), timeout=15) + latest = service(request).require_room(code) + yield _sse_fragment(request, latest, current_member) + except TimeoutError: + _, current_member = _authenticate(request, code) + yield ": keepalive\n\n" + if await request.is_disconnected(): + break + finally: + service(request).broker.unsubscribe(room.code, queue) + + return StreamingResponse( + stream(), media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"} + ) + + +def _sse_fragment(request: Request, room: Room, member: Member) -> str: + rendered = templates.get_template("partials/room_state.html").render(_context(request, room, member)) + return "event: room\n" + "\n".join(f"data: {line}" for line in rendered.splitlines()) + "\n\n" + + +@router.get("/schemas/content-pack-v1.json") +def schema() -> Response: + """Return the JSON Schema for content-pack version one.""" + return JSONResponse(content_pack_schema()) + + +@router.get("/healthz") +def health() -> dict[str, str]: + """Report that the HTTP process is running.""" + return {"status": "ok"} + + +@router.get("/readyz") +def readyz(request: Request) -> dict[str, str]: + """Report readiness after checking the database connection.""" + request.app.state.repository._connection.execute("SELECT 1").fetchone() # noqa: SLF001 + return {"status": "ready"} diff --git a/python/gems/security.py b/python/gems/security.py new file mode 100644 index 0000000..3471236 --- /dev/null +++ b/python/gems/security.py @@ -0,0 +1,40 @@ +"""Opaque browser credentials and CSRF protection.""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + +class CredentialService: + """Hash browser credentials with a stable per-installation key.""" + + def __init__(self, key_path: Path) -> None: + """Load or create the installation's credential-signing key.""" + key_path.parent.mkdir(parents=True, exist_ok=True) + if not key_path.exists(): + key_path.write_bytes(secrets.token_bytes(32)) + key_path.chmod(0o600) + self._key = key_path.read_bytes() + + @staticmethod + def issue() -> str: + """Issue a cryptographically random browser credential.""" + return secrets.token_urlsafe(32) + + def digest(self, credential: str) -> str: + """Create the persistent keyed digest of a browser credential.""" + return hmac.new(self._key, credential.encode(), hashlib.sha256).hexdigest() + + def csrf(self, credential: str, room_code: str) -> str: + """Create a room-scoped CSRF token for a browser credential.""" + return hmac.new(self._key, f"csrf:{credential}:{room_code}".encode(), hashlib.sha256).hexdigest() + + def valid_csrf(self, credential: str, room_code: str, candidate: str) -> bool: + """Validate a candidate CSRF token using constant-time comparison.""" + return hmac.compare_digest(self.csrf(credential, room_code), candidate) diff --git a/python/gems/static/gems.css b/python/gems/static/gems.css new file mode 100644 index 0000000..1f1669d --- /dev/null +++ b/python/gems/static/gems.css @@ -0,0 +1 @@ +:root{color-scheme:dark;--bg:#0d1114;--panel:#161c21;--panel2:#1d252b;--line:#2c373f;--text:#f2f0e9;--muted:#99a5ad;--gold:#e7b95a;--teal:#63c8b7;--danger:#ef7e72;--radius:18px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text)}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at 80% -10%,#183832 0,transparent 35%),var(--bg)}a{color:var(--teal)}button,input,select{font:inherit}button{border:1px solid var(--line);border-radius:10px;background:var(--panel2);color:var(--text);padding:.7rem 1rem;cursor:pointer}button:hover{border-color:var(--teal)}button.primary{background:var(--gold);border-color:var(--gold);color:#18130b;font-weight:800}.quiet{background:transparent}.icon-button{padding:.2rem .55rem;font-size:1.3rem}.topbar{height:68px;display:flex;align-items:center;padding:0 max(1.2rem,calc((100vw - 1400px)/2));border-bottom:1px solid #ffffff10}.brand{text-decoration:none;color:var(--text);font-weight:800;letter-spacing:.04em}.brand-mark{color:var(--gold)}main{max-width:1400px;margin:auto;padding:2rem 1.2rem 7rem}.hero{max-width:1000px;margin:5vh auto}.hero h1{font-size:clamp(3rem,8vw,6.8rem);letter-spacing:-.06em;line-height:.92;margin:.25em 0}.eyebrow{text-transform:uppercase;letter-spacing:.18em;color:var(--gold);font-size:.72rem;font-weight:800}.lede{font-size:1.2rem;max-width:630px;color:var(--muted);line-height:1.6}.home-grid,.lobby-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;margin-top:2rem}.panel{background:linear-gradient(145deg,var(--panel),#11171b);border:1px solid var(--line);border-radius:var(--radius);padding:1.35rem;box-shadow:0 20px 50px #0003}.panel h1,.panel h2,.panel h3{margin-top:0}.narrow{max-width:520px;margin:8vh auto}.fine-print,.muted,small{color:var(--muted);font-size:.85rem}.alert{background:#4a201d;border:1px solid var(--danger);border-radius:12px;padding:.9rem 1rem;white-space:pre-line}.floating{position:sticky;top:1rem;z-index:10}label{display:grid;gap:.4rem;color:var(--muted);font-size:.85rem;margin-bottom:1rem}input,select{width:100%;background:#0e1418;border:1px solid var(--line);border-radius:9px;color:var(--text);padding:.7rem}.room-header,.section-heading,.status-strip,.start-bar{display:flex;align-items:center;justify-content:space-between;gap:1rem}.room-header h1{font-size:2.4rem;letter-spacing:.16em;margin:.1rem 0 1rem}.room-header p{margin:0}.lobby-grid .rules-panel{grid-column:1/-1}.seat-list{list-style:none;margin:0 0 1rem;padding:0}.seat-list li{display:grid;grid-template-columns:2rem 1fr auto;align-items:center;gap:.7rem;padding:.7rem 0;border-bottom:1px solid var(--line)}.seat-list small{display:block}.seat-number{display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;width:1.8rem;height:1.8rem}.inline-form{display:flex;gap:.5rem}.settings-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.8rem}.toggle-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem}.toggle{display:flex;align-items:center;gap:.5rem;background:#0e1418;border:1px solid var(--line);padding:.7rem;border-radius:10px}.toggle input{width:auto;margin:0}.advanced{grid-template-columns:repeat(3,1fr);margin:1rem 0}.success{color:var(--teal)}.start-bar{position:sticky;bottom:1rem;background:#17211fdd;backdrop-filter:blur(16px);border:1px solid #35665e;border-radius:15px;padding:1rem;margin-top:1rem;z-index:5}.status-strip{border-block:1px solid var(--line);padding:1rem 0;margin-bottom:1rem}.game-layout{display:grid;grid-template-columns:190px 1fr;gap:1rem}.compact{padding:1rem}.resource-row{display:grid;grid-template-columns:1rem 1fr auto;gap:.5rem;padding:.55rem 0;border-bottom:1px solid var(--line)}.resource-dot{width:.7rem;height:.7rem;margin-top:.3rem;background:var(--gold);transform:rotate(45deg)}.market{display:grid;gap:1rem}.market-row{background:#11171baa;border:1px solid var(--line);border-radius:var(--radius);padding:1rem}.tier-label{display:flex;justify-content:space-between;margin-bottom:.8rem;color:var(--muted)}.card-row{display:grid;grid-template-columns:repeat(4,minmax(140px,1fr));gap:.7rem}.game-card{min-height:170px;padding:.9rem;border-radius:14px;background:linear-gradient(145deg,#273138,#171e23);border:1px solid #3d4a52}.game-card h3{margin:1.2rem 0 .3rem}.game-card p{color:var(--muted);font-size:.75rem}.card-top{display:flex;justify-content:space-between;font-weight:900;color:var(--gold)}.cost{display:flex;gap:.3rem;flex-wrap:wrap}.cost span,.token-line span{font-size:.7rem;background:#0d1215;border:1px solid var(--line);border-radius:999px;padding:.25rem .45rem}.players-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.8rem;margin:1rem 0}.player.active{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold),0 20px 50px #0003}.token-line{display:flex;flex-wrap:wrap;gap:.3rem}.action-dock{position:sticky;bottom:1rem;background:#151c21ee;backdrop-filter:blur(16px);border:1px solid var(--gold);border-radius:16px;padding:1rem;display:grid;grid-template-columns:170px 1fr;gap:1rem;z-index:4}.action-dock p{margin:0}.actions{display:flex;gap:.45rem;overflow-x:auto;padding-bottom:.2rem}.actions form{flex:none}.history{margin-top:1rem}.result{text-align:center;margin:1rem 0}.result h2{font-size:2.5rem}.htmx-request{opacity:.65;pointer-events:none}@media(max-width:900px){.home-grid,.lobby-grid,.game-layout{grid-template-columns:1fr}.settings-grid,.toggle-grid,.advanced{grid-template-columns:repeat(2,1fr)}.card-row{display:flex;overflow-x:auto}.game-card{min-width:170px}.players-grid{grid-template-columns:repeat(2,1fr)}.action-dock{grid-template-columns:1fr}}@media(max-width:540px){main{padding-inline:.75rem}.hero{margin-top:2vh}.home-grid,.players-grid,.settings-grid,.toggle-grid,.advanced{grid-template-columns:1fr}.room-header{align-items:flex-start}.start-bar{align-items:flex-start;flex-direction:column}.start-bar button{width:100%}} diff --git a/python/gems/static/overrides.css b/python/gems/static/overrides.css new file mode 100644 index 0000000..d96b711 --- /dev/null +++ b/python/gems/static/overrides.css @@ -0,0 +1,18 @@ +.preset-row{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem}.invite-link{margin:0;min-width:min(430px,55vw)}.danger{color:var(--danger)}.pack-status{border-left:3px solid var(--danger);padding:.65rem .8rem;background:#4a201d55;border-radius:0 9px 9px 0}button:disabled,button:disabled:hover{background:#252b2f;border-color:#3b4348;color:#7f898f;cursor:not-allowed;opacity:.7} +.room-topbar{height:auto;min-height:68px;gap:1.25rem}.room-identity{display:flex;align-items:baseline;gap:.65rem;margin-right:auto}.room-identity .eyebrow{margin:0}.room-identity strong{font-size:1.2rem;letter-spacing:.14em}.room-invite{display:flex;align-items:center;grid-template-columns:auto minmax(260px,430px);gap:.65rem}.room-invite input{padding:.5rem .65rem} +@media(max-width:700px){.room-topbar{flex-wrap:wrap;padding-block:.7rem}.room-invite{order:3;width:100%;grid-template-columns:auto 1fr}.invite-link{min-width:0}.room-identity strong{font-size:1rem}} + +.game-layout{grid-template-columns:330px minmax(0,1fr) 280px;grid-template-areas:"players market supply";align-items:start}.board-sidebar{display:contents}.players-stack{grid-area:players;display:grid;gap:.55rem}.market{grid-area:market}.right-supply{grid-area:supply;position:sticky;top:1rem}.sidebar-player{padding:.75rem}.sidebar-player.active{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold),0 12px 28px #0003}.sidebar-player-summary{list-style:none;cursor:pointer}.sidebar-player-summary::-webkit-details-marker{display:none}.sidebar-player-heading{display:flex;justify-content:space-between;align-items:center;gap:.5rem}.sidebar-player-heading>span{color:var(--gold);font-size:.82rem}.count-legend{display:flex;justify-content:space-between;color:var(--muted);font-size:.68rem;margin:.45rem 0 .25rem}.player-color-counts{display:grid;grid-template-columns:repeat(3,1fr);gap:.3rem}.player-color-count{--gem-color:#697780;display:flex;align-items:center;justify-content:space-between;gap:.25rem;padding:.3rem .4rem;border:1px solid color-mix(in srgb,var(--gem-color) 55%,var(--line));border-radius:8px;background:color-mix(in srgb,var(--gem-color) 10%,#0e1418);font-size:.75rem}.player-color-count b{color:color-mix(in srgb,var(--gem-color) 70%,white);text-shadow:0 1px 2px #000}.player-detail-hint{display:block;margin-top:.45rem}.sidebar-player-details{border-top:1px solid var(--line);margin-top:.65rem;padding-top:.65rem}.owned-card-grid{display:grid;gap:.4rem;max-height:18rem;overflow-y:auto}.owned-card-inspector{--gem-color:#697780;border:1px solid color-mix(in srgb,var(--gem-color) 55%,var(--line));border-radius:9px;overflow:hidden;background:color-mix(in srgb,var(--gem-color) 9%,#0e1418)}.owned-card-inspector>summary{display:grid;grid-template-columns:1.8rem 1fr auto;align-items:center;gap:.45rem;padding:.5rem;cursor:pointer}.owned-card-bonus{display:grid;place-items:center;width:1.8rem;height:1.8rem;border-radius:50%;background:var(--gem-color);color:var(--gem-ink,#fff);font-weight:900;text-shadow:0 1px 2px #000}.owned-card-detail{padding:.5rem;border-top:1px solid var(--line);font-size:.78rem}.owned-card-detail p{margin:.25rem 0}.owned-card-detail .resource-cost{display:inline-block;margin:.15rem} +.gem-picker{display:grid;gap:.55rem}.gem-picker .fine-print{margin:.1rem 0 .35rem}.gem-pile,.wild-pile{--gem-color:#697780;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:.55rem;margin:0;padding:.55rem;border:1px solid color-mix(in srgb,var(--gem-color) 65%,var(--line));border-radius:12px;background:#0e1418;color:var(--text)}.gem-pile input{width:1.1rem;height:1.1rem;margin:0;accent-color:var(--gem-color)}.gem-choice{display:grid;grid-template-columns:2.6rem 1fr;align-items:center;gap:.55rem;margin:0;cursor:pointer}.gem-pile:has(input:checked){border-color:var(--gem-color);box-shadow:0 0 0 1px var(--gem-color),0 0 14px color-mix(in srgb,var(--gem-color) 35%,transparent);background:color-mix(in srgb,var(--gem-color) 18%,#0e1418)}.gem-pile.empty{opacity:.45}.take-pair{padding:.45rem .55rem;white-space:nowrap}.gem-disc{display:grid;place-items:center;width:2.5rem;height:2.5rem;border-radius:50%;border:3px solid color-mix(in srgb,var(--gem-color) 55%,white);background:radial-gradient(circle at 35% 30%,color-mix(in srgb,var(--gem-color) 45%,white),var(--gem-color) 65%);color:var(--gem-ink,#fff);text-shadow:0 1px 2px #000,0 0 3px #000;font-size:1.15rem;font-weight:900;box-shadow:0 4px 8px #0008,0 0 8px color-mix(in srgb,var(--gem-color) 35%,transparent)}.card-bonus-token{width:2rem;height:2rem;border-width:2px;font-size:.9rem;line-height:1;flex:none}.gem-pile strong,.wild-pile strong{color:color-mix(in srgb,var(--gem-color) 68%,white)}.gem-pile small,.wild-pile small{display:block}.gem-picker.readonly .gem-pile{grid-template-columns:2.6rem 1fr;cursor:default}.wild-pile{grid-template-columns:2.6rem 1fr;margin-top:.7rem}.resource-cost{border-color:color-mix(in srgb,var(--gem-color) 65%,var(--line))!important;box-shadow:inset 3px 0 0 var(--gem-color)} +.gem-disc.light-gem{border-color:#090c0e;background:radial-gradient(circle at 32% 25%,#fff 0 18%,#f8f7f2 48%,#d9dde0 100%);color:#090c0e;font-weight:800;text-shadow:none;-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision;box-shadow:0 4px 9px #0009,0 0 0 1px #596168,inset 0 0 0 2px #fff9}.hand-gem.light-gem>b{border:2px solid #090c0e;background:radial-gradient(circle at 32% 25%,#fff 0 18%,#f8f7f2 48%,#d9dde0 100%);color:#090c0e;font-weight:800;text-shadow:none;-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision;box-shadow:0 2px 5px #0008,0 0 0 1px #596168,inset 0 0 0 1px #fff9} +.card-bonus-token.light-gem{border-color:#090c0e;background:radial-gradient(circle at 32% 25%,#fff 0 16%,#f7f6f1 42%,#d9dde0 100%);color:#090c0e;text-shadow:none;box-shadow:0 3px 7px #000b,0 0 0 1px #596168,inset 0 0 0 2px #fff9} +.tier-label{align-items:center}.compact-button{padding:.35rem .55rem}.game-card{display:flex;flex-direction:column;padding:0;overflow:hidden}.card-face{min-height:170px;padding:.9rem;flex:1}.game-card.actionable{border-color:#46615d}.card-actions{display:flex;gap:.45rem;padding:.75rem;border-top:1px solid var(--line);background:#0e1418}.card-actions form,.card-actions>button{flex:1}.card-actions button{width:100%;padding:.5rem}.board-help{grid-template-columns:170px 1fr}.board-help p{margin:0;color:var(--muted)} +.reserved-cards{margin-top:1rem}.reserved-cards h3{margin-bottom:.5rem}.reserved-card{border:1px solid var(--line);border-radius:10px;margin-top:.4rem;overflow:hidden}.reserved-card-face{display:flex;justify-content:space-between;gap:.5rem;padding:.6rem}.reserved-card.actionable{border-color:var(--teal)}.reserved-card-face span{color:var(--muted);font-size:.8rem} +@media(max-width:900px){.game-layout{grid-template-columns:1fr;grid-template-areas:"players" "market" "supply"}.right-supply{position:static}.gem-picker{grid-template-columns:repeat(2,minmax(0,1fr))}.gem-picker .fine-print,.gem-picker>.take-checked{grid-column:1/-1}} +@media(max-width:540px){.gem-picker{grid-template-columns:1fr}.gem-picker .fine-print,.gem-picker>.take-checked{grid-column:auto}.board-help{display:flex}} +.player-gem-dock{position:fixed;left:50%;bottom:.55rem;transform:translateX(-50%);z-index:15;display:flex;align-items:center;gap:.65rem;max-width:calc(100vw - 1rem);padding:.4rem .65rem;border:1px solid var(--gold);border-radius:14px;background:#151c21ee;backdrop-filter:blur(16px);box-shadow:0 12px 35px #0009}.player-gem-dock>strong{white-space:nowrap;color:var(--gold)}.hand-total{display:grid;place-items:center;min-width:3.8rem;padding:.25rem .45rem;border:1px solid var(--gold);border-radius:10px;background:#2a2417}.hand-total strong{color:var(--gold);font-size:.9rem}.hand-total small{font-size:.6rem;text-transform:uppercase;letter-spacing:.08em}.hand-legend{white-space:nowrap;color:var(--muted);font-size:.72rem}.player-gem-hand{display:flex;gap:.4rem}.hand-gem{--gem-color:#697780;display:grid;grid-template-columns:1.8rem auto;align-items:center;gap:.35rem;padding:.25rem .5rem .25rem .25rem;border:1px solid color-mix(in srgb,var(--gem-color) 65%,var(--line));border-radius:999px;background:color-mix(in srgb,var(--gem-color) 13%,#0e1418)}.hand-gem>b{display:grid;place-items:center;width:1.8rem;height:1.8rem;border-radius:50%;background:var(--gem-color);color:var(--gem-ink,#fff);text-shadow:0 1px 2px #000}.hand-gem>span{display:flex;align-items:center;gap:.2rem}.hand-gem>span strong{font-size:.95rem}.hand-gem i{color:var(--muted);font-style:normal}.action-dock{bottom:4.7rem} +.bottom-reserved{display:flex;align-items:center;gap:.35rem;padding-left:.6rem;border-left:1px solid var(--line)}.bottom-reserved-card{display:flex;align-items:center;gap:.35rem;padding:.25rem;border:1px solid var(--line);border-radius:9px;background:#0e1418}.bottom-reserved-card>span{max-width:8rem}.bottom-reserved-card strong,.bottom-reserved-card small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bottom-reserved-card strong{font-size:.72rem}.bottom-reserved-card small{color:var(--muted);font-size:.62rem}.bottom-reserved-card button{padding:.35rem .45rem;font-size:.7rem} +.action-dock>div:first-child small{display:block;margin-top:.25rem}.discard-picker{display:flex;align-items:center;gap:.65rem;min-width:0}.discard-colors{display:flex;gap:.4rem;overflow-x:auto;padding:.15rem;min-width:0}.discard-color{display:grid;grid-template-columns:2rem minmax(4.5rem,auto) 3.5rem;align-items:center;gap:.4rem;flex:none;margin:0;padding:.35rem;border:1px solid color-mix(in srgb,var(--gem-color) 65%,var(--line));border-radius:10px;background:color-mix(in srgb,var(--gem-color) 10%,#0e1418)}.discard-color .gem-disc{width:2rem;height:2rem;font-size:.9rem}.discard-color>span:nth-child(2){font-size:.75rem;font-weight:700;color:color-mix(in srgb,var(--gem-color) 70%,white)}.discard-color small{display:block;font-size:.6rem}.discard-color input{width:3.5rem;margin:0;padding:.4rem}.discard-picker>button{flex:none;white-space:nowrap} +@media(max-width:700px){.player-gem-dock{width:calc(100vw - 1rem);overflow-x:auto;justify-content:flex-start}.player-gem-hand{flex:none}.player-gem-dock>strong{font-size:.8rem}.hand-gem{grid-template-columns:1.5rem auto}.hand-gem b{width:1.5rem;height:1.5rem}} + +@media(min-width:901px){.board-main{max-width:1900px;padding:clamp(.55rem,1.2vh,1rem) 1rem 7rem;scroll-padding-bottom:7rem}.board-main .status-strip{padding:.5rem 0;margin-bottom:.55rem}.board-main .game-layout{grid-template-columns:300px minmax(0,1fr) 270px;gap:.55rem}.board-main .players-stack{gap:.35rem}.board-main .sidebar-player{padding:.5rem}.board-main .count-legend{margin:.25rem 0 .15rem}.board-main .player-color-counts{gap:.2rem}.board-main .player-color-count{padding:.2rem .3rem}.board-main .player-detail-hint{margin-top:.25rem}.board-main .market{gap:.45rem}.board-main .market-row{padding:.55rem}.board-main .tier-label{margin-bottom:.4rem}.board-main .card-row{gap:.45rem}.board-main .card-face{min-height:108px;padding:.6rem}.board-main .game-card h3{font-size:1rem;line-height:1.15;margin:.55rem 0 .25rem}.board-main .game-card p{margin:.25rem 0}.board-main .card-actions{padding:.4rem;gap:.3rem}.board-main .card-actions button{padding:.4rem .25rem}.board-main .right-supply{padding:.65rem;top:.5rem}.board-main .right-supply h2{font-size:1.1rem;margin-bottom:.35rem}.board-main .right-supply .section-heading{margin-bottom:.35rem}.board-main .right-supply .gem-picker{gap:.3rem}.board-main .right-supply .gem-pile,.board-main .right-supply .wild-pile{padding:.3rem;gap:.35rem}.board-main .right-supply .gem-choice{grid-template-columns:2rem 1fr;gap:.35rem}.board-main .right-supply .gem-disc{width:2rem;height:2rem;font-size:.9rem}.board-main .right-supply .take-pair{padding:.3rem}.board-main .right-supply .fine-print{font-size:.7rem}.history-inline{position:relative}.history-inline summary{cursor:pointer;color:var(--teal)}.history-inline>div{position:absolute;right:0;top:1.8rem;width:min(360px,70vw);max-height:50vh;overflow:auto;z-index:20;padding:.7rem;border:1px solid var(--line);border-radius:10px;background:var(--panel);box-shadow:0 16px 40px #0008}.history-inline p{margin:.3rem 0;font-size:.8rem}.patrons-section{padding-bottom:.5rem;margin-bottom:.55rem;border-bottom:1px solid var(--line)}.patron-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.3rem}.patron-card{display:grid;grid-template-columns:auto 1fr;align-items:start;gap:.3rem;padding:.35rem;border:1px solid var(--line);border-radius:8px;background:#0e1418;font-size:.72rem}.patron-card>strong{color:var(--gold)}.patron-card b,.patron-card small{display:block}.requirement-chip{display:inline-block;margin:.15rem .1rem 0;padding:.1rem .25rem;border:1px solid var(--line);border-radius:999px}.claimed-patrons{display:grid;gap:.25rem;margin-top:.5rem}.claimed-patrons span{font-size:.78rem;color:var(--gold)}} diff --git a/python/gems/static/vendor/HTMX-LICENSE b/python/gems/static/vendor/HTMX-LICENSE new file mode 100644 index 0000000..d3061a3 --- /dev/null +++ b/python/gems/static/vendor/HTMX-LICENSE @@ -0,0 +1,13 @@ +Zero-Clause BSD +============= + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/python/gems/static/vendor/SSE-LICENSE b/python/gems/static/vendor/SSE-LICENSE new file mode 100644 index 0000000..2e176b0 --- /dev/null +++ b/python/gems/static/vendor/SSE-LICENSE @@ -0,0 +1,14 @@ +BSD Zero Clause License + +Copyright (c) 2023, Alexander Petros + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/python/gems/static/vendor/htmx.min.js b/python/gems/static/vendor/htmx.min.js new file mode 100644 index 0000000..3b7ac1a --- /dev/null +++ b/python/gems/static/vendor/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.10"};Q.onLoad=j;Q.process=Ft;Q.on=ye;Q.off=xe;Q.trigger=ae;Q.ajax=Nn;Q.find=f;Q.findAll=y;Q.closest=g;Q.remove=z;Q.addClass=w;Q.removeClass=b;Q.toggleClass=G;Q.takeClass=W;Q.swap=_e;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=$;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:U,findThisElement:we,filterValues:yn,swap:_e,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:A,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Se,makeFragment:P,mergeObjects:le,makeSettleInfo:Sn,oobSwap:He,querySelectorExt:ce,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function A(e,t){while(e&&!t(e)){e=c(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;A(t,function(e){return!!(r=o(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function D(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){D(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function M(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function se(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){H(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function V(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function y(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return y(te(),e)}}function x(){return window}function z(e,t){e=S(e);if(t){x().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function w(e,t,n){e=ue(S(e));if(!e){return}if(n){x().setTimeout(function(){w(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function b(e,t,n){let r=ue(S(e));if(!r){return}if(n){x().setTimeout(function(){b(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function G(e,t){e=S(e);e.classList.toggle(t)}function W(e,t){e=S(e);ie(e.parentElement.children,function(e){b(e,t)});w(ue(e),t)}function g(e,t){e=ue(S(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Z(e,t){return e.substring(e.length-t.length)===t}function Y(e){const t=e.trim();if(l(t,"<")&&Z(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=S(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=Y(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),Y(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),Y(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,Y(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=ge(t,Y(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const u=p(q(t,!!n));i.push(...F(u.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ce(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function S(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function me(e,t,n,r){if(k(t)){return{target:te().body,event:K(e),listener:t,options:n}}else{return{target:S(e),event:K(t),listener:n,options:r}}}function ye(t,n,r,o){Gn(function(){const e=me(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function xe(t,n,r){Gn(function(){const e=me(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const be=te().createElement("output");function ve(t,n){const e=ne(t,n);if(e){if(e==="this"){return[we(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ue(A(t,function(e){return e!==t&&s(ue(e),n)}));if(i){r.push(...ve(i,n))}}if(r.length===0){H('The selector "'+e+'" on '+n+" returned no matches!");return[be]}else{return r}}}}function we(e,t){return ue(A(e,function(e){return a(ue(e),t)!=null}))}function Se(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return we(e,"hx-target")}else{return ce(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ee(e){return Q.config.attributesToSettle.includes(e)}function Ce(t,n){ie(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ee(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ee(e.name)){t.setAttribute(e.name,e.value)}})}function Oe(t,e){const n=Jn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!Oe(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){Re(t);je(s,e,e,t,i);Te()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function Te(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function Re(e){ie(y(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","
");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function qe(i,e,s){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const e=p(i);const r=e&&e.querySelector(CSS.escape(t.tagName)+"#"+CSS.escape(n));if(r&&r!==e){const o=t.cloneNode();Ce(t,r);s.tasks.push(function(){Ce(t,o)})}}})}function Ae(e){return function(){b(e,Q.config.addedClass);Ft(ue(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function u(e,t,n,r){qe(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;w(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function _e(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=S(h);const r=g.contextElement?q(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=P(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t0){x().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){x().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function ze(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,C)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,Ze);const l=o.length;const u=O(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};O(o,Ze);c.pollInterval=d(O(o,/[,\[\s]/));O(o,Ze);var i=nt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const f={trigger:u};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,Ze);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,C))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,C);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,C))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,C)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,C)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ut(e,t,n){const r=oe(e);r.timeout=x().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ut(e,t,n)}},n.pollInterval)}function ct(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ct(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,u,e,c,f){const a=oe(l);let t;if(c.from){t=m(l,c.from)}else{t=[l]}if(c.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(c)){a.lastValue.set(c,new WeakMap)}a.lastValue.get(c).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(c.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,i)){e.preventDefault()}if(pt(c,l,e)){return}const t=oe(e);t.triggerSpec=c;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(c.consume){e.stopPropagation()}if(c.target&&e.target){if(!h(ue(e.target),c.target)){return}}if(c.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(c.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(c);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(c.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");u(l,e);a.throttle=x().setTimeout(function(){a.throttle=null},c.throttle)}}else if(c.delay>0){a.delayed=x().setTimeout(function(){ae(l,"htmx:trigger");u(l,e)},c.delay)}else{ae(l,"htmx:trigger");u(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:c.trigger,listener:s,on:i});i.addEventListener(c.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){x().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ce(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ut(ue(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Rt(e){const t=At(e.target);const n=It(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=It(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ue(e),"button, input[type='submit']")}function Nt(e){return e.form||g(e,"form")}function It(e){const t=At(e.target);if(!t){return}const n=Nt(t);if(!n){return}return oe(n)}function Lt(e){e.addEventListener("click",Rt);e.addEventListener("focusin",Rt);e.addEventListener("focusout",qt)}function Dt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){De(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!U()){return null}t=V(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);_e(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});$t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:_t(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){_e(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});$t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=ve(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;w(e,Q.config.requestClass)});return t}function nn(e){let t=ve(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){b(e,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function cn(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,cn(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){un(e.name,cn(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Q.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Nt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const f=ee(c,"name");ln(f,c.value,o)}const u=ve(e,"hx-include");ie(u,function(e){fn(n,r,i,ue(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{H("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Dn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ce(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){x().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ce(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const u in n){if(n.hasOwnProperty(u)){if(i[u]==null){i[u]=n[u]}}}}return Cn(ue(c(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Tn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Rn(e,t){return le(Hn(e,t),Tn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function T(e,t){return t.test(e.getAllResponseHeaders())}function Nn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:S(r)||be,returnPromise:true})}else{let e=S(r.target);if(r.target&&!e||r.source&&!e&&!S(r.source)){e=be}return he(t,n,S(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return he(t,n,null,null,{returnPromise:true})}}function In(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Ln(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const M=i.handler||Vn;const F=i.select||null;if(!se(r)){re(s);return e}const u=i.targetOverride||ue(Se(r));if(u==null||u==be){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let c=oe(r);const f=c.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const N=ee(f,"formmethod");if(N!=null){if(de.includes(N.toLowerCase())){t=N}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let B=false;if(d){const I=d.split(":");const L=I[0].trim();if(L==="this"){h=we(r,"hx-sync")}else{h=ue(ce(r,L))}d=(I[1]||"drop").trim();c=oe(h);if(d==="drop"&&c.xhr&&c.abortable!==true){re(s);return e}else if(d==="abort"){if(c.xhr){re(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(c.xhr){if(c.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const D=oe(o);if(D&&D.triggerSpec&&D.triggerSpec.queue){p=D.triggerSpec.queue}}if(p==null){p="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(p==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;c.xhr=g;c.abortable=B;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const X=ne(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:u})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,u,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const V=U.formData;if(i.values){hn(V,Dn(i.values))}const j=Dn(Rn(r,o));const v=hn(V,j);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const $=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let H=n;if(E){H=z;const Z=!w.keys().next().done;if(Z){if(H.indexOf("?")<0){H+="?"}else{H+="&"}H+=gn(w);if(O){H+="#"+O}}}if(!Ln(r,H,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),H,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const P in x){if(x.hasOwnProperty(P)){const Y=x[P];qn(g,P,Y)}}}const T={xhr:g,target:u,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:H,responsePath:null,anchor:O}};g.onload=function(){try{const t=In(r);T.pathInfo.responsePath=An(g);M(r,T);if(T.keepIndicators!==true){rn(R,q)}ae(r,"htmx:afterRequest",T);ae(r,"htmx:afterOnLoad",T);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",T);ae(e,"htmx:afterOnLoad",T)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},T));throw e}finally{m()}};g.onerror=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendError",T);re(l);m()};g.onabort=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendAbort",T);re(l);m()};g.ontimeout=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:timeout",T);re(l);m()};if(!ae(r,"htmx:beforeRequest",T)){re(s);m();return e}var R=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",T);const J=E?null:wn(g,r,w);g.send(J);return e}function Mn(e,t){const n=t.xhr;let r=null;let o=null;if(T(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(T(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(T(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;let l=t.etc.push||ne(e,"hx-push-url");let u=t.etc.replace||ne(e,"hx-replace-url");if(l==="false")l=null;if(u==="false")u=null;const c=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(u){f="replace";a=u}else if(c){f="push";a=s||i}if(a){if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Fn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Bn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};x().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/python/gems/static/vendor/sse.min.js b/python/gems/static/vendor/sse.min.js new file mode 100644 index 0000000..2ba734c --- /dev/null +++ b/python/gems/static/vendor/sse.min.js @@ -0,0 +1 @@ +(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e + + + + + + + {% block title %}Gems{% endblock %} + + + + + + + {% block topbar %}
Gems
{% endblock %} +
{% block content %}{% endblock %}
+ + diff --git a/python/gems/templates/home.html b/python/gems/templates/home.html new file mode 100644 index 0000000..d2d6c3f --- /dev/null +++ b/python/gems/templates/home.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block content %} +
+

A private strategy table

+

Build an engine.
Race for prestige.

+

Bring your own content pack, invite up to three friends or bots, and shape the rules before the first turn.

+ {% if error %}{% endif %} +
+
+

Create a table

+ + +
+
+

Join a table

+ + +
+
+

No cards, artwork, or playable content are bundled with Gems.

+
+{% endblock %} diff --git a/python/gems/templates/join.html b/python/gems/templates/join.html new file mode 100644 index 0000000..ffb3cc2 --- /dev/null +++ b/python/gems/templates/join.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block content %} +
+

Invitation {{ code }}

+
+

Take a seat

+ {% if error %}{% endif %} +
+ + +
+
+
+{% endblock %} diff --git a/python/gems/templates/partials/room_state.html b/python/gems/templates/partials/room_state.html new file mode 100644 index 0000000..8b8fbd7 --- /dev/null +++ b/python/gems/templates/partials/room_state.html @@ -0,0 +1,211 @@ +{% if error %}{% endif %} + +{% if room.status == "lobby" %} +
+
+

Seats

{{ members|length }}/4
+
    + {% for seat in members %} +
  1. + {{ seat.seat + 1 }} + {{ seat.name }}{{ seat.difficulty|capitalize if seat.controller == 'ai' else ('Ready' if seat.ready else 'Not ready') }}{% if seat.is_host %} · Host{% endif %} + {% if member.is_host and not seat.is_host %} +
    + +
    +
    + +
    + {% endif %} +
  2. + {% endfor %} +
+ {% if member.is_host and members|length < 4 %} +
+ + + +
+ {% endif %} + {% if member.controller == 'human' %} +
+ + +
+ {% endif %} +
+ +
+

Content pack

{% if room.pack %}Loaded and validated{% else %}Not loaded{% endif %}
+ {% if room.pack %}

{{ room.pack.metadata.name }} v{{ room.pack.metadata.version }}

{% else %}

No content pack is loaded. The game cannot start until the host uploads a valid JSON pack.

{% endif %} + {% if member.is_host %} +
+ + +
+ {% endif %} +

JSON Schema{% if room.pack %} · Download active pack{% endif %}

+
+ +
+

Table rules

Frozen on start
+ {% if member.is_host %}
+ {% for key, label in [('classic','Classic'),('objectives','Objective race'),('objectives_outposts','Objective + outpost'),('eastern_fortifications','Eastern + fortification'),('all','All modules')] %} +
+ {% endfor %} +
{% endif %} +
+ +
+
+ + + + + + + + + + +
+

Modules

+
+ {% for key, label in [('objectives','Objectives'),('outposts','Outposts'),('eastern_decks','Eastern decks'),('fortifications','Fortifications')] %} + + {% endfor %} +
+
Advanced module interactions
+ {% for key, value in room.settings.interactions %}{% endfor %} +
+
+ {% if member.is_host %}{% endif %} +
+
+
+{% if member.is_host %} +
{{ 'Pack loaded' if room.pack else 'Pack required' }} · {{ 'Players ready' if all_humans_ready else 'Waiting for every human to ready up' }} · Rules set
+{% endif %} + +{% else %} +
+ Round {{ state.round_number }}{% if room.status == 'finished' %}Game complete · {% for seat in state.winners %}{{ state.players[seat].name }}{% if not loop.last %}, {% endif %}{% endfor %}{% else %}{{ state.players[state.current_seat].name }}’s turn{% endif %}Target {{ room.settings.target_score }}
History
{% for entry in state.log[-12:]|reverse %}

{{ entry }}

{% endfor %}
{% if room.status == 'finished' %}{% if member.is_host %}
{% else %}Waiting for the host{% endif %}{% endif %} +
+ +
+ +
+ {% for key, market in state.markets.items() %} +
+
{{ key|replace(':',' · Tier ') }}{{ state.decks[key] }} left{% if deck_reserve_actions.get(key) %}
{% else %}{% endif %}
+
+ {% for card_id in market %}{% set card = cards[card_id] %}{% set available = card_actions.get(card_id, {}) %} +
+
{{ card.points }}◆{% if card.bonus_resource %}{{ resources[card.bonus_resource].symbol }}{% else %}{% endif %}

{{ card.label }}

Tier {{ card.tier }} · {{ card.effect.kind|replace('_',' ') }}

{% for resource_id, value in card.cost.items() %}{{ resources[resource_id].symbol }} {{ value }}{% endfor %}
{% if state.fortifications.get(card_id) %}Fortified{% endif %}
+
+ {% if available.get('purchase') %}
{% else %}{% endif %} + {% if available.get('reserve') %}
{% else %}{% endif %} +
+
+ {% endfor %} +
+
+ {% endfor %} +
+
+ +{% set viewing_player = state.players[member.seat] %} +
+ {{ viewing_player.name }}{{ viewing_player.tokens.values()|sum }} / {{ room.settings.token_limit }}GemsGems / Cards +
+ {% for resource_id in normal_resource_ids %}{% set resource = resources[resource_id] %}{{ resource.symbol }}{{ viewing_player.tokens[resource_id] }}/{{ bonus_counts[viewing_player.seat].get(resource_id, 0) }}{% endfor %} + {% set wild = pack.wild_resource %}{{ wild.symbol }}{{ viewing_player.tokens[wild.id] }}/0 +
+ {% if viewing_player.reserved %}
Reserved{% for card_id in viewing_player.reserved %}{% set card = cards[card_id] %}{% set purchase = card_actions.get(card_id, {}).get('purchase') %}
{{ card.label }}{{ card.points }}◆ · {% for resource_id, value in card.cost.items() %}{{ resources[resource_id].symbol }}{{ value }} {% endfor %}{% if purchase %}
{% else %}{% endif %}
{% endfor %}
{% endif %} +
+ +{% if room.status != 'finished' and member.seat == state.current_seat and state.pending %} +

Your move

Resolve: {{ state.pending.kind|replace('_',' ') }}{% if state.pending.kind == 'discard_tokens' %}Return exactly {{ state.pending.amount }} excess gem{{ '' if state.pending.amount == 1 else 's' }}.{% endif %}
+ {% if state.pending.kind == 'discard_tokens' %} +
+ +
{% for resource_id in all_resource_ids %}{% set resource = resources[resource_id] %}{% endfor %}
+ +
+ {% else %} +
{% for label, command_json in actions %}
{% else %}No legal action available.{% endfor %}
+ {% endif %} +
+{% endif %} +{% endif %} diff --git a/python/gems/templates/room.html b/python/gems/templates/room.html new file mode 100644 index 0000000..9628b76 --- /dev/null +++ b/python/gems/templates/room.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Room {{ room.code }} · Gems{% endblock %} +{% block main_class %}board-main{% endblock %} +{% block topbar %} +
+ Gems +
Room{{ room.code }}
+ +
+{% endblock %} +{% block content %} +
+
+ {% include "partials/room_state.html" %} +
+
+{% endblock %} diff --git a/python/gems/web.py b/python/gems/web.py new file mode 100644 index 0000000..e30036e --- /dev/null +++ b/python/gems/web.py @@ -0,0 +1,11 @@ +"""Template and static resource configuration.""" + +from pathlib import Path + +from fastapi.templating import Jinja2Templates + +PACKAGE_DIR = Path(__file__).resolve().parent +TEMPLATE_DIR = PACKAGE_DIR / "templates" +STATIC_DIR = PACKAGE_DIR / "static" + +templates = Jinja2Templates(directory=TEMPLATE_DIR) diff --git a/systems/jeeves/services/gems.nix b/systems/jeeves/services/gems.nix new file mode 100644 index 0000000..1f52e60 --- /dev/null +++ b/systems/jeeves/services/gems.nix @@ -0,0 +1,52 @@ +{ + inputs, + pkgs, + ... +}: +let + vars = import ../vars.nix; + stateDir = "${vars.services}/gems"; +in +{ + users.groups.gems = { }; + users.users.gems = { + isSystemUser = true; + group = "gems"; + home = stateDir; + }; + + systemd.tmpfiles.rules = [ + "d ${stateDir} 0750 gems gems - -" + ]; + + systemd.services.gems = { + description = "Gems multiplayer game"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = { + PYTHONPATH = "${inputs.self}"; + GEMS_DATABASE_PATH = "${stateDir}/gems.sqlite3"; + GEMS_KEY_PATH = "${stateDir}/instance.key"; + GEMS_PUBLIC_ORIGIN = "https://gems.tmmworkshop.com"; + GEMS_SECURE_COOKIES = "true"; + }; + + serviceConfig = { + Type = "simple"; + User = "gems"; + Group = "gems"; + ExecStart = "${pkgs.my_python}/bin/python -m python.gems.main --host 127.0.0.1 --port 8002"; + Restart = "on-failure"; + RestartSec = "5s"; + StandardOutput = "journal"; + StandardError = "journal"; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + ReadOnlyPaths = [ "${inputs.self}" ]; + ReadWritePaths = [ stateDir ]; + }; + }; +} diff --git a/systems/jeeves/web_services/acme.nix b/systems/jeeves/web_services/acme.nix index 90cf112..eede8f2 100644 --- a/systems/jeeves/web_services/acme.nix +++ b/systems/jeeves/web_services/acme.nix @@ -3,6 +3,7 @@ let "audiobookshelf" "cache" "gitea" + "gems" "jellyfin" "share" ]; diff --git a/systems/jeeves/web_services/haproxy.cfg b/systems/jeeves/web_services/haproxy.cfg index 14d4be2..2eefc63 100644 --- a/systems/jeeves/web_services/haproxy.cfg +++ b/systems/jeeves/web_services/haproxy.cfg @@ -23,7 +23,7 @@ defaults #Application Setup frontend ContentSwitching bind *:80 v4v6 - bind *:443 v4v6 ssl crt /var/lib/acme/audiobookshelf.tmmworkshop.com/full.pem crt /var/lib/acme/cache.tmmworkshop.com/full.pem crt /var/lib/acme/jellyfin.tmmworkshop.com/full.pem crt /var/lib/acme/share.tmmworkshop.com/full.pem crt /var/lib/acme/gitea.tmmworkshop.com/full.pem crt /var/lib/acme/www.norn-sight.com/full.pem + bind *:443 v4v6 ssl crt /var/lib/acme/audiobookshelf.tmmworkshop.com/full.pem crt /var/lib/acme/cache.tmmworkshop.com/full.pem crt /var/lib/acme/gems.tmmworkshop.com/full.pem crt /var/lib/acme/jellyfin.tmmworkshop.com/full.pem crt /var/lib/acme/share.tmmworkshop.com/full.pem crt /var/lib/acme/gitea.tmmworkshop.com/full.pem crt /var/lib/acme/www.norn-sight.com/full.pem mode http # ACME challenge routing (must be first) @@ -35,6 +35,7 @@ frontend ContentSwitching acl host_jellyfin hdr(host) -i jellyfin.tmmworkshop.com acl host_share hdr(host) -i share.tmmworkshop.com acl host_gitea hdr(host) -i gitea.tmmworkshop.com + acl host_gems hdr(host) -i gems.tmmworkshop.com acl host_norn_sight hdr(host) -i www.norn-sight.com # --- Request logging --- @@ -84,6 +85,15 @@ frontend ContentSwitching http-request track-sc1 src table st_compare if host_gitea is_gitea_compare !rate_limit_allowlist http-request deny deny_status 429 if host_gitea is_gitea_compare !rate_limit_allowlist { sc_http_req_rate(1,st_compare) gt 1 } + # Anonymous Gems rooms use high-entropy invitation codes, with an additional + # per-IP limit on room creation and join attempts. + acl gems_entry path -i /rooms + acl gems_join path_beg -i /join + acl request_post method POST + http-request track-sc2 src table st_gems_join if host_gems request_post gems_entry + http-request track-sc2 src table st_gems_join if host_gems request_post gems_join + http-request deny deny_status 429 if host_gems { sc_http_req_rate(2,st_gems_join) gt 20 } + # Hosts allowed to serve plain HTTP (add entries to skip the HTTPS redirect) acl allow_http hdr(host) -i __none__ # acl allow_http hdr(host) -i example.tmmworkshop.com @@ -97,6 +107,7 @@ frontend ContentSwitching use_backend jellyfin if host_jellyfin use_backend share_nodes if host_share use_backend gitea if host_gitea + use_backend gems if host_gems use_backend norn_sight if host_norn_sight # Stick-table only (no servers): tracks per-IP request rate to Gitea's compare @@ -104,6 +115,9 @@ frontend ContentSwitching backend st_compare stick-table type ipv6 size 100k expire 600s store http_req_rate(300s) +backend st_gems_join + stick-table type ipv6 size 100k expire 120s store http_req_rate(60s) + backend acme_challenge mode http server acme 127.0.0.1:8402 @@ -129,6 +143,12 @@ backend gitea mode http server server 127.0.0.1:6443 +backend gems + mode http + option forwardfor + timeout server 1h + server gems 127.0.0.1:8002 + backend norn_sight mode http server server 127.0.0.1:8001 diff --git a/tests/gems/__init__.py b/tests/gems/__init__.py new file mode 100644 index 0000000..26f2171 --- /dev/null +++ b/tests/gems/__init__.py @@ -0,0 +1 @@ +"""Tests for the Gems application.""" diff --git a/tests/gems/conftest.py b/tests/gems/conftest.py new file mode 100644 index 0000000..7c5648a --- /dev/null +++ b/tests/gems/conftest.py @@ -0,0 +1,76 @@ +"""Synthetic content builders; no playable pack is bundled with the app.""" + +from __future__ import annotations + +import pytest + +from python.gems.domain.models import ( + AlternateCost, + CardDefinition, + CardEffect, + CardEffectKind, + ContentPack, + ObjectiveDefinition, + OutpostDefinition, + OutpostPower, + PackMetadata, + PatronDefinition, + Requirement, + RequirementKind, + ResourceDefinition, +) + + +@pytest.fixture +def content_pack() -> ContentPack: + resources = [ + ResourceDefinition(id=name, label=name.title(), symbol=str(index + 1), color=f"#{index + 2}{index + 2}4455") + for index, name in enumerate(("pearl", "wave", "leaf", "flame", "stone")) + ] + cards = [] + for deck in ("base", "eastern"): + for tier in (1, 2, 3): + for index, resource in enumerate(resources): + effect = CardEffect() + if deck == "eastern" and tier == 1 and index == 0: + effect = CardEffect(kind=CardEffectKind.VIRTUAL_WILD, amount=2) + if deck == "eastern" and tier == 1 and index == 1: + effect = CardEffect(kind=CardEffectKind.COPY_BONUS) + if deck == "eastern" and tier == 2 and index == 0: + effect = CardEffect(kind=CardEffectKind.COPY_AND_CLAIM, target_tier=1) + if deck == "eastern" and tier == 2 and index == 1: + effect = CardEffect(kind=CardEffectKind.MULTI_BONUS, amount=2) + if deck == "eastern" and tier == 3 and index == 0: + effect = CardEffect(kind=CardEffectKind.CLAIM_FREE, target_tier=2) + alternate = ( + AlternateCost(discard_resource="stone", count=2) + if deck == "eastern" and tier == 3 and index == 1 + else None + ) + cards.append( + CardDefinition( + id=f"{deck}_{tier}_{index}", + label=f"{deck.title()} {tier}-{index}", + deck=deck, + tier=tier, + points=tier - 1, + bonus_resource=resource.id, + cost={resources[(index + 1) % 5].id: tier}, + effect=effect, + alternate_cost=alternate, + ) + ) + requirement = Requirement(id="need_pearl", kind=RequirementKind.COLOR, resource="pearl", count=1) + return ContentPack( + schema_version=1, + metadata=PackMetadata(id="synthetic", name="Synthetic Tests", version="1"), + resources=resources, + wild_resource=ResourceDefinition(id="wild", label="Wild", symbol="*", color="#aaaaaa"), + cards=cards, + patrons=[PatronDefinition(id="patron_one", label="Patron One", points=3, requirements=[requirement])], + objectives=[ObjectiveDefinition(id="goal_one", label="Goal One", minimum_score=0, requirements=[requirement])], + outposts=[ + OutpostDefinition(id=f"post_{power.value}", label=power.value, requirements=[requirement], power=power) + for power in OutpostPower + ], + ) diff --git a/tests/gems/test_base_rules.py b/tests/gems/test_base_rules.py new file mode 100644 index 0000000..359ab7d --- /dev/null +++ b/tests/gems/test_base_rules.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import pytest + +from python.gems.ai import choose_ai_command +from python.gems.domain.engine import RuleError, apply_command, bonuses, new_game, public_state, score +from python.gems.domain.legal_actions import command, legal_commands +from python.gems.domain.models import GameSettings, OwnedCard + + +def test_setup_and_hidden_information(content_pack) -> None: + state = new_game("ABCDEFGH", ["A", "B", "C", "D"], content_pack, GameSettings(), seed=9) + assert state.supply["pearl"] == 7 + assert all(len(market) == 4 for market in state.markets.values()) + state.players[1].reserved.append("base_1_0") + view = public_state(state, 0) + assert view["players"][1]["reserved"] == [None] + assert isinstance(view["decks"]["base:1"], int) + + +def test_distinct_take_requires_three_when_available(content_pack) -> None: + settings = GameSettings(first_player_mode="selected", first_player_seat=0) + state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=1) + bad = command(state, "take_distinct", {"resources": ["pearl", "wave"]}) + with pytest.raises(RuleError, match="exactly three"): + apply_command(state, bad, content_pack, settings, actor_seat=0) + good = command(state, "take_distinct", {"resources": ["pearl", "wave", "leaf"]}) + state = apply_command(state, good, content_pack, settings, actor_seat=0) + assert state.players[0].tokens["pearl"] == 1 + assert state.current_seat == 1 + + +def test_purchase_uses_discount_and_returns_payment(content_pack) -> None: + settings = GameSettings(first_player_mode="selected", first_player_seat=0) + state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=2) + card_id = state.markets["base:1"][0] + card = content_pack.card(card_id) + resource, cost = next(iter(card.cost.items())) + state.players[0].tokens[resource] = cost + state.supply[resource] -= cost + buy = command(state, "purchase", {"card_id": card_id, "payment": {resource: cost}}) + state = apply_command(state, buy, content_pack, settings, actor_seat=0) + assert any(item.card_id == card_id for item in state.players[0].cards) + assert state.supply[resource] >= cost + assert bonuses(state.players[0], content_pack)[card.bonus_resource] == 1 + + +def test_reserving_visible_card_refills_market(content_pack) -> None: + settings = GameSettings(first_player_mode="selected", first_player_seat=0) + state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=12) + card_id = state.markets["base:1"][0] + deck_size = len(state.decks["base:1"]) + + state = apply_command(state, command(state, "reserve", {"card_id": card_id}), content_pack, settings, actor_seat=0) + + assert card_id in state.players[0].reserved + assert card_id not in state.markets["base:1"] + assert len(state.markets["base:1"]) == settings.base_market_size + assert len(state.decks["base:1"]) == deck_size - 1 + + +def test_score_counts_cards_patrons_and_scoring_outpost(content_pack) -> None: + state = new_game("ABCDEFGH", ["A"], content_pack, GameSettings(), seed=3) + player = state.players[0] + player.cards.append(OwnedCard(card_id="base_3_0")) + player.patrons.append("patron_one") + player.outposts.append("post_points_per_outpost") + assert score(player, content_pack) == 6 + + +def test_each_ai_level_returns_a_legal_command(content_pack) -> None: + settings = GameSettings(first_player_mode="selected", first_player_seat=0) + state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=4) + legal = legal_commands(state, content_pack, settings, 0) + legal_shapes = {(item.type, str(item.payload)) for item in legal} + for difficulty in ("easy", "medium", "hard"): + chosen = choose_ai_command(state, content_pack, settings, 0, difficulty) + assert (chosen.type, str(chosen.payload)) in legal_shapes + + +def test_first_player_can_be_selected_or_random(content_pack) -> None: + selected = GameSettings(first_player_mode="selected", first_player_seat=2) + selected_state = new_game("ABCDEFGH", ["A", "B", "C"], content_pack, selected, seed=8) + assert selected_state.first_seat == 2 + assert selected_state.current_seat == 2 + + random_settings = GameSettings(first_player_mode="random") + first = new_game("ABCDEFGH", ["A", "B", "C"], content_pack, random_settings, seed=8) + repeated = new_game("ABCDEFGH", ["A", "B", "C"], content_pack, random_settings, seed=8) + assert first.first_seat == repeated.first_seat + assert 0 <= first.first_seat < 3 + + +def test_excess_tokens_can_be_discarded_and_returned_to_supply(content_pack) -> None: + settings = GameSettings(token_limit=1, first_player_mode="selected", first_player_seat=0) + state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=6) + colors = list(content_pack.resource_ids[:3]) + + state = apply_command( + state, + command(state, "take_distinct", {"resources": colors}), + content_pack, + settings, + actor_seat=0, + ) + assert state.pending is not None + assert state.pending.kind == "discard_tokens" + assert state.pending.amount == 2 + + discard = legal_commands(state, content_pack, settings, 0)[0] + returned = sum(discard.payload["tokens"].values()) + state = apply_command(state, discard, content_pack, settings, actor_seat=0) + + assert returned == 2 + assert sum(state.players[0].tokens.values()) == 1 + assert state.pending is None + assert state.current_seat == 1 diff --git a/tests/gems/test_content.py b/tests/gems/test_content.py new file mode 100644 index 0000000..2680266 --- /dev/null +++ b/tests/gems/test_content.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json + +import pytest + +from python.gems.content import ContentPackError, content_pack_schema, parse_content_pack + + +def test_schema_uses_neutral_patrons_name() -> None: + assert "patrons" in content_pack_schema()["properties"] + assert "governors" not in content_pack_schema()["properties"] + + +def test_governors_is_accepted_as_input_alias(content_pack) -> None: + data = content_pack.model_dump(mode="json") + data["governors"] = data.pop("patrons") + parsed = parse_content_pack(json.dumps(data)) + assert parsed.pack.patrons[0].id == "patron_one" + + +def test_both_patron_names_are_rejected(content_pack) -> None: + data = content_pack.model_dump(mode="json") + data["governors"] = data["patrons"] + with pytest.raises(ContentPackError, match="use patrons or governors"): + parse_content_pack(json.dumps(data)) + + +def test_standard_symbols_are_normalized_in_canonical_pack_data(content_pack) -> None: + data = content_pack.model_dump(mode="json") + data["resources"][0].update(label="Onyx", symbol="B") + data["wild_resource"].update(label="Gold", symbol="Au") + + parsed = parse_content_pack(json.dumps(data)) + canonical = json.loads(parsed.canonical_json) + + assert canonical["resources"][0]["symbol"] == "O" + assert canonical["wild_resource"]["symbol"] == "G" + + +def test_unknown_resource_reference_is_rejected(content_pack) -> None: + data = content_pack.model_dump(mode="json") + data["cards"][0]["cost"] = {"missing": 1} + with pytest.raises(ContentPackError, match="unknown cost resource"): + parse_content_pack(json.dumps(data)) diff --git a/tests/gems/test_modules.py b/tests/gems/test_modules.py new file mode 100644 index 0000000..3bc1dd4 --- /dev/null +++ b/tests/gems/test_modules.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from python.gems.domain.engine import bonuses, new_game +from python.gems.domain.models import GameSettings, Modules, OwnedCard, Requirement, RequirementKind, ResourceDefinition +from python.gems.domain.requirements import requirements_met + + +def test_any_color_requirements_can_be_distinct() -> None: + requirements = [ + Requirement(id="first", kind=RequirementKind.ANY_COLOR, count=2), + Requirement(id="second", kind=RequirementKind.ANY_COLOR, count=2, distinct_from=["first"]), + ] + assert requirements_met(requirements, {"pearl": 2, "wave": 2}) + assert not requirements_met(requirements, {"pearl": 3, "wave": 1}) + + +def test_light_resource_colors_use_dark_token_lettering() -> None: + light = ResourceDefinition(id="light", label="Light", symbol="W", color="#f4f2ed") + gold = ResourceDefinition(id="gold", label="Gold", symbol="Au", color="#d9a928") + dark = ResourceDefinition(id="dark", label="Dark", symbol="B", color="#151719") + assert light.ink_color == "#111111" + assert gold.ink_color == "#ffffff" + assert dark.ink_color == "#ffffff" + + +def test_standard_resources_store_conventional_symbols() -> None: + """Standard gem labels normalize legacy abbreviations in pack data.""" + standard = { + "Onyx": "O", + "Sapphire": "S", + "Emerald": "E", + "Ruby": "R", + "Diamond": "D", + "Gold": "G", + } + + for label, expected in standard.items(): + resource = ResourceDefinition(id=label.casefold(), label=label, symbol="?", color="#334455") + assert resource.symbol == expected + + custom = ResourceDefinition(id="pearl", label="Pearl", symbol="P", color="#334455") + assert custom.symbol == "P" + + +def test_all_modules_deal_their_content(content_pack) -> None: + settings = GameSettings(modules=Modules(objectives=True, outposts=True, eastern_decks=True, fortifications=True)) + state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=5) + assert "eastern:1" in state.markets + assert state.available_objectives == ["goal_one"] + assert not state.available_patrons + assert state.players[0].fortifications_available == 3 + + +def test_multi_bonus_counts_twice_but_is_one_card(content_pack) -> None: + settings = GameSettings(modules=Modules(eastern_decks=True)) + state = new_game("ABCDEFGH", ["A"], content_pack, settings, seed=5) + player = state.players[0] + player.cards.append(OwnedCard(card_id="eastern_2_1")) + assert bonuses(player, content_pack)[content_pack.card("eastern_2_1").bonus_resource] == 2 + assert len(player.cards) == 1 diff --git a/tests/gems/test_web.py b/tests/gems/test_web.py new file mode 100644 index 0000000..8f015a7 --- /dev/null +++ b/tests/gems/test_web.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import html +import json +import re + +from fastapi.testclient import TestClient + +from python.gems.config import GemsConfig +from python.gems.main import create_app + + +def make_client(tmp_path): + app = create_app() + app.state.config = GemsConfig( + database_path=tmp_path / "gems.sqlite3", + key_path=tmp_path / "instance.key", + public_origin="http://testserver", + ) + return TestClient(app) + + +def test_create_join_upload_and_reconnect(tmp_path, content_pack) -> None: + with make_client(tmp_path) as host: + response = host.post("/rooms", data={"name": "Host"}, follow_redirects=False) + assert response.status_code == 303 + assert response.cookies.get("gems_display_name") == "Host" + code = response.headers["location"].rsplit("/", 1)[1] + page = host.get(f"/rooms/{code}") + assert code in page.text + assert 'class="topbar room-topbar"' in page.text + assert page.text.count(" None: + with make_client(tmp_path) as client: + response = client.get("/schemas/content-pack-v1.json") + assert response.status_code == 200 + assert "patrons" in response.json()["properties"] + home = client.get("/") + assert home.headers["x-frame-options"] == "DENY" + assert "script-src 'self'" in home.headers["content-security-policy"] + assert "style-src-attr 'unsafe-inline'" in home.headers["content-security-policy"] + assert '"code":"422","swap":true' in home.text + + +def test_solo_lobby_can_start_and_render_legal_actions(tmp_path, content_pack) -> None: + with make_client(tmp_path) as client: + created = client.post("/rooms", data={"name": "Solo"}, follow_redirects=False) + code = created.headers["location"].rsplit("/", 1)[1] + page = client.get(f"/rooms/{code}") + csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1) + assert "Pack required · Waiting for every human to ready up" in page.text + assert '' in page.text + assert 'name="first_player_mode"' in page.text + assert 'name="first_player_seat"' in page.text + uploaded = client.post( + f"/rooms/{code}/pack", + data={"csrf": csrf}, + files={"pack_file": ("pack.json", json.dumps(content_pack.model_dump(mode="json")), "application/json")}, + ) + assert "Pack loaded · Waiting for every human to ready up" in uploaded.text + assert '' in uploaded.text + ready = client.post(f"/rooms/{code}/ready", data={"csrf": csrf, "ready": "true"}) + assert ready.status_code == 200 + assert "Pack loaded · Players ready · Rules set" in ready.text + assert '' in ready.text + started = client.post(f"/rooms/{code}/start", data={"csrf": csrf}) + assert started.status_code == 200 + assert all(marker in started.text for marker in ("Gem piles", "Solo", "History", "Patrons", "Patron One")) + assert all( + marker in started.text + for marker in ( + 'class="players-stack"', + "Gems / Cards", + "Click for card details", + 'class="supply right-supply panel compact"', + 'class="player-gem-dock"', + 'aria-label="Solo gems and cards"', + 'class="hand-total"', + 'class="gem-disc card-bonus-token"', + ) + ) + assert "players-grid" not in started.text + assert f"/rooms/{code}/take-gems" in started.text + assert 'name="resources"' in started.text + assert f"--gem-color:{content_pack.resources[0].color}" in started.text + assert '' in started.text + assert "" in started.text + assert "command_json" in started.text + + pair_resource = content_pack.resource_ids[0] + paired = client.post( + f"/rooms/{code}/take-gems", + data={"csrf": csrf, "pair_resource": pair_resource}, + ) + assert paired.status_code == 200 + assert f'value="{pair_resource}" disabled>Take 2' in paired.text + + reserve_commands = re.findall(r'name="command_json" value="([^"]+)">', paired.text) + reserve_command = next( + item + for item in reserve_commands + if content_pack.card(json.loads(html.unescape(item))["payload"]["card_id"]).bonus_resource != "pearl" + ) + reserved = client.post( + f"/rooms/{code}/commands", + data={"csrf": csrf, "command_json": html.unescape(reserve_command)}, + ) + assert reserved.status_code == 200 + assert "Your reserved cards" in reserved.text + assert 'class="bottom-reserved"' in reserved.text + assert ">Purchase" in reserved.text + reserved_label = re.search(r'
([^<]+)', reserved.text).group(1) + purchase_command = re.search( + r'name="command_json" value="([^"]+)">', reserved.text + ).group(1) + purchased = client.post( + f"/rooms/{code}/commands", + data={"csrf": csrf, "command_json": html.unescape(purchase_command)}, + ) + assert purchased.status_code == 200 + assert 'class="owned-card-inspector"' in purchased.text + assert reserved_label in purchased.text + + taken = client.post( + f"/rooms/{code}/take-gems", + data={"csrf": csrf, "resources": list(content_pack.resource_ids[:3])}, + ) + assert taken.status_code == 200 + assert "Gem piles" in taken.text + + invalid = client.post( + f"/rooms/{code}/take-gems", + data={"csrf": csrf, "resources": list(content_pack.resource_ids[:2])}, + ) + assert invalid.status_code == 422 + assert "Choose three different available gems" in invalid.text + + +def test_token_overflow_shows_and_processes_specific_discard_actions(tmp_path, content_pack) -> None: + with make_client(tmp_path) as client: + created = client.post("/rooms", data={"name": "Solo"}, follow_redirects=False) + code = created.headers["location"].rsplit("/", 1)[1] + page = client.get(f"/rooms/{code}") + csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1) + client.post( + f"/rooms/{code}/pack", + data={"csrf": csrf}, + files={"pack_file": ("pack.json", json.dumps(content_pack.model_dump(mode="json")), "application/json")}, + ) + client.post( + f"/rooms/{code}/settings", + data={ + "csrf": csrf, + "token_limit": "1", + "first_player_mode": "selected", + "first_player_seat": "0", + }, + ) + client.post(f"/rooms/{code}/ready", data={"csrf": csrf, "ready": "true"}) + client.post(f"/rooms/{code}/start", data={"csrf": csrf}) + + overflow = client.post( + f"/rooms/{code}/take-gems", + data={"csrf": csrf, "resources": list(content_pack.resource_ids[:3])}, + ) + assert overflow.status_code == 200 + assert "Resolve: discard tokens" in overflow.text + assert f"/rooms/{code}/discard-tokens" in overflow.text + assert "Return exactly 2 excess gems" in overflow.text + returned_colors = content_pack.resource_ids[:2] + discarded = client.post( + f"/rooms/{code}/discard-tokens", + data={ + "csrf": csrf, + f"token_{returned_colors[0]}": "1", + f"token_{returned_colors[1]}": "1", + }, + ) + assert discarded.status_code == 200 + assert "Resolve: discard tokens" not in discarded.text + assert "1 / 1" in discarded.text + + +def test_cli_port_override_accepts_same_origin_mutations(tmp_path) -> None: + app = create_app() + app.state.config = GemsConfig( + database_path=tmp_path / "gems.sqlite3", + key_path=tmp_path / "instance.key", + ) + with TestClient(app, base_url="http://127.0.0.1:8002") as client: + created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False) + code = created.headers["location"].rsplit("/", 1)[1] + page = client.get(f"/rooms/{code}") + csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1) + assert f"http://127.0.0.1:8002/join/{code}" in page.text + + response = client.post( + f"/rooms/{code}/seats/ai", + data={"csrf": csrf, "difficulty": "medium"}, + headers={"Origin": "http://127.0.0.1:8002"}, + ) + + assert response.status_code == 200 + assert "Medium AI" in response.text + + +def test_cross_origin_mutation_is_rejected(tmp_path) -> None: + with make_client(tmp_path) as client: + created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False) + code = created.headers["location"].rsplit("/", 1)[1] + page = client.get(f"/rooms/{code}") + csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1) + + response = client.post( + f"/rooms/{code}/seats/ai", + data={"csrf": csrf, "difficulty": "medium"}, + headers={"Origin": "https://attacker.example"}, + ) + + assert response.status_code == 422 + assert "Request origin was rejected" in response.text + + +def test_invalid_content_pack_returns_visible_feedback(tmp_path) -> None: + with make_client(tmp_path) as client: + created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False) + code = created.headers["location"].rsplit("/", 1)[1] + page = client.get(f"/rooms/{code}") + csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1) + assert "No content pack is loaded" in page.text + + response = client.post( + f"/rooms/{code}/pack", + data={"csrf": csrf}, + files={"pack_file": ("broken.json", b"not json", "application/json")}, + ) + + assert response.status_code == 422 + assert 'role="alert"' in response.text + assert "Invalid JSON" in response.text + assert "No content pack is loaded" in response.text + + +def test_host_can_play_again_with_same_table_setup(tmp_path, content_pack) -> None: + with make_client(tmp_path) as client: + created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False) + code = created.headers["location"].rsplit("/", 1)[1] + page = client.get(f"/rooms/{code}") + csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1) + client.post( + f"/rooms/{code}/pack", + data={"csrf": csrf}, + files={"pack_file": ("pack.json", json.dumps(content_pack.model_dump(mode="json")), "application/json")}, + ) + client.post(f"/rooms/{code}/ready", data={"csrf": csrf, "ready": "true"}) + client.post(f"/rooms/{code}/start", data={"csrf": csrf}) + + room = client.app.state.repository.get_room(code) + room.status = "finished" + room.state.finished = True + room.state.winners = [0] + room.state.revision += 1 + room.revision = room.state.revision + client.app.state.repository.save_state(room, "test-finish", 0, {"type": "test_finish"}) + + finished = client.get(f"/rooms/{code}") + assert "Play again" in finished.text + replayed = client.post(f"/rooms/{code}/play-again", data={"csrf": csrf}) + assert replayed.status_code == 200 + assert "Round 1" in replayed.text + assert "Play again" not in replayed.text + fresh_room = client.app.state.repository.get_room(code) + assert fresh_room.status == "playing" + assert not fresh_room.state.finished + assert fresh_room.pack.metadata.id == content_pack.metadata.id