From 535f941ee1f604732697db70fb3fad7d59d4ed50 Mon Sep 17 00:00:00 2001 From: withrvr Date: Mon, 14 Sep 2026 11:01:07 +0000 Subject: [PATCH 1/2] feat(rooms): human chat, wired into existing AI persona chat (Phase 11) Personas and AI-side chat generation (AgentController.say, persona-driven voice) already existed since Phase 5. The actual gap: humans had no way to send a chat message at all, and AgentController.hear() existed but nothing ever called it, so an AI never learned about anyone else's chat. - RoomService.send_chat + POST /rooms/{room_id}/chat: own-seat-only, rate limited (RATE_LIMIT_CHAT, an already-scaffolded-but-unused setting), works whether or not a game is running (chat never touches game state, unlike /moves). Feeds a running game's AI seats via hear() and tags the event with that game's own correlation.game_id so it archives alongside the game, matching how an agent's own chat already does. - Chat.tsx: message list (seat badge showing an AI's persona instead of a bare seat number) + compose box, gated on having a seat. Wired into the room detail page for everyone -- chat is public. - Fixed a real type inaccuracy found while building this: seat was typed number | null in the hand-written chat event types; the real schema never allows null. - Found and fixed while documenting the route: GET /rooms/{id}/events' since pattern was still the old ^\d+-\d+$, missed when the websocket route's identical pattern was widened during PR #20's review round -- breaking transport parity. Widened to match. Also found and fixed a stale WS route doc row (still showed the pre-review-fix token flow). AI-to-AI hear() fan-out remains unwired -- a separate, older Phase-5 gap, found while wiring the human side, deliberately not fixed here. New tests: backend/tests/rooms/test_chat.py (6), a hear()-fan-out test in test_room_service.py, and a real two-human chat-exchange Playwright test. Claude-Session: https://claude.ai/code/session_01UbiPa1rKCyqhqufgwJWkeW --- PROGRESS.md | 64 ++++++- backend/app/api/routes/rooms.py | 35 +++- backend/app/rooms/service.py | 47 ++++- backend/tests/rooms/test_chat.py | 169 ++++++++++++++++++ backend/tests/rooms/test_room_service.py | 38 ++++ docs/agents.md | 12 +- docs/api/api-reference.md | 6 +- docs/rooms.md | 1 + frontend/src/client/index.ts | 2 +- frontend/src/client/sdk.gen.ts | 23 ++- frontend/src/client/types.gen.ts | 40 +++++ frontend/src/components/Rooms/Chat.tsx | 93 ++++++++++ frontend/src/lib/events.ts | 2 +- frontend/src/lib/roomLiveState.ts | 2 +- frontend/src/routes/_layout/rooms.$roomId.tsx | 8 + frontend/tests/rooms.spec.ts | 55 ++++++ 16 files changed, 586 insertions(+), 11 deletions(-) create mode 100644 backend/tests/rooms/test_chat.py create mode 100644 frontend/src/components/Rooms/Chat.tsx diff --git a/PROGRESS.md b/PROGRESS.md index 07aeb07..916e483 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -40,7 +40,7 @@ branch; only the phase branch opens a PR into `master`. | 9.6 | Upgrade to latest stable versions | done 2026-09-13 (PR #17) | | 9.7 | Docker, CI/CD, and server-start optimization | done 2026-09-14 (branch phase-9.7-infra-optimization, PR pending) | | 10 | Frontend: play, spectate, admin, focus view | done (branch phase-10-frontend, PR pending) | -| 11 | Chat and personas | | +| 11 | Chat and personas | done (branch phase-11-chat-and-personas, PR pending) | | 12 | Leaderboard | | | 13 | Observability | | | 14 | CI/CD (local dry-run; no server yet) | | @@ -826,6 +826,68 @@ calls have any path to influence. Both `rooms.py` and `deps.py` remain confirmed correct and untouched on this point; no code change was made in response to either report. +## Phase 11 — done 2026-09-14 (branch phase-11-chat-and-personas) + +Started by re-checking scope rather than assuming: personas +(`app/agents/persona.py`, 6 built-ins) and AI-side chat generation +(`AgentController.say()`, `build_chat_prompt`) already existed since +Phase 5 -- an AI seat already chats in its own persona's voice and +publishes real `chat.message` events. The actual gap was narrower than +the phase name suggests: **humans had no way to send a chat message at +all** (confirmed: zero `chat` routes anywhere), and `AgentController.hear()` +existed but nothing ever called it, so an AI never learned about anyone +else's chat either. + +- **`RoomService.send_chat(user, room_id, text)`** (`app/rooms/service.py`): + resolves the caller's own seat (`own_seat`, same as `/moves`/`/view`), + validates/strips text (empty or >200 chars -> `ValueError` -> 422, + matching the AI side's own 200-char cap), publishes a public + `chat.message`. Unlike `submit_move`, **not** gated on + `RoomNotRunningError` -- chat never touches game state (DECISIONS §8), + so it works in `waiting` too. If a game is running: tags the event with + that game's own `correlation.game_id` (so it archives alongside the + game exactly like an agent's own chat already does) and feeds the + message to every AI seat's `hear()` -- the first thing that ever calls + it. AI-to-AI `hear()` fan-out (one agent's chat reaching a sibling + agent's `recent_chat`) remains unwired -- a separate, older Phase-5 gap, + found while wiring the human side, deliberately not fixed here. +- **`POST /rooms/{room_id}/chat`**: rate limited (`RATE_LIMIT_CHAT`, + already a scaffolded-but-unused setting, default `10/10`), 403 for an + unseated caller, 422 for empty/oversized text. +- **Frontend**: `components/Rooms/Chat.tsx` -- message list (seat badge, + showing an AI seat's persona instead of a bare seat number) plus a + compose box, gated on having a seat (matching the backend's own-seat + rule, rather than letting a spectator submit and hit a confusing 403). + Wired into the room detail page for everyone, not just privileged + callers -- chat is public. Fixed a real type inaccuracy in + `src/lib/events.ts`/`roomLiveState.ts` found while building this: + `ChatMessagePayload.seat`/`ChatEntry.seat` were typed `number | null`; + the real schema (`ChatMessage(seat: int, text: str)`) never allows + `null`. +- **Found and fixed while documenting the route** (not asked for, but a + direct accuracy/consistency issue): `GET /rooms/{room_id}/events`'s + `since` query pattern was still `^\d+-\d+$`, rejecting the bus's own + bare `"0"` convention -- the *websocket* route's identical pattern was + already widened to `^\d+(-\d+)?$` during PR #20's review-fix round, but + the polling route was missed, breaking the "both transports must never + diverge" principle Phase 10 was built on. Widened to match. Also found + `docs/api/api-reference.md`'s `WS /ws` row still documented the + pre-review-fix token flow (raw `access_token`) and the old `since` + pattern -- corrected, and added the `ws-ticket` row it was missing. + +New tests: `backend/tests/rooms/test_chat.py` (6 tests: send visible via +`GET /events`, works before the game starts, 403 for an outsider, 422 for +empty/oversized text, rate limited) and +`test_room_service.py::test_send_chat_reaches_the_agent_seats_hear` (a +pure, synchronous check of the `hear()` fan-out -- no real LLM call +needed to observe it). `frontend/tests/rooms.spec.ts`: a real two-human +chat exchange, both directions, both pages, confirmed live over the +websocket (765 -> 772 backend tests; 56 -> 57 Playwright tests). + +Full gate: backend suite (772 tests) green, `bun run build`/`bun run lint` +clean, full Playwright suite (57 tests) green against the real docker +compose stack, `prek run --all-files` clean. + ## Phase 9.7 — done 2026-09-14 (branch phase-9.7-infra-optimization) Infrastructure-only pass: Docker image build, Compose, CI/CD workflows, diff --git a/backend/app/api/routes/rooms.py b/backend/app/api/routes/rooms.py index 4b08127..b5afa50 100644 --- a/backend/app/api/routes/rooms.py +++ b/backend/app/api/routes/rooms.py @@ -379,6 +379,39 @@ def submit_move( return Message(message="submitted") +class ChatSend(BaseModel): + text: str + + +@router.post( + "/{room_id}/chat", + response_model=Message, + dependencies=[ + Depends(rate_limit_from_setting("room:chat", settings.RATE_LIMIT_CHAT)) + ], +) +async def send_chat( + *, + service: RoomServiceDep, + user: CurrentUser, + room_id: uuid.UUID, + body: ChatSend, +) -> Any: + """Publishes a public ``chat.message`` from the caller's own seat + (DECISIONS.md §8) -- own-seat-only, matching ``/view``/``/moves``, and + unlike those, works whether or not a game is currently running: chat + never touches game state (docs/rooms.md).""" + try: + await service.send_chat(user, room_id, body.text) + except LookupError: + raise HTTPException(status_code=404, detail="Room not found") + except NotSeatedError: + raise HTTPException(status_code=403, detail="not seated in this room") + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + return Message(message="sent") + + @router.get("/{room_id}/moves/schema") def moves_schema( *, @@ -405,7 +438,7 @@ async def room_events( request: Request, user: CurrentUser, room_id: uuid.UUID, - since: Annotated[str | None, Query(pattern=r"^\d+-\d+$")] = None, + since: Annotated[str | None, Query(pattern=r"^\d+(-\d+)?$")] = None, limit: int = 200, ) -> Any: """Poll the room's event stream from ``since`` (an event's own diff --git a/backend/app/rooms/service.py b/backend/app/rooms/service.py index c0ade6e..d07cd4d 100644 --- a/backend/app/rooms/service.py +++ b/backend/app/rooms/service.py @@ -32,7 +32,14 @@ from app.api.views import view_payload from app.authz.service import grant_role from app.core.db import engine -from app.events import PUBLIC, EventBus, RoomCreated, SeatFilled, make_event +from app.events import ( + PUBLIC, + ChatMessage, + EventBus, + RoomCreated, + SeatFilled, + make_event, +) from app.games import Seat, get_game from app.models import ( GameEvent, @@ -695,3 +702,41 @@ def submit_move(self, user: User, room_id: uuid.UUID, move: dict[str, Any]) -> N if running.game.current_seat(running.state) != seat.seat: raise NotYourTurnError(f"seat {seat.seat} is not the current turn") self.runner.submit_move(str(room_id), seat.seat, move) + + async def send_chat(self, user: User, room_id: uuid.UUID, text: str) -> None: + """Publishes ``text`` as a public ``chat.message`` from the caller's + own seat (DECISIONS.md §8: downstream-only, shared between human and + AI players). Unlike ``submit_move``, chat never touches game state, + so this works whether or not a game is currently running -- there is + no ``RoomNotRunningError`` gate here. + + If a game *is* running: the message is fed to every AI seat's + ``hear()`` so personas actually react to what a human just said + (``AgentController.hear`` existed but nothing called it before this), + and tagged with that game's own ``correlation.game_id`` so it + archives alongside the game it was said during + (``RoomService._archive_game``, Phase 9) exactly like an agent's own + chat already does (``AgentController.say``). + """ + seat = self.own_seat(user, room_id) + stripped = text.strip() + if not stripped: + msg = "chat message must not be empty" + raise ValueError(msg) + if len(stripped) > 200: + msg = "chat message must be 200 characters or fewer" + raise ValueError(msg) + + chat = ChatMessage(seat=seat.seat, text=stripped) + game_id: str | None = None + with contextlib.suppress(KeyError): + running = self.runner.get(str(room_id)) + game_id = running.referee.game_id + for controller in running.controllers.values(): + if isinstance(controller, AgentController): + controller.hear([chat]) + + correlation = {"game_id": game_id} if game_id else None + await self.bus.publish( + make_event(str(room_id), chat, PUBLIC, correlation=correlation) + ) diff --git a/backend/tests/rooms/test_chat.py b/backend/tests/rooms/test_chat.py new file mode 100644 index 0000000..64c4098 --- /dev/null +++ b/backend/tests/rooms/test_chat.py @@ -0,0 +1,169 @@ +"""HTTP-level tests for ``POST /rooms/{room_id}/chat`` (Phase 11, +DECISIONS.md §8). Uses the real app (a real ``GameRunner``) so real human +seats send real chat through the HTTP surface, not mocks. +""" + +from __future__ import annotations + +import time +from collections.abc import Iterator +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings + +pytestmark = pytest.mark.redis + +API = settings.API_V1_STR + +LiveGame = tuple[str, dict[int, dict[str, str]]] + + +def _body(seat_count: int = 2, visibility: str = "private") -> dict[str, Any]: + return { + "game": "uno_classic", + "seat_count": seat_count, + "visibility": visibility, + "seats": [{"kind": "human"} for _ in range(seat_count)], + } + + +def _new_user_headers(client: TestClient) -> dict[str, str]: + from tests.utils.user import user_authentication_headers + from tests.utils.utils import random_email, random_lower_string + + email, password = random_email(), random_lower_string() + r = client.post(f"{API}/users/signup", json={"email": email, "password": password}) + assert r.status_code == 200, r.text + return user_authentication_headers(client=client, email=email, password=password) + + +def _create_and_join(client: TestClient, creator_headers: dict[str, str]) -> LiveGame: + """Creates a private 2-human-seat room and joins two fresh users to + fill both seats (the creator is never auto-seated -- RoomService.create + only seats a human whose user_id was given, which this doesn't). + Deliberately does *not* start it, so tests can check chat works before + a game exists too.""" + r = client.post(f"{API}/rooms/", headers=creator_headers, json=_body()) + assert r.status_code == 200, r.text + room_id, code = r.json()["id"], r.json()["code"] + + seats: dict[int, dict[str, str]] = {} + for _ in range(2): + headers = _new_user_headers(client) + joined = client.post(f"{API}/rooms/join", headers=headers, json={"code": code}) + assert joined.status_code == 200, joined.text + seats[joined.json()["seat"]] = headers + return room_id, seats + + +@pytest.fixture +def waiting_room( + client: TestClient, superuser_token_headers: dict[str, str] +) -> Iterator[LiveGame]: + room_id, seats = _create_and_join(client, superuser_token_headers) + yield room_id, seats + client.post(f"{API}/rooms/{room_id}/stop", headers=superuser_token_headers) + + +@pytest.fixture +def live_game( + client: TestClient, superuser_token_headers: dict[str, str] +) -> Iterator[LiveGame]: + room_id, seats = _create_and_join(client, superuser_token_headers) + r = client.post(f"{API}/rooms/{room_id}/start", headers=superuser_token_headers) + assert r.status_code == 200, r.text + yield room_id, seats + client.post(f"{API}/rooms/{room_id}/stop", headers=superuser_token_headers) + + +def _poll_events( + client: TestClient, room_id: str, headers: dict[str, str], predicate: Any +) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for _ in range(60): + r = client.get(f"{API}/rooms/{room_id}/events", headers=headers) + assert r.status_code == 200, r.text + events = r.json()["events"] + if predicate(events): + return events + time.sleep(0.05) + return events + + +def test_chat_send_is_visible_via_events( + client: TestClient, live_game: LiveGame +) -> None: + room_id, seats = live_game + r = client.post( + f"{API}/rooms/{room_id}/chat", headers=seats[0], json={"text": "gl hf"} + ) + assert r.status_code == 200, r.text + assert r.json()["message"] == "sent" + + events = _poll_events( + client, + room_id, + seats[1], + lambda evs: any(e["type"] == "chat.message" for e in evs), + ) + chats = [e for e in events if e["type"] == "chat.message"] + assert any( + c["payload"]["seat"] == 0 and c["payload"]["text"] == "gl hf" for c in chats + ) + + +def test_chat_works_before_the_game_starts( + client: TestClient, waiting_room: LiveGame +) -> None: + """Chat never touches game state -- unlike /moves, it must not require + a running game.""" + room_id, seats = waiting_room + r = client.post( + f"{API}/rooms/{room_id}/chat", headers=seats[0], json={"text": "hello"} + ) + assert r.status_code == 200, r.text + + +def test_chat_403s_for_an_outsider(client: TestClient, live_game: LiveGame) -> None: + room_id, _seats = live_game + outsider = _new_user_headers(client) + r = client.post( + f"{API}/rooms/{room_id}/chat", headers=outsider, json={"text": "hi"} + ) + assert r.status_code == 403 + + +def test_chat_422s_for_empty_text(client: TestClient, live_game: LiveGame) -> None: + room_id, seats = live_game + r = client.post( + f"{API}/rooms/{room_id}/chat", headers=seats[0], json={"text": " "} + ) + assert r.status_code == 422 + + +def test_chat_422s_for_text_over_200_chars( + client: TestClient, live_game: LiveGame +) -> None: + room_id, seats = live_game + r = client.post( + f"{API}/rooms/{room_id}/chat", headers=seats[0], json={"text": "x" * 201} + ) + assert r.status_code == 422 + + +def test_chat_is_rate_limited(client: TestClient, live_game: LiveGame) -> None: + """Review precedent: every mutation route (create, join, move) is rate + limited; chat should be too (RATE_LIMIT_CHAT, default 10/10).""" + room_id, seats = live_game + statuses = [] + for i in range(30): + r = client.post( + f"{API}/rooms/{room_id}/chat", headers=seats[0], json={"text": f"msg {i}"} + ) + statuses.append(r.status_code) + if r.status_code == 429: + break + assert 429 in statuses diff --git a/backend/tests/rooms/test_room_service.py b/backend/tests/rooms/test_room_service.py index 67589f9..f85bacb 100644 --- a/backend/tests/rooms/test_room_service.py +++ b/backend/tests/rooms/test_room_service.py @@ -353,6 +353,44 @@ async def test_all_agent_room_starts_directly( assert runner.active() == [] +# -- chat (Phase 11) ---------------------------------------------------------- + + +def _human_and_agent(creator_id: uuid.UUID) -> RoomCreate: + return RoomCreate( + game="uno_classic", + seat_count=2, + visibility="private", + seats=[ + RoomSeatSpec(kind="human", user_id=creator_id), + RoomSeatSpec(kind="agent"), + ], + ) + + +async def test_send_chat_reaches_the_agent_seats_hear( + db: Session, service: RoomService, runner: GameRunner +) -> None: + """``send_chat`` feeds the message to every AI seat's ``hear()`` -- + ``AgentController.hear`` existed but nothing called it before this, so + a human's chat never reached an agent's ``recent_chat`` at all. This + is a pure, synchronous effect of ``hear()`` itself -- no real LLM call + (``decide``/``say``) is needed to observe it.""" + creator = create_random_user(db) + room = await service.create(creator, _human_and_agent(creator.id)) + started = await service.start(room.id) + assert started.status == "in_progress" + + await service.send_chat(creator, room.id, "good luck!") + + running = runner.get(str(room.id)) + agent_seat = next(s.seat for s in service.seats(room.id) if s.kind == "agent") + controller = running.controllers[agent_seat] + assert any(m.text == "good luck!" for m in controller.recent_chat) + + await service.stop(room.id, "test cleanup") + + async def test_stop_on_a_room_that_never_started_is_a_safe_noop( db: Session, service: RoomService ) -> None: diff --git a/docs/agents.md b/docs/agents.md index 1b7ce0f..885f334 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -151,8 +151,16 @@ default). - **Call 2** (`say`): after the referee applied the move it calls `build_chat_prompt` and publishes a non-empty `say` as `chat.message` (public). Failures are swallowed: chat never affects the game. -- `hear(messages)` receives other seats' chat from the referee's fan-out; - the last 20 lines go into the next prompts. +- `hear(messages)` feeds other seats' chat into the last 20 lines of + `recent_chat`, which go into the next prompts. Phase 11 (`docs/rooms.md`) + is the first thing that actually calls it: `RoomService.send_chat` feeds + a human's message to every AI seat's `hear()` right after publishing it. + Nothing calls `hear()` for one *agent's* chat reaching a sibling + agent's `recent_chat` yet -- `say()` only ever appends to its own seat's + list -- so an AI currently reacts to what humans say but not (yet) to + what other AI seats say. Found while wiring the human side; a real, + separate gap in the Phase 5-era referee/controller wiring, not + something Phase 11 was asked to fix. - Every call is recorded on the current wide event under `llm.calls` (model, latency, tokens, ok/error, repairs); Phase 13 turns these into Prometheus series. diff --git a/docs/api/api-reference.md b/docs/api/api-reference.md index 32eb9d9..f225fd2 100644 --- a/docs/api/api-reference.md +++ b/docs/api/api-reference.md @@ -128,9 +128,11 @@ caller (the room's creator, or a holder of `room:view_seat_detail`); | `GET /rooms/{room_id}/queue` | Bearer | — | `QueueStatus {position, length, eta_seconds}` | `404` not queued | | `GET /rooms/{room_id}/view` | own human seat only | — | the caller's live `PlayerView` (`view_payload`, from `GameRunner`'s in-memory state, never the database) | `404` unknown room, `403` not seated, `409` game not running | | `POST /rooms/{room_id}/moves` | own human seat only + rate limit (`RATE_LIMIT_ROOM_MOVE`, default `30/10`) | `MoveSubmission {move: dict}` (≤4096 bytes serialized) | `Message` | `422` oversized body, `404` unknown room, `403` not seated, `409` game not running or not your turn | +| `POST /rooms/{room_id}/chat` | own human seat only + rate limit (`RATE_LIMIT_CHAT`, default `10/10`) | `ChatSend {text: str}` (1-200 chars after stripping) | `Message` | `422` empty/oversized text, `404` unknown room, `403` not seated | | `GET /rooms/{room_id}/moves/schema` | subscription-guard admitted (seated or privileged) | — | the game's move JSON Schema (`move_json_schema`) | `404` denied or unknown room | -| `GET /rooms/{room_id}/events` | subscription-guard admitted (seated or privileged) | query `since` (pattern `^\d+-\d+$`), `limit` (default 200) | `{"events": [...], "next_since": }` | `422` malformed `since`, `404` denied or unknown room | -| `WS /rooms/{room_id}/ws` | JWT in `?token=` query param (browsers cannot set a websocket's `Authorization` header); subscription-guard admitted (seated or privileged) | query `token`, optional `since` (pattern `^\d+-\d+$`, omit for live-only) | a stream of `EventEnvelope` JSON frames, filtered identically to `GET /events` | closes with `1008` (policy violation) for a bad/missing token, unknown room, or denied subscription — the three are never distinguishable, matching `GET /{room_id}`'s 404-either-way rule | +| `GET /rooms/{room_id}/events` | subscription-guard admitted (seated or privileged) | query `since` (pattern `^\d+(-\d+)?$`), `limit` (default 200) | `{"events": [...], "next_since": }` | `422` malformed `since`, `404` denied or unknown room | +| `GET /rooms/{room_id}/ws-ticket` | Bearer | — | `{"ticket": <30s JWT>}` — mint just before connecting to `/ws`; a long-lived `access_token` in a URL is a much wider log-exposure surface than a header ever gets | — | +| `WS /rooms/{room_id}/ws` | a `ws-ticket` (or any valid JWT) in `?token=` query param (browsers cannot set a websocket's `Authorization` header); subscription-guard admitted (seated or privileged); rate limited by client host | query `token`, optional `since` (pattern `^\d+(-\d+)?$`, omit for live-only) | a stream of `EventEnvelope` JSON frames, filtered identically to `GET /events` | closes with `1008` (policy violation) for a bad/missing token, unknown room, or denied subscription — the three are never distinguishable, matching `GET /{room_id}`'s 404-either-way rule | ## Games (`games.py`, prefix `/games`) — durable history, Postgres-backed, never the live bus diff --git a/docs/rooms.md b/docs/rooms.md index e24b01e..10141da 100644 --- a/docs/rooms.md +++ b/docs/rooms.md @@ -302,6 +302,7 @@ computes `privileged`/`viewer_id` per caller and redacts accordingly; `POST | `GET /{room_id}/queue` | authenticated | the caller's own `QueueStatus` for `room:{room_id}`; 404 if not queued | | `GET /{room_id}/view` | own human seat only | the caller's live `PlayerView` (`view_payload`, straight from `GameRunner`, never the DB); `NotSeatedError` -> 403, `RoomNotRunningError` -> 409 | | `POST /{room_id}/moves` | own human seat only | body `{"move": {...}}`, forwarded verbatim to `GameRunner.submit_move` for the caller's own seat (server-derived, never client-supplied); `NotSeatedError` -> 403, `RoomNotRunningError` -> 409, `NotYourTurnError` -> 409 | +| `POST /{room_id}/chat` | own human seat only + rate limit (`RATE_LIMIT_CHAT`, default `10/10`) | body `{"text": str}` (1-200 chars after stripping); publishes a public `chat.message` from the caller's own seat -- unlike `/moves`, works whether or not a game is running (chat never touches game state); if a game *is* running, also feeds every AI seat's `AgentController.hear()` and tags the event with that game's `correlation.game_id` (Phase 11) | | `GET /{room_id}/moves/schema` | `subscription_guard`-admitted (seated or privileged) | `app.games.move_schema.move_json_schema(game)` — a terminal client's guide to constructing a valid move body; denial -> 404 | | `GET /{room_id}/events` | `subscription_guard`-admitted (seated or privileged) | `?since=&limit=200`; polls `EventBus.history` then drops anything `filter_for_subscriber` denies for the caller's resolved `Subscriber`; returns `{"events": [...], "next_since": }` for the next poll; denial -> 404 | diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index a0367e1..b8b2935 100644 --- a/frontend/src/client/index.ts +++ b/frontend/src/client/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { AdminService, FlagsService, GamesService, HealthService, ItemsService, LoginService, type Options, PrivateService, RolesService, RoomsService, UsersService, UtilsService } from './sdk.gen'; -export type { adminCreateOverrideData, adminCreateOverrideError, adminCreateOverrideErrors, adminCreateOverrideResponse, adminCreateOverrideResponses, adminReadFlagsData, adminReadFlagsResponse, adminReadFlagsResponses, adminReadSettingsData, adminReadSettingsResponse, adminReadSettingsResponses, adminRemoveOverrideData, adminRemoveOverrideError, adminRemoveOverrideErrors, adminRemoveOverrideResponse, adminRemoveOverrideResponses, adminUpdateFlagData, adminUpdateFlagError, adminUpdateFlagErrors, adminUpdateFlagResponse, adminUpdateFlagResponses, adminUpdateSettingData, adminUpdateSettingError, adminUpdateSettingErrors, adminUpdateSettingResponse, adminUpdateSettingResponses, Body_login_login_access_token, ClientOptions, FeatureFlagOverrideCreate, FeatureFlagOverridePublic, FeatureFlagPublic, FeatureFlagUpdate, flagsReadMyFlagsData, flagsReadMyFlagsResponse, flagsReadMyFlagsResponses, GameRecordPublic, gamesGameEventsData, gamesGameEventsError, gamesGameEventsErrors, gamesGameEventsResponses, gamesGetGameRecordData, gamesGetGameRecordError, gamesGetGameRecordErrors, gamesGetGameRecordResponse, gamesGetGameRecordResponses, gamesReplayData, gamesReplayError, gamesReplayErrors, gamesReplayResponses, healthHealthData, healthHealthLlmData, healthHealthLlmResponse, healthHealthLlmResponses, healthHealthResponse, healthHealthResponses, HealthReport, HTTPValidationError, ItemCreate, ItemPublic, itemsCreateItemData, itemsCreateItemError, itemsCreateItemErrors, itemsCreateItemResponse, itemsCreateItemResponses, itemsDeleteItemData, itemsDeleteItemError, itemsDeleteItemErrors, itemsDeleteItemResponse, itemsDeleteItemResponses, ItemsPublic, itemsReadItemData, itemsReadItemError, itemsReadItemErrors, itemsReadItemResponse, itemsReadItemResponses, itemsReadItemsData, itemsReadItemsError, itemsReadItemsErrors, itemsReadItemsResponse, itemsReadItemsResponses, itemsUpdateItemData, itemsUpdateItemError, itemsUpdateItemErrors, itemsUpdateItemResponse, itemsUpdateItemResponses, ItemUpdate, LLMHealth, loginLoginAccessTokenData, loginLoginAccessTokenError, loginLoginAccessTokenErrors, loginLoginAccessTokenResponse, loginLoginAccessTokenResponses, loginRecoverPasswordData, loginRecoverPasswordError, loginRecoverPasswordErrors, loginRecoverPasswordHtmlContentData, loginRecoverPasswordHtmlContentError, loginRecoverPasswordHtmlContentErrors, loginRecoverPasswordHtmlContentResponse, loginRecoverPasswordHtmlContentResponses, loginRecoverPasswordResponse, loginRecoverPasswordResponses, loginResetPasswordData, loginResetPasswordError, loginResetPasswordErrors, loginResetPasswordResponse, loginResetPasswordResponses, loginTestTokenData, loginTestTokenResponse, loginTestTokenResponses, Message, MoveSubmission, NewPassword, PermissionsPublic, privateCreateUserData, privateCreateUserError, privateCreateUserErrors, privateCreateUserResponse, privateCreateUserResponses, PrivateUserCreate, RoleAssignment, RolePublic, rolesGrantUserRoleData, rolesGrantUserRoleError, rolesGrantUserRoleErrors, rolesGrantUserRoleResponse, rolesGrantUserRoleResponses, rolesReadPermissionsData, rolesReadPermissionsResponse, rolesReadPermissionsResponses, rolesReadRolesData, rolesReadRolesResponse, rolesReadRolesResponses, rolesReadUserRolesData, rolesReadUserRolesError, rolesReadUserRolesErrors, rolesReadUserRolesResponse, rolesReadUserRolesResponses, rolesRevokeUserRoleData, rolesRevokeUserRoleError, rolesRevokeUserRoleErrors, rolesRevokeUserRoleResponse, rolesRevokeUserRoleResponses, RoomCreate, RoomJoinRequest, RoomPublic, roomsCreateRoomData, roomsCreateRoomError, roomsCreateRoomErrors, roomsCreateRoomResponse, roomsCreateRoomResponses, RoomSeatJoinPublic, RoomSeatPublic, RoomSeatSpec, roomsForceStartRoomData, roomsForceStartRoomError, roomsForceStartRoomErrors, roomsForceStartRoomResponse, roomsForceStartRoomResponses, roomsGetRoomData, roomsGetRoomError, roomsGetRoomErrors, roomsGetRoomResponse, roomsGetRoomResponses, roomsJoinRoomData, roomsJoinRoomError, roomsJoinRoomErrors, roomsJoinRoomResponse, roomsJoinRoomResponses, roomsLeaveRoomData, roomsLeaveRoomError, roomsLeaveRoomErrors, roomsLeaveRoomResponse, roomsLeaveRoomResponses, roomsListRoomsData, roomsListRoomsError, roomsListRoomsErrors, roomsListRoomsResponse, roomsListRoomsResponses, roomsMovesSchemaData, roomsMovesSchemaError, roomsMovesSchemaErrors, roomsMovesSchemaResponses, RoomsPublic, roomsRoomEventsData, roomsRoomEventsError, roomsRoomEventsErrors, roomsRoomEventsResponses, roomsRoomQueueData, roomsRoomQueueError, roomsRoomQueueErrors, roomsRoomQueueResponses, roomsRoomViewData, roomsRoomViewError, roomsRoomViewErrors, roomsRoomViewResponses, roomsStartRoomData, roomsStartRoomError, roomsStartRoomErrors, roomsStartRoomResponse, roomsStartRoomResponses, roomsStopRoomData, roomsStopRoomError, roomsStopRoomErrors, roomsStopRoomResponse, roomsStopRoomResponses, roomsSubmitMoveData, roomsSubmitMoveError, roomsSubmitMoveErrors, roomsSubmitMoveResponse, roomsSubmitMoveResponses, roomsWsTicketData, roomsWsTicketError, roomsWsTicketErrors, roomsWsTicketResponse, roomsWsTicketResponses, RuntimeSettingPublic, RuntimeSettingsPublic, RuntimeSettingUpdate, Token, UpdatePassword, UserCreate, UserPublic, UserRegister, UserRolesPublic, usersCreateUserData, usersCreateUserError, usersCreateUserErrors, usersCreateUserResponse, usersCreateUserResponses, usersDeleteUserData, usersDeleteUserError, usersDeleteUserErrors, usersDeleteUserMeData, usersDeleteUserMeResponse, usersDeleteUserMeResponses, usersDeleteUserResponse, usersDeleteUserResponses, UsersPublic, usersReadUserByIdData, usersReadUserByIdError, usersReadUserByIdErrors, usersReadUserByIdResponse, usersReadUserByIdResponses, usersReadUserMeData, usersReadUserMePermissionsData, usersReadUserMePermissionsResponse, usersReadUserMePermissionsResponses, usersReadUserMeResponse, usersReadUserMeResponses, usersReadUsersData, usersReadUsersError, usersReadUsersErrors, usersReadUsersResponse, usersReadUsersResponses, usersRegisterUserData, usersRegisterUserError, usersRegisterUserErrors, usersRegisterUserResponse, usersRegisterUserResponses, usersUpdatePasswordMeData, usersUpdatePasswordMeError, usersUpdatePasswordMeErrors, usersUpdatePasswordMeResponse, usersUpdatePasswordMeResponses, usersUpdateUserData, usersUpdateUserError, usersUpdateUserErrors, usersUpdateUserMeData, usersUpdateUserMeError, usersUpdateUserMeErrors, usersUpdateUserMeResponse, usersUpdateUserMeResponses, usersUpdateUserResponse, usersUpdateUserResponses, UserUpdate, UserUpdateMe, utilsHealthCheckData, utilsHealthCheckResponse, utilsHealthCheckResponses, utilsTestEmailData, utilsTestEmailError, utilsTestEmailErrors, utilsTestEmailResponse, utilsTestEmailResponses, ValidationError } from './types.gen'; +export type { adminCreateOverrideData, adminCreateOverrideError, adminCreateOverrideErrors, adminCreateOverrideResponse, adminCreateOverrideResponses, adminReadFlagsData, adminReadFlagsResponse, adminReadFlagsResponses, adminReadSettingsData, adminReadSettingsResponse, adminReadSettingsResponses, adminRemoveOverrideData, adminRemoveOverrideError, adminRemoveOverrideErrors, adminRemoveOverrideResponse, adminRemoveOverrideResponses, adminUpdateFlagData, adminUpdateFlagError, adminUpdateFlagErrors, adminUpdateFlagResponse, adminUpdateFlagResponses, adminUpdateSettingData, adminUpdateSettingError, adminUpdateSettingErrors, adminUpdateSettingResponse, adminUpdateSettingResponses, Body_login_login_access_token, ChatSend, ClientOptions, FeatureFlagOverrideCreate, FeatureFlagOverridePublic, FeatureFlagPublic, FeatureFlagUpdate, flagsReadMyFlagsData, flagsReadMyFlagsResponse, flagsReadMyFlagsResponses, GameRecordPublic, gamesGameEventsData, gamesGameEventsError, gamesGameEventsErrors, gamesGameEventsResponses, gamesGetGameRecordData, gamesGetGameRecordError, gamesGetGameRecordErrors, gamesGetGameRecordResponse, gamesGetGameRecordResponses, gamesReplayData, gamesReplayError, gamesReplayErrors, gamesReplayResponses, healthHealthData, healthHealthLlmData, healthHealthLlmResponse, healthHealthLlmResponses, healthHealthResponse, healthHealthResponses, HealthReport, HTTPValidationError, ItemCreate, ItemPublic, itemsCreateItemData, itemsCreateItemError, itemsCreateItemErrors, itemsCreateItemResponse, itemsCreateItemResponses, itemsDeleteItemData, itemsDeleteItemError, itemsDeleteItemErrors, itemsDeleteItemResponse, itemsDeleteItemResponses, ItemsPublic, itemsReadItemData, itemsReadItemError, itemsReadItemErrors, itemsReadItemResponse, itemsReadItemResponses, itemsReadItemsData, itemsReadItemsError, itemsReadItemsErrors, itemsReadItemsResponse, itemsReadItemsResponses, itemsUpdateItemData, itemsUpdateItemError, itemsUpdateItemErrors, itemsUpdateItemResponse, itemsUpdateItemResponses, ItemUpdate, LLMHealth, loginLoginAccessTokenData, loginLoginAccessTokenError, loginLoginAccessTokenErrors, loginLoginAccessTokenResponse, loginLoginAccessTokenResponses, loginRecoverPasswordData, loginRecoverPasswordError, loginRecoverPasswordErrors, loginRecoverPasswordHtmlContentData, loginRecoverPasswordHtmlContentError, loginRecoverPasswordHtmlContentErrors, loginRecoverPasswordHtmlContentResponse, loginRecoverPasswordHtmlContentResponses, loginRecoverPasswordResponse, loginRecoverPasswordResponses, loginResetPasswordData, loginResetPasswordError, loginResetPasswordErrors, loginResetPasswordResponse, loginResetPasswordResponses, loginTestTokenData, loginTestTokenResponse, loginTestTokenResponses, Message, MoveSubmission, NewPassword, PermissionsPublic, privateCreateUserData, privateCreateUserError, privateCreateUserErrors, privateCreateUserResponse, privateCreateUserResponses, PrivateUserCreate, RoleAssignment, RolePublic, rolesGrantUserRoleData, rolesGrantUserRoleError, rolesGrantUserRoleErrors, rolesGrantUserRoleResponse, rolesGrantUserRoleResponses, rolesReadPermissionsData, rolesReadPermissionsResponse, rolesReadPermissionsResponses, rolesReadRolesData, rolesReadRolesResponse, rolesReadRolesResponses, rolesReadUserRolesData, rolesReadUserRolesError, rolesReadUserRolesErrors, rolesReadUserRolesResponse, rolesReadUserRolesResponses, rolesRevokeUserRoleData, rolesRevokeUserRoleError, rolesRevokeUserRoleErrors, rolesRevokeUserRoleResponse, rolesRevokeUserRoleResponses, RoomCreate, RoomJoinRequest, RoomPublic, roomsCreateRoomData, roomsCreateRoomError, roomsCreateRoomErrors, roomsCreateRoomResponse, roomsCreateRoomResponses, RoomSeatJoinPublic, RoomSeatPublic, RoomSeatSpec, roomsForceStartRoomData, roomsForceStartRoomError, roomsForceStartRoomErrors, roomsForceStartRoomResponse, roomsForceStartRoomResponses, roomsGetRoomData, roomsGetRoomError, roomsGetRoomErrors, roomsGetRoomResponse, roomsGetRoomResponses, roomsJoinRoomData, roomsJoinRoomError, roomsJoinRoomErrors, roomsJoinRoomResponse, roomsJoinRoomResponses, roomsLeaveRoomData, roomsLeaveRoomError, roomsLeaveRoomErrors, roomsLeaveRoomResponse, roomsLeaveRoomResponses, roomsListRoomsData, roomsListRoomsError, roomsListRoomsErrors, roomsListRoomsResponse, roomsListRoomsResponses, roomsMovesSchemaData, roomsMovesSchemaError, roomsMovesSchemaErrors, roomsMovesSchemaResponses, RoomsPublic, roomsRoomEventsData, roomsRoomEventsError, roomsRoomEventsErrors, roomsRoomEventsResponses, roomsRoomQueueData, roomsRoomQueueError, roomsRoomQueueErrors, roomsRoomQueueResponses, roomsRoomViewData, roomsRoomViewError, roomsRoomViewErrors, roomsRoomViewResponses, roomsSendChatData, roomsSendChatError, roomsSendChatErrors, roomsSendChatResponse, roomsSendChatResponses, roomsStartRoomData, roomsStartRoomError, roomsStartRoomErrors, roomsStartRoomResponse, roomsStartRoomResponses, roomsStopRoomData, roomsStopRoomError, roomsStopRoomErrors, roomsStopRoomResponse, roomsStopRoomResponses, roomsSubmitMoveData, roomsSubmitMoveError, roomsSubmitMoveErrors, roomsSubmitMoveResponse, roomsSubmitMoveResponses, roomsWsTicketData, roomsWsTicketError, roomsWsTicketErrors, roomsWsTicketResponse, roomsWsTicketResponses, RuntimeSettingPublic, RuntimeSettingsPublic, RuntimeSettingUpdate, Token, UpdatePassword, UserCreate, UserPublic, UserRegister, UserRolesPublic, usersCreateUserData, usersCreateUserError, usersCreateUserErrors, usersCreateUserResponse, usersCreateUserResponses, usersDeleteUserData, usersDeleteUserError, usersDeleteUserErrors, usersDeleteUserMeData, usersDeleteUserMeResponse, usersDeleteUserMeResponses, usersDeleteUserResponse, usersDeleteUserResponses, UsersPublic, usersReadUserByIdData, usersReadUserByIdError, usersReadUserByIdErrors, usersReadUserByIdResponse, usersReadUserByIdResponses, usersReadUserMeData, usersReadUserMePermissionsData, usersReadUserMePermissionsResponse, usersReadUserMePermissionsResponses, usersReadUserMeResponse, usersReadUserMeResponses, usersReadUsersData, usersReadUsersError, usersReadUsersErrors, usersReadUsersResponse, usersReadUsersResponses, usersRegisterUserData, usersRegisterUserError, usersRegisterUserErrors, usersRegisterUserResponse, usersRegisterUserResponses, usersUpdatePasswordMeData, usersUpdatePasswordMeError, usersUpdatePasswordMeErrors, usersUpdatePasswordMeResponse, usersUpdatePasswordMeResponses, usersUpdateUserData, usersUpdateUserError, usersUpdateUserErrors, usersUpdateUserMeData, usersUpdateUserMeError, usersUpdateUserMeErrors, usersUpdateUserMeResponse, usersUpdateUserMeResponses, usersUpdateUserResponse, usersUpdateUserResponses, UserUpdate, UserUpdateMe, utilsHealthCheckData, utilsHealthCheckResponse, utilsHealthCheckResponses, utilsTestEmailData, utilsTestEmailError, utilsTestEmailErrors, utilsTestEmailResponse, utilsTestEmailResponses, ValidationError } from './types.gen'; diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 961bd46..faa6e18 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, type ClientMeta, type Options as Options2, type RequestResult, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { client } from './client.gen'; -import type { adminCreateOverrideData, adminCreateOverrideErrors, adminCreateOverrideResponses, adminReadFlagsData, adminReadFlagsResponses, adminReadSettingsData, adminReadSettingsResponses, adminRemoveOverrideData, adminRemoveOverrideErrors, adminRemoveOverrideResponses, adminUpdateFlagData, adminUpdateFlagErrors, adminUpdateFlagResponses, adminUpdateSettingData, adminUpdateSettingErrors, adminUpdateSettingResponses, flagsReadMyFlagsData, flagsReadMyFlagsResponses, gamesGameEventsData, gamesGameEventsErrors, gamesGameEventsResponses, gamesGetGameRecordData, gamesGetGameRecordErrors, gamesGetGameRecordResponses, gamesReplayData, gamesReplayErrors, gamesReplayResponses, healthHealthData, healthHealthLlmData, healthHealthLlmResponses, healthHealthResponses, itemsCreateItemData, itemsCreateItemErrors, itemsCreateItemResponses, itemsDeleteItemData, itemsDeleteItemErrors, itemsDeleteItemResponses, itemsReadItemData, itemsReadItemErrors, itemsReadItemResponses, itemsReadItemsData, itemsReadItemsErrors, itemsReadItemsResponses, itemsUpdateItemData, itemsUpdateItemErrors, itemsUpdateItemResponses, loginLoginAccessTokenData, loginLoginAccessTokenErrors, loginLoginAccessTokenResponses, loginRecoverPasswordData, loginRecoverPasswordErrors, loginRecoverPasswordHtmlContentData, loginRecoverPasswordHtmlContentErrors, loginRecoverPasswordHtmlContentResponses, loginRecoverPasswordResponses, loginResetPasswordData, loginResetPasswordErrors, loginResetPasswordResponses, loginTestTokenData, loginTestTokenResponses, privateCreateUserData, privateCreateUserErrors, privateCreateUserResponses, rolesGrantUserRoleData, rolesGrantUserRoleErrors, rolesGrantUserRoleResponses, rolesReadPermissionsData, rolesReadPermissionsResponses, rolesReadRolesData, rolesReadRolesResponses, rolesReadUserRolesData, rolesReadUserRolesErrors, rolesReadUserRolesResponses, rolesRevokeUserRoleData, rolesRevokeUserRoleErrors, rolesRevokeUserRoleResponses, roomsCreateRoomData, roomsCreateRoomErrors, roomsCreateRoomResponses, roomsForceStartRoomData, roomsForceStartRoomErrors, roomsForceStartRoomResponses, roomsGetRoomData, roomsGetRoomErrors, roomsGetRoomResponses, roomsJoinRoomData, roomsJoinRoomErrors, roomsJoinRoomResponses, roomsLeaveRoomData, roomsLeaveRoomErrors, roomsLeaveRoomResponses, roomsListRoomsData, roomsListRoomsErrors, roomsListRoomsResponses, roomsMovesSchemaData, roomsMovesSchemaErrors, roomsMovesSchemaResponses, roomsRoomEventsData, roomsRoomEventsErrors, roomsRoomEventsResponses, roomsRoomQueueData, roomsRoomQueueErrors, roomsRoomQueueResponses, roomsRoomViewData, roomsRoomViewErrors, roomsRoomViewResponses, roomsStartRoomData, roomsStartRoomErrors, roomsStartRoomResponses, roomsStopRoomData, roomsStopRoomErrors, roomsStopRoomResponses, roomsSubmitMoveData, roomsSubmitMoveErrors, roomsSubmitMoveResponses, roomsWsTicketData, roomsWsTicketErrors, roomsWsTicketResponses, usersCreateUserData, usersCreateUserErrors, usersCreateUserResponses, usersDeleteUserData, usersDeleteUserErrors, usersDeleteUserMeData, usersDeleteUserMeResponses, usersDeleteUserResponses, usersReadUserByIdData, usersReadUserByIdErrors, usersReadUserByIdResponses, usersReadUserMeData, usersReadUserMePermissionsData, usersReadUserMePermissionsResponses, usersReadUserMeResponses, usersReadUsersData, usersReadUsersErrors, usersReadUsersResponses, usersRegisterUserData, usersRegisterUserErrors, usersRegisterUserResponses, usersUpdatePasswordMeData, usersUpdatePasswordMeErrors, usersUpdatePasswordMeResponses, usersUpdateUserData, usersUpdateUserErrors, usersUpdateUserMeData, usersUpdateUserMeErrors, usersUpdateUserMeResponses, usersUpdateUserResponses, utilsHealthCheckData, utilsHealthCheckResponses, utilsTestEmailData, utilsTestEmailErrors, utilsTestEmailResponses } from './types.gen'; +import type { adminCreateOverrideData, adminCreateOverrideErrors, adminCreateOverrideResponses, adminReadFlagsData, adminReadFlagsResponses, adminReadSettingsData, adminReadSettingsResponses, adminRemoveOverrideData, adminRemoveOverrideErrors, adminRemoveOverrideResponses, adminUpdateFlagData, adminUpdateFlagErrors, adminUpdateFlagResponses, adminUpdateSettingData, adminUpdateSettingErrors, adminUpdateSettingResponses, flagsReadMyFlagsData, flagsReadMyFlagsResponses, gamesGameEventsData, gamesGameEventsErrors, gamesGameEventsResponses, gamesGetGameRecordData, gamesGetGameRecordErrors, gamesGetGameRecordResponses, gamesReplayData, gamesReplayErrors, gamesReplayResponses, healthHealthData, healthHealthLlmData, healthHealthLlmResponses, healthHealthResponses, itemsCreateItemData, itemsCreateItemErrors, itemsCreateItemResponses, itemsDeleteItemData, itemsDeleteItemErrors, itemsDeleteItemResponses, itemsReadItemData, itemsReadItemErrors, itemsReadItemResponses, itemsReadItemsData, itemsReadItemsErrors, itemsReadItemsResponses, itemsUpdateItemData, itemsUpdateItemErrors, itemsUpdateItemResponses, loginLoginAccessTokenData, loginLoginAccessTokenErrors, loginLoginAccessTokenResponses, loginRecoverPasswordData, loginRecoverPasswordErrors, loginRecoverPasswordHtmlContentData, loginRecoverPasswordHtmlContentErrors, loginRecoverPasswordHtmlContentResponses, loginRecoverPasswordResponses, loginResetPasswordData, loginResetPasswordErrors, loginResetPasswordResponses, loginTestTokenData, loginTestTokenResponses, privateCreateUserData, privateCreateUserErrors, privateCreateUserResponses, rolesGrantUserRoleData, rolesGrantUserRoleErrors, rolesGrantUserRoleResponses, rolesReadPermissionsData, rolesReadPermissionsResponses, rolesReadRolesData, rolesReadRolesResponses, rolesReadUserRolesData, rolesReadUserRolesErrors, rolesReadUserRolesResponses, rolesRevokeUserRoleData, rolesRevokeUserRoleErrors, rolesRevokeUserRoleResponses, roomsCreateRoomData, roomsCreateRoomErrors, roomsCreateRoomResponses, roomsForceStartRoomData, roomsForceStartRoomErrors, roomsForceStartRoomResponses, roomsGetRoomData, roomsGetRoomErrors, roomsGetRoomResponses, roomsJoinRoomData, roomsJoinRoomErrors, roomsJoinRoomResponses, roomsLeaveRoomData, roomsLeaveRoomErrors, roomsLeaveRoomResponses, roomsListRoomsData, roomsListRoomsErrors, roomsListRoomsResponses, roomsMovesSchemaData, roomsMovesSchemaErrors, roomsMovesSchemaResponses, roomsRoomEventsData, roomsRoomEventsErrors, roomsRoomEventsResponses, roomsRoomQueueData, roomsRoomQueueErrors, roomsRoomQueueResponses, roomsRoomViewData, roomsRoomViewErrors, roomsRoomViewResponses, roomsSendChatData, roomsSendChatErrors, roomsSendChatResponses, roomsStartRoomData, roomsStartRoomErrors, roomsStartRoomResponses, roomsStopRoomData, roomsStopRoomErrors, roomsStopRoomResponses, roomsSubmitMoveData, roomsSubmitMoveErrors, roomsSubmitMoveResponses, roomsWsTicketData, roomsWsTicketErrors, roomsWsTicketResponses, usersCreateUserData, usersCreateUserErrors, usersCreateUserResponses, usersDeleteUserData, usersDeleteUserErrors, usersDeleteUserMeData, usersDeleteUserMeResponses, usersDeleteUserResponses, usersReadUserByIdData, usersReadUserByIdErrors, usersReadUserByIdResponses, usersReadUserMeData, usersReadUserMePermissionsData, usersReadUserMePermissionsResponses, usersReadUserMeResponses, usersReadUsersData, usersReadUsersErrors, usersReadUsersResponses, usersRegisterUserData, usersRegisterUserErrors, usersRegisterUserResponses, usersUpdatePasswordMeData, usersUpdatePasswordMeErrors, usersUpdatePasswordMeResponses, usersUpdateUserData, usersUpdateUserErrors, usersUpdateUserMeData, usersUpdateUserMeErrors, usersUpdateUserMeResponses, usersUpdateUserResponses, utilsHealthCheckData, utilsHealthCheckResponses, utilsTestEmailData, utilsTestEmailErrors, utilsTestEmailResponses } from './types.gen'; export type Options = Options2 & { /** @@ -723,6 +723,27 @@ export class RoomsService { }); } + /** + * Send Chat + * + * Publishes a public ``chat.message`` from the caller's own seat + * (DECISIONS.md §8) -- own-seat-only, matching ``/view``/``/moves``, and + * unlike those, works whether or not a game is currently running: chat + * never touches game state (docs/rooms.md). + */ + public static sendChat(options: Options): RequestResult { + return (options.client ?? client).post({ + responseType: 'json', + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/rooms/{room_id}/chat', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + /** * Moves Schema * diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index aedd4a5..b4ec48b 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -34,6 +34,16 @@ export type Body_login_login_access_token = { client_secret?: string | null; }; +/** + * ChatSend + */ +export type ChatSend = { + /** + * Text + */ + text: string; +}; + /** * FeatureFlagOverrideCreate */ @@ -2127,6 +2137,36 @@ export type roomsSubmitMoveResponses = { export type roomsSubmitMoveResponse = roomsSubmitMoveResponses[keyof roomsSubmitMoveResponses]; +export type roomsSendChatData = { + body: ChatSend; + path: { + /** + * Room Id + */ + room_id: string; + }; + query?: never; + url: '/api/v1/rooms/{room_id}/chat'; +}; + +export type roomsSendChatErrors = { + /** + * Validation Error + */ + 422: HTTPValidationError; +}; + +export type roomsSendChatError = roomsSendChatErrors[keyof roomsSendChatErrors]; + +export type roomsSendChatResponses = { + /** + * Successful Response + */ + 200: Message; +}; + +export type roomsSendChatResponse = roomsSendChatResponses[keyof roomsSendChatResponses]; + export type roomsMovesSchemaData = { body?: never; path: { diff --git a/frontend/src/components/Rooms/Chat.tsx b/frontend/src/components/Rooms/Chat.tsx new file mode 100644 index 0000000..4a605cb --- /dev/null +++ b/frontend/src/components/Rooms/Chat.tsx @@ -0,0 +1,93 @@ +import { useMutation } from "@tanstack/react-query" +import { useState } from "react" + +import type { RoomSeatPublic } from "@/client" +import { RoomsService } from "@/client" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import useCustomToast from "@/hooks/useCustomToast" +import type { ChatEntry } from "@/lib/roomLiveState" +import { handleError } from "@/utils" + +interface ChatProps { + roomId: string + chatLog: ChatEntry[] + seats: RoomSeatPublic[] + /** No seat, no send box -- matches the backend's own own-seat-only rule + * (DECISIONS.md §8 is "players," not spectators) rather than letting a + * spectator submit and hit a confusing 403. */ + mySeat: number | null +} + +const MAX_CHAT_LENGTH = 200 + +/** Public chat, shared between human and AI seats (DECISIONS.md §8) -- + * downstream-only, never affects game state. Every seat's messages render + * the same way regardless of kind; the seat badge (human/AI + persona) + * is what makes an AI's voice legible as a distinct character rather + * than an anonymous "seat 2". */ +export function Chat({ roomId, chatLog, seats, mySeat }: ChatProps) { + const [draft, setDraft] = useState("") + const { showErrorToast } = useCustomToast() + + const mutation = useMutation({ + mutationFn: (text: string) => + RoomsService.sendChat({ path: { room_id: roomId }, body: { text } }), + onSuccess: () => setDraft(""), + onError: handleError.bind(showErrorToast), + }) + + const seatLabel = (seat: number) => { + const s = seats.find((seat_) => seat_.seat === seat) + return s?.kind === "agent" ? (s.persona ?? "AI") : `Seat ${seat}` + } + + return ( +
+

Chat

+
+ {chatLog.length === 0 ? ( +

No messages yet.

+ ) : ( + chatLog.map((entry) => ( +
+ + {seatLabel(entry.seat)} + + {entry.text} +
+ )) + )} +
+ {mySeat !== null ? ( +
{ + e.preventDefault() + const text = draft.trim() + if (text) mutation.mutate(text) + }} + > + setDraft(e.target.value)} + /> + +
+ ) : ( +

+ Only seated players can chat. +

+ )} +
+ ) +} diff --git a/frontend/src/lib/events.ts b/frontend/src/lib/events.ts index 4d8d0e1..3adce15 100644 --- a/frontend/src/lib/events.ts +++ b/frontend/src/lib/events.ts @@ -103,6 +103,6 @@ export interface AgentReasoningPayload { } export interface ChatMessagePayload { - seat: number | null + seat: number text: string } diff --git a/frontend/src/lib/roomLiveState.ts b/frontend/src/lib/roomLiveState.ts index 268d5ce..21cb75b 100644 --- a/frontend/src/lib/roomLiveState.ts +++ b/frontend/src/lib/roomLiveState.ts @@ -34,7 +34,7 @@ export interface MoveLogEntry { } export interface ChatEntry { - seat: number | null + seat: number text: string ts: string } diff --git a/frontend/src/routes/_layout/rooms.$roomId.tsx b/frontend/src/routes/_layout/rooms.$roomId.tsx index 3bc9a30..936167b 100644 --- a/frontend/src/routes/_layout/rooms.$roomId.tsx +++ b/frontend/src/routes/_layout/rooms.$roomId.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react" import { toast } from "sonner" import { RoomsService } from "@/client" +import { Chat } from "@/components/Rooms/Chat" import { FocusView } from "@/components/Rooms/FocusView" import { GameTable } from "@/components/Rooms/GameTable" import { Badge } from "@/components/ui/badge" @@ -164,6 +165,13 @@ function RoomDetail() { ) : null} + + ) } diff --git a/frontend/tests/rooms.spec.ts b/frontend/tests/rooms.spec.ts index 74ab00e..86681ae 100644 --- a/frontend/tests/rooms.spec.ts +++ b/frontend/tests/rooms.spec.ts @@ -102,4 +102,59 @@ test.describe("Create and play a room", () => { await creatorContext.close() await joinerContext.close() }) + + test("Two human players can exchange chat, seen live on both pages", async ({ + browser, + }) => { + const creatorEmail = randomEmail() + const creatorPassword = randomPassword() + const joinerEmail = randomEmail() + const joinerPassword = randomPassword() + await createUser({ email: creatorEmail, password: creatorPassword }) + await createUser({ email: joinerEmail, password: joinerPassword }) + + const creatorContext = await browser.newContext() + const joinerContext = await browser.newContext() + const creatorPage = await creatorContext.newPage() + const joinerPage = await joinerContext.newPage() + + await logInUser(creatorPage, creatorEmail, creatorPassword) + await logInUser(joinerPage, joinerEmail, joinerPassword) + + await creatorPage.getByRole("button", { name: "Create room" }).click() + await creatorPage + .getByRole("button", { name: "Create", exact: true }) + .click() + + const codeText = await creatorPage.getByText(/^Code /).textContent() + const code = codeText?.replace("Code ", "").split(" ")[0] ?? "" + expect(code).not.toBe("") + + await joinerPage.goto("/") + await joinerPage.getByPlaceholder("Join code").fill(code) + await joinerPage.getByRole("button", { name: "Join" }).click() + await joinerPage.waitForURL(/\/rooms\/.+/) + + // Chat works before the game even starts (docs/rooms.md) -- no Start + // click needed for this test. + await creatorPage.getByLabel("Chat message").fill("good luck!") + await creatorPage.getByRole("button", { name: "Send" }).click() + + await expect(joinerPage.getByText("good luck!")).toBeVisible({ + timeout: 10_000, + }) + // The sender's own page reflects the send too, over the same websocket + // that delivered it to the joiner -- not a locally-echoed optimistic + // update. + await expect(creatorPage.getByText("good luck!")).toBeVisible() + + await joinerPage.getByLabel("Chat message").fill("thanks, you too") + await joinerPage.getByRole("button", { name: "Send" }).click() + await expect(creatorPage.getByText("thanks, you too")).toBeVisible({ + timeout: 10_000, + }) + + await creatorContext.close() + await joinerContext.close() + }) }) From c54da0be2e73ec49f95a0dd5078714ba7a9395df Mon Sep 17 00:00:00 2001 From: withrvr Date: Mon, 14 Sep 2026 11:25:19 +0000 Subject: [PATCH 2/2] fix: address python-reviewer/react-reviewer findings on PR #21 - Chat.tsx: reset draft on room switch (same bug class as useRoomSocket), add aria-live/role=log to the message list, key on event_id instead of ts+seat - send_chat: rename text param (shadowed sqlalchemy's text()), name the 200-char cap _MAX_CHAT_CHARS Claude-Session: https://claude.ai/code/session_01UbiPa1rKCyqhqufgwJWkeW --- PROGRESS.md | 56 ++++++++++++++++++++++++++ backend/app/rooms/service.py | 19 ++++----- frontend/src/components/Rooms/Chat.tsx | 22 +++++++--- frontend/src/lib/roomLiveState.ts | 12 +++++- 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 916e483..7a9431b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -888,6 +888,62 @@ Full gate: backend suite (772 tests) green, `bun run build`/`bun run lint` clean, full Playwright suite (57 tests) green against the real docker compose stack, `prek run --all-files` clean. +### Review fixes (PR #21) + +`python-reviewer` and `react-reviewer` each reviewed the PR. Real, fixed: + +- **`Chat.tsx`'s `draft` compose-input state never reset on room switch** + (react-reviewer, HIGH): the exact same bug class already fixed once in + `useRoomSocket` during PR #20's review round -- `Chat` isn't remounted + just because `roomId` changes, so a half-typed message could survive a + room switch and get sent into the wrong room. Added a `[roomId]`-keyed + `useEffect` clearing `draft` (with a `biome-ignore` on + `useExhaustiveDependencies`, since `roomId` is a reset trigger here, not + a value the effect body actually reads). +- **Chat message list had no `aria-live`/`role`** (react-reviewer, HIGH): + new messages arrive asynchronously over the websocket, independent of + any user action, with nothing telling a screen reader they arrived. + Added `role="log"` `aria-live="polite"` to the message-list container. +- **`key={entry.ts + entry.seat}` was an unsafe, delimiter-less composite + key** (react-reviewer, MEDIUM): the server's `ts` (Python + `datetime.isoformat()`) is variable-length when microseconds are + exactly zero, so the concatenation isn't collision-safe. Threaded the + event envelope's own already-unique `event_id` into `ChatEntry` + (`roomLiveState.ts`) and keyed on that instead. +- **`send_chat`'s `text` parameter shadowed the module-level `sqlalchemy + text()` import** (python-reviewer, MEDIUM): harmless today, but a trap + for the next edit to that method. Renamed the parameter to `message`. +- **200-char chat cap was a bare magic number** (python-reviewer, MEDIUM): + named it `_MAX_CHAT_CHARS`, matching this file's own precedent + (`_MAX_CODE_ATTEMPTS`, `_START_LOCK_KEY`). + +**Not fixed, deliberately**: python-reviewer's optional suggestion to add +a Pydantic-level `max_length` to `ChatSend.text` (LOW) -- skipped because +`own_seat`'s identity check deliberately runs *before* any content +validation (an unseated caller always gets 403, never a validation error +that would leak whether their body was otherwise well-formed), and a +schema-level length constraint would run before that check, inverting the +order for oversized bodies. The existing per-user rate limit already +bounds the abuse case; not worth the ordering trade for it. Also not +fixed: the pre-existing (not introduced by this PR) dead `LookupError` -> +404 branch on `/chat`, `/moves`, and `/view` -- none of the three ever +call `service.get()`, so a nonexistent room 403s (via `own_seat`) rather +than 404s. Consistent with existing precedent on the other two routes; +flagged by python-reviewer as informational, not a regression. + +**The recurring tool-output anomaly is resolved.** python-reviewer traced +it to ground truth: `except InvalidTokenError, ValidationError:` in +`rooms.py`/`deps.py` is not Python-2 syntax at all -- it's valid under +[PEP 758](https://peps.python.org/pep-0758/) (unparenthesized +multi-exception `except`), accepted starting Python 3.14, and this +project's `pyproject.toml` pins `requires-python = ">=3.14,<4.0"`. +Confirmed parsing correctly under the project's actual `uv`-managed +3.14.7 interpreter. There was never a phantom tool-reading bug -- every +prior report of a "SyntaxError" here was a reviewer (human or agent) +correctly reading genuinely new, valid syntax and misclassifying it as +an old error. No code change needed; this closes out the anomaly noted +repeatedly in Phase 10's review-fix section above. + ## Phase 9.7 — done 2026-09-14 (branch phase-9.7-infra-optimization) Infrastructure-only pass: Docker image build, Compose, CI/CD workflows, diff --git a/backend/app/rooms/service.py b/backend/app/rooms/service.py index d07cd4d..846db1c 100644 --- a/backend/app/rooms/service.py +++ b/backend/app/rooms/service.py @@ -72,6 +72,7 @@ # lock alone would not be enough. _START_LOCK_KEY = 0x726F6F6D_73746172 # arbitrary bigint, "room" + "star" as bytes _ROOM_CODE_UNIQUE_INDEX = "ix_room_code" +_MAX_CHAT_CHARS = 200 def _is_code_collision(exc: IntegrityError) -> bool: @@ -703,12 +704,12 @@ def submit_move(self, user: User, room_id: uuid.UUID, move: dict[str, Any]) -> N raise NotYourTurnError(f"seat {seat.seat} is not the current turn") self.runner.submit_move(str(room_id), seat.seat, move) - async def send_chat(self, user: User, room_id: uuid.UUID, text: str) -> None: - """Publishes ``text`` as a public ``chat.message`` from the caller's - own seat (DECISIONS.md §8: downstream-only, shared between human and - AI players). Unlike ``submit_move``, chat never touches game state, - so this works whether or not a game is currently running -- there is - no ``RoomNotRunningError`` gate here. + async def send_chat(self, user: User, room_id: uuid.UUID, message: str) -> None: + """Publishes ``message`` as a public ``chat.message`` from the + caller's own seat (DECISIONS.md §8: downstream-only, shared between + human and AI players). Unlike ``submit_move``, chat never touches + game state, so this works whether or not a game is currently + running -- there is no ``RoomNotRunningError`` gate here. If a game *is* running: the message is fed to every AI seat's ``hear()`` so personas actually react to what a human just said @@ -719,12 +720,12 @@ async def send_chat(self, user: User, room_id: uuid.UUID, text: str) -> None: chat already does (``AgentController.say``). """ seat = self.own_seat(user, room_id) - stripped = text.strip() + stripped = message.strip() if not stripped: msg = "chat message must not be empty" raise ValueError(msg) - if len(stripped) > 200: - msg = "chat message must be 200 characters or fewer" + if len(stripped) > _MAX_CHAT_CHARS: + msg = f"chat message must be {_MAX_CHAT_CHARS} characters or fewer" raise ValueError(msg) chat = ChatMessage(seat=seat.seat, text=stripped) diff --git a/frontend/src/components/Rooms/Chat.tsx b/frontend/src/components/Rooms/Chat.tsx index 4a605cb..9ee82ec 100644 --- a/frontend/src/components/Rooms/Chat.tsx +++ b/frontend/src/components/Rooms/Chat.tsx @@ -1,5 +1,5 @@ import { useMutation } from "@tanstack/react-query" -import { useState } from "react" +import { useEffect, useState } from "react" import type { RoomSeatPublic } from "@/client" import { RoomsService } from "@/client" @@ -31,6 +31,15 @@ export function Chat({ roomId, chatLog, seats, mySeat }: ChatProps) { const [draft, setDraft] = useState("") const { showErrorToast } = useCustomToast() + // Same bug class fixed once already in useRoomSocket: without this, a + // half-typed message survives a room switch and can get sent into the + // wrong room. roomId isn't read in the body -- it's a reset trigger, not + // a data dependency. + // biome-ignore lint/correctness/useExhaustiveDependencies: roomId is the reset trigger, not a body dependency + useEffect(() => { + setDraft("") + }, [roomId]) + const mutation = useMutation({ mutationFn: (text: string) => RoomsService.sendChat({ path: { room_id: roomId }, body: { text } }), @@ -46,15 +55,16 @@ export function Chat({ roomId, chatLog, seats, mySeat }: ChatProps) { return (

Chat

-
+
{chatLog.length === 0 ? (

No messages yet.

) : ( chatLog.map((entry) => ( -
+
{seatLabel(entry.seat)} diff --git a/frontend/src/lib/roomLiveState.ts b/frontend/src/lib/roomLiveState.ts index 21cb75b..f3df393 100644 --- a/frontend/src/lib/roomLiveState.ts +++ b/frontend/src/lib/roomLiveState.ts @@ -37,6 +37,11 @@ export interface ChatEntry { seat: number text: string ts: string + /** The event's own globally-unique id (uuid4 hex, server-assigned) -- + * used as the list key instead of deriving one from `ts`+`seat`, since + * `ts` (Python `datetime.isoformat()`) is variable-length and the two + * concatenated fields aren't delimited. */ + eventId: string } export interface RoomLiveState { @@ -159,7 +164,12 @@ export function applyRoomEvent( } case "chat.message": { const p = event.payload as unknown as ChatMessagePayload - const entry: ChatEntry = { seat: p.seat, text: p.text, ts: event.ts } + const entry: ChatEntry = { + seat: p.seat, + text: p.text, + ts: event.ts, + eventId: event.event_id, + } return { ...state, chatLog: [...state.chatLog, entry].slice(-MAX_LOG_ENTRIES),