Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
27 changes: 25 additions & 2 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
-----------
Expand Down Expand Up @@ -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 <https://github.com/letsbuilda/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"]}'
4 changes: 4 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ Changelog
JSON-serialisable :class:`mundane.engine.state.GameState` that stores cards by id.
* :feature:`0` A thin `Litestar <https://litestar.dev>`_ 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 <https://github.com/letsbuilda/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.
34 changes: 23 additions & 11 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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>`_. This site
documents the *implementation*; the spec describes the *game*.
The game's rules live in the meta/spec repository, not here: the
`specification <https://github.com/letsbuilda/mundane/blob/main/specs/>`_ and
`rulebook <https://github.com/letsbuilda/mundane/blob/main/rulebook/>`_. Card *content* is published
as JSON `card sets <https://github.com/letsbuilda/mundane/blob/main/specs/card-sets.md>`_ in
`mundane-cards <https://github.com/letsbuilda/mundane-cards>`_. This site documents the
*implementation*; the spec describes the *game*.

Architecture
------------
Expand All @@ -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
~~~~~~~
Expand All @@ -45,20 +49,28 @@ 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

* - 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
-------------
Expand Down
53 changes: 53 additions & 0 deletions examples/core.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
26 changes: 17 additions & 9 deletions examples/demo.py
Original file line number Diff line number Diff line change
@@ -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}")

Expand All @@ -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}")
Expand Down
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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",
Expand All @@ -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]
Expand Down
Loading
Loading