diff --git a/.omc/project-memory.json b/.omc/project-memory.json index f762367..11c4f78 100644 --- a/.omc/project-memory.json +++ b/.omc/project-memory.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "lastScanned": 1778506113057, + "lastScanned": 1779116781682, "projectRoot": "/home/galadriel/Documents/Cline/risus-cli", "techStack": { "languages": [ @@ -59,7 +59,7 @@ "path": "build", "purpose": "Build output", "fileCount": 3, - "lastAccessed": 1778506113053, + "lastAccessed": 1779116781679, "keyFiles": [ "README.md", "entitlements.plist", @@ -70,7 +70,7 @@ "path": "client", "purpose": null, "fileCount": 4, - "lastAccessed": 1778506113054, + "lastAccessed": 1779116781680, "keyFiles": [ "__init__.py", "config.py", @@ -82,7 +82,7 @@ "path": "docker", "purpose": null, "fileCount": 1, - "lastAccessed": 1778506113055, + "lastAccessed": 1779116781680, "keyFiles": [ "server.Dockerfile" ] @@ -91,14 +91,14 @@ "path": "docs", "purpose": "Documentation", "fileCount": 0, - "lastAccessed": 1778506113055, + "lastAccessed": 1779116781680, "keyFiles": [] }, "server": { "path": "server", "purpose": null, "fileCount": 9, - "lastAccessed": 1778506113055, + "lastAccessed": 1779116781680, "keyFiles": [ "__init__.py", "app.py", @@ -111,14 +111,14 @@ "path": "specs", "purpose": null, "fileCount": 0, - "lastAccessed": 1778506113055, + "lastAccessed": 1779116781681, "keyFiles": [] }, "tests": { "path": "tests", "purpose": "Test files", "fileCount": 1, - "lastAccessed": 1778506113056, + "lastAccessed": 1779116781681, "keyFiles": [ "__init__.py" ] @@ -126,303 +126,111 @@ }, "hotPaths": [ { - "path": "risus.py", - "accessCount": 56, - "lastAccessed": 1777916869248, - "type": "file" - }, - { - "path": "specs/004-secure-session/spec.md", - "accessCount": 30, - "lastAccessed": 1777793576675, - "type": "file" - }, - { - "path": "AGENTS.md", - "accessCount": 30, - "lastAccessed": 1777916704724, - "type": "file" - }, - { - "path": ".specify/memory/constitution.md", - "accessCount": 24, - "lastAccessed": 1777916715339, - "type": "file" - }, - { - "path": "client/ws_client.py", - "accessCount": 22, - "lastAccessed": 1777916405389, - "type": "file" - }, - { - "path": "specs/003-standalone-client/spec.md", - "accessCount": 20, - "lastAccessed": 1777749597169, - "type": "file" - }, - { - "path": "specs/004-secure-session/research.md", - "accessCount": 18, - "lastAccessed": 1777793585167, - "type": "file" - }, - { - "path": "tests/unit/test_startup.py", - "accessCount": 17, - "lastAccessed": 1777829168338, - "type": "file" - }, - { - "path": "specs/003-standalone-client/research.md", - "accessCount": 15, - "lastAccessed": 1777748689762, - "type": "file" - }, - { - "path": "pyproject.toml", - "accessCount": 15, - "lastAccessed": 1777918559393, - "type": "file" - }, - { - "path": "specs/004-secure-session/tasks.md", - "accessCount": 14, - "lastAccessed": 1777793571549, - "type": "file" - }, - { - "path": "specs/003-standalone-client/quickstart.md", - "accessCount": 14, - "lastAccessed": 1777870035001, - "type": "file" - }, - { - "path": "specs/003-standalone-client/plan.md", - "accessCount": 13, - "lastAccessed": 1777750552764, - "type": "file" - }, - { - "path": "specs/004-secure-session/plan.md", - "accessCount": 11, - "lastAccessed": 1777793576461, - "type": "file" - }, - { - "path": "CONTRIBUTING.md", - "accessCount": 11, - "lastAccessed": 1777811756662, - "type": "file" - }, - { - "path": "tests/unit/test_token_auth.py", - "accessCount": 11, - "lastAccessed": 1777829168275, - "type": "file" - }, - { - "path": "CLAUDE.md", - "accessCount": 11, - "lastAccessed": 1777831479114, - "type": "file" - }, - { - "path": "PLAYER.md", - "accessCount": 11, - "lastAccessed": 1777870046991, - "type": "file" - }, - { - "path": "specs/003-standalone-client/tasks.md", - "accessCount": 10, - "lastAccessed": 1777749596432, - "type": "file" - }, - { - "path": "specs/003-standalone-client/contracts/config-file.md", - "accessCount": 10, - "lastAccessed": 1777749610191, - "type": "file" - }, - { - "path": "server/ws.py", - "accessCount": 10, - "lastAccessed": 1777805461916, - "type": "file" - }, - { - "path": "client/config.py", - "accessCount": 10, - "lastAccessed": 1777827349702, - "type": "file" - }, - { - "path": "specs/003-standalone-client/data-model.md", - "accessCount": 9, - "lastAccessed": 1777748689564, - "type": "file" - }, - { - "path": "README.md", - "accessCount": 9, - "lastAccessed": 1777754918429, - "type": "file" - }, - { - "path": "specs/004-secure-session/data-model.md", - "accessCount": 9, - "lastAccessed": 1777793585576, - "type": "file" - }, - { - "path": "specs/003-standalone-client/contracts/interactive-prompt.md", - "accessCount": 8, - "lastAccessed": 1777749610999, - "type": "file" - }, - { - "path": "tests/unit/test_config.py", - "accessCount": 8, - "lastAccessed": 1777805289485, - "type": "file" - }, - { - "path": ".markdownlint.json", - "accessCount": 7, - "lastAccessed": 1777793394439, - "type": "file" - }, - { - "path": "specs/004-secure-session/quickstart.md", - "accessCount": 7, - "lastAccessed": 1777811757100, - "type": "file" - }, - { - "path": "docs/features/secure-session/concept.md", - "accessCount": 6, - "lastAccessed": 1777790692529, - "type": "file" - }, - { - "path": "specs/004-secure-session/contracts/ws-token-auth.md", + "path": "docs/architecture/component-communication.md", "accessCount": 6, - "lastAccessed": 1777792672902, + "lastAccessed": 1779118337802, "type": "file" }, { - "path": "docker-compose.yml", - "accessCount": 6, - "lastAccessed": 1777805376531, - "type": "file" - }, - { - "path": "docs/features/secure-session/prd.md", - "accessCount": 5, - "lastAccessed": 1777790692581, - "type": "file" - }, - { - "path": "tests/unit/test_ws_protocol.py", - "accessCount": 5, - "lastAccessed": 1777794140867, + "path": "docs/architecture/system-overview.md", + "accessCount": 3, + "lastAccessed": 1779118385782, "type": "file" }, { - "path": "tests/e2e/conftest.py", - "accessCount": 5, - "lastAccessed": 1777805289045, + "path": "docs/architecture/websocket-protocol.md", + "accessCount": 2, + "lastAccessed": 1779118338566, "type": "file" }, { - "path": "tests/e2e/test_token_auth.py", - "accessCount": 5, - "lastAccessed": 1777805532249, + "path": "docs/architecture/interaction-flows.md", + "accessCount": 2, + "lastAccessed": 1779118339002, "type": "file" }, { - "path": ".specify/feature.json", - "accessCount": 5, - "lastAccessed": 1777830776429, + "path": "docs/architecture/rest-endpoints.md", + "accessCount": 2, + "lastAccessed": 1779118339321, "type": "file" }, { - "path": "specs/004-secure-session/checklists/requirements.md", - "accessCount": 4, - "lastAccessed": 1777791778635, + "path": "docs/architecture/threading-model.md", + "accessCount": 2, + "lastAccessed": 1779118339409, "type": "file" }, { - "path": ".specify/templates/tasks-template.md", - "accessCount": 4, - "lastAccessed": 1777831528588, + "path": "risus.py", + "accessCount": 1, + "lastAccessed": 1779117009705, "type": "file" }, { - "path": "specs/003-standalone-client/checklists/requirements.md", - "accessCount": 3, - "lastAccessed": 1777744903324, + "path": "server/app.py", + "accessCount": 1, + "lastAccessed": 1779117009921, "type": "file" }, { - "path": ".specify/templates/spec-template.md", - "accessCount": 3, - "lastAccessed": 1777791070410, + "path": "server/models.py", + "accessCount": 1, + "lastAccessed": 1779117010121, "type": "file" }, { - "path": "risus.cfg.example", - "accessCount": 3, - "lastAccessed": 1777827879021, + "path": "server/rest.py", + "accessCount": 1, + "lastAccessed": 1779117010238, "type": "file" }, { - "path": "specs/003-standalone-client/contracts/artifact-naming.md", - "accessCount": 2, - "lastAccessed": 1777749611630, + "path": "server/ws.py", + "accessCount": 1, + "lastAccessed": 1779117014411, "type": "file" }, { - "path": "tests/e2e/test_two_clients.py", - "accessCount": 2, - "lastAccessed": 1777794263877, + "path": "client/ws_client.py", + "accessCount": 1, + "lastAccessed": 1779117014660, "type": "file" }, { - "path": "", + "path": "server/commands.py", "accessCount": 1, - "lastAccessed": 1776702156268, - "type": "directory" + "lastAccessed": 1779117014859, + "type": "file" }, { - "path": ".specify/templates/constitution-template.md", + "path": "client/state.py", "accessCount": 1, - "lastAccessed": 1777738982727, + "lastAccessed": 1779117015114, "type": "file" }, { - "path": ".specify/templates/plan-template.md", + "path": "server/locks.py", "accessCount": 1, - "lastAccessed": 1777738988998, + "lastAccessed": 1779117017876, "type": "file" }, { - "path": "specs/003-standalone-client/PROMPT.md", + "path": "server/db.py", "accessCount": 1, - "lastAccessed": 1777749396158, + "lastAccessed": 1779117019947, "type": "file" }, { - "path": "server/app.py", + "path": "client/config.py", "accessCount": 1, - "lastAccessed": 1777789320234, + "lastAccessed": 1779117020034, "type": "file" }, { - "path": "tests/e2e/test_compose_up.py", + "path": "AGENTS.md", "accessCount": 1, - "lastAccessed": 1777794234408, + "lastAccessed": 1779117030188, "type": "file" } ], diff --git a/README.md b/README.md index 9e8da77..68980be 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ instructions. See [CONTRIBUTING.md](CONTRIBUTING.md) for full setup, running, and testing instructions. +FINDING: Server cannot be built and started w/o installing ".[dev]" dependencies! +FINDING: python risus.py does not work w/o installing ".[client]" dependencies! + ```bash # Start the stack podman-compose up -d # or: docker compose up -d diff --git a/docs/architecture/component-communication.md b/docs/architecture/component-communication.md new file mode 100644 index 0000000..3c107b4 --- /dev/null +++ b/docs/architecture/component-communication.md @@ -0,0 +1,17 @@ +# Component Communication Architecture + +Risus CLI — multiplayer battle tracker. Pure CLI client + FastAPI server over WebSocket and REST. + +This document is an index. Each diagram lives in its own file. + +--- + +## Diagrams + +| Diagram | Description | +|---------|-------------| +| [System Overview](system-overview.md) | All client and server components with their internal wiring, cross-boundary WebSocket and REST connections, and the three PostgreSQL tables. | +| [WebSocket Message Protocol](websocket-protocol.md) | All 7 client→server command types and 6 server→client frame types, annotated with lock requirements, broadcast scope, and which commands produce which responses. | +| [Key Interaction Flows](interaction-flows.md) | Four sequence diagrams: the lock/edit/unlock happy path, concurrent edit rejection, automatic lock release on client disconnect, and battle save/load. | +| [REST Endpoints](rest-endpoints.md) | The three HTTP endpoints (`/healthz`, `/state`, `/saves`), the CLI code paths that call them, and their PostgreSQL backing tables. | +| [Threading Model](threading-model.md) | How the CLI splits work across a synchronous main thread and a background asyncio thread, connected via two queues and a thread-safe state mirror with an update event. | diff --git a/docs/architecture/interaction-flows.md b/docs/architecture/interaction-flows.md new file mode 100644 index 0000000..08353e8 --- /dev/null +++ b/docs/architecture/interaction-flows.md @@ -0,0 +1,90 @@ +# Key Interaction Flows + +Four sequence diagrams covering the most important runtime scenarios. The first shows the happy path: a client acquires a lock, edits a player, then releases the lock, with broadcasts propagating to other clients at each step. The second shows concurrent conflict: a second client attempts an edit on an already-locked player and receives a private error. The third shows automatic cleanup: when a client disconnects the server releases all its held locks and broadcasts presence/lock updates. The fourth shows persistence: a client saves the current battle state to a named snapshot or restores one, replacing all live player data. + +--- + +## Lock → Edit → Unlock + +```mermaid +sequenceDiagram + actor A as Client A + participant S as Server (ws.py + commands.py) + participant L as LockManager + participant D as Database + actor B as Client B + + A->>S: lock {player_name} + S->>L: acquire(player_name, A) + L-->>S: granted + S-->>A: lock_acquired (caller) + S-->>B: lock_acquired (broadcast) + + A->>S: switch_cliche {player_name, cliche, dice} + S->>L: check holder == A + L-->>S: ok + S->>D: UPDATE players + S-->>A: state (broadcast) + S-->>B: state (broadcast) + + A->>S: unlock {player_name} + S->>L: release(player_name) + S-->>A: lock_released (broadcast) + S-->>B: lock_released (broadcast) +``` + +## Concurrent Edit — Lock Denied + +```mermaid +sequenceDiagram + actor A as Client A (holds lock) + participant S as Server + participant L as LockManager + actor B as Client B + + Note over A,L: A already holds lock on "Aragorn" + + B->>S: switch_cliche {player_name: "Aragorn", ...} + S->>L: check holder + L-->>S: locked by A + S-->>B: error "lock required" (caller only) +``` + +## Client Disconnect — Auto Release + +```mermaid +sequenceDiagram + participant S as Server (ws.py) + participant L as LockManager + participant D as Database + actor X as Client (disconnects) + actor O as Other Clients + + X--xS: WebSocket closed + S->>L: release_all(client_name) + loop for each released lock + S-->>O: lock_released (broadcast) + end + S->>D: update presence + S-->>O: presence (broadcast) +``` + +## Save / Load + +```mermaid +sequenceDiagram + actor C as Client + participant S as Server + participant D as Database + + C->>S: save {save_name} + S->>D: INSERT/UPSERT saves (JSONB snapshot) + D-->>S: ok + S-->>C: (no broadcast — silent) + + C->>S: load {save_name} + S->>D: SELECT saves WHERE save_name + D-->>S: snapshot data + S->>D: DELETE players, INSERT from snapshot + S-->>C: state (broadcast all) +``` diff --git a/docs/architecture/rest-endpoints.md b/docs/architecture/rest-endpoints.md new file mode 100644 index 0000000..10d392d --- /dev/null +++ b/docs/architecture/rest-endpoints.md @@ -0,0 +1,31 @@ +# REST Endpoints + +Shows the three HTTP endpoints exposed by `server/rest.py` and the CLI code paths that call them. The client calls `/healthz` for liveness checks, `/state` to load the current player list on startup, and `/saves` to populate the load-battle menu. Each endpoint is backed directly by a PostgreSQL table — `players` for healthz and state, `saves` for the saves listing. + +--- + +```mermaid +graph LR + subgraph CLIENT + UI2["risus.py
Load menu
Health check"] + end + + subgraph REST_LAYER["server/rest.py"] + R1["GET /healthz
→ {ok: true}"] + R2["GET /state
→ {type: state, players: [...]}"] + R3["GET /saves
→ [{save_name, saved_at}]"] + end + + subgraph DB2["PostgreSQL"] + P["players"] + SV["saves"] + end + + UI2 -->|"liveness check"| R1 + UI2 -->|"initial state on connect"| R2 + UI2 -->|"list saves in menu"| R3 + + R1 -->|"SELECT 1"| P + R2 --> P + R3 --> SV +``` diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md new file mode 100644 index 0000000..d987a68 --- /dev/null +++ b/docs/architecture/system-overview.md @@ -0,0 +1,54 @@ +# System Overview + +Shows all major components of the Risus CLI system and how they connect. The CLI client subgraph contains the main loop, WebSocket client, state mirror, and config reader. The FastAPI server subgraph groups connection management, command dispatch, lock management, REST endpoints, DB helpers, and Pydantic models. PostgreSQL sits at the bottom holding three tables. Arrows show data flow: WebSocket frames between client and server, REST calls for initial state and saves listing, and SQL queries to the DB. + +--- + +```mermaid +graph TB + subgraph CLIENT["CLI Client (risus.py + client/)"] + direction TB + UI["risus.py
Main Loop / UI Renderer
(select.select polling)"] + WS_C["ws_client.py
Async WebSocket Client
(background thread)"] + STATE["state.py
ClientState
(thread-safe mirror)"] + CFG["config.py
Config Persistence
(risus.cfg)"] + + UI -->|"enqueue outbox"| WS_C + WS_C -->|"enqueue inbox"| UI + WS_C -->|"apply frame / set update_event"| STATE + STATE -->|"snapshot_*()"| UI + CFG -->|"server / name / token"| WS_C + end + + subgraph SERVER["FastAPI Server (server/)"] + direction TB + APP["app.py
Lifespan / DI
(DB pool, managers)"] + WS_S["ws.py
ConnectionManager
(presence, broadcast)"] + CMD["commands.py
Message Handlers
(8 command types)"] + LOCKS["locks.py
LockManager
(in-memory, session)"] + REST["rest.py
REST Endpoints"] + DB["db.py
DB Helpers
(asyncpg)"] + MODELS["models.py
Pydantic Models"] + + APP -->|"injects"| WS_S + APP -->|"injects"| LOCKS + APP -->|"injects"| DB + WS_S -->|"dispatch"| CMD + CMD -->|"acquire/release"| LOCKS + CMD -->|"CRUD / snapshots"| DB + CMD -->|"broadcast"| WS_S + REST -->|"read"| DB + MODELS -.->|"schema"| CMD + MODELS -.->|"schema"| WS_S + end + + subgraph STORAGE["PostgreSQL"] + TBL_P["players
(name PK, cliche, dice, lost_dice)"] + TBL_S["saves
(save_name PK, saved_at, data JSONB)"] + TBL_L["locks
(player_name PK, locked_by, acquired_at)
audit log — truncated on startup"] + end + + WS_C -->|"ws[s]://host/ws/{name}?token=…"| WS_S + UI -->|"GET /state · GET /saves · GET /healthz"| REST + DB -->|"queries"| STORAGE +``` diff --git a/docs/architecture/threading-model.md b/docs/architecture/threading-model.md new file mode 100644 index 0000000..8be586d --- /dev/null +++ b/docs/architecture/threading-model.md @@ -0,0 +1,38 @@ +# Threading Model + +Shows how the CLI client splits work across two threads. The main thread runs a synchronous `select.select()` poll loop that handles user input and terminal rendering. A background thread runs an asyncio event loop managing the WebSocket connection with automatic exponential-backoff reconnect. The two threads communicate through two thread-safe queues (outbox: main→ws, inbox: ws→main) and a `ClientState` object protected by a `threading.Lock`. The `update_event` flag wakes the main loop whenever the server pushes new state. + +--- + +```mermaid +graph TB + subgraph MAIN["Main Thread (sync)"] + LOOP["select.select() poll loop"] + RENDER["Terminal render
print() / clear()"] + INPUT["input() handlers"] + SNAP["state.snapshot_*()"] + end + + subgraph BG["Background Thread (asyncio)"] + ALOOP["asyncio event loop"] + WSC["WebSocket connection
auto-reconnect
1→2→4→8s backoff"] + RECV["recv task"] + SEND["send task"] + end + + subgraph SYNC["Shared (thread-safe)"] + OUTBOX["outbox Queue
(main→ws)"] + INBOX["inbox Queue
(ws→main)"] + CSTT["ClientState
(threading.Lock)
update_event"] + end + + INPUT -->|"put"| OUTBOX + OUTBOX -->|"get"| SEND + SEND -->|"ws.send_str"| WSC + WSC -->|"ws.receive_str"| RECV + RECV -->|"put"| INBOX + INBOX -->|"get / apply frame"| CSTT + CSTT -->|"update_event.set()"| LOOP + LOOP -->|"update_event triggered"| SNAP + SNAP --> RENDER +``` diff --git a/docs/architecture/websocket-protocol.md b/docs/architecture/websocket-protocol.md new file mode 100644 index 0000000..6daf22b --- /dev/null +++ b/docs/architecture/websocket-protocol.md @@ -0,0 +1,38 @@ +# WebSocket Message Protocol + +Maps every WebSocket message type in both directions. The left subgraph lists the 7 client-to-server commands (add player, edit cliche, reduce dice, lock, unlock, save, load) with their required fields and lock constraints. The right subgraph lists the 6 server-to-client frames (state, presence, lock_acquired, lock_released, lock_denied, error) annotated with whether they broadcast to all clients or go to the caller only. Edges show which command produces which response frame. + +--- + +```mermaid +graph LR + subgraph C2S["Client → Server"] + direction TB + c1["add_player
name · cliche · dice"] + c2["switch_cliche
player_name · cliche · dice
⚠ lock required"] + c3["reduce_dice
player_name · amount · is_dead
⚠ lock required"] + c4["lock
player_name"] + c5["unlock
player_name"] + c6["save
save_name"] + c7["load
save_name"] + end + + subgraph S2C["Server → Client"] + direction TB + s1["state
players[]
→ broadcast all
→ triggers UI refresh"] + s2["presence
clients[]
→ broadcast all
→ triggers UI refresh"] + s3["lock_acquired
player_name · locked_by
→ broadcast all
→ triggers UI refresh"] + s4["lock_released
player_name
→ broadcast all
→ triggers UI refresh"] + s5["lock_denied
player_name · locked_by
→ caller only"] + s6["error
message
→ caller only"] + end + + c1 -->|"DB insert →"| s1 + c2 -->|"lock check / DB update →"| s1 + c3 -->|"lock check / DB update/delete →"| s1 + c4 -->|"acquire"| s3 + c4 -->|"already held"| s5 + c5 -->|"release"| s4 + c6 -->|"DB save (no broadcast)"| s6 + c7 -->|"release all locks / DB replace →"| s1 +``` diff --git a/docs/features/health-status/prd.md b/docs/features/health-status/prd.md new file mode 100644 index 0000000..30fc66c --- /dev/null +++ b/docs/features/health-status/prd.md @@ -0,0 +1,60 @@ +Anforderungen +============= + +Vorbereitende Gedanken +---------------------- + +- Wie teilen wir die Health des jeweiligen Spielers mit? + +- Wann teilen wir die Health des jeweiligen Spielers mit? + +- Wie ändern wir den Health State? + +- Wie wird er angezeigt? + +- Wie werden die effektiv wirksamen Cliché Würfel angezeigt? + +- Welchen Cliché Wert muss der Player eingeben, wenn er Health 3 hat und das + Cliché wechselt - das reduzierte Cliché oder das volle Cliché? + -> A: + +Beispiel UI +=========== + +Gesunder Anfangszustand für 1 Player +------------------------------------ + +```text +Connected: El_Maestro +Battle state +======================================== + NAME HEALTH EFF. DICE CLICHE (DICE) + ---------------- --------- --------- ---------------- + Evil Wizard 6 3 Magic (3) + + 1. Add player + 2. Switch cliche + 3. Reduce dice + 4. Save + 5. Load + 6. Quit +``` + +Abzug wegen Health Zustand für 1 Player +--------------------------------------- + +```text +Connected: El_Maestro +Battle state +======================================== + NAME HEALTH EFF. DICE CLICHE (DICE) + ---------------- --------- --------- ---------------- + Evil Wizard 4 2 Magic (3) + + 1. Add player + 2. Switch cliche + 3. Reduce dice + 4. Save + 5. Load + 6. Quit +```