Metadata-Version: 2.4
Name: kirby-api
Version: 0.1.0
Summary: Kirby — HERO System VTT backend API (FastAPI mono)
Author: PeterB
License: PolyForm Noncommercial License 1.0.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastapi<0.136,>=0.115
Requires-Dist: starlette<1.0,>=0.46
Requires-Dist: uvicorn[standard]>=0.32
Requires-Dist: httpx>=0.28
Requires-Dist: sqlalchemy>=2.0
Requires-Dist: alembic>=1.14
Requires-Dist: psycopg[binary]>=3.2
Requires-Dist: asyncpg>=0.30
Requires-Dist: pydantic>=2.10
Requires-Dist: pydantic-settings>=2.7
Requires-Dist: authlib>=1.3
Requires-Dist: cryptography>=44
Requires-Dist: python-jose[cryptography]>=3.3
Requires-Dist: pyjwt[crypto]>=2.10
Requires-Dist: redis>=5.2
Requires-Dist: kirby-cost>=0.6.1
Requires-Dist: lxml>=5.3
Requires-Dist: python-multipart>=0.0.20
Requires-Dist: email-validator>=2.2
Requires-Dist: docling>=2.91
Requires-Dist: sentence-transformers>=3.0
Requires-Dist: pgvector>=0.3
Requires-Dist: pypdfium2>=4.30
Requires-Dist: kirby-combat==0.18.5
Requires-Dist: kirby-dice>=0.1.0
Requires-Dist: kirby-ai>=0.3.0
Requires-Dist: prometheus-client>=0.21
Requires-Dist: prometheus-fastapi-instrumentator>=7.0
Requires-Dist: tenacity>=8.0
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: pytest>=8.3; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
Requires-Dist: pytest-cov>=6.0; extra == "dev"
Requires-Dist: httpx>=0.28; extra == "dev"
Requires-Dist: httpx-ws>=0.7; extra == "dev"
Requires-Dist: ruff>=0.8; extra == "dev"
Requires-Dist: mypy>=1.13; extra == "dev"
Requires-Dist: testcontainers[postgres]>=4.0; extra == "dev"
Dynamic: license-file

# kirby-api

FastAPI mono backend for **Kirby**, a HERO System VTT platform. Runs on Hetzner as a systemd service behind Cloudflare Tunnel at `kirby-api.productbinder.net`, port 9013 internally. Serves the SvelteKit frontend at `kirby.productbinder.io` (see `../kirby-app/`) plus the forked Foundry module that talks to it.

This is the backend half of a two-service stack. The frontend lives at `../kirby-app/`. The full design is in `../kirby/docs/superpowers/specs/2026-04-07-kirby-design.md`.

## What's here (minimum viable skeleton)

- `kirby/` — the Python package (FastAPI app, domains, engines)
  - `main.py` — FastAPI application entry point
  - `settings.py` — pydantic-settings config loaded from env
  - `auth/` — Mother Box: Discord OAuth, JWT issuance, JWKS, middleware
  - `tenants/` — multi-tenant-by-GM scoping
  - `characters/` — character CRUD, feature-flag-gated editor
    - `services/entity_service.py` — HDC import with byte-preserving round-trip; substitutes no value, refuses instead
  - `campaigns/` — campaign management
  - `library/` — The Source: bestiary, scenarios, lore
  - `media/` — Boom Tube: R2-backed asset storage
  - `templates/` — HDT template management (Main6E + 16 others)
  - `engines/` — pluggable engine modules
    - `cost/` — cost engine (absorbed from kirby-cost)
  - `combat/` — **Krackle**: a HARNESS over the `kirby-combat` engine (see below)
    - `models.py` — four SQLAlchemy tables (combat_session, combat_participant, combat_event, combat_narration)
    - `schemas.py` — Pydantic request/response shapes
    - `harness.py` — rehydrate-by-replay, one Phase per step, pinned builds, derived dice
    - `create.py` — seating a fight: rows, pinned builds, the engine's opening. Commits nothing
    - `choosers.py` — routes each fighter's Phase to its seat (player submission or model)
    - `events.py` — engine event ↔ `combat_event` row
    - `routes.py` — REST + WebSocket under `/api/combat/*`
    - `random_fight/` — matchmaking an N-way free-for-all, and the worker that runs it
    - `services/publish.py` + `services/subscriber.py` — Redis pub/sub fan-out
  - `admin/` — operator-only routes
  - `shared/` — DB session, Redis client, tenant-query helpers, errors
- `alembic/` — migration files
- `data/` — engine data files (template_6e.json, language_chart.json, etc.)
- `tests/` — pytest suite (unit, integration, fixtures, combat/)

## Combat module (Krackle) — a harness, not an engine

kirby-api resolves no rule. `kirby-combat` decides who acts, what the menu
is, whether the Segment is spent and whether the fight is over;
`kirby-ai` chooses and narrates. What this module owns is HTTP,
persistence, tenancy, fan-out and matchmaking. There is no api-side menu,
no api-side mapping of an engine word onto a local one, and no fallback:
`run_phase(..., on_unresolvable="raise")` always, so a kind the engine
cannot execute stops the fight instead of quietly becoming a rule not
applied.

The design is
[`../kirby/docs/superpowers/specs/2026-09-17-api-is-a-harness-design.md`](../kirby/docs/superpowers/specs/2026-09-17-api-is-a-harness-design.md).

### The surface

| Route | What it does |
| --- | --- |
| `POST /api/combat/sessions` | Seat a fight: participants, `template_id`, `seat`, optional `narration_seat`, `dice_seed`, `scene_seed`. GM only. |
| `GET /api/combat/sessions/{id}` | The fight's identity plus the state its log folds to and its `scene` (the engine's `Scene` — walls, surfaces, hazards, constructs — `None` when `scene_seed` is `None`). |
| `GET .../situation` | The Phase the engine would offer next, without running it. |
| `POST .../step` | Run ONE Phase with the fight's own seats. GM only. |
| `POST .../choose` | Declare for the combatant whose Phase it is, then run that Phase. |
| `POST .../run` | Repeated `step` to a decision or a Turn cap. Its `events`/`narration` are ONE PAGE (the same 500 cap `GET .../events` has) with `truncated` and `last_sequence` to page from. |
| `GET .../events` | The record, paged (`since=`, `limit`). |
| `GET .../state?at=` | The fight as it stood at a sequence — the ENGINE's `state_view()`, forwarded unchanged: vitals, position and facing, conditions, who each man can perceive, and whose Phase is next. A sequence past the record is a 400, never a silent clamp. |
| `GET .../narration` | The narration table: at most one line per Phase, keyed by the engine's sequence, each carrying `speaker_id` — the Phase actor's combatant id, in the same vocabulary as `combat_participant`'s combatant id — beside the `seat` that spoke it. |
| `WS .../live` | Live events; multi-worker fan-out over the `combat:{id}` Redis channel. |
| `GET /api/combat/version` | The engine and seat-package versions this deployment runs. A client that generated its event types from one engine's schema compares and refuses; this route holds no opinion about the comparison. |
| `POST /api/combat/random-fight` | Matchmake an N-way free-for-all and queue the loop. GM only. |

`POST /api/combat/random-fight` **needs `KIRBY_CORPUS_TENANT_ID` set to
the tenant the villain books were imported into.** The matchmaker draws
from that tenant and the caller's own, and from nowhere else: the
production corpus lives in the kirby-api database, `character_def.source`
is set freely by the caller on create, so `source IN ('CV1','CV2','CV3',
'OSE')` was never a fence. Unset, the route refuses with 409 rather than
opening the pool to every tenant.

`step`, `choose` and `run` report two failures rather than hiding them:
`narration_error` (the narration seat raised; the Phase stands) and
`publish_error` (the fan-out raised; the Phase stands and is on disk).
Neither is ever a placeholder — "the seat failed" and "he said nothing"
stay different facts.

### What playback promises

**The record holds the build the fight was fought with.** Each
participant's costed build doc is pinned into
`combat_participant.build_jsonb` when the fight is created, so every
rehydration rebuilds the starting men from the sheet they started with —
an XP spend, a variant or a re-import afterwards cannot rewrite that
fight's history.

**The api projects nothing here.** `state` on `state?at=`, on
`GET /sessions/{id}` and on `step` / `choose` / `run` is the engine's own
`kirby_combat.state_view()`, serialised by the engine's own `to_dict`, so
what a client reads is what the engine folded and not a second reading of
the log made here.

Rehydration is replay and only replay: the starting combatants from those
pinned builds, the arena from `scene_seed` (the engine generates it), then
the engine's own `apply_event` over every persisted row in order. There is
no snapshot and no mutable per-fight state column to drift from the
record.

Two honest limits:

- **Rolls are not logged.** Each step's roller is derived as
  `blake2b(f"{dice_seed}:{next_sequence}")`, so the dice a step drew are
  reproducible from the record — but no roll is stored, and a fight whose
  `dice_seed` is NULL (the row saying RANDOM) cannot be re-derived at all.
  State replays; the rolls behind it are re-derived, not read back.
- **A library release can re-cost a finished fight's numbers.** The build
  doc in the record is fixed, but a cost read off it comes from whichever
  `kirby-cost` is installed. The fight's events do not change; a total
  displayed beside them can.

## Install and run

`kirby-api` is published to public PyPI. It runs wherever Python 3.11+ and
Postgres run; the Hetzner box is one consumer of the published version, not
the place the software lives.

```bash
pip install kirby-api
kirby-api migrate      # alembic upgrade head, against the packaged migrations
kirby-api serve        # uvicorn on kirby.main:app
```

`serve` takes `--host`, `--port`, `--workers` and `--log-level`. The
migrations ship inside the wheel, so `migrate` needs no checkout.

### What it needs in the environment

Nothing has a production default, and a missing value is refused rather than
guessed at:

| Variable | What it is |
| --- | --- |
| `DATABASE_URL` | Postgres, with the `vector` extension available (pgvector). **Required** — both commands refuse and name it if it is unset. |
| `REDIS_URL` | Redis, for cross-worker WebSocket fan-out of live combat. |
| `KIRBY_MEDIALIB_API_URL` | The medialib instance every generation call goes through. kirby-api holds no model table and no provider key. |
| `TANNHAUSER_ISSUER` / `TANNHAUSER_JWKS_URL` / `TANNHAUSER_AUDIENCE` | The identity provider. kirby-api is a relying party: it validates JWTs, it never issues them. |
| `KIRBY_CORPUS_TENANT_ID` | The tenant holding the canonical character corpus the random fight matchmakes from. Unset means "the caller's own tenant only" — a deployment that has not said where its corpus lives gets a refusal, not somebody else's characters. |
| `KIRBY_API_URL` / `KIRBY_APP_URL` / `CORS_ORIGINS` | Self URL, frontend URL, and the origins allowed to call the API. |

`.env.example` carries the full list. No secret value belongs in any of the
files in this repository.

### A note on the test suite

Part of the suite reads Hero Games licensed material — the character corpus
and the HERO Designer `.hdt` template, addressed by `KIRBY_CORPUS_URL`,
`OSE_HDC_DIR`, `CHAMPIONS_HDC_DIR` and `KIRBY_COST_HDT`. That material is
never vendored, and those tests skip themselves wherever it is absent. They
gate the founder's own deployment; they do not gate a release of the
software, and the release workflow runs without any of it.

## Running locally

```bash
cd kirby-api
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
pip install -e ../kirby-cost   # cost engine, see below
cp .env.example .env
# edit .env with your local Postgres credentials + Discord app credentials
alembic upgrade head
uvicorn kirby.main:app --reload --port 9013
```

Then `curl http://127.0.0.1:9013/health` should return `{"status": "ok"}`.

### Cost engine setup

The cost engine (HDC parsing + HERO 6E cost math) lives in the sibling
`kirby-cost` repo and is consumed via the
`kirby.engines.cost` facade (see `kirby/engines/cost/__init__.py`).
Install it as an editable local package:

```bash
.venv/bin/pip install -e ../kirby-cost
```

This is a sibling-repo editable install — same pattern Codex uses for
PyTorch — so it's not declared in `pyproject.toml`. The facade re-exports
`HDCLoader`, `LoadedHero`, `Modifier`, `Adder`, `Multipower`,
`VariablePowerPool`, and `ElementalControl`, plus `load_hdc(bytes)` and
`load_hdc_from_path(path)` for the REST upload path.

## Running on Hetzner (production)

Via `kirby-api.service` (systemd), `git pull` + restart. See the design spec for full deploy notes. Mirrors the `medialib-api` operational shape.

## Environment variables

See `.env.example`. Key ones:

- `DATABASE_URL` — Postgres connection string (port 15432 on Hetzner, 5432 locally usually)
- `REDIS_URL` — Redis connection string (port 16379 on Hetzner)
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` — Discord OAuth app credentials
- `JWT_PRIVATE_KEY_PATH` / `JWT_PUBLIC_KEY_PATH` — RSA keypair for RS256 JWT signing
- `KIRBY_API_URL` — self-URL for OIDC discovery (`https://kirby-api.productbinder.net` in prod)
- `KIRBY_APP_URL` — frontend URL for OAuth callback redirects (`https://kirby.productbinder.io` in prod)

## Engine provenance (short version)

- **Cost engine** — Python port of HERO Designer's math, built and validated against the Java HD oracle (655 fixtures, 28,593 / 28,593 cost calculations matching). Fully covered by PeterB's $50 HD source code license purchased from herogames.com on 2026-04-07 (Simon's license, customer #29 ever).
- **Combat engine** — Pure-Python port of `dmdorman/hero6e-foundryvtt` (MIT). Lives in the separate `kirby-combat` package (`git@github.com:pdbethke/kirby-combat.git`), consumed as a library. Attribution to Dorman in `kirby-combat/LICENSE` and README. Phase 1 (to-hit, damage, defense, knockback, status, action pipeline) shipped 2026-04-16 with 98% test coverage. **This is the live combat engine — it is NOT conditional on any NDA.** An earlier plan to port from David Tannen's HCM under NDA was abandoned when David became unresponsive; PeterB's written commitments to David (no HCM source, no decompiled HCM reliance, Foundry-talking-backend architecture) are all honored by the Dorman-based approach.

## What publishes, and what never does

The **software** publishes: `kirby-api` goes to public PyPI alongside
`kirby-combat`, `kirby-cost`, `kirby-dice` and `kirby-ai` (founder decision,
2026-09-18). It is a product, not a box.

The **licensed material does not, ever**: no Hero Games character corpus, no
`.hdc` files, no HERO Designer `.hdt` templates, no HD source. None of it is
in this repository, none of it is in the wheel, and
`tests/test_wheel_contents.py` fails the build if anything secret-shaped
turns up in package data. See `project_hd_source_license.md`,
`project_david_tannen_emails.md` and `project_hero_games_org_chart.md` in
Claude's project memory for the full licensing posture and the commitments
PeterB has made.
