From a826c1af1bf5b9d1dd99bfa4f6624ad80b1a1cff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 22:57:47 +0000 Subject: [PATCH] Externalize the card library behind an allowlisted set loader Replace the hardcoded CARD_LIBRARY with an effect-factory vocabulary (EFFECTS) and a JSON-card loader (build_card/build_pool) in engine/cards.py; a card names an effect plus params and cannot define new behaviour. Thread the resolved card pool through apply_action(state, action, cards) and onto Game, keeping it off GameState so state still round-trips through JSON. Add api/set_loader.py to allowlist set URLs (parsed host + path), fetch them with hardening (https-only, timeout, no redirects, size cap, content-type), validate against a vendored copy of the card-set schema, build the pool, reject duplicate composed ids, and snapshot the resolved pool with a sha256 content hash. POST /games accepts an optional set_urls body (default: the core set); the export includes the snapshot so a saved game replays self-contained. Map bad input to 422 and upstream fetch failures to 502, leaving the store untouched on any failure. Update the engine to add a flavor field, the demo and tests to build pools from a fixture set, add httpx + jsonschema as runtime dependencies, and refresh README/AGENTS/ CONTRIBUTING/docs (fixing the stale game-docs links). https://claude.ai/code/session_015NZxntWsQego4NzHjt1Cmx --- AGENTS.md | 32 +++- CONTRIBUTING.md | 5 + README.md | 15 ++ docs/api.rst | 27 ++- docs/changelog.rst | 4 + docs/index.rst | 34 ++-- examples/core.json | 53 ++++++ examples/demo.py | 26 ++- pyproject.toml | 7 +- src/mundane/api/app.py | 87 +++++++-- src/mundane/api/card_schema/__init__.py | 6 + .../api/card_schema/card-set.schema.json | 85 +++++++++ src/mundane/api/set_loader.py | 138 ++++++++++++++ src/mundane/engine/cards.py | 180 ++++++++++++------ src/mundane/engine/game.py | 39 ++-- src/mundane/engine/rules.py | 17 +- src/mundane/engine/serialize.py | 5 + src/mundane/engine/state.py | 13 +- tests/fixtures/core.json | 53 ++++++ tests/test_api.py | 124 +++++++++++- tests/test_replay.py | 21 +- tests/test_rules.py | 81 ++++---- uv.lock | 72 ++++++- 23 files changed, 933 insertions(+), 191 deletions(-) create mode 100644 examples/core.json create mode 100644 src/mundane/api/card_schema/__init__.py create mode 100644 src/mundane/api/card_schema/card-set.schema.json create mode 100644 src/mundane/api/set_loader.py create mode 100644 tests/fixtures/core.json diff --git a/AGENTS.md b/AGENTS.md index f95515b..3289108 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,12 @@ the rules live in the engine. - **Stack:** Python 3.14+, [Litestar](https://litestar.dev) 3 (pre-release, tracked from git `main`), Pydantic, Uvicorn. Managed with [`uv`](https://docs.astral.sh/uv/). -- **Rules & cards spec** live in the meta repo, not here: - [SPEC.md](https://github.com/letsbuilda/mundane/blob/main/game-docs/SPEC.md) and - [CARDS.md](https://github.com/letsbuilda/mundane/blob/main/game-docs/CARDS.md). +- **Rules & cards spec** live in the meta repo, not here: the + [specification](https://github.com/letsbuilda/mundane/blob/main/specs/) (notably + [cards.md](https://github.com/letsbuilda/mundane/blob/main/specs/cards.md) and + [card-sets.md](https://github.com/letsbuilda/mundane/blob/main/specs/card-sets.md)) and the + [rulebook](https://github.com/letsbuilda/mundane/blob/main/rulebook/). Card **content** is published + as JSON sets in [`mundane-cards`](https://github.com/letsbuilda/mundane-cards). ## Setup @@ -83,13 +86,15 @@ src/mundane/ engine/ # the game, with NO HTTP knowledge state.py # Card, CardType, Player, StackItem, GameState actions.py # PlayCard, CastInstant, PassPriority, IllegalAction - rules.py # apply_action + helpers — the one door - cards.py # CARD_LIBRARY (id -> Card) + effect functions - serialize.py # state/action -> JSON-ready data - game.py # Game: state + action log + submit() / export() + rules.py # apply_action(state, action, cards) + helpers — the one door + cards.py # EFFECTS vocabulary + build_card/build_pool loader + load errors + serialize.py # state/action -> JSON-ready data; canonical_json for hashing + game.py # Game: state + card pool + action log + snapshot; submit() / export() api/ - app.py # Litestar app, in-memory GameStore, exception handler + app.py # Litestar app, in-memory GameStore, exception handlers (422 / 502) schemas.py # action JSON (tagged union) -> action dataclasses + set_loader.py # allowlist + hardened fetch + schema-validate + snapshot/hash + card_schema/ # vendored, pinned copy of card-set.schema.json examples/demo.py # runnable scenario tests/ # pytest suite docs/ # Sphinx docs @@ -111,6 +116,17 @@ Invariants to preserve when changing code: identity (there's a test for it). - The game store is an in-memory dict behind a small `GameStore` interface (`create`/`get`/`save`); games are volatile. Prefer extending this interface over reaching around it. +- **Cards are external data; effects are engine code.** `cards.py` holds the fixed effect vocabulary + (`EFFECTS`) and the loader (`build_card` / `build_pool`); a JSON card names an effect + `params` and + cannot add behaviour. The engine rejects unknown effect names and bad params at load time; the + schema validates JSON *shape* only. Adding a new effect is an engine change here, not a cards-repo PR. +- **All fetching / allowlisting / validation / snapshotting lives in `api/set_loader.py`** — the + engine stays HTTP-free. Sets load only from the allowlisted `mundane-cards` raw origin; the resolved + pool is snapshotted (with a content hash) into the game and included in the export. `GameStore.create` + loads **before** it touches the store, so any loader error (422/502) leaves the store untouched. +- **The resolved pool is threaded through `apply_action(state, action, cards)` and lives on `Game`, + never on `GameState`.** State holds composed card **ids** (`set_id:id`), never `Card` objects (whose + bound effect closures are code), so a whole `GameState` still round-trips through JSON. ## Making changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2ac887..7e1b1e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,6 +79,11 @@ mundane is split so the rules are testable without HTTP — please keep these bo nothing), then transitions. Add new rules there. - HTTP action bodies are a **tagged union** keyed by `type`; `api/schemas.py` only translates (no rules) and maps bad bodies to HTTP 422. Keep the tags in step with the engine's actions. +- Card **content** is external data: games load JSON [card sets](https://github.com/letsbuilda/mundane-cards) + via `api/set_loader.py` (allowlist + fetch + schema-validate + snapshot). Card **behaviour** is the + engine's fixed `EFFECTS` vocabulary in `engine/cards.py` — a card names an effect, it can't define + one. The card-set schema is vendored at `api/card_schema/`; re-sync it when `mundane-cards` ships a + new `schema-v1`. `AGENTS.md` has the same boundaries spelled out in more detail. diff --git a/README.md b/README.md index 9de6cc8..a352638 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,18 @@ Two households face off. The engine is a **referee**: a whole game is a fold ove current state and then transitions it. Illegal moves are rejected (the state is left untouched), not crashed on. The HTTP API is a thin shell that translates requests into engine actions; all the rules live in the engine. + +## Card sets + +Cards are **content**, not code. Each game loads its cards at creation from JSON +[card sets](https://github.com/letsbuilda/mundane/blob/main/specs/card-sets.md) published in +[`mundane-cards`](https://github.com/letsbuilda/mundane-cards) — a card names an *effect* from the +engine's fixed vocabulary and supplies `params`; it cannot define new behaviour. `POST /games` takes +an optional `{"set_urls": [...]}` body (default: the core set). Each URL is **allowlisted** (only the +`mundane-cards` raw origin, matched by parsed host + path), **fetched** with hardening (https-only, +hard timeout, size cap, content-type check), **validated** against a vendored copy of the card-set +JSON Schema, **built** into cards (the engine rejects unknown effects, bad params, and duplicate +composed ids), and **snapshotted** with a sha256 hash into the game so `GET /games/{id}/export` +replays self-contained. Bad input is rejected before anything is stored: non-allowlisted URL / +schema-invalid set / unknown effect / bad params / duplicate id → **422**; fetch failure / timeout / +oversize → **502**. diff --git a/docs/api.rst b/docs/api.rst index 700e6a7..68acc1c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -20,7 +20,7 @@ Endpoints - Purpose * - POST - ``/games`` - - create a game + - create a game (optional ``{"set_urls": [...]}`` body; default the core set) * - GET - ``/games/{id}`` - read current state @@ -29,7 +29,7 @@ Endpoints - submit a move (422 if illegal) * - GET - ``/games/{id}/export`` - - download the game log + final state + - download the game log + final state + card snapshot Exercise it ----------- @@ -68,3 +68,26 @@ The action body is a tagged union — every action carries a ``type``: - ``player``, ``hand_index``, optional ``target_id`` * - ``pass_priority`` - ``player`` + +Card sets +--------- + +Cards are loaded at game creation from JSON *sets* published in +`mundane-cards `_. ``POST /games`` accepts an optional +``{"set_urls": [...]}`` body and defaults to the core set. Each URL is **allowlisted** (only the +``mundane-cards`` raw origin, matched by parsed host + path), **fetched** with hardening (https-only, +hard timeout, size cap, content-type check), **validated** against a vendored copy of the card-set +JSON Schema, and **built** into cards by the engine — which rejects unknown effect names, bad params, +and duplicate composed ids. The resolved pool is **snapshotted** with a ``sha256`` content hash into +the game and returned by the export, so a saved game replays self-contained. + +Bad input is rejected before anything is stored: a non-allowlisted URL, a schema-invalid set, an +unknown effect, bad params, or a duplicate id give ``422``; a fetch failure, timeout, or oversize +give ``502``. + +.. code-block:: bash + + # create a game from an explicit (allowlisted) set URL + curl -s -X POST localhost:8000/games \ + -H 'content-type: application/json' \ + -d '{"set_urls": ["https://raw.githubusercontent.com/letsbuilda/mundane-cards/main/sets/core.json"]}' diff --git a/docs/changelog.rst b/docs/changelog.rst index 4824d35..01e3bc4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,3 +6,7 @@ Changelog JSON-serialisable :class:`mundane.engine.state.GameState` that stores cards by id. * :feature:`0` A thin `Litestar `_ HTTP API (:mod:`mundane.api`) to create games, read state, submit moves (rejected moves become HTTP 422), and export the action log. +* :feature:`0` Externalised the card library: cards load from allowlisted JSON sets in + `mundane-cards `_, validated against a vendored schema + and snapshotted with a content hash into each game (:mod:`mundane.api.set_loader`). ``POST /games`` + takes an optional ``set_urls`` body; the export now includes the card snapshot. diff --git a/docs/index.rst b/docs/index.rst index 4f2f616..f1dc3eb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,10 +12,12 @@ into engine actions; all the rules live in the engine. .. note:: - The game's rules and card catalog live in the meta/spec repository, not here: - `SPEC.md `_ and - `CARDS.md `_. This site - documents the *implementation*; the spec describes the *game*. + The game's rules live in the meta/spec repository, not here: the + `specification `_ and + `rulebook `_. Card *content* is published + as JSON `card sets `_ in + `mundane-cards `_. This site documents the + *implementation*; the spec describes the *game*. Architecture ------------ @@ -30,11 +32,13 @@ reducer:: final_state = reduce(apply_action, actions, initial_state) -The state is fully JSON-serialisable. Cards are referenced **by id** everywhere; card *objects* and -the effect functions they carry live only in :data:`mundane.engine.cards.CARD_LIBRARY`, never in the -state. That separation is what lets a whole :class:`mundane.engine.state.GameState` round-trip through -JSON. A :class:`mundane.engine.game.Game` pairs that state with the ordered log of accepted actions, -so games are event-sourced: the log alone can rebuild — and replay — the state. +The state is fully JSON-serialisable. Cards are referenced **by id** everywhere (the composed +``set_id:id``); card *objects* — built from JSON sets by :func:`mundane.engine.cards.build_card` — and +the effect closures they carry live only in the per-game pool, never in the state. That separation is +what lets a whole :class:`mundane.engine.state.GameState` round-trip through JSON. A +:class:`mundane.engine.game.Game` pairs that state with the card pool, the ordered log of accepted +actions, and a card snapshot, so games are event-sourced: the log plus the snapshot can rebuild — and +replay — the state. The API ~~~~~~~ @@ -45,6 +49,13 @@ rejected move (:class:`~mundane.engine.actions.IllegalAction`) onto **HTTP 422** in-memory store behind a small ``create`` / ``get`` / ``save`` interface, so they are volatile (lost when the process restarts) but the store is swappable for Redis or SQLite later. +Loading a game's cards — allowlisting set URLs, fetching them with hardening, validating against a +vendored copy of the card-set JSON Schema, and snapshotting the resolved pool with a content hash — +lives in :mod:`mundane.api.set_loader`. The engine never reaches the network; it receives only the +resolved pool. A non-allowlisted URL, a schema-invalid set, an unknown effect, bad params, or a +duplicate id give **HTTP 422**; an upstream fetch failure gives **HTTP 502**. Either way the store is +left untouched. + .. list-table:: :header-rows: 1 :widths: 40 60 @@ -52,13 +63,14 @@ when the process restarts) but the store is swappable for Redis or SQLite later. * - Method and path - Purpose * - ``POST /games`` - - Create a game; return its id and initial state. + - Create a game (optional ``{"set_urls": [...]}`` body, default the core set); return its id and + initial state. * - ``GET /games/{id}`` - Read the current state. * - ``POST /games/{id}/actions`` - Submit a move (HTTP 422 if illegal; the stored game is unchanged). * - ``GET /games/{id}/export`` - - Download the action log and final state. + - Download the action log, final state, and card snapshot (resolved cards + hash). API reference ------------- diff --git a/examples/core.json b/examples/core.json new file mode 100644 index 0000000..a41ee10 --- /dev/null +++ b/examples/core.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "set_id": "core", + "name": "Core Set", + "version": "1.0.0", + "cards": [ + { + "id": "throw_a_house_party", + "name": "Throw a House Party", + "cost": 3, + "type": "task", + "effect": "damage_composure", + "params": { "amount": 3 }, + "text": "Deal 3 chaos to your opponent's Composure." + }, + { + "id": "noise_complaint", + "name": "Noise Complaint", + "cost": 1, + "type": "instant", + "effect": "counter_task", + "params": {}, + "text": "Counter target task on the stack." + }, + { + "id": "helpful_roommate", + "name": "Helpful Roommate", + "cost": 2, + "type": "person", + "effect": "none", + "params": {}, + "text": "A dependable body around the house. Resolves onto your board." + }, + { + "id": "espresso_machine", + "name": "Espresso Machine", + "cost": 2, + "type": "appliance", + "effect": "none", + "params": {}, + "text": "A trusty appliance. Resolves onto your board." + }, + { + "id": "morning_jog", + "name": "Morning Jog", + "cost": 1, + "type": "habit", + "effect": "none", + "params": {}, + "text": "A wholesome routine. Resolves onto your board." + } + ] +} diff --git a/examples/demo.py b/examples/demo.py index 3367dd7..dfa7c44 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -1,26 +1,34 @@ """Runnable demo: Steve's house party gets shut down by Alex's noise complaint. Run with ``python examples/demo.py``. This is the scenario the engine was designed around; -it doubles as a smoke check that ``apply_action`` still behaves as intended. +it doubles as a smoke check that ``apply_action`` still behaves as intended. The cards come from a +local set fixture (``examples/core.json``), built through the same loader the API uses. """ -from functools import reduce +import json +from functools import partial, reduce +from pathlib import Path from mundane.engine.actions import Action, CastInstant, IllegalAction, PassPriority, PlayCard -from mundane.engine.cards import CARD_LIBRARY +from mundane.engine.cards import build_pool from mundane.engine.rules import apply_action from mundane.engine.state import GameState, Player +_CORE_SET = json.loads((Path(__file__).parent / "core.json").read_text(encoding="utf-8")) + def demo() -> None: """Play the canonical scenario and print the outcome.""" - steve = Player(name="Steve", time=5, hand=["throw_a_house_party"]) - alex = Player(name="Alex", time=5, hand=["noise_complaint"]) + pool = build_pool(_CORE_SET) + step = partial(apply_action, cards=pool) + + steve = Player(name="Steve", time=5, hand=["core:throw_a_house_party"]) + alex = Player(name="Alex", time=5, hand=["core:noise_complaint"]) state = GameState(players=[steve, alex], active_player=0, priority_player=0, phase="PLAN") # The engine is a referee. Illegal actions are rejected and change nothing: try: - apply_action(state, CastInstant(player=0, hand_index=0)) # party isn't an instant + apply_action(state, CastInstant(player=0, hand_index=0), pool) # the party isn't an instant except IllegalAction as exc: print(f"rejected, state untouched: {exc}") @@ -35,10 +43,10 @@ def demo() -> None: PassPriority(player=0), # stack empty now; passes again... PassPriority(player=1), # ...both pass -> phase advances ] - final = reduce(apply_action, action_log, state) + final = reduce(step, action_log, state) - steve_discard = [CARD_LIBRARY[card_id].name for card_id in final.players[0].discard] - alex_discard = [CARD_LIBRARY[card_id].name for card_id in final.players[1].discard] + steve_discard = [pool[card_id].name for card_id in final.players[0].discard] + alex_discard = [pool[card_id].name for card_id in final.players[1].discard] print(f"Alex's Composure: {final.players[1].composure} (20 = the party never landed)") print(f"Steve's discard: {steve_discard}") print(f"Alex's discard: {alex_discard}") diff --git a/pyproject.toml b/pyproject.toml index d6428fc..d6808b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,9 @@ dependencies = [ # See the 3.x changelog: https://docs.litestar.dev/3-dev/release-notes/changelog.html "litestar @ git+https://github.com/litestar-org/litestar.git@main", "uvicorn>=0.30", + # Fetching external card sets and validating them against the vendored schema (api/set_loader.py). + "httpx>=0.28.1", + "jsonschema>=4.26", ] [project.urls] @@ -34,6 +37,8 @@ dev = [ "ruff>=0.15.13", "mypy>=2.0.0", "ty>=0.0.37", + # Type stubs (jsonschema ships no py.typed) + "types-jsonschema>=4.26", ] docs = [ "sphinx>=7.4.4", @@ -48,8 +53,6 @@ tests = [ "pytest-randomly>=4.0.1", # Reporting "allure-pytest>=2.15.3", - # Litestar's TestClient is built on httpx - "httpx>=0.28.1", ] [tool.uv] diff --git a/src/mundane/api/app.py b/src/mundane/api/app.py index 391a972..65ce937 100644 --- a/src/mundane/api/app.py +++ b/src/mundane/api/app.py @@ -2,26 +2,40 @@ The API is a thin translator. It never mutates :class:`GameState` directly — it parses requests into engine actions and calls ``Game.submit``, then maps the engine's :class:`IllegalAction` onto HTTP 422. -All game legality lives in the engine. +All game legality lives in the engine. Loading a game's cards (allowlist, fetch, schema-validate, +snapshot) lives in :mod:`mundane.api.set_loader`; the engine receives only the resolved pool. The game store is an in-memory ``dict``, supplied via dependency injection. It is **volatile** (lost on restart); see the README. Keeping it behind the small :class:`GameStore` interface (create / get / save) makes swapping it for Redis or SQLite a localised change. """ -from typing import Any +from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 from litestar import Litestar, MediaType, Request, Response, get, post from litestar.di import Provide from litestar.exceptions import NotFoundException -from litestar.status_codes import HTTP_422_UNPROCESSABLE_ENTITY +from litestar.status_codes import HTTP_422_UNPROCESSABLE_ENTITY, HTTP_502_BAD_GATEWAY from mundane.engine.actions import IllegalAction +from mundane.engine.cards import DuplicateCardError, InvalidEffectParamsError, UnknownEffectError from mundane.engine.game import Game, new_game from mundane.engine.serialize import state_to_dict from .schemas import parse_action +from .set_loader import ( + DEFAULT_SET_URLS, + Fetcher, + SetFetchError, + SetSchemaError, + SetURLNotAllowedError, + default_fetch, + load_sets, +) + +if TYPE_CHECKING: + from collections.abc import Sequence class GameStore: @@ -29,17 +43,24 @@ class GameStore: Deliberately an opaque service (not a dataclass), so the framework treats it as an injected dependency rather than request data to introspect. Swapping in Redis or SQLite later means - reimplementing just create / get / save behind this same interface. + reimplementing just create / get / save behind this same interface. ``fetch`` is injectable so + tests can resolve set URLs from a local fixture instead of the network. """ - def __init__(self) -> None: - """Start with no games.""" + def __init__(self, *, fetch: Fetcher = default_fetch) -> None: + """Start with no games, using ``fetch`` to retrieve card sets.""" self.games: dict[str, Game] = {} + self._fetch = fetch + + def create(self, set_urls: Sequence[str] | None = None) -> tuple[str, Game]: + """Resolve ``set_urls`` (default: the core set), then create and store a new game. - def create(self) -> tuple[str, Game]: - """Create and store a new game, returning its id and the game.""" + Loading happens **before** the store is touched, so any loader error leaves it unchanged. + """ + pool = load_sets(set_urls or DEFAULT_SET_URLS, fetch=self._fetch) game_id = uuid4().hex - game = new_game() + game = new_game(pool.cards) + game.card_snapshot = pool.snapshot self.games[game_id] = game return game_id, game @@ -56,8 +77,8 @@ def save(self, game_id: str, game: Game) -> None: self.games[game_id] = game -def _illegal_action_handler(_request: Request[Any, Any, Any], exc: Exception) -> Response[dict[str, str]]: - """Map a rejected move (``IllegalAction``) onto HTTP 422; the stored game is left unchanged.""" +def _unprocessable_handler(_request: Request[Any, Any, Any], exc: Exception) -> Response[dict[str, str]]: + """Map a rejected move or bad set input onto HTTP 422; the stored game is left unchanged.""" return Response( content={"detail": str(exc)}, status_code=HTTP_422_UNPROCESSABLE_ENTITY, @@ -65,10 +86,30 @@ def _illegal_action_handler(_request: Request[Any, Any, Any], exc: Exception) -> ) +def _bad_gateway_handler(_request: Request[Any, Any, Any], exc: Exception) -> Response[dict[str, str]]: + """Map an upstream set-fetch failure (network/timeout/oversize) onto HTTP 502.""" + return Response( + content={"detail": str(exc)}, + status_code=HTTP_502_BAD_GATEWAY, + media_type=MediaType.JSON, + ) + + +def _parse_set_urls(data: dict[str, object] | None) -> Sequence[str] | None: + """Pull ``set_urls`` out of the request body, rejecting anything that isn't a list of strings.""" + if data is None or "set_urls" not in data: + return None + set_urls = data["set_urls"] + if not isinstance(set_urls, list) or not all(isinstance(url, str) for url in set_urls): + msg = "'set_urls' must be a list of strings" + raise SetURLNotAllowedError(msg) + return cast("list[str]", set_urls) + + @post("/games", sync_to_thread=False) -def create_game(store: GameStore) -> dict[str, object]: - """Create a new game; return its id and initial state.""" - game_id, game = store.create() +def create_game(store: GameStore, data: dict[str, object] | None = None) -> dict[str, object]: + """Create a new game from ``set_urls`` (default: the core set); return its id and initial state.""" + game_id, game = store.create(_parse_set_urls(data)) return {"game_id": game_id, "state": state_to_dict(game.state)} @@ -89,10 +130,11 @@ def submit_action(game_id: str, data: dict[str, object], store: GameStore) -> di @get("/games/{game_id:str}/export", sync_to_thread=False) def export_game(game_id: str, store: GameStore) -> Response[dict[str, object]]: - """Return the game's action log and final state as a downloadable JSON attachment. + """Return the game's log, final state, and card snapshot as a downloadable JSON attachment. - The Content-Disposition attachment header is what turns this response into a saved file — it is - all the "Download game log" button on the game-over screen needs to point at. + The snapshot (resolved cards + content hash) makes the download self-contained: it replays + without reaching the cards repo. The Content-Disposition attachment header is what the + "Download game log" button on the game-over screen points at. """ export = store.get(game_id).export() filename = f"mundane-game-{game_id}.json" @@ -114,7 +156,16 @@ def provide_store() -> GameStore: return Litestar( route_handlers=[create_game, read_game, submit_action, export_game], dependencies={"store": Provide(provide_store, sync_to_thread=False)}, - exception_handlers={IllegalAction: _illegal_action_handler}, + # Bad input -> 422 (the store is left untouched); an upstream fetch failure -> 502. + exception_handlers={ + IllegalAction: _unprocessable_handler, + SetURLNotAllowedError: _unprocessable_handler, + SetSchemaError: _unprocessable_handler, + UnknownEffectError: _unprocessable_handler, + InvalidEffectParamsError: _unprocessable_handler, + DuplicateCardError: _unprocessable_handler, + SetFetchError: _bad_gateway_handler, + }, ) diff --git a/src/mundane/api/card_schema/__init__.py b/src/mundane/api/card_schema/__init__.py new file mode 100644 index 0000000..37b1151 --- /dev/null +++ b/src/mundane/api/card_schema/__init__.py @@ -0,0 +1,6 @@ +"""Vendored, pinned copy of the Mundane card-set JSON Schema (the validation contract). + +This mirrors the meta repo (``mundane``) ``schemas/card-set.schema.json`` and tracks its ``schema-v1`` +tag. It is **vendored** — never fetched at runtime — so set validation never depends on the network +or on unrelated changes to the schema's ``main``. Re-vendor this file when ``schema-v1`` is bumped. +""" diff --git a/src/mundane/api/card_schema/card-set.schema.json b/src/mundane/api/card_schema/card-set.schema.json new file mode 100644 index 0000000..4a13e66 --- /dev/null +++ b/src/mundane/api/card_schema/card-set.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docs.letsbuilda.dev/mundane/schemas/card-set.schema.json", + "title": "Mundane card set", + "description": "A set of Mundane cards. Cards carry bare ids; the loader composes the namespaced runtime id as `set_id:id`. The `effect` field names an effect from the engine's fixed vocabulary (validated by the engine at load time, not by this schema); `params` is a generic object the engine interprets per effect.", + "type": "object", + "required": ["set_id", "name", "version", "cards"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Optional editor/tooling hint; the file is validated against this card-set schema, not the value here." + }, + "set_id": { + "type": "string", + "pattern": "^[a-z0-9_]+$", + "description": "Namespace for every card in this set; composed with each card id as `set_id:id`." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable set name." + }, + "version": { + "type": "string", + "description": "Set version: semver (e.g. 1.0.0) or an ISO-8601 date (e.g. 2026-05-31).", + "anyOf": [ + { "pattern": "^\\d+\\.\\d+\\.\\d+$" }, + { "pattern": "^\\d{4}-\\d{2}-\\d{2}$" } + ] + }, + "cards": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/card" }, + "description": "The cards in this set (non-empty)." + } + }, + "$defs": { + "card": { + "type": "object", + "required": ["id", "name", "cost", "type", "effect", "text"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9_]+$", + "description": "Bare card id, unique within the set; the loader composes `set_id:id`." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Display name." + }, + "cost": { + "type": "integer", + "minimum": 0, + "description": "Time required to play the card." + }, + "type": { + "description": "One of the engine's five card types.", + "enum": ["person", "appliance", "habit", "task", "instant"] + }, + "effect": { + "type": "string", + "pattern": "^[a-z0-9_]+$", + "description": "Name of an effect in the engine's fixed vocabulary. Permanents use `none`. The engine rejects unknown names at load time; this schema does not check the vocabulary." + }, + "params": { + "type": "object", + "default": {}, + "description": "Generic parameters for the named effect; the engine validates these per effect." + }, + "text": { + "type": "string", + "description": "Rules text shown to players." + }, + "flavor": { + "type": "string", + "description": "Optional flavor text." + } + } + } + } +} diff --git a/src/mundane/api/set_loader.py b/src/mundane/api/set_loader.py new file mode 100644 index 0000000..9dfd54d --- /dev/null +++ b/src/mundane/api/set_loader.py @@ -0,0 +1,138 @@ +"""Fetch, allowlist, validate, and snapshot external card sets — the only HTTP-aware card code. + +The engine never reaches the network; this module does. Given a list of set URLs it: (1) allowlists +each (only the ``mundane-cards`` raw origin), (2) fetches with hardening (https, timeout, no +redirects, size cap, content-type check), (3) validates each body against the **vendored** schema, +(4) builds the cards via the engine loader, rejecting duplicate composed ids, and (5) returns the +resolved pool plus a JSON-ready snapshot with a content hash so an exported game replays +self-contained. On any failure it raises before the caller stores anything. +""" + +import json +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from hashlib import sha256 +from importlib import resources +from typing import TYPE_CHECKING +from urllib.parse import urlsplit + +import httpx +from jsonschema import Draft202012Validator +from jsonschema import ValidationError as JSONSchemaValidationError + +from mundane.engine.cards import DuplicateCardError, build_card +from mundane.engine.serialize import canonical_json + +if TYPE_CHECKING: + from mundane.engine.state import Card + +DEFAULT_SET_URLS: tuple[str, ...] = ("https://raw.githubusercontent.com/letsbuilda/mundane-cards/main/sets/core.json",) +ALLOWLIST_HOST = "raw.githubusercontent.com" +ALLOWLIST_PATH_PREFIX = "/letsbuilda/mundane-cards/" +MAX_SET_BYTES = 1 << 20 # 1 MiB is plenty for a JSON card set +FETCH_TIMEOUT_SECONDS = 5.0 + +_SCHEMA: dict[str, object] = json.loads( + resources.files("mundane.api.card_schema").joinpath("card-set.schema.json").read_text(encoding="utf-8"), +) +_VALIDATOR = Draft202012Validator(_SCHEMA) + +Fetcher = Callable[[str], bytes] +"""Fetches the raw bytes of a set at a URL. Injectable so tests can serve a fixture offline.""" + + +class SetURLNotAllowedError(Exception): + """A requested set URL is not on the allowlist. Maps to HTTP 422.""" + + +class SetFetchError(Exception): + """A set could not be fetched (network, timeout, status, content-type, or size). Maps to 502.""" + + +class SetSchemaError(Exception): + """A fetched set was not valid JSON or failed schema validation. Maps to HTTP 422.""" + + +@dataclass(frozen=True) +class ResolvedPool: + """The engine-facing pool plus the serialisable snapshot (resolved cards + content hash).""" + + cards: dict[str, Card] + snapshot: dict[str, object] + + +def _check_allowed(url: str, host: str, prefix: str) -> None: + """Reject ``url`` unless it is https, exactly on ``host``, and under ``prefix`` (parsed, not substring).""" + parts = urlsplit(url) + host_ok = (parts.hostname or "").lower() == host.lower() + if parts.scheme != "https" or not host_ok or not parts.path.startswith(prefix): + msg = f"set URL is not allowlisted: {url!r}" + raise SetURLNotAllowedError(msg) + + +def default_fetch(url: str) -> bytes: + """Fetch ``url`` with hardening: a hard timeout, no redirects, a size cap, and a content-type check.""" + try: + with ( + httpx.Client(timeout=httpx.Timeout(FETCH_TIMEOUT_SECONDS), follow_redirects=False) as client, + client.stream("GET", url) as response, + ): + if response.status_code != httpx.codes.OK: + msg = f"failed to fetch {url!r}: HTTP {response.status_code}" + raise SetFetchError(msg) + content_type = response.headers.get("content-type", "") + if not content_type.startswith(("application/json", "text/plain")): + msg = f"unexpected content-type for {url!r}: {content_type!r}" + raise SetFetchError(msg) + chunks: list[bytes] = [] + total = 0 + for chunk in response.iter_bytes(): + total += len(chunk) + if total > MAX_SET_BYTES: + msg = f"set at {url!r} exceeds {MAX_SET_BYTES} bytes" + raise SetFetchError(msg) + chunks.append(chunk) + except httpx.HTTPError as exc: + msg = f"failed to fetch {url!r}: {exc}" + raise SetFetchError(msg) from exc + return b"".join(chunks) + + +def load_sets( + set_urls: Sequence[str], + *, + fetch: Fetcher = default_fetch, + allowlist_host: str = ALLOWLIST_HOST, + allowlist_prefix: str = ALLOWLIST_PATH_PREFIX, +) -> ResolvedPool: + """Allowlist, fetch, validate, and build the combined pool + snapshot for ``set_urls``. + + Raises before returning on any problem: :class:`SetURLNotAllowedError` / + :class:`SetSchemaError` / engine ``UnknownEffectError`` / ``InvalidEffectParamsError`` / + ``DuplicateCardError`` (all 422 at the API), or :class:`SetFetchError` (502). + """ + pool: dict[str, Card] = {} + snapshot_cards: list[dict[str, object]] = [] + for url in set_urls: + _check_allowed(url, allowlist_host, allowlist_prefix) + raw = fetch(url) + try: + body = json.loads(raw) + except json.JSONDecodeError as exc: + msg = f"set at {url!r} is not valid JSON: {exc}" + raise SetSchemaError(msg) from exc + try: + _VALIDATOR.validate(body) + except JSONSchemaValidationError as exc: + msg = f"set at {url!r} failed schema validation: {exc.message}" + raise SetSchemaError(msg) from exc + set_id = body["set_id"] + for card_dict in body["cards"]: + card = build_card(set_id, card_dict) + if card.id in pool: + msg = f"duplicate card id {card.id!r}" + raise DuplicateCardError(msg) + pool[card.id] = card + snapshot_cards.append({**card_dict, "id": card.id}) + content_hash = "sha256:" + sha256(canonical_json(snapshot_cards).encode()).hexdigest() + return ResolvedPool(cards=pool, snapshot={"cards": snapshot_cards, "content_hash": content_hash}) diff --git a/src/mundane/engine/cards.py b/src/mundane/engine/cards.py index 41510fa..145a626 100644 --- a/src/mundane/engine/cards.py +++ b/src/mundane/engine/cards.py @@ -1,73 +1,127 @@ -"""The card library: id -> Card definitions, and the effect functions they carry. +"""The effect vocabulary (the fixed set of behaviours) and the JSON-card loader. -This is the ONLY place effect functions (code) live. State references cards by id; resolving an id -through :data:`CARD_LIBRARY` is how the engine recovers a card's type, cost, and effect. +This is the ONLY place effect functions live. A JSON card names an effect from :data:`EFFECTS` and +supplies ``params``; it cannot define new behaviour. :func:`build_card` composes the namespaced id +``set_id:id`` and binds the named effect's ``params`` into a closure. The hardcoded ``CARD_LIBRARY`` +is gone: card *content* now lives in JSON sets (see ``mundane-cards``), fetched and validated by the +API, while card *behaviour* stays here as code. """ -from .state import Card, CardType, GameState, StackItem +from collections.abc import Callable, Mapping +from typing import cast +from .state import Card, CardType, Effect, GameState, StackItem -def throw_a_party(state: GameState, item: StackItem) -> None: - """Deal 3 chaos to the opponent's Composure.""" - opponent = state.opponent(item.controller) - state.players[opponent].composure -= 3 +class UnknownEffectError(Exception): + """A JSON card names an effect that is not in the :data:`EFFECTS` vocabulary.""" -def noise_complaint(state: GameState, item: StackItem) -> None: - """Counter a spell on the stack. - With an explicit ``target_id``, counter that item; otherwise counter the spell directly beneath - it (the most recent one). A countered card goes to its controller's discard. +class InvalidEffectParamsError(Exception): + """A JSON card's ``params`` are missing or the wrong type for its named effect.""" + + +class DuplicateCardError(Exception): + """Two cards resolved to the same composed id.""" + + +type EffectFactory = Callable[[Mapping[str, object]], Effect] +"""Takes a card's ``params`` and returns a bound :data:`~mundane.engine.state.Effect` closure.""" + + +def _require_int_param(params: Mapping[str, object], key: str) -> int: + """Return ``params[key]`` as an int, or reject the card with :class:`InvalidEffectParamsError`.""" + value = params.get(key) + if not isinstance(value, int) or isinstance(value, bool): + msg = f"effect param {key!r} must be an integer" + raise InvalidEffectParamsError(msg) + return value + + +def damage_composure(params: Mapping[str, object]) -> Effect: + """Return an effect that deals ``params['amount']`` chaos to the opponent's Composure.""" + amount = _require_int_param(params, "amount") + + def effect(state: GameState, item: StackItem) -> None: + """Deal the bound amount of chaos to the opponent's Composure.""" + opponent = state.opponent(item.controller) + state.players[opponent].composure -= amount + + return effect + + +def counter_task(_params: Mapping[str, object]) -> Effect: + """Return an effect that counters a task on the stack (the old Noise Complaint). No params.""" + + def effect(state: GameState, item: StackItem) -> None: + """Counter the targeted stack item, or the one directly beneath, if any.""" + target: StackItem | None = None + if item.target_id is not None: + target = next((s for s in state.stack if s.id == item.target_id), None) + elif state.stack: + target = state.stack[-1] + if target is not None: + state.stack.remove(target) + state.players[target.controller].discard.append(target.card_id) + + return effect + + +def none(_params: Mapping[str, object]) -> Effect: + """Return a no-op effect. Permanents name this so that every JSON card names an effect.""" + + def effect(_state: GameState, _item: StackItem) -> None: + """Do nothing. Permanents resolve onto the board, so their effect never fires.""" + + return effect + + +EFFECTS: dict[str, EffectFactory] = { + "damage_composure": damage_composure, + "counter_task": counter_task, + "none": none, +} +"""The fixed effect vocabulary: name -> factory. The engine is the sole authority on what's valid.""" + + +def build_card(set_id: str, card_dict: Mapping[str, object]) -> Card: + """Build one :class:`Card` from a (schema-validated) JSON card dict in set ``set_id``. + + Composes the namespaced id ``set_id:id``, looks the ``effect`` name up in :data:`EFFECTS` + (raising :class:`UnknownEffectError` if absent), and binds ``params`` into the effect closure + (the factory raises :class:`InvalidEffectParamsError` on bad params). """ - target: StackItem | None = None - if item.target_id is not None: - target = next((s for s in state.stack if s.id == item.target_id), None) - elif state.stack: - target = state.stack[-1] - if target is not None: - state.stack.remove(target) - state.players[target.controller].discard.append(target.card_id) - - -CARD_LIBRARY: dict[str, Card] = { - card.id: card - for card in ( - Card( - id="throw_a_house_party", - name="Throw a House Party", - cost=3, - type=CardType.TASK, - effect=throw_a_party, - text="Deal 3 chaos to your opponent's Composure.", - ), - Card( - id="noise_complaint", - name="Noise Complaint", - cost=1, - type=CardType.INSTANT, - effect=noise_complaint, - text="Counter target task on the stack.", - ), - Card( - id="helpful_roommate", - name="Helpful Roommate", - cost=2, - type=CardType.PERSON, - text="A dependable body around the house. Resolves onto your board.", - ), - Card( - id="espresso_machine", - name="Espresso Machine", - cost=2, - type=CardType.APPLIANCE, - text="A trusty appliance. Resolves onto your board.", - ), - Card( - id="morning_jog", - name="Morning Jog", - cost=1, - type=CardType.HABIT, - text="A wholesome routine. Resolves onto your board.", - ), + effect_name = cast("str", card_dict["effect"]) + try: + factory = EFFECTS[effect_name] + except KeyError as exc: + msg = f"unknown effect {effect_name!r} (known: {sorted(EFFECTS)})" + raise UnknownEffectError(msg) from exc + params = cast("Mapping[str, object]", card_dict.get("params", {})) + bound = factory(params) # may raise InvalidEffectParamsError + return Card( + id=f"{set_id}:{cast('str', card_dict['id'])}", + name=cast("str", card_dict["name"]), + cost=cast("int", card_dict["cost"]), + type=CardType(cast("str", card_dict["type"])), + effect=bound, + text=cast("str", card_dict.get("text", "")), + flavor=cast("str", card_dict.get("flavor", "")), ) -} + + +def build_pool(set_dict: Mapping[str, object]) -> dict[str, Card]: + """Build ``{composed_id: Card}`` from one parsed set dict (offline; used by tests and the demo). + + The API's ``set_loader`` does the same across multiple fetched sets; this is the pure, no-I/O + helper for a single already-parsed set. Raises :class:`DuplicateCardError` on a repeated id. + """ + set_id = cast("str", set_dict["set_id"]) + pool: dict[str, Card] = {} + for raw in cast("list[Mapping[str, object]]", set_dict["cards"]): + card = build_card(set_id, raw) + if card.id in pool: + msg = f"duplicate card id {card.id!r}" + raise DuplicateCardError(msg) + pool[card.id] = card + return pool diff --git a/src/mundane/engine/game.py b/src/mundane/engine/game.py index f157ce7..29202a8 100644 --- a/src/mundane/engine/game.py +++ b/src/mundane/engine/game.py @@ -1,9 +1,13 @@ -"""The Game wrapper: a :class:`GameState` plus the ordered log of actions that produced it. +"""The Game wrapper: a :class:`GameState`, the card pool it runs on, and the action log. -A game is event-sourced. The state is a fold of ``apply_action`` over the log, so the log alone can -rebuild the state — which is exactly the "download game log" / replay payload. ``submit`` appends to -the log **only when the action is accepted**; a rejected move (``IllegalAction``) never happened and -is never logged. +A game is event-sourced. The state is a fold of ``apply_action`` over the log, so the log alone (plus +the card snapshot) can rebuild the state — exactly the "download game log" / replay payload. ``submit`` +appends to the log **only when the action is accepted**; a rejected move (``IllegalAction``) never +happened and is never logged. + +The resolved card **pool** lives here (not on :class:`GameState`): it carries bound effect closures, +which are code and must never reach serialisable state. The **snapshot** is the pool's plain-data, +JSON-ready mirror (set by the API at creation time) so an exported game replays self-contained. """ from dataclasses import dataclass, field @@ -11,7 +15,7 @@ from .rules import apply_action from .serialize import action_to_dict, state_to_dict -from .state import GameState, Player +from .state import Card, GameState, Player if TYPE_CHECKING: from .actions import Action @@ -19,28 +23,35 @@ @dataclass class Game: - """A live game: its current state and the action log that produced it.""" + """A live game: its state, the card pool it runs on, the action log, and the export snapshot.""" state: GameState + cards: dict[str, Card] log: list[Action] = field(default_factory=list) + card_snapshot: dict[str, object] = field(default_factory=dict) def submit(self, action: Action) -> GameState: """Apply ``action`` (raising ``IllegalAction`` if illegal) and, only on success, log it.""" - apply_action(self.state, action) + apply_action(self.state, action, self.cards) self.log.append(action) return self.state def export(self) -> dict[str, object]: - """Return the serialised action log and final state — the download payload and replay seed.""" + """Return the serialised log, final state, and card snapshot — the download/replay payload.""" return { "log": [action_to_dict(action) for action in self.log], "final_state": state_to_dict(self.state), + "card_snapshot": self.card_snapshot, } -def new_game() -> Game: - """Create the opening position: Steve (the party) vs Alex (the complaint), 5 Time each, in Plan.""" - steve = Player(name="Steve", time=5, hand=["throw_a_house_party"]) - alex = Player(name="Alex", time=5, hand=["noise_complaint"]) +def new_game(cards: dict[str, Card]) -> Game: + """Create the opening position: Steve (the party) vs Alex (the complaint), 5 Time each, in Plan. + + ``cards`` is the resolved pool the game runs on. The opening hands name cards from the core set + (the demo deal); turning the pool into real per-player decks is a separate concern. + """ + steve = Player(name="Steve", time=5, hand=["core:throw_a_house_party"]) + alex = Player(name="Alex", time=5, hand=["core:noise_complaint"]) state = GameState(players=[steve, alex], active_player=0, priority_player=0, phase="PLAN") - return Game(state=state) + return Game(state=state, cards=cards) diff --git a/src/mundane/engine/rules.py b/src/mundane/engine/rules.py index bf11f26..4de289a 100644 --- a/src/mundane/engine/rules.py +++ b/src/mundane/engine/rules.py @@ -3,12 +3,11 @@ Every state change in the game goes through here. Each branch runs its ``_require`` preconditions first (which raise :class:`IllegalAction` on a bad move, mutating nothing) and only then mutates. The function returns the state so it composes as a reducer: -``final = reduce(apply_action, actions, initial)``. +``final = reduce(partial(apply_action, cards=pool), actions, initial)``. """ from .actions import Action, CastInstant, IllegalAction, PassPriority, PlayCard -from .cards import CARD_LIBRARY -from .state import PERMANENTS, PHASES, CardType, GameState, StackItem +from .state import PERMANENTS, PHASES, Card, CardType, GameState, StackItem def _require(condition: bool, message: str) -> None: # noqa: FBT001 (this is the rule-check idiom) @@ -17,7 +16,7 @@ def _require(condition: bool, message: str) -> None: # noqa: FBT001 (this is t raise IllegalAction(message) -def apply_action(state: GameState, action: Action) -> GameState: +def apply_action(state: GameState, action: Action, cards: dict[str, Card]) -> GameState: """Validate ``action`` against ``state``, then transition. Returns ``state``.""" _require(state.winner is None, "the game is over") @@ -32,7 +31,7 @@ def apply_action(state: GameState, action: Action) -> GameState: _require(not state.stack, "the stack must be empty for sorcery-speed cards") p = state.players[player] _require(0 <= idx < len(p.hand), "no such card in hand") - card = CARD_LIBRARY[p.hand[idx]] + card = cards[p.hand[idx]] _require(card.type != CardType.INSTANT, "use CastInstant for instants") _require(p.time >= card.cost, f"not enough Time ({p.time}/{card.cost})") card_id = p.hand.pop(idx) @@ -43,7 +42,7 @@ def apply_action(state: GameState, action: Action) -> GameState: _require(player == state.priority_player, "you don't have priority") p = state.players[player] _require(0 <= idx < len(p.hand), "no such card in hand") - card = CARD_LIBRARY[p.hand[idx]] + card = cards[p.hand[idx]] _require(card.type == CardType.INSTANT, "that card isn't an instant") _require(p.time >= card.cost, f"not enough Time ({p.time}/{card.cost})") card_id = p.hand.pop(idx) @@ -56,7 +55,7 @@ def apply_action(state: GameState, action: Action) -> GameState: if state.passes_in_a_row >= len(state.players): state.passes_in_a_row = 0 if state.stack: - _resolve_top(state) # the stack empties one item at a time + _resolve_top(state, cards) # the stack empties one item at a time state.priority_player = state.active_player else: _advance_phase(state) # nothing pending -> move on @@ -86,10 +85,10 @@ def _grant_priority_after_stack_change(state: GameState) -> None: state.passes_in_a_row = 0 -def _resolve_top(state: GameState) -> None: +def _resolve_top(state: GameState, cards: dict[str, Card]) -> None: """Resolve the top stack item (LIFO): permanents hit the board, others fire then go to discard.""" item = state.stack.pop() # LIFO: last on, first off - card = CARD_LIBRARY[item.card_id] + card = cards[item.card_id] if card.type in PERMANENTS: state.players[item.controller].board.append(item.card_id) else: diff --git a/src/mundane/engine/serialize.py b/src/mundane/engine/serialize.py index 1f42563..5a9e299 100644 --- a/src/mundane/engine/serialize.py +++ b/src/mundane/engine/serialize.py @@ -44,3 +44,8 @@ def action_to_dict(action: Action) -> dict[str, object]: def dumps(obj: object) -> str: """Serialise ``obj`` to a pretty-printed JSON string.""" return json.dumps(obj, indent=2) + + +def canonical_json(obj: object) -> str: + """Serialise ``obj`` deterministically for hashing: sorted keys, no insignificant whitespace.""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) diff --git a/src/mundane/engine/state.py b/src/mundane/engine/state.py index f350f74..e3271b9 100644 --- a/src/mundane/engine/state.py +++ b/src/mundane/engine/state.py @@ -1,9 +1,9 @@ """Game-state dataclasses: the single source of truth for a Mundane game. Nothing in this module mutates state; :func:`mundane.engine.rules.apply_action` is the only -thing that does. Cards are referenced **by id** throughout the state (see -:data:`mundane.engine.cards.CARD_LIBRARY`); card *objects* — and the effect functions they carry — -live only in the library, never in serialisable state. That separation is what lets a whole +thing that does. Cards are referenced **by id** throughout the state; card *objects* — built from +JSON sets by :func:`mundane.engine.cards.build_card` — and the effect closures they carry live only +in the per-game pool, never in serialisable state. That separation is what lets a whole :class:`GameState` round-trip through JSON. """ @@ -32,7 +32,11 @@ def _no_effect(_state: GameState, _item: StackItem) -> None: @dataclass class Card: - """A card *definition*. Lives only in CARD_LIBRARY; state refers to it by ``id``.""" + """A card *definition*, built from a JSON set by :func:`mundane.engine.cards.build_card`. + + State refers to a card only by its composed ``id`` (``set_id:id``); the bound ``effect`` closure + lives in the per-game pool, never in serialisable state. + """ id: str name: str @@ -40,6 +44,7 @@ class Card: type: CardType effect: Effect = _no_effect text: str = "" + flavor: str = "" PERMANENTS = (CardType.PERSON, CardType.APPLIANCE, CardType.HABIT) diff --git a/tests/fixtures/core.json b/tests/fixtures/core.json new file mode 100644 index 0000000..a41ee10 --- /dev/null +++ b/tests/fixtures/core.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "set_id": "core", + "name": "Core Set", + "version": "1.0.0", + "cards": [ + { + "id": "throw_a_house_party", + "name": "Throw a House Party", + "cost": 3, + "type": "task", + "effect": "damage_composure", + "params": { "amount": 3 }, + "text": "Deal 3 chaos to your opponent's Composure." + }, + { + "id": "noise_complaint", + "name": "Noise Complaint", + "cost": 1, + "type": "instant", + "effect": "counter_task", + "params": {}, + "text": "Counter target task on the stack." + }, + { + "id": "helpful_roommate", + "name": "Helpful Roommate", + "cost": 2, + "type": "person", + "effect": "none", + "params": {}, + "text": "A dependable body around the house. Resolves onto your board." + }, + { + "id": "espresso_machine", + "name": "Espresso Machine", + "cost": 2, + "type": "appliance", + "effect": "none", + "params": {}, + "text": "A trusty appliance. Resolves onto your board." + }, + { + "id": "morning_jog", + "name": "Morning Jog", + "cost": 1, + "type": "habit", + "effect": "none", + "params": {}, + "text": "A wholesome routine. Resolves onto your board." + } + ] +} diff --git a/tests/test_api.py b/tests/test_api.py index 9bae231..425a2c9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,12 +1,15 @@ """API tests: drive the Litestar app over HTTP and confirm it is a thin shell over the engine.""" +import json +from pathlib import Path from typing import TYPE_CHECKING import pytest from litestar.testing import TestClient -from mundane.api.app import create_app +from mundane.api.app import GameStore, create_app from mundane.api.schemas import parse_action +from mundane.api.set_loader import DEFAULT_SET_URLS, Fetcher, SetFetchError from mundane.engine.actions import ACTION_TYPES, CastInstant, PassPriority, PlayCard from mundane.engine.serialize import action_to_dict @@ -17,6 +20,41 @@ from mundane.engine.actions import Action +_FIXTURE_BYTES = (Path(__file__).parent / "fixtures" / "core.json").read_bytes() +_DEFAULT_URL = DEFAULT_SET_URLS[0] +# A second allowlisted URL the "bad set" fetchers can answer for. +_ALLOWLISTED_URL = "https://raw.githubusercontent.com/letsbuilda/mundane-cards/main/sets/extra.json" + + +def _fake_fetch(url: str) -> bytes: + """Serve the core fixture for the default (allowlisted) URL; raise if any other URL is reached.""" + if url == _DEFAULT_URL: + return _FIXTURE_BYTES + msg = f"unexpected fetch in test: {url!r}" + raise SetFetchError(msg) + + +def _serve(body: bytes) -> Fetcher: + """Return a fetcher that answers with ``body`` (the URL must still pass the allowlist first).""" + + def fetch(_url: str) -> bytes: + """Return the captured body regardless of URL.""" + return body + + return fetch + + +def _boom_fetch(_url: str) -> bytes: + """Fail as if the upstream were down (a fetcher that always raises).""" + msg = "upstream is down" + raise SetFetchError(msg) + + +def _set_bytes(cards: list[dict[str, object]]) -> bytes: + """Serialise a minimal, otherwise-valid set wrapping ``cards`` to bytes.""" + return json.dumps({"set_id": "extra", "name": "Extra", "version": "1.0.0", "cards": cards}).encode() + + COUNTERED_PARTY_SEQUENCE: list[dict[str, object]] = [ {"type": "play_card", "player": 0, "hand_index": 0}, {"type": "pass_priority", "player": 0}, @@ -37,8 +75,8 @@ @pytest.fixture def client() -> Iterator[TestClient[Litestar]]: - """Yield a TestClient backed by a freshly created app (and its own in-memory store).""" - with TestClient(app=create_app()) as test_client: + """Yield a TestClient whose store resolves card sets from the local core fixture (offline).""" + with TestClient(app=create_app(store=GameStore(fetch=_fake_fetch))) as test_client: yield test_client @@ -89,14 +127,86 @@ def test_malformed_action_body_returns_422(client: TestClient[Litestar], body: d assert client.post(f"/games/{game_id}/actions", json=body).status_code == 422 -def test_export_returns_log_and_final_state(client: TestClient[Litestar]) -> None: - """The export endpoint returns the serialised action log alongside the final state.""" +def test_create_game_snapshots_the_pool(client: TestClient[Litestar]) -> None: + """Creating a game snapshots the resolved pool (composed ids + a content hash) into the export.""" + game_id = client.post("/games").json()["game_id"] + snapshot = client.get(f"/games/{game_id}/export").json()["card_snapshot"] + assert snapshot["content_hash"].startswith("sha256:") + ids = [card["id"] for card in snapshot["cards"]] + assert "core:throw_a_house_party" in ids + assert "core:noise_complaint" in ids + + +@pytest.mark.parametrize( + "url", + [ + "https://evil.example/letsbuilda/mundane-cards/main/sets/core.json", # wrong host + "https://raw.githubusercontent.com.evil.com/letsbuilda/mundane-cards/main/sets/core.json", # host as prefix + "https://raw.githubusercontent.com/letsbuilda/other-repo/main/sets/core.json", # wrong path prefix + "http://raw.githubusercontent.com/letsbuilda/mundane-cards/main/sets/core.json", # not https + ], +) +def test_non_allowlisted_url_returns_422(client: TestClient[Litestar], url: str) -> None: + """A set URL off the allowlist (host parsed, not substring-matched) is rejected with 422.""" + assert client.post("/games", json={"set_urls": [url]}).status_code == 422 + + +def test_set_urls_must_be_a_list_of_strings(client: TestClient[Litestar]) -> None: + """A ``set_urls`` field that is not a list of strings is rejected with 422.""" + assert client.post("/games", json={"set_urls": "not-a-list"}).status_code == 422 + + +def test_schema_invalid_set_returns_422() -> None: + """A fetched set that fails schema validation (here: no ``cards``) is rejected with 422.""" + body = json.dumps({"set_id": "extra", "name": "Extra", "version": "1.0.0"}).encode() + with TestClient(app=create_app(store=GameStore(fetch=_serve(body)))) as client: + assert client.post("/games", json={"set_urls": [_ALLOWLISTED_URL]}).status_code == 422 + + +def test_unknown_effect_returns_422() -> None: + """A schema-valid set whose card names an effect outside the engine vocabulary is rejected (422).""" + body = _set_bytes([{"id": "x", "name": "X", "cost": 1, "type": "task", "effect": "teleport", "text": "t"}]) + with TestClient(app=create_app(store=GameStore(fetch=_serve(body)))) as client: + assert client.post("/games", json={"set_urls": [_ALLOWLISTED_URL]}).status_code == 422 + + +def test_bad_effect_params_returns_422() -> None: + """A card whose params are wrong for its effect (damage_composure without amount) is rejected (422).""" + body = _set_bytes([{"id": "x", "name": "X", "cost": 3, "type": "task", "effect": "damage_composure", "text": "t"}]) + with TestClient(app=create_app(store=GameStore(fetch=_serve(body)))) as client: + assert client.post("/games", json={"set_urls": [_ALLOWLISTED_URL]}).status_code == 422 + + +def test_duplicate_ids_returns_422() -> None: + """A set that defines the same composed id twice is rejected with 422.""" + card: dict[str, object] = {"id": "dup", "name": "Dup", "cost": 1, "type": "task", "effect": "none", "text": "t"} + body = _set_bytes([card, {**card, "name": "Dup 2"}]) + with TestClient(app=create_app(store=GameStore(fetch=_serve(body)))) as client: + assert client.post("/games", json={"set_urls": [_ALLOWLISTED_URL]}).status_code == 422 + + +def test_fetch_failure_returns_502() -> None: + """An upstream fetch failure (network/timeout/oversize) maps to 502, not 500.""" + with TestClient(app=create_app(store=GameStore(fetch=_boom_fetch))) as client: + assert client.post("/games", json={"set_urls": [_ALLOWLISTED_URL]}).status_code == 502 + + +def test_loader_failure_leaves_store_untouched() -> None: + """When loading fails, nothing is stored — the referee discipline holds at the API layer too.""" + store = GameStore(fetch=_fake_fetch) + with TestClient(app=create_app(store=store)) as client: + assert client.post("/games", json={"set_urls": ["https://evil.example/x"]}).status_code == 422 + assert store.games == {} + + +def test_export_returns_log_final_state_and_snapshot(client: TestClient[Litestar]) -> None: + """The export endpoint returns the action log, the final state, and the card snapshot.""" game_id = client.post("/games").json()["game_id"] for action in COUNTERED_PARTY_SEQUENCE: client.post(f"/games/{game_id}/actions", json=action) exported = client.get(f"/games/{game_id}/export").json() - assert sorted(exported) == ["final_state", "log"] + assert sorted(exported) == ["card_snapshot", "final_state", "log"] log = exported["log"] assert len(log) == 7 assert log[0] == {"type": "play_card", "player": 0, "hand_index": 0} @@ -111,7 +221,7 @@ def test_export_is_a_downloadable_attachment(client: TestClient[Litestar]) -> No assert response.status_code == 200 assert response.headers["content-disposition"] == f'attachment; filename="mundane-game-{game_id}.json"' assert response.headers["content-type"].startswith("application/json") - assert sorted(response.json()) == ["final_state", "log"] + assert sorted(response.json()) == ["card_snapshot", "final_state", "log"] def test_action_json_round_trips_through_the_parser() -> None: diff --git a/tests/test_replay.py b/tests/test_replay.py index 9f4730f..9810ce2 100644 --- a/tests/test_replay.py +++ b/tests/test_replay.py @@ -1,14 +1,20 @@ """Replay tests: a game's state is a pure fold of its action log (the event-sourcing property).""" -from functools import reduce +import json +from functools import partial, reduce +from pathlib import Path import pytest from mundane.engine.actions import CastInstant, IllegalAction, PassPriority, PlayCard +from mundane.engine.cards import build_pool from mundane.engine.game import Game, new_game from mundane.engine.rules import apply_action from mundane.engine.serialize import state_to_dict +POOL = build_pool(json.loads((Path(__file__).parent / "fixtures" / "core.json").read_text(encoding="utf-8"))) +_step = partial(apply_action, cards=POOL) + def _play_countered_party(game: Game) -> None: """Submit the canonical countered-party sequence to ``game``.""" @@ -23,30 +29,31 @@ def _play_countered_party(game: Game) -> None: def test_state_is_the_fold_of_its_log() -> None: """Folding a game's recorded log over a fresh initial state reproduces the live state.""" - game = new_game() + game = new_game(POOL) _play_countered_party(game) - replayed = reduce(apply_action, game.log, new_game().state) + replayed = reduce(_step, game.log, new_game(POOL).state) assert replayed == game.state def test_rejected_moves_are_not_logged() -> None: """A rejected action does not appear in the log, and the log stays a faithful replay.""" - game = new_game() + game = new_game(POOL) with pytest.raises(IllegalAction): game.submit(CastInstant(player=0, hand_index=0)) # the party is not an instant assert game.log == [] _play_countered_party(game) assert len(game.log) == 7 - assert reduce(apply_action, game.log, new_game().state) == game.state + assert reduce(_step, game.log, new_game(POOL).state) == game.state def test_export_contains_log_and_final_state() -> None: - """export() returns the serialised log plus the final state, ready to download and to replay.""" - game = new_game() + """export() returns the serialised log, the final state, and the card snapshot — ready to replay.""" + game = new_game(POOL) _play_countered_party(game) exported = game.export() assert exported["final_state"] == state_to_dict(game.state) + assert "card_snapshot" in exported log = exported["log"] assert isinstance(log, list) diff --git a/tests/test_rules.py b/tests/test_rules.py index c07d229..22fa8bc 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -3,22 +3,27 @@ import dataclasses import json from copy import deepcopy -from functools import reduce +from functools import partial, reduce +from pathlib import Path from typing import cast import pytest from demo import demo from mundane.engine.actions import Action, CastInstant, IllegalAction, PassPriority, PlayCard +from mundane.engine.cards import build_pool from mundane.engine.rules import apply_action from mundane.engine.serialize import _jsonify, dumps, state_to_dict from mundane.engine.state import CardType, GameState, Player +POOL = build_pool(json.loads((Path(__file__).parent / "fixtures" / "core.json").read_text(encoding="utf-8"))) +step = partial(apply_action, cards=POOL) + def party_scenario() -> GameState: """Build the opening position: Steve holds the party, Alex holds the complaint, 5 Time each.""" - steve = Player(name="Steve", time=5, hand=["throw_a_house_party"]) - alex = Player(name="Alex", time=5, hand=["noise_complaint"]) + steve = Player(name="Steve", time=5, hand=["core:throw_a_house_party"]) + alex = Player(name="Alex", time=5, hand=["core:noise_complaint"]) return GameState(players=[steve, alex], active_player=0, priority_player=0, phase="PLAN") @@ -37,16 +42,16 @@ def _assert_rejected(state: GameState, action: Action, needle: str) -> None: """Assert ``action`` is rejected with a message matching ``needle`` and state is unchanged.""" before = deepcopy(state) with pytest.raises(IllegalAction, match=needle): - apply_action(state, action) + apply_action(state, action, POOL) assert state == before def test_noise_complaint_counters_the_party() -> None: """Alex's Noise Complaint counters Steve's party; Alex stays at 20 and both cards hit discard.""" - final = reduce(apply_action, COUNTERED_PARTY_LOG, party_scenario()) + final = reduce(step, COUNTERED_PARTY_LOG, party_scenario()) assert final.players[1].composure == 20 - assert final.players[0].discard == ["throw_a_house_party"] - assert final.players[1].discard == ["noise_complaint"] + assert final.players[0].discard == ["core:throw_a_house_party"] + assert final.players[1].discard == ["core:noise_complaint"] assert final.phase == "DO_STUFF" assert final.stack == [] @@ -54,20 +59,20 @@ def test_noise_complaint_counters_the_party() -> None: def test_state_is_json_serialisable_by_id() -> None: """A mid-game state serialises to JSON containing card ids, never effect-function reprs.""" state = party_scenario() - apply_action(state, PlayCard(player=0, hand_index=0)) # party now on the stack + apply_action(state, PlayCard(player=0, hand_index=0), POOL) # party now on the stack blob = json.dumps(dataclasses.asdict(state)) - assert "throw_a_house_party" in blob + assert "core:throw_a_house_party" in blob assert " None: """A mid-game state serialises to a JSON string that parses back to the same dict.""" state = party_scenario() - apply_action(state, PlayCard(player=0, hand_index=0)) # party on the stack + apply_action(state, PlayCard(player=0, hand_index=0), POOL) # party on the stack blob = dumps(state_to_dict(state)) parsed = json.loads(blob) assert parsed == state_to_dict(state) - assert parsed["stack"][0]["card_id"] == "throw_a_house_party" + assert parsed["stack"][0]["card_id"] == "core:throw_a_house_party" assert parsed["players"][0]["hand"] == [] @@ -98,15 +103,15 @@ def test_play_card_only_during_plan() -> None: def test_play_card_requires_empty_stack() -> None: """Sorcery-speed cards require an empty stack.""" state = party_scenario() - state.players[0].hand = ["throw_a_house_party", "espresso_machine"] - apply_action(state, PlayCard(player=0, hand_index=0)) # party -> stack, priority stays with Steve + state.players[0].hand = ["core:throw_a_house_party", "core:espresso_machine"] + apply_action(state, PlayCard(player=0, hand_index=0), POOL) # party -> stack, priority stays with Steve _assert_rejected(state, PlayCard(player=0, hand_index=0), "stack must be empty") def test_play_card_rejects_an_instant() -> None: """An instant must be cast with CastInstant, not played at sorcery speed.""" state = party_scenario() - state.players[0].hand = ["noise_complaint"] + state.players[0].hand = ["core:noise_complaint"] _assert_rejected(state, PlayCard(player=0, hand_index=0), "use CastInstant for instants") @@ -120,7 +125,7 @@ def test_play_card_requires_enough_time() -> None: def test_cast_instant_requires_priority() -> None: """Only the player who holds priority may cast an instant.""" state = party_scenario() - state.players[1].hand = ["noise_complaint"] + state.players[1].hand = ["core:noise_complaint"] _assert_rejected(state, CastInstant(player=1, hand_index=0), "don't have priority") @@ -139,10 +144,10 @@ def test_no_actions_after_game_over() -> None: def test_permanent_resolves_onto_the_board() -> None: """A PERSON / APPLIANCE / HABIT resolves onto its controller's board, not the discard.""" state = party_scenario() - state.players[0].hand = ["helpful_roommate"] + state.players[0].hand = ["core:helpful_roommate"] log: list[Action] = [PlayCard(player=0, hand_index=0), PassPriority(player=0), PassPriority(player=1)] - final = reduce(apply_action, log, state) - assert final.players[0].board == ["helpful_roommate"] + final = reduce(step, log, state) + assert final.players[0].board == ["core:helpful_roommate"] assert final.players[0].discard == [] @@ -151,7 +156,7 @@ def test_winner_is_set_when_composure_hits_zero() -> None: state = party_scenario() state.players[1].composure = 2 # the party deals 3 log: list[Action] = [PlayCard(player=0, hand_index=0), PassPriority(player=0), PassPriority(player=1)] - final = reduce(apply_action, log, state) + final = reduce(step, log, state) assert final.players[1].composure == -1 assert final.winner == 0 @@ -160,24 +165,27 @@ def test_apply_action_rejects_an_unknown_action() -> None: """A value that is not one of the known actions is rejected (the engine's defensive guard).""" state = party_scenario() with pytest.raises(IllegalAction, match="unknown action"): - apply_action(state, cast("Action", object())) + apply_action(state, cast("Action", object()), POOL) def _pass_n(state: GameState, n: int) -> None: """Apply ``n`` PassPriority actions, each from whoever currently holds priority.""" for _ in range(n): - apply_action(state, PassPriority(player=state.priority_player)) + apply_action(state, PassPriority(player=state.priority_player), POOL) def test_passing_through_all_phases_ends_the_turn_and_draws() -> None: """Passing with an empty stack advances phases; after Wind Down the turn ends, Time refreshes, a card is drawn.""" - state = GameState(players=[Player(name="A", time=5), Player(name="B", deck=["espresso_machine"])], phase="PLAN") + state = GameState( + players=[Player(name="A", time=5), Player(name="B", deck=["core:espresso_machine"])], + phase="PLAN", + ) _pass_n(state, 6) # PLAN -> DO_STUFF -> WIND_DOWN -> end of turn assert state.active_player == 1 assert state.phase == "RESET" assert state.turn == 2 assert state.players[1].time == 5 - assert state.players[1].hand == ["espresso_machine"] + assert state.players[1].hand == ["core:espresso_machine"] assert state.players[1].deck == [] @@ -194,22 +202,25 @@ def test_turn_end_with_empty_deck_skips_the_draw() -> None: def test_noise_complaint_can_counter_a_specific_target_by_id() -> None: """Noise Complaint with an explicit target_id counters that stack item by its id.""" state = party_scenario() - apply_action(state, PlayCard(player=0, hand_index=0)) # party -> stack as id 1 - apply_action(state, PassPriority(player=0)) - apply_action(state, CastInstant(player=1, hand_index=0, target_id=1)) # target the party explicitly - apply_action(state, PassPriority(player=0)) - apply_action(state, PassPriority(player=1)) # both pass -> complaint resolves and counters party 1 - assert state.players[0].discard == ["throw_a_house_party"] - assert state.players[1].discard == ["noise_complaint"] + apply_action(state, PlayCard(player=0, hand_index=0), POOL) # party -> stack as id 1 + apply_action(state, PassPriority(player=0), POOL) + apply_action(state, CastInstant(player=1, hand_index=0, target_id=1), POOL) # target the party explicitly + apply_action(state, PassPriority(player=0), POOL) + apply_action(state, PassPriority(player=1), POOL) # both pass -> complaint resolves and counters party 1 + assert state.players[0].discard == ["core:throw_a_house_party"] + assert state.players[1].discard == ["core:noise_complaint"] def test_noise_complaint_with_nothing_to_counter_is_a_noop() -> None: """Cast with an empty stack beneath it, Noise Complaint counters nothing and just goes to discard.""" - state = GameState(players=[Player(name="A", time=5, hand=["noise_complaint"]), Player(name="B")], phase="PLAN") - apply_action(state, CastInstant(player=0, hand_index=0)) # only the complaint is ever on the stack - apply_action(state, PassPriority(player=0)) - apply_action(state, PassPriority(player=1)) # complaint resolves with no spell beneath it - assert state.players[0].discard == ["noise_complaint"] + state = GameState( + players=[Player(name="A", time=5, hand=["core:noise_complaint"]), Player(name="B")], + phase="PLAN", + ) + apply_action(state, CastInstant(player=0, hand_index=0), POOL) # only the complaint is ever on the stack + apply_action(state, PassPriority(player=0), POOL) + apply_action(state, PassPriority(player=1), POOL) # complaint resolves with no spell beneath it + assert state.players[0].discard == ["core:noise_complaint"] assert state.stack == [] diff --git a/uv.lock b/uv.lock index eb228f2..cb434c8 100644 --- a/uv.lock +++ b/uv.lock @@ -335,6 +335,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "jsonschema-specifications", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "referencing", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "rpds-py", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -436,6 +463,8 @@ name = "mundane" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "httpx", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "jsonschema", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "litestar", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "pydantic", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "pydantic-settings", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, @@ -449,6 +478,7 @@ dev = [ { name = "prek", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "ruff", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "ty", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "types-jsonschema", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] docs = [ { name = "furo", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, @@ -459,13 +489,14 @@ docs = [ tests = [ { name = "allure-pytest", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "coverage", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "httpx", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "pytest", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "pytest-randomly", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] [package.metadata] requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "jsonschema", specifier = ">=4.26" }, { name = "litestar", git = "https://github.com/litestar-org/litestar.git?rev=main" }, { name = "pydantic", specifier = ">=2.13.4" }, { name = "pydantic-settings", specifier = ">=2.14.1" }, @@ -479,6 +510,7 @@ dev = [ { name = "prek", specifier = ">=0.4.0" }, { name = "ruff", specifier = ">=0.15.13" }, { name = "ty", specifier = ">=0.0.37" }, + { name = "types-jsonschema", specifier = ">=4.26" }, ] docs = [ { name = "furo", specifier = ">=2024.5.6" }, @@ -489,7 +521,6 @@ docs = [ tests = [ { name = "allure-pytest", specifier = ">=2.15.3" }, { name = "coverage", specifier = ">=7.14.0" }, - { name = "httpx", specifier = ">=0.28.1" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-randomly", specifier = ">=4.0.1" }, ] @@ -701,6 +732,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "rpds-py", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "releases" version = "2.1.1" @@ -765,6 +809,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, +] + [[package]] name = "ruff" version = "0.15.14" @@ -929,6 +985,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/53/64c4a27067a46643fea2b3fcf21a8a2f838d91a65ffdd14f2e82945b9538/ty-0.0.39-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:eceb6c91dcd05a231119f82abdd9aa337513de23ca6ac990bc44f88791dc1799", size = 11792477, upload-time = "2026-05-22T21:09:24.923Z" }, ] +[[package]] +name = "types-jsonschema" +version = "4.26.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/46/73b6a5d61a61015c4248030a8cb07e5bdddb4041430fae9e585a68692578/types_jsonschema-4.26.0.20260518.tar.gz", hash = "sha256:e1dd53dc97a64f5eccdd6fa9839666e09bb500a8ebba2db6fdaf1789faea81a6", size = 16638, upload-time = "2026-05-18T06:06:44.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d5/134f8a147dcecda10db7f60cfc6af0578a25a5c53c87b3907a64385e0184/types_jsonschema-4.26.0.20260518-py3-none-any.whl", hash = "sha256:30b30a518c7fe335df85c919fcbcc631b69c03d4a4b5b632fa916bea03065307", size = 16072, upload-time = "2026-05-18T06:06:43.264Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"