From f0b9fd1064387053020ee0e2c662540b215e56f0 Mon Sep 17 00:00:00 2001 From: Eudicy Date: Mon, 18 May 2026 17:28:19 +0200 Subject: [PATCH 1/3] docs: add component communication architecture diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mermaid diagrams covering system overview, WebSocket protocol, key interaction flows (lock/edit/unlock, lock denied, disconnect, save/load), REST endpoints, and threading model. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude Code --- docs/architecture/component-communication.md | 249 +++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 docs/architecture/component-communication.md diff --git a/docs/architecture/component-communication.md b/docs/architecture/component-communication.md new file mode 100644 index 0000000..3611a7c --- /dev/null +++ b/docs/architecture/component-communication.md @@ -0,0 +1,249 @@ +# Component Communication Architecture + +Risus CLI — multiplayer battle tracker. Pure CLI client + FastAPI server over WebSocket and REST. + +--- + +## System Overview + +```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 +``` + +--- + +## WebSocket Message Protocol + +```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 +``` + +--- + +## Key Interaction Flows + +### 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) +``` + +--- + +## REST Endpoints + +```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 +``` + +--- + +## Threading Model + +```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 +``` From 499131d45519fe0af7e9e9a15a4ddf9635229b32 Mon Sep 17 00:00:00 2001 From: Eudicy Date: Mon, 18 May 2026 17:33:33 +0200 Subject: [PATCH 2/3] docs: split architecture diagrams into per-section files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract each section of component-communication.md into its own file with a short prose description per diagram. Convert the original file into a table-of-contents with links and one-line summaries. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude Code --- docs/architecture/component-communication.md | 250 +------------------ docs/architecture/interaction-flows.md | 90 +++++++ docs/architecture/rest-endpoints.md | 31 +++ docs/architecture/system-overview.md | 54 ++++ docs/architecture/threading-model.md | 38 +++ docs/architecture/websocket-protocol.md | 38 +++ 6 files changed, 260 insertions(+), 241 deletions(-) create mode 100644 docs/architecture/interaction-flows.md create mode 100644 docs/architecture/rest-endpoints.md create mode 100644 docs/architecture/system-overview.md create mode 100644 docs/architecture/threading-model.md create mode 100644 docs/architecture/websocket-protocol.md diff --git a/docs/architecture/component-communication.md b/docs/architecture/component-communication.md index 3611a7c..3c107b4 100644 --- a/docs/architecture/component-communication.md +++ b/docs/architecture/component-communication.md @@ -2,248 +2,16 @@ Risus CLI — multiplayer battle tracker. Pure CLI client + FastAPI server over WebSocket and REST. ---- - -## System Overview - -```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 -``` - ---- - -## WebSocket Message Protocol - -```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 -``` - ---- - -## Key Interaction Flows - -### 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) -``` +This document is an index. Each diagram lives in its own file. --- -## REST Endpoints - -```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 -``` - ---- - -## Threading Model - -```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 +## Diagrams - 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 -``` +| 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 +``` From 90c52b4ce5eb432f8304df99f6b8046007302c2a Mon Sep 17 00:00:00 2001 From: Eudicy Date: Mon, 18 May 2026 18:28:33 +0200 Subject: [PATCH 3/3] docs: draft health-status feature (wip) --- .omc/project-memory.json | 304 ++++++----------------------- README.md | 3 + docs/features/health-status/prd.md | 60 ++++++ 3 files changed, 119 insertions(+), 248 deletions(-) create mode 100644 docs/features/health-status/prd.md 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/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 +```