From dea6c17e0b939a30fcc2877b47fd7a7e384e3706 Mon Sep 17 00:00:00 2001 From: M Asif Date: Fri, 26 Jun 2026 15:35:51 +0530 Subject: [PATCH 1/7] fix: the existing integration issue --- frontend/src/services/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d8..26e8bf5d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -19,7 +19,7 @@ export interface RoomSessionResponse { room: RoomSnapshot; } -const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001/bug"; +const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001"; async function request(path: string, init?: RequestInit) { const response = await fetch(`${API_BASE_URL}${path}`, { From 5d3835598dc319b1eed65e71457e45115c283c1b Mon Sep 17 00:00:00 2001 From: M Asif Date: Fri, 26 Jun 2026 16:05:59 +0530 Subject: [PATCH 2/7] add discovery and project constitution files --- .gitignore | 3 + .specify/memory/constitution.md | 121 ++++++++++++++++++++++++++++++++ .specify/memory/discovery.md | 105 +++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 .specify/memory/constitution.md create mode 100644 .specify/memory/discovery.md diff --git a/.gitignore b/.gitignore index 75a55bc1..b158a52e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ precheck-results/ .vscode/ .vite/ +.opencode/ +.specify/** +!.specify/memory/** \ No newline at end of file diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 00000000..5b04f382 --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,121 @@ + + +# Scribble Constitution + +## Core Principles + +### I. TypeScript-First (Strict Mode) + +TypeScript strict mode MUST be enabled (`strict: true` in tsconfig). All code +MUST be fully typed. Avoid `any`; use `unknown` for truly dynamic types. Every +new file and refactor MUST include proper type annotations. + +Rationale: Strict mode catches null/undefined errors, implicit-any leaks, and +unchecked callbacks at compile time — critical for a multiplayer game where +multiple clients interact through a shared API. + +### II. Extend, Don't Rewrite + +All changes MUST extend the existing starter codebase, never replace it. +Preserve established directory structure, naming conventions, component +boundaries, and data flow patterns. + +Rationale: The lab evaluates incremental brownfield enhancement. Rewriting +defeats the purpose and creates drift between spec, plan, and implementation. + +### III. Spec-Driven Development + +Spec Kit artifacts MUST be produced before implementation in this order: +discovery → spec → plan → tasks. Each feature MUST have documented acceptance +criteria using Given/When/Then scenarios. Implementation MUST satisfy all +acceptance criteria before progressing to the next feature. + +Rationale: Ensures traceability from requirements to code and prevents scope +creep. + +### IV. Deterministic Game Logic + +All game rules MUST be deterministic. No randomness in word selection, drawer +assignment, or scoring. Scoring is flat: 100 points for a correct guess, 0 for +incorrect. Round duration is turn-based (no timers). Word selection uses a +deterministic function of round number against the fixed seed list. + +Rationale: Deterministic behavior ensures consistent results across clients and +enables reproducible testing. + +### V. Minimal Dependencies + +No new state-management or routing libraries beyond what the starter ships. +No WebSockets, databases, or authentication modules. No top-level dependencies +without explicit justification in the feature specification. + +Rationale: Every dependency is a maintenance burden and an attack surface. The +starter's dependency set (Express, Zod, React, React Router, Vite) is +sufficient for all in-scope features. + +## Technical Constraints + +- **Sync mechanism**: HTTP polling only. WebSockets, Socket.io, or any + real-time push protocol is strictly forbidden. +- **Storage**: In-memory only. No SQL, NoSQL, SQLite, or any database. +- **Authentication**: None. No accounts, sessions, JWT, or OAuth. +- **Validation**: Use Zod for all backend request payload and response + validation. +- **Error handling**: Backend MUST use centralized error handlers. Frontend + MUST gracefully handle API failures without crashing. +- **Imports**: ES modules with standard relative paths. Backend follows NodeNext + module resolution. +- **Immutability**: Prefer immutable data structures and pure functions. +- **Frontend components**: MUST use functional components with hooks. Class + components are forbidden. +- **Test coverage**: Vitest MUST be used for all tests. Line coverage MUST + meet a minimum of 80%. Coverage thresholds are enforced — dropping below + blocks the build. + +## Development Workflow + +1. **Discovery first**: Read relevant starter files and document gaps and + assumptions before writing any code. +2. **Specify**: Write feature specification with acceptance criteria using + Given/When/Then format. +3. **Clarify**: Resolve ambiguity before planning — never guess requirements. +4. **Plan**: Update state model, data flow, and file-level changes. +5. **Task**: Decompose the plan into ordered, testable work items. +6. **Implement**: Complete one meaningful slice at a time. Commit after each + slice. +7. **Validate**: Verify acceptance criteria with two browser tabs before + moving on. +8. **Build check**: Run `npm run build` in both `backend/` and `frontend/` + before handing off changes. + +## Governance + +- This constitution supersedes all other practices and workflow documentation. +- Amendments MUST be documented with version, date, and rationale. +- All PRs and reviews MUST verify compliance with these principles. +- Complexity MUST be justified: any deviation from these principles requires + explicit documentation in the relevant spec or plan artifact. +- Versioning follows semantic versioning: + - MAJOR: backward-incompatible governance or principle removals/redefinitions + - MINOR: new principle or section added, materially expanded guidance + - PATCH: clarifications, wording, typo fixes, non-semantic refinements + +**Version**: 0.2.0 | **Ratified**: 2026-06-26 | **Last Amended**: 2026-06-26 diff --git a/.specify/memory/discovery.md b/.specify/memory/discovery.md new file mode 100644 index 00000000..c28c8dc5 --- /dev/null +++ b/.specify/memory/discovery.md @@ -0,0 +1,105 @@ +# Discovery Notes — Scribble Assignment + + +## Missing Features (Gaps) + +### 1. Host tracking & host-only permissions +The backend creates rooms (`POST /rooms`) but does not mark any participant as the **host**. There is no endpoint or logic that enforces host-only actions (e.g., starting the game). The `Participant` model in `backend/src/models/game.ts` has no `isHost` flag. + +**Relevant files:** `backend/src/models/game.ts`, `backend/src/api/rooms.ts`, `backend/src/services/roomStore.ts` + +### 2. Player name validation +Player names are accepted as-is with no trimming and no rejection of empty or whitespace-only input. The `createRoomSchema` and `joinRoomSchema` in `backend/src/api/schemas.ts` only check that `playerName` is a string. + +**Relevant files:** `backend/src/api/schemas.ts`, `frontend/src/pages/CreateRoomPage.tsx`, `frontend/src/pages/JoinRoomPage.tsx` + +### 3. Automatic lobby polling +The Lobby page has a manual "Refresh Room" button but no automatic polling mechanism. Scenario 1 requires the lobby to refresh automatically at ~2s intervals. + +**Relevant files:** `frontend/src/pages/LobbyPage.tsx`, `frontend/src/state/roomStore.ts` + +### 4. Start game flow +There is no backend endpoint to transition a room from `"lobby"` status to an active game state. The `RoomStatus` type in `backend/src/models/game.ts` only defines `"lobby"`. No drawer assignment, word selection, or round initialization exists. + +**Relevant files:** `backend/src/api/rooms.ts`, `backend/src/models/game.ts`, `backend/src/services/roomStore.ts`, `frontend/src/pages/LobbyPage.tsx` + +### 5. Drawing interaction & clear canvas +The Game page shows a placeholder canvas area but no interactive drawing. There is no clear canvas action. + +**Relevant files:** `frontend/src/pages/GamePage.tsx` + +### 6. Guess submission & history +No backend endpoint exists for submitting guesses. No guess history is tracked on the room state or synced to players via polling. + +**Relevant files:** `backend/src/api/rooms.ts`, `backend/src/models/game.ts`, `backend/src/services/roomStore.ts`, `frontend/src/pages/GamePage.tsx` + +### 7. Scoring +No scoring logic exists. Scenario 3 requires correct guesses to award 100 points. + +**Relevant files:** `backend/src/models/game.ts`, `backend/src/services/roomStore.ts` + +### 8. Result state & restart +No result display after a round ends. No restart flow to return players to the lobby with round state cleared. + +**Relevant files:** `backend/src/api/rooms.ts`, `backend/src/models/game.ts`, `frontend/src/pages/GamePage.tsx` + +### 9. Secret word visibility rules +There is no mechanism to ensure the secret word is visible only to the drawer. All players would currently see the same data. + +**Relevant files:** `backend/src/api/rooms.ts`, `backend/src/models/game.ts`, `frontend/src/pages/GamePage.tsx` + +### 10. Round lifecycle management +No round state (current round number, round active flag, round end logic). The `Room` model has no concept of rounds. + +**Relevant files:** `backend/src/models/game.ts`, `backend/src/services/roomStore.ts` + +## Assumptions + +### A1. Polling-based sync (no WebSockets) +The README and AGENTS.md explicitly forbid WebSockets. All real-time sync between players is assumed to use HTTP polling at ~2s intervals. This affects lobby refresh, guess history sync, and game state updates. + +### A2. First player (creator) is host +Since there is no explicit host assignment yet, the assumed convention is that the room creator (first player to join) is the host. The host is the only player who can start the game. + +### A3. Deterministic word selection +Words are selected from the fixed seed list (`rocket`, `pizza`, `castle`, `guitar`, `sunflower`) using a deterministic method (e.g., based on round number), not random. This ensures consistent behavior across clients. + +### A4. Flat scoring (100 / 0) +Correct guesses score exactly 100 points. Incorrect guesses add 0. No speed bonuses, streak bonuses, or partial credit. This keeps scoring simple and deterministic. + +### A5. Single round only (no rotation) +Multiple rounds, drawer rotation, timers, and countdowns are explicitly out of scope. The implementation covers exactly one round per game session, then a restart back to lobby. + +### A6. In-memory only (no persistence) +All room data is stored in memory via `Map` in `roomStore.ts`. Restarting the backend clears all rooms. No database of any kind is used. + +### A7. No authentication +Anyone can create or join a room without accounts, login, or sessions. Room codes are the only access mechanism. No rate limiting or security measures are in scope. + +### A8. Case-insensitive guess comparison +Guesses are compared case-insensitively against the secret word. Input is trimmed before comparison. Empty guesses are rejected. + +## Out of Scope (Confirmed from README) + +### Technical +- WebSockets / real-time sync +- Databases / persistent storage +- Authentication / accounts / sessions +- Deployment / hosting / CI pipelines +- Docker / containerization +- New state-management or routing libraries (beyond what the starter ships) + +### Game features +- Multiple rounds +- Drawer rotation +- Round timers / countdowns +- Speed or drawer bonuses +- Custom or random word packs +- Spectator mode +- Room moderation (kick / mute) +- Room passwords or invite links + +### Process +- Rewriting the starter from scratch — extend, don't replace +- Adding top-level dependencies your spec doesn't justify +- Refactoring unrelated code From 31ff611ee954dc2d7e4e479138380f8b8a6b9bb7 Mon Sep 17 00:00:00 2001 From: M Asif Date: Fri, 26 Jun 2026 17:19:55 +0530 Subject: [PATCH 3/7] feat: implement Room Setup & Lobby with polling and host-only game start --- AGENTS.md | 4 + backend/src/api/rooms.ts | 37 +++- backend/src/api/router.ts | 5 +- backend/src/api/schemas.test.ts | 75 ++++++- backend/src/api/schemas.ts | 10 +- backend/src/models/game.ts | 4 +- backend/src/services/roomStore.test.ts | 31 ++- backend/src/services/roomStore.ts | 13 ++ frontend/src/pages/CreateRoomPage.tsx | 12 +- frontend/src/pages/JoinRoomPage.tsx | 12 +- frontend/src/pages/LobbyPage.tsx | 46 +++- frontend/src/services/api.ts | 9 +- frontend/src/state/roomStore.ts | 58 +++++ .../checklists/requirements.md | 34 +++ .../checklists/spec-review.md | 53 +++++ specs/001-room-setup-lobby/contracts/api.md | 171 +++++++++++++++ specs/001-room-setup-lobby/data-model.md | 53 +++++ specs/001-room-setup-lobby/plan.md | 101 +++++++++ specs/001-room-setup-lobby/quickstart.md | 71 +++++++ specs/001-room-setup-lobby/research.md | 44 ++++ specs/001-room-setup-lobby/spec.md | 200 ++++++++++++++++++ specs/001-room-setup-lobby/tasks.md | 161 ++++++++++++++ 22 files changed, 1178 insertions(+), 26 deletions(-) create mode 100644 specs/001-room-setup-lobby/checklists/requirements.md create mode 100644 specs/001-room-setup-lobby/checklists/spec-review.md create mode 100644 specs/001-room-setup-lobby/contracts/api.md create mode 100644 specs/001-room-setup-lobby/data-model.md create mode 100644 specs/001-room-setup-lobby/plan.md create mode 100644 specs/001-room-setup-lobby/quickstart.md create mode 100644 specs/001-room-setup-lobby/research.md create mode 100644 specs/001-room-setup-lobby/spec.md create mode 100644 specs/001-room-setup-lobby/tasks.md diff --git a/AGENTS.md b/AGENTS.md index 940bc464..53d2f758 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,3 +40,7 @@ You are working on a monolithic repository for a multiplayer drawing game ("Scri - Give concise, direct answers. - Do not output large blocks of code if a small change suffices. - When creating or editing files, ensure consistency with the existing directory structure detailed above. + + +Current plan: specs/001-room-setup-lobby/plan.md + diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c97..843a95dd 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -4,9 +4,10 @@ import { HttpError, joinRoomSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startGameSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, startGame, toRoomSnapshot } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -32,7 +33,7 @@ export function createRoomsRouter() { const result = joinRoom(code.toUpperCase(), playerName); if (!result) { - throw new HttpError(404, "Unable to join room"); + throw new HttpError(404, "Room not found"); } response.json({ @@ -62,5 +63,35 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { playerId } = startGameSchema.parse(request.body); + const upperCode = code.toUpperCase(); + + const room = getRoom(upperCode); + if (!room) { + throw new HttpError(404, "Room not found"); + } + if (room.hostId !== playerId) { + throw new HttpError(403, "Only the host can start the game"); + } + if (room.participants.length < 2) { + throw new HttpError(400, "At least 2 players are required to start"); + } + + const updatedRoom = startGame(upperCode); + if (!updatedRoom) { + throw new HttpError(500, "Failed to start game"); + } + + response.json({ + room: toRoomSnapshot(updatedRoom) + }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/backend/src/api/router.ts b/backend/src/api/router.ts index 12705954..bd47217d 100644 --- a/backend/src/api/router.ts +++ b/backend/src/api/router.ts @@ -24,13 +24,14 @@ export function notFoundHandler(_request: Request, response: Response) { } export function errorHandler( - error: Error & { statusCode?: number }, + error: Error & { statusCode?: number; errors?: Array<{ message: string }> }, _request: Request, response: Response, _next: NextFunction ) { if (error.name === "ZodError") { - response.status(400).json({ message: "Invalid request payload" }); + const firstMessage = (error as { errors?: Array<{ message: string }> }).errors?.[0]?.message ?? "Invalid request payload"; + response.status(400).json({ message: firstMessage }); return; } diff --git a/backend/src/api/schemas.test.ts b/backend/src/api/schemas.test.ts index 641efea3..1c336049 100644 --- a/backend/src/api/schemas.test.ts +++ b/backend/src/api/schemas.test.ts @@ -1,14 +1,77 @@ import { describe, expect, it } from "vitest"; -import { createRoomSchema, roomCodeParamsSchema } from "./schemas.js"; +import { + createRoomSchema, + joinRoomSchema, + roomCodeParamsSchema, + startGameSchema +} from "./schemas.js"; describe("schemas", () => { - it("createRoomSchema accepts a valid body with playerName", () => { - const result = createRoomSchema.parse({ playerName: "Alice" }); + describe("createRoomSchema", () => { + it("accepts a valid player name", () => { + const result = createRoomSchema.parse({ playerName: "Alice" }); - expect(result.playerName).toBe("Alice"); + expect(result.playerName).toBe("Alice"); + }); + + it("trims whitespace from player name", () => { + const result = createRoomSchema.parse({ playerName: " Alice " }); + + expect(result.playerName).toBe("Alice"); + }); + + it("rejects empty player name", () => { + expect(() => createRoomSchema.parse({ playerName: "" })).toThrow("Player name is required"); + }); + + it("rejects whitespace-only player name", () => { + expect(() => createRoomSchema.parse({ playerName: " " })).toThrow("Player name is required"); + }); }); - it("roomCodeParamsSchema rejects missing code", () => { - expect(() => roomCodeParamsSchema.parse({})).toThrow(); + describe("joinRoomSchema", () => { + it("accepts a valid player name", () => { + const result = joinRoomSchema.parse({ playerName: "Bob" }); + + expect(result.playerName).toBe("Bob"); + }); + + it("trims whitespace from player name", () => { + const result = joinRoomSchema.parse({ playerName: " Bob " }); + + expect(result.playerName).toBe("Bob"); + }); + + it("rejects empty player name", () => { + expect(() => joinRoomSchema.parse({ playerName: "" })).toThrow("Player name is required"); + }); + + it("rejects whitespace-only player name", () => { + expect(() => joinRoomSchema.parse({ playerName: " " })).toThrow("Player name is required"); + }); + }); + + describe("startGameSchema", () => { + it("accepts a valid playerId", () => { + const result = startGameSchema.parse({ playerId: "some-uuid" }); + + expect(result.playerId).toBe("some-uuid"); + }); + + it("rejects missing playerId", () => { + expect(() => startGameSchema.parse({})).toThrow(); + }); + }); + + describe("roomCodeParamsSchema", () => { + it("accepts a valid room code", () => { + const result = roomCodeParamsSchema.parse({ code: "ABCD" }); + + expect(result.code).toBe("ABCD"); + }); + + it("rejects missing code", () => { + expect(() => roomCodeParamsSchema.parse({})).toThrow(); + }); }); }); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba08..ad7bc9a4 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,11 +1,17 @@ import { z } from "zod"; +const trimmedNonEmptyString = z.string().trim().min(1, { message: "Player name is required" }); + export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: trimmedNonEmptyString }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: trimmedNonEmptyString +}); + +export const startGameSchema = z.object({ + playerId: z.string({ message: "Player ID is required" }) }); export const roomCodeParamsSchema = z.object({ diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce9466..b3af2f0b 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,5 +1,5 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "playing"; export interface Participant { id: string; @@ -10,6 +10,7 @@ export interface Participant { export interface Room { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; createdAt: string; updatedAt: string; @@ -18,6 +19,7 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index b70ef77b..6a3b0e87 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom } from "./roomStore.js"; +import { createRoom, joinRoom, startGame, toRoomSnapshot } from "./roomStore.js"; describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { @@ -11,9 +11,38 @@ describe("roomStore", () => { expect(result.participantId).toBeDefined(); }); + it("createRoom assigns the creator as host", () => { + const result = createRoom("Alice"); + + expect(result.room.hostId).toBe(result.participantId); + }); + + it("toRoomSnapshot includes hostId", () => { + const { room, participantId } = createRoom("Alice"); + const snapshot = toRoomSnapshot(room, participantId); + + expect(snapshot.hostId).toBe(participantId); + }); + it("joinRoom returns null for an unknown room code", () => { const result = joinRoom("ZZZZ", "Bob"); expect(result).toBeNull(); }); + + it("startGame returns null for an unknown room code", () => { + const result = startGame("ZZZZ"); + + expect(result).toBeNull(); + }); + + it("startGame transitions room status to playing", () => { + const { room } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const updated = startGame(room.code); + + expect(updated).not.toBeNull(); + expect(updated!.status).toBe("playing"); + }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a4..8cdc5fb1 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -54,6 +54,7 @@ export function createRoom(playerName?: string) { const room: Room = { code: generateUniqueCode(), status: "lobby", + hostId: participant.id, participants: [participant], createdAt: now(), updatedAt: now() @@ -90,6 +91,17 @@ export function getRoom(code: string) { return room ? cloneRoom(room) : null; } +export function startGame(code: string) { + const room = rooms.get(code); + + if (!room) { + return null; + } + + room.status = "playing"; + return saveRoom(room); +} + export function saveRoom(room: Room) { room.updatedAt = now(); rooms.set(room.code, cloneRoom(room)); @@ -102,6 +114,7 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn return { code: room.code, status: room.status, + hostId: room.hostId, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), roles: [...STARTER_ROLES] diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee3..bd42b930 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -9,12 +9,20 @@ export function CreateRoomPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); + const trimmedName = playerName.trim(); + const isNameValid = trimmedName.length > 0; + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + if (!isNameValid) { + setError("Player name is required"); + return; + } + try { setError(null); - await roomStore.createRoom(playerName); + await roomStore.createRoom(trimmedName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to create room"); @@ -40,7 +48,7 @@ export function CreateRoomPage() { {error ?

{error}

: null}
- - + {isHost ? ( + + ) : null}
); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 26e8bf5d..444a8b55 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -8,7 +8,8 @@ export interface Participant { export interface RoomSnapshot { code: string; - status: "lobby"; + status: "lobby" | "playing"; + hostId: string; participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; @@ -57,5 +58,11 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + startRoom(code: string, playerId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ playerId }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd3739..6c921cf7 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -13,6 +13,7 @@ export interface RoomState { room: RoomSnapshot | null; participantId: string | null; error: string | null; + pollingError: string | null; isLoading: boolean; } @@ -23,11 +24,17 @@ class RoomStore { room: null, participantId: null, error: null, + pollingError: null, isLoading: false }; private listeners = new Set(); + private pollTimerId: ReturnType | null = null; + private isPolling = false; + private consecutiveFailures = 0; + private pollInterval = 2000; + subscribe = (listener: Listener) => { this.listeners.add(listener); return () => { @@ -89,6 +96,16 @@ class RoomStore { return response; } + async startGame() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No room to start"); + } + + const response = await api.startRoom(this.state.room.code, this.state.participantId); + this.setRoomSnapshot(response.room); + return response.room; + } + async fetchRoom() { if (!this.state.room) { return null; @@ -98,6 +115,47 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + startPolling() { + this.stopPolling(); + this.isPolling = true; + this.scheduleNextPoll(); + } + + stopPolling() { + this.isPolling = false; + if (this.pollTimerId !== null) { + clearTimeout(this.pollTimerId); + this.pollTimerId = null; + } + this.consecutiveFailures = 0; + this.pollInterval = 2000; + this.setState({ pollingError: null }); + } + + private scheduleNextPoll() { + if (!this.isPolling) return; + + this.pollTimerId = setTimeout(async () => { + if (!this.isPolling) return; + + try { + await this.fetchRoom(); + this.consecutiveFailures = 0; + this.pollInterval = 2000; + this.setState({ pollingError: null }); + } catch { + this.consecutiveFailures++; + this.pollInterval = Math.min(this.pollInterval * 2, 8000); + + if (this.consecutiveFailures >= 3) { + this.setState({ pollingError: "Unable to reach server. Retrying..." }); + } + } + + this.scheduleNextPoll(); + }, this.pollInterval); + } } const RoomStoreContext = createContext(null); diff --git a/specs/001-room-setup-lobby/checklists/requirements.md b/specs/001-room-setup-lobby/checklists/requirements.md new file mode 100644 index 00000000..13bc7bf4 --- /dev/null +++ b/specs/001-room-setup-lobby/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Room Setup & Lobby + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-26 +**Feature**: specs/001-room-setup-lobby/spec.md + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +All items pass. No [NEEDS CLARIFICATION] markers present. Spec is ready for `/speckit.plan`. diff --git a/specs/001-room-setup-lobby/checklists/spec-review.md b/specs/001-room-setup-lobby/checklists/spec-review.md new file mode 100644 index 00000000..2e2aef79 --- /dev/null +++ b/specs/001-room-setup-lobby/checklists/spec-review.md @@ -0,0 +1,53 @@ +# Spec Review Checklist: Room Setup & Lobby + +**Purpose**: Full requirements quality audit of the Room Setup & Lobby specification +**Created**: 2026-06-26 +**Feature**: specs/001-room-setup-lobby/spec.md + +**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. + +## Requirement Completeness + +- [ ] CHK001 Are requirements specified for the page refresh / reconnection flow (currently only documented in edge cases)? [Completeness, Spec §Edge Cases] +- [ ] CHK002 Is the host transfer scenario (host leaves → next player promoted) reflected as a functional requirement, not just an assumption? [Completeness, Spec §Assumptions, Clarifications] +- [ ] CHK003 Are requirements specified for polling lifecycle (when polling starts, when it stops, error recovery strategy)? [Completeness, Spec §FR-009] +- [ ] CHK004 Are requirements defined for simultaneous join attempts (concurrent room operations)? [Completeness, Gap] +- [ ] CHK005 Are requirements specified for the scenario where the host who left was the only remaining player in the room? [Completeness, Gap, Spec §Clarifications] + +## Requirement Clarity + +- [ ] CHK006 Is "valid non-empty player name" defined with explicit constraints (min/max length, allowed characters)? [Clarity, Spec §FR-001] +- [ ] CHK007 Is "clear error message" defined with specificity (content, placement, duration, dismissal behavior)? [Clarity, Spec §FR-004, FR-006, FR-007] +- [ ] CHK008 Is "approximately 2 seconds" for polling interval bounded with an acceptable range (e.g., 1-3s)? [Clarity, Spec §FR-009] +- [ ] CHK009 Is "unique room code" defined with generation criteria (length, character set, collision retry strategy)? [Clarity, Spec §FR-003] +- [ ] CHK010 Is "navigate all participants to the game screen" defined in terms of who or what triggers the navigation? [Clarity, Spec §FR-012] + +## Requirement Consistency + +- [ ] CHK011 Is "host" terminology consistent across all user stories (creator = initial host, transferable on departure)? [Consistency, Spec §US1, US4, Assumptions] +- [ ] CHK012 Do acceptance scenarios for host-only start (US4) account for host transfer mid-lobby? [Consistency, Spec §US4, Clarifications] +- [ ] CHK013 Do polling interval requirements (FR-009) align with the timing stated in success criteria (SC-003)? [Consistency, Spec §FR-009, SC-003] + +## Acceptance Criteria Quality + +- [ ] CHK014 Can SC-001 "see the lobby screen within 3 seconds" be objectively measured with a specific pass condition? [Measurability, Spec §SC-001] +- [ ] CHK015 Can SC-005 "rooms operate independently" be verified with a defined test procedure? [Measurability, Spec §SC-005] +- [ ] CHK016 Is the "waiting state" for non-host players (US4) defined with observable criteria visible in the UI? [Measurability, Spec §US4] + +## Scenario Coverage + +- [ ] CHK017 Are requirements specified for concurrent room operations (two players joining the same room simultaneously)? [Coverage, Gap] +- [ ] CHK018 Are requirements specified for polling failure / retry behavior on transient network errors? [Coverage, Gap, Spec §FR-009] +- [ ] CHK019 Are requirements specified for the scenario where the host's browser tab is closed (not just "leaves")? [Coverage, Gap] + +## Dependencies & Assumptions + +- [ ] CHK020 Is the assumption "in-memory only — backend restart clears all rooms" validated against expected user experience? [Assumption, Spec §Assumptions] +- [ ] CHK021 Is the dependency on the starter's existing room code generator explicitly documented? [Dependency, Gap] +- [ ] CHK022 Are the constitutional constraints (no auth, no WebSockets, polling-only) reflected in the spec's assumptions section? [Consistency, Spec §Assumptions, Constitution] + +## Notes + +- Items marked [Gap] indicate missing requirements that may need to be added before implementation. +- This checklist is a peer review aid — check off items as they are verified against the spec. +- Cross-reference with the auto-generated specification quality checklist in `requirements.md`. diff --git a/specs/001-room-setup-lobby/contracts/api.md b/specs/001-room-setup-lobby/contracts/api.md new file mode 100644 index 00000000..615518d2 --- /dev/null +++ b/specs/001-room-setup-lobby/contracts/api.md @@ -0,0 +1,171 @@ +# API Contracts: Room Setup & Lobby + +## Base URL + +`http://localhost:3001` + +## Endpoints + +### Health Check + +``` +GET /health +``` + +**Response** `200`: +```json +{ "ok": true } +``` + +--- + +### Create Room + +``` +POST /rooms +Content-Type: application/json + +{ + "playerName": "Alice" +} +``` + +**Response** `201`: +```json +{ + "participantId": "p1", + "room": { + "code": "ABCD", + "participants": [ + { "id": "p1", "name": "Alice", "joinedAt": "2026-06-26T00:00:00.000Z" } + ], + "status": "lobby", + "hostId": "p1", + "availableWords": ["apple", "banana", "cat", "dog", "elephant"], + "roles": ["drawer", "guesser"] + } +} +``` + +**Error** `400` (empty/whitespace name): +```json +{ "message": "Player name is required" } +``` + +--- + +### Join Room + +``` +POST /rooms/:code/join +Content-Type: application/json + +{ + "playerName": "Bob" +} +``` + +**Response** `200`: +```json +{ + "participantId": "p2", + "room": { + "code": "ABCD", + "participants": [ + { "id": "p1", "name": "Alice", "joinedAt": "2026-06-26T00:00:00.000Z" }, + { "id": "p2", "name": "Bob", "joinedAt": "2026-06-26T00:00:01.000Z" } + ], + "status": "lobby", + "hostId": "p1", + "availableWords": ["apple", "banana", "cat", "dog", "elephant"], + "roles": ["drawer", "guesser"] + } +} +``` + +**Error** `400` (empty/whitespace name): +```json +{ "message": "Player name is required" } +``` + +**Error** `404` (room not found): +```json +{ "message": "Room not found" } +``` + +--- + +### Get Room + +``` +GET /rooms/:code?participantId=p1 +``` + +**Response** `200`: +```json +{ + "room": { + "code": "ABCD", + "participants": [ + { "id": "p1", "name": "Alice", "joinedAt": "2026-06-26T00:00:00.000Z" }, + { "id": "p2", "name": "Bob", "joinedAt": "2026-06-26T00:00:01.000Z" } + ], + "status": "lobby", + "hostId": "p1", + "availableWords": ["apple", "banana", "cat", "dog", "elephant"], + "roles": ["drawer", "guesser"] + } +} +``` + +**Error** `404` (room not found): +```json +{ "message": "Room not found" } +``` + +--- + +### Start Game (NEW) + +``` +POST /rooms/:code/start +Content-Type: application/json + +{ + "playerId": "p1" +} +``` + +**Response** `200`: +```json +{ + "room": { + "code": "ABCD", + "participants": [ + { "id": "p1", "name": "Alice", "joinedAt": "2026-06-26T00:00:00.000Z" }, + { "id": "p2", "name": "Bob", "joinedAt": "2026-06-26T00:00:01.000Z" } + ], + "status": "playing", + "hostId": "p1", + "availableWords": ["apple", "banana", "cat", "dog", "elephant"], + "roles": ["drawer", "guesser"] + } +} +``` + +**Error** `403` (non-host attempts start): +```json +{ "message": "Only the host can start the game" } +``` + +**Error** `400` (less than 2 players): +```json +{ "message": "At least 2 players are required to start" } +``` + +## Polling Contract + +- Frontend polls `GET /rooms/:code` at ~2s intervals while on the lobby page. +- On network error: retry at 2s → 4s → 8s (exponential back-off). +- After 3 consecutive failures: frontend shows error notification, continues polling at 8s. +- When room status transitions to `"playing"`: frontend navigates to game screen. diff --git a/specs/001-room-setup-lobby/data-model.md b/specs/001-room-setup-lobby/data-model.md new file mode 100644 index 00000000..4885c724 --- /dev/null +++ b/specs/001-room-setup-lobby/data-model.md @@ -0,0 +1,53 @@ +# Data Model: Room Setup & Lobby + +## Entities + +### Room + +| Field | Type | Description | +|-------|------|-------------| +| `code` | `string` (4-char) | Unique room identifier. Generated from custom alphabet. | +| `status` | `"lobby"` | Current room phase. Expands to `"playing"`, `"finished"` in later scenarios. | +| `participants` | `Participant[]` | Ordered list of players in the room. | +| `hostId` | `string` | ID of the current host participant. | + +### Participant + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Unique participant identifier (generated on join). | +| `name` | `string` | Display name. Trimmed, non-empty. | +| `role` | `"drawer" \| "guesser"` | Not used in lobby (determined at game start in later scenario). | +| `score` | `number` | Cumulative score (starts at 0, updated in later scenario). | +| `isHost` (derived) | `boolean` | True if `id === room.hostId`. | + +### Room Code + +- 4-character alphanumeric string. +- Character set: A-Z excluding I, O, U (to avoid visual ambiguity). +- Must be unique across all active rooms. +- Generated by the system on room creation. + +## State Transitions + +```text +[Room Created] ──→ status: "lobby" + │ + ├── Participant joins ──→ participants.append() + ├── Host leaves ──→ hostId = next-earliest participant + ├── Last player leaves ──→ room deleted + └── Host starts game ──→ status: "playing" (future scenario) +``` + +## Validation Rules + +- Participant name: Must be non-empty after trimming. Max length TBD. +- Room code: Generated server-side, must be unique. Retry on collision. +- Join room: Code must exist and room status must be `"lobby"`. +- Start game: Caller must be host, participants must be ≥ 2. + +## Relationships + +- Room contains 1..N participants. +- Room has exactly 1 host (a participant in that room). +- Room codes are globally unique, not scoped. diff --git a/specs/001-room-setup-lobby/plan.md b/specs/001-room-setup-lobby/plan.md new file mode 100644 index 00000000..1b8396da --- /dev/null +++ b/specs/001-room-setup-lobby/plan.md @@ -0,0 +1,101 @@ +# Implementation Plan: Room Setup & Lobby + +**Branch**: `001-room-setup-lobby` | **Date**: 2026-06-26 | **Spec**: specs/001-room-setup-lobby/spec.md + +**Input**: Feature specification from `specs/001-room-setup-lobby/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +Implement room creation, joining by code, lobby auto-refresh via polling, +host designation, and host-only game start with a 2-player minimum. +All state is in-memory; sync uses HTTP polling. + +## Technical Context + +**Language/Version**: TypeScript (ES2022, NodeNext module resolution, strict mode) + +**Primary Dependencies**: Express 4 + Zod (backend); React 18 + React Router 6 + Vite (frontend) + +**Storage**: In-memory only (Map-based room store on backend) + +**Testing**: Vitest, minimum 80% line coverage + +**Target Platform**: Modern web browsers (Chrome, Firefox, Safari, Edge) + +**Project Type**: Web application (Express backend + React frontend) + +**Performance Goals**: Lobby polling at ~2s intervals; create/join response under 500ms p95 + +**Constraints**: No WebSockets, no databases, no auth, polling-only sync. +Backend centralized error handling. Zod for request validation. +Functional components with hooks on frontend. + +**Scale/Scope**: Lab project — small scale, no production deployment. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **TypeScript-First (Strict Mode)**: TypeScript strict mode enabled, `any` forbidden. +- **Extend, Don't Rewrite**: All changes extend starter codebase, preserve structure. +- **Spec-Driven Development**: Spec artifacts committed before implementation. +- **Deterministic Game Logic**: Game rules deterministic (in-scope for later scenarios). +- **Minimal Dependencies**: No new state-management or routing libraries beyond starter. +- **Technical Constraints**: No WebSockets, no DB, no auth, polling-only, Zod validation, + functional components, 80% Vitest coverage. +- **No violations detected**: Complexity tracking not required. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-room-setup-lobby/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +backend/ +├── src/ +│ ├── models/ +│ │ └── game.ts # Room, Participant types (extend existing) +│ ├── services/ +│ │ └── roomStore.ts # In-memory store (extend existing) +│ └── api/ +│ ├── router.ts # API router (extend existing) +│ ├── rooms.ts # Room endpoints (extend existing) +│ └── schemas.ts # Zod schemas (extend existing) +└── src/ + └── services/ + └── roomStore.test.ts # Extend existing tests + +frontend/ +├── src/ +│ ├── pages/ +│ │ ├── LobbyPage.tsx # Add auto-polling (extend existing) +│ │ ├── CreateRoomPage.tsx # Add name validation (extend existing) +│ │ ├── JoinRoomPage.tsx # Add name validation (extend existing) +│ │ └── GamePage.tsx # Placeholder (existing) +│ ├── services/ +│ │ └── api.ts # Add startRoom method (extend existing) +│ └── state/ +│ └── roomStore.ts # Add polling logic (extend existing) +``` + +**Structure Decision**: Option 2 — Web application (`frontend` + `backend`). All +changes extend existing starter files, never replace them. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +No violations detected. Complexity tracking not required. diff --git a/specs/001-room-setup-lobby/quickstart.md b/specs/001-room-setup-lobby/quickstart.md new file mode 100644 index 00000000..73323d09 --- /dev/null +++ b/specs/001-room-setup-lobby/quickstart.md @@ -0,0 +1,71 @@ +# Quickstart: Room Setup & Lobby + +## Prerequisites + +- Node.js 18+ +- Backend and frontend dependencies installed (`npm install` in both `backend/` and `frontend/`) + +## Setup + +```bash +# Terminal 1: Start backend +cd backend +npm run dev + +# Terminal 2: Start frontend +cd frontend +npm run dev +``` + +Backend runs on `http://localhost:3001`, frontend on `http://localhost:5173`. + +## Validation Scenarios + +### Scenario 1: Create a Room + +1. Open `http://localhost:5173` in a browser. +2. Click **Create Room**. +3. Enter a player name (e.g., "Alice") and submit. +4. **Expected**: Lobby screen appears with a 4-character room code. + Alice is identified as the host. + +### Scenario 2: Join a Room + +1. From Scenario 1, note the room code. +2. Open a second browser tab at `http://localhost:5173`. +3. Click **Join Room**. +4. Enter the room code and a player name (e.g., "Bob") and submit. +5. **Expected**: Both tabs show Alice and Bob in the participant list. + +### Scenario 3: Lobby Auto-Refresh + +1. Complete Scenario 2. +2. **Expected**: The lobby in tab A (Alice) shows Bob within ~2-3 seconds + without any manual refresh. + +### Scenario 4: Host-Only Start + +1. With 2 players in the lobby (from Scenario 2), observe the host tab (Alice). +2. **Expected**: Alice sees an active **Start Game** button. +3. In Bob's tab, **Expected**: No start button is visible. +4. Alice clicks **Start Game**. +5. **Expected**: Both tabs navigate to the game screen. + +### Scenario 5: Validation + +1. Try creating/joining with an empty name. + **Expected**: Clear error message, no room created. +2. Try joining a non-existent code. + **Expected**: "Room not found" error. +3. Try starting the game with only 1 player. + **Expected**: Start button disabled/hidden with "need 2 players" message. +4. Create two rooms in separate sessions. + **Expected**: Each room has a unique code and independent participant lists. + +## Contracts Reference + +See `contracts/api.md` for full API endpoint specifications. + +## Data Model Reference + +See `data-model.md` for entity definitions and validation rules. diff --git a/specs/001-room-setup-lobby/research.md b/specs/001-room-setup-lobby/research.md new file mode 100644 index 00000000..2aedef8c --- /dev/null +++ b/specs/001-room-setup-lobby/research.md @@ -0,0 +1,44 @@ +# Research: Room Setup & Lobby + +**Phase**: 0 (Pre-design research) +**Date**: 2026-06-26 + +## Unknowns Resolution + +No [NEEDS CLARIFICATION] markers remained in the spec after the `/speckit.clarify` +session. All technical context is known from the starter codebase. + +## Existing Code Patterns + +### Backend (Express + TypeScript) + +- **Room store** (`roomStore.ts`): In-memory `Map`, 4-char room codes + from custom alphabet (A-Z excluding I/O/U to avoid confusion), default name "Player". +- **Room endpoints** (`rooms.ts`): `POST /rooms`, `POST /rooms/:code/join`, + `GET /rooms/:code`. Use Zod schemas for validation. +- **Error handling** (`router.ts`): Centralized error handler catches `ZodError` → 400, + other errors → status code or 500. +- **Models** (`game.ts`): `Participant`, `Room`, `RoomSnapshot`, `RoomSessionResponse`, + `ParticipantRole` (drawer|guesser), `RoomStatus` (only `"lobby"`). +- **Seed data** (`starterData.ts`): 5 words, 2 roles. + +### Frontend (React 18 + Vite) + +- **State** (`roomStore.ts`): Context-based state with `createRoom`, `joinRoom`, + `fetchRoom`, `clearRoom` actions. Manual refresh pattern. +- **Pages**: StartPage (landing), CreateRoomPage (form), JoinRoomPage (form), + LobbyPage (participant list + manual refresh + start button), GamePage (placeholder). +- **Routing** (`routes/index.tsx`): React Router v6 with 5 routes + catch-all. + +## Key Design Decisions (from Clarifications) + +1. **Host departure**: Host transfers to next-earliest player in room. +2. **Polling error recovery**: Exponential back-off (2s → 4s → 8s), error after 3 failures. + +## Technology Confirmation + +All dependencies are already present in the starter: +- Backend: `express`, `cors`, `zod`, `tsx`, `typescript`, `vitest` +- Frontend: `react`, `react-dom`, `react-router-dom`, `vite`, `vitest`, `jsdom` + +No new dependencies needed for this feature. diff --git a/specs/001-room-setup-lobby/spec.md b/specs/001-room-setup-lobby/spec.md new file mode 100644 index 00000000..e30dba8f --- /dev/null +++ b/specs/001-room-setup-lobby/spec.md @@ -0,0 +1,200 @@ +# Feature Specification: Room Setup & Lobby + +**Feature Branch**: `001-room-setup-lobby` + +**Created**: 2026-06-26 + +**Status**: Draft + +**Input**: User description: "Scenario 1 — Room Setup & Lobby — Given a player wants to host or join a drawing game, When they create or join a room via a unique code, Then the creator is automatically the host; invalid/empty codes are rejected with clear feedback; rooms are fully isolated; the lobby refreshes via polling (~2s); and only the host can start the game once at least 2 players are present." + +## Clarifications + +### Session 2026-06-26 + +- Q: What should happen when the host disconnects or leaves while players are in the lobby? → A: Host transfers to the next-earliest player still in the room. +- Q: What should the polling error recovery behavior be on transient network errors? → A: Retry silently with exponential back-off (2s → 4s → 8s), then surface an error to the user after 3 consecutive failures. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Create a Room (Priority: P1) + +A player opens the app and creates a new game room. They are automatically +designated as the host. A unique room code is generated so others can join. + +**Why this priority**: Room creation is the entry point for every game session. +No other scenario can proceed without it. + +**Independent Test**: Open the app, click "Create Room", enter a player name, +and submit. Verify the player lands on the lobby screen with a visible room +code and is identified as the host. + +**Acceptance Scenarios**: + +1. **Given** a player is on the start screen, **When** they enter a valid player + name and click "Create Room", **Then** a room is created, the player is + marked as host, a unique room code is displayed on the lobby screen. +2. **Given** a player submits the create form with an empty or whitespace-only + name, **When** they attempt to create a room, **Then** a clear error message + is shown and no room is created. +3. **Given** two players each create a room, **When** both rooms exist + simultaneously, **Then** each room has a unique code and the participant + lists are isolated (players from room A do not appear in room B). + +--- + +### User Story 2 - Join a Room by Code (Priority: P1) + +A player joins an existing room by entering its unique code. They become a +guesser (not host). + +**Why this priority**: Multiplayer requires at least two players. Joining is the +second essential flow alongside creation. + +**Independent Test**: Open a second browser tab, click "Join Room", enter the +room code from the first tab and a player name. Verify the player lands on the +same room's lobby. + +**Acceptance Scenarios**: + +1. **Given** a room exists with a known code, **When** a second player enters + that code and a valid name, **Then** they join the room and see the lobby + with both participants listed. +2. **Given** a player submits the join form with an empty or whitespace-only + name, **When** they attempt to join, **Then** a clear error message is shown + and they are not added to the room. +3. **Given** a player submits the join form with a non-existent room code, + **When** they attempt to join, **Then** a clear error message is shown + indicating the room was not found. + +--- + +### User Story 3 - Lobby Auto-Refresh (Priority: P2) + +Players in the lobby see an up-to-date participant list without manually +refreshing. The lobby polls the server automatically. + +**Why this priority**: Without auto-refresh, players would need to manually +click a button to see who else has joined, creating a poor user experience. + +**Independent Test**: Create a room in tab A, join it from tab B. Wait up to +3 seconds and confirm tab A shows the new participant without any manual +refresh. + +**Acceptance Scenarios**: + +1. **Given** a player is in a lobby, **When** another player joins the room, + **Then** the lobby updates to show the new participant within approximately + 2 seconds without manual interaction. +2. **Given** a player is in a lobby, **When** no changes occur, **Then** the + lobby silently polls without disrupting the user interface. + +--- + +### User Story 4 - Host Starts Game (Priority: P2) + +Only the host can start the game, and only when at least 2 players are present. +Non-host players see a waiting state. + +**Why this priority**: Starting the game is the gateway to gameplay. The +host-only rule prevents conflicts, and the 2-player minimum ensures the game +is playable. + +**Independent Test**: With two players in a lobby, confirm the host (and only +the host) can click "Start Game". Verify the game screen loads for all +players. + +**Acceptance Scenarios**: + +1. **Given** exactly one player (the host) is in the lobby, **When** the host + looks at the lobby, **Then** the "Start Game" button is disabled or hidden, + with a message indicating at least one more player is needed. +2. **Given** at least 2 players are in the lobby, **When** the host clicks + "Start Game", **Then** all players navigate to the game screen. +3. **Given** at least 2 players are in the lobby, **When** a non-host player + attempts to start the game (e.g., looks for a start button), **Then** they + see no option to start and/or a message that only the host can start. + +### Edge Cases + +- What happens when a player attempts to join a room that is full? + — Not applicable; the starter does not define a room capacity limit. +- How does the system handle a player who refreshes the page while in the + lobby? — The browser re-fetches room state from the API using the stored + room code; the player rejoins the same lobby. +- What happens when the backend is restarted (losing in-memory state) while + players are in a lobby? — The polling request returns a 404 or error; the + frontend shows a "Room no longer available" message. +- What happens when a transient network error occurs during lobby polling? + — The client retries silently with exponential back-off (2s → 4s → 8s). + After 3 consecutive failures, an error notification is displayed but polling + continues at 8s intervals to self-recover when the connection is restored. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST allow a player to create a room with a valid + non-empty player name. +- **FR-002**: System MUST assign the room creator as the host. +- **FR-003**: System MUST generate a unique room code for each room. +- **FR-004**: System MUST reject room creation with empty or whitespace-only + player names and display a clear error message. +- **FR-005**: System MUST allow a player to join an existing room using its + unique code and a valid player name. +- **FR-006**: System MUST reject join attempts with empty or whitespace-only + player names and display a clear error message. +- **FR-007**: System MUST reject join attempts with non-existent room codes + and display a clear error message. +- **FR-008**: System MUST keep rooms fully isolated — participants in one room + must never appear in another room's data. +- **FR-009**: System MUST automatically refresh the lobby participant list via + polling at approximately 2-second intervals. +- **FR-010**: System MUST allow only the host to start the game. +- **FR-011**: System MUST enforce a minimum of 2 participants before the game + can be started. +- **FR-012**: System MUST navigate all participants to the game screen when + the host starts the game. +- **FR-013**: System MUST handle lobby polling failures by retrying silently + with exponential back-off (2s → 4s → 8s) and must display an error + notification after 3 consecutive failures. + +### Key Entities *(include if feature involves data)* + +- **Room**: A game session identified by a unique code. Contains a list of + participants, a host designation, and a status (lobby, in-progress). +- **Participant**: A player within a room. Has a name and a role (host or + non-host). The creator is the initial host. +- **Room Code**: A short alphanumeric string used to identify and join a room. + Must be unique across all active rooms. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A player can create a room and see the lobby screen within + 3 seconds of submitting the form. +- **SC-002**: A player can join a room by code and see the updated lobby with + all participants within 3 seconds of submitting the form. +- **SC-003**: When a new player joins, all existing lobby participants see the + updated participant list within 3 seconds (accounting for polling interval). +- **SC-004**: The host can start the game when at least 2 players are present; + non-host players cannot start the game under any condition. +- **SC-005**: Two simultaneous rooms operate independently — actions in one + room never affect the other. + +## Assumptions + +- Player names are trimmed of leading/trailing whitespace before validation + and storage. +- Room codes are generated by the system (players do not choose their own + codes). +- The first player to create a room is the initial host. +- Host transfer on departure is deferred to a future scenario. The current + scenario does not provide a leave/disconnect mechanism. +- Lobby polling stops once the game starts (replaced by game-state polling in + a later scenario). +- The polling interval of approximately 2 seconds is acceptable for a casual + game; no configurable interval is needed. +- No room capacity limit is enforced beyond what the starter defines. +- No authentication is required — any player can create or join a room. diff --git a/specs/001-room-setup-lobby/tasks.md b/specs/001-room-setup-lobby/tasks.md new file mode 100644 index 00000000..345bab30 --- /dev/null +++ b/specs/001-room-setup-lobby/tasks.md @@ -0,0 +1,161 @@ +--- + +description: "Task list for Room Setup & Lobby feature" + +--- + +# Tasks: Room Setup & Lobby + +**Input**: Design documents from `specs/001-room-setup-lobby/` + +**Prerequisites**: plan.md (required), spec.md (required), data-model.md, contracts/api.md, research.md, quickstart.md + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3, US4) +- Include exact file paths in descriptions + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +No setup tasks required — the starter project is already initialized with all +necessary dependencies (Express, Zod, React, React Router, Vite, Vitest). + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [ ] T001 Add `hostId` field to `Room` type in `backend/src/models/game.ts` +- [ ] T001b [P] Add `"playing"` to `RoomStatus` type in `backend/src/models/game.ts` and `frontend/src/services/api.ts` +- [ ] T002 [P] Update `createRoomSchema` and `joinRoomSchema` in `backend/src/api/schemas.ts` to require non-empty, non-whitespace `playerName` (trimmed, min length 1 after trim) +- [ ] T003 [P] Add `startGameSchema` (playerId required) and `startRoomParamsSchema` (code required) in `backend/src/api/schemas.ts` + +**Checkpoint**: Foundation ready — user story implementation can now begin + +--- + +## Phase 3: User Story 1 - Create a Room (Priority: P1) 🎯 MVP + +**Goal**: A player can create a room with a valid name and is automatically designated as the host. + +**Independent Test**: Open the app, click "Create Room", enter a name, submit. +Verify the lobby shows the room code and the player is marked as host. + +- [ ] T004 [P] [US1] Update `createRoom` in `backend/src/services/roomStore.ts` to set creator as host (`room.hostId = participant.id`) +- [ ] T005 [P] [US1] Update `toRoomSnapshot` in `backend/src/services/roomStore.ts` to include `hostId` in snapshot +- [ ] T006 [P] [US1] Add `hostId` field to `RoomSnapshot` interface in `backend/src/models/game.ts` +- [ ] T007 [P] [US1] Add `hostId` field to `RoomSnapshot` interface in `frontend/src/services/api.ts` +- [ ] T008 [US1] Add name trimming (before submit) and local validation in `frontend/src/pages/CreateRoomPage.tsx` — disable submit for empty/whitespace-only name and show inline error + +**Checkpoint**: US1 complete — a single player can create a room and see the host badge in lobby. + +--- + +## Phase 4: User Story 2 - Join a Room by Code (Priority: P1) + +**Goal**: A second player can join an existing room by entering its code and a valid name. + +**Independent Test**: Open a second tab, click "Join Room", enter the room code +and a name. Verify both tabs show both participants in the lobby. + +- [ ] T009 [P] [US2] Add name trimming and local validation in `frontend/src/pages/JoinRoomPage.tsx` — disable submit for empty/whitespace-only name and show inline error +- [ ] T010 [US2] Update join error handling in `backend/src/api/rooms.ts` to return distinct messages: "Room not found" for unknown codes vs validation errors + +**Checkpoint**: US2 complete — two players can be in the same lobby together. + +--- + +## Phase 5: User Story 3 - Lobby Auto-Refresh (Priority: P2) + +**Goal**: The lobby participant list refreshes automatically via polling at ~2s intervals. + +**Independent Test**: Create room in tab A, join in tab B. Wait up to 3s and +confirm tab A shows the new participant without manual refresh. + +- [ ] T011 [P] [US3] Add `pollRoom` method with exponential back-off logic to `frontend/src/state/roomStore.ts` (retry at 2s → 4s → 8s on error, surface error after 3 consecutive failures) +- [ ] T012 [US3] Wire `pollRoom` into `frontend/src/pages/LobbyPage.tsx` using `useEffect` with `setInterval` — start polling on mount, stop on unmount or when navigating away +- [ ] T013 [US3] Add error notification UI in `frontend/src/pages/LobbyPage.tsx` for polling failures (after 3 consecutive failures) + +**Checkpoint**: US3 complete — lobby refreshes automatically without manual intervention. + +--- + +## Phase 6: User Story 4 - Host Starts Game (Priority: P2) + +**Goal**: Only the host can start the game, and only when at least 2 players are present. + +**Independent Test**: With 2 players in lobby, confirm host sees active start +button and non-host does not. Click start on host tab — verify both tabs +navigate to the game screen. + +- [ ] T014 [US4] Add `startGame` function in `backend/src/services/roomStore.ts` — validate caller is host (`participants[?].id === room.hostId`), validate ≥ 2 participants, set room status to `"playing"`, return updated room +- [ ] T015 [US4] Add `POST /rooms/:code/start` endpoint in `backend/src/api/rooms.ts` — parse body with `startGameSchema`, call `startGame`, return snapshot +- [ ] T016 [P] [US4] Add `startRoom` method to `frontend/src/services/api.ts` — `POST /rooms/:code/start` with `{ playerId }` +- [ ] T017 [US4] Update `frontend/src/pages/LobbyPage.tsx` — show "Start Game" button only for host, disable with <2 players tooltip, call `startRoom` on click +- [ ] T018 [US4] Detect game start in polling response in `frontend/src/pages/LobbyPage.tsx` — when room status transitions to `"playing"`, navigate all players to `/game` + +**Checkpoint**: US4 complete — host can start the game and all players navigate to game screen. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] T019 [P] Write test for host assignment in `backend/src/services/roomStore.test.ts` +- [ ] T020 [P] Write test for join with invalid name in `backend/src/services/roomStore.test.ts` +- [ ] T021 [P] Write test for start game validation (non-host, <2 players) in `backend/src/services/roomStore.test.ts` +- [ ] T022 [P] Update schema tests in `backend/src/api/schemas.test.ts` — empty name rejection, valid name acceptance +- [ ] T023 [P] Run `npm run build` in `backend/` — fix any type/build errors +- [ ] T024 [P] Run `npm run build` in `frontend/` — fix any type/build errors + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — already complete +- **Foundational (Phase 2)**: Blocks all user stories +- **User Stories (Phase 3-6)**: + - **US1 (Phase 3)**: Can start after Phase 2 — no dependency on other stories + - **US2 (Phase 4)**: Depends on US1 (needs a room to join) + - **US3 (Phase 5)**: Depends on US1 + US2 (needs multi-player to test refresh) + - **US4 (Phase 6)**: Depends on US1 + US2 + US3 (needs 2 players + polling) +- **Polish (Phase 7)**: Depends on all user stories being complete + +### Within Each User Story + +- Models before services +- Services before endpoints +- Backend before frontend for new endpoints +- Core implementation before integration + +### Parallel Opportunities + +- All Phase 2 [P] tasks can run in parallel +- All [P] tasks within a user story can run in parallel + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 2: Foundational +2. Complete Phase 3: US1 (Create Room) +3. **STOP and VALIDATE**: Single player can create room, see host badge, lobby renders + +### Incremental Delivery + +1. Add US1 → Test independently → Commit +2. Add US2 → Test with two tabs → Commit +3. Add US3 → Test auto-refresh → Commit +4. Add US4 → Test start game flow → Commit +5. Polish and build validation → Commit From 6139f27317cecda594f3d9f2b2d3a86e04e04bad Mon Sep 17 00:00:00 2001 From: M Asif Date: Fri, 26 Jun 2026 18:35:50 +0530 Subject: [PATCH 4/7] feat: implement Game Start & Drawer Flow --- AGENTS.md | 2 +- backend/src/api/rooms.ts | 5 +- backend/src/models/game.ts | 6 + backend/src/services/roomStore.test.ts | 80 +++++++++ backend/src/services/roomStore.ts | 18 +- frontend/src/components/CanvasPlaceholder.tsx | 41 +++++ frontend/src/pages/GamePage.tsx | 57 ++++-- frontend/src/services/api.ts | 3 + frontend/src/state/roomStore.ts | 34 ++++ .../checklists/api-contracts.md | 50 ++++++ .../checklists/edge-cases.md | 56 ++++++ .../checklists/game-flow.md | 52 ++++++ .../checklists/requirements.md | 35 ++++ .../checklists/spec-review.md | 53 ++++++ .../contracts/api.md | 100 +++++++++++ .../002-game-start-drawer-flow/data-model.md | 41 +++++ specs/002-game-start-drawer-flow/plan.md | 93 ++++++++++ .../002-game-start-drawer-flow/quickstart.md | 90 ++++++++++ specs/002-game-start-drawer-flow/research.md | 74 ++++++++ specs/002-game-start-drawer-flow/spec.md | 162 ++++++++++++++++++ specs/002-game-start-drawer-flow/tasks.md | 134 +++++++++++++++ 21 files changed, 1164 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/CanvasPlaceholder.tsx create mode 100644 specs/002-game-start-drawer-flow/checklists/api-contracts.md create mode 100644 specs/002-game-start-drawer-flow/checklists/edge-cases.md create mode 100644 specs/002-game-start-drawer-flow/checklists/game-flow.md create mode 100644 specs/002-game-start-drawer-flow/checklists/requirements.md create mode 100644 specs/002-game-start-drawer-flow/checklists/spec-review.md create mode 100644 specs/002-game-start-drawer-flow/contracts/api.md create mode 100644 specs/002-game-start-drawer-flow/data-model.md create mode 100644 specs/002-game-start-drawer-flow/plan.md create mode 100644 specs/002-game-start-drawer-flow/quickstart.md create mode 100644 specs/002-game-start-drawer-flow/research.md create mode 100644 specs/002-game-start-drawer-flow/spec.md create mode 100644 specs/002-game-start-drawer-flow/tasks.md diff --git a/AGENTS.md b/AGENTS.md index 53d2f758..ac54280d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,5 +42,5 @@ You are working on a monolithic repository for a multiplayer drawing game ("Scri - When creating or editing files, ensure consistency with the existing directory structure detailed above. -Current plan: specs/001-room-setup-lobby/plan.md +Current plan: specs/002-game-start-drawer-flow/plan.md diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 843a95dd..67fae9bf 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -73,6 +73,9 @@ export function createRoomsRouter() { if (!room) { throw new HttpError(404, "Room not found"); } + if (room.status !== "lobby") { + throw new HttpError(400, "Game has already started"); + } if (room.hostId !== playerId) { throw new HttpError(403, "Only the host can start the game"); } @@ -86,7 +89,7 @@ export function createRoomsRouter() { } response.json({ - room: toRoomSnapshot(updatedRoom) + room: toRoomSnapshot(updatedRoom, playerId) }); } catch (error) { next(error); diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index b3af2f0b..68afb58b 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -14,6 +14,9 @@ export interface Room { participants: Participant[]; createdAt: string; updatedAt: string; + currentRound?: number; + drawerId?: string; + secretWord?: string; } export interface RoomSnapshot { @@ -23,6 +26,9 @@ export interface RoomSnapshot { participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; + currentRound?: number; + drawerId?: string; + secretWord?: string; } export interface RoomSessionResponse { diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index 6a3b0e87..32ef3ec0 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { STARTER_WORDS } from "../seed/starterData.js"; import { createRoom, joinRoom, startGame, toRoomSnapshot } from "./roomStore.js"; describe("roomStore", () => { @@ -45,4 +46,83 @@ describe("roomStore", () => { expect(updated).not.toBeNull(); expect(updated!.status).toBe("playing"); }); + + it("startGame assigns drawerId to host", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const updated = startGame(room.code); + + expect(updated!.drawerId).toBe(participantId); + }); + + it("startGame sets currentRound to 1", () => { + const { room } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const updated = startGame(room.code); + + expect(updated!.currentRound).toBe(1); + }); + + it("startGame sets secretWord to STARTER_WORDS[0]", () => { + const { room } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const updated = startGame(room.code); + + expect(updated!.secretWord).toBe(STARTER_WORDS[0]); + }); + + it("toRoomSnapshot includes currentRound and drawerId when playing", () => { + const { room } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const updated = startGame(room.code); + const snapshot = toRoomSnapshot(updated!); + + expect(snapshot.currentRound).toBe(1); + expect(snapshot.drawerId).toBe(updated!.drawerId); + }); + + it("toRoomSnapshot includes secretWord when viewer is the drawer", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const updated = startGame(room.code); + const snapshot = toRoomSnapshot(updated!, participantId); + + expect(snapshot.secretWord).toBe(STARTER_WORDS[0]); + }); + + it("toRoomSnapshot omits secretWord for non-drawer viewers", () => { + const { room, participantId } = createRoom("Alice"); + const second = joinRoom(room.code, "Bob"); + + const updated = startGame(room.code); + const snapshot = toRoomSnapshot(updated!, second!.participantId); + + expect(snapshot.secretWord).toBeUndefined(); + }); + + it("toRoomSnapshot omits secretWord when no viewerParticipantId given", () => { + const { room } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const updated = startGame(room.code); + const snapshot = toRoomSnapshot(updated!); + + expect(snapshot.secretWord).toBeUndefined(); + }); + + it("toRoomSnapshot does not include game fields in lobby state", () => { + const { room, participantId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const snapshot = toRoomSnapshot(room, participantId); + + expect(snapshot.currentRound).toBeUndefined(); + expect(snapshot.drawerId).toBeUndefined(); + expect(snapshot.secretWord).toBeUndefined(); + }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 8cdc5fb1..8bf14c52 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -99,6 +99,9 @@ export function startGame(code: string) { } room.status = "playing"; + room.currentRound = 1; + room.drawerId = room.hostId; + room.secretWord = STARTER_WORDS[room.currentRound - 1]; return saveRoom(room); } @@ -109,9 +112,7 @@ export function saveRoom(room: Room) { } export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; - - return { + const snapshot: RoomSnapshot = { code: room.code, status: room.status, hostId: room.hostId, @@ -119,4 +120,15 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn availableWords: listWords(), roles: [...STARTER_ROLES] }; + + if (room.status === "playing") { + snapshot.currentRound = room.currentRound; + snapshot.drawerId = room.drawerId; + + if (viewerParticipantId === room.drawerId) { + snapshot.secretWord = room.secretWord; + } + } + + return snapshot; } diff --git a/frontend/src/components/CanvasPlaceholder.tsx b/frontend/src/components/CanvasPlaceholder.tsx new file mode 100644 index 00000000..3a3239f3 --- /dev/null +++ b/frontend/src/components/CanvasPlaceholder.tsx @@ -0,0 +1,41 @@ +interface CanvasPlaceholderProps { + isDrawer: boolean; + drawerName: string | null; + secretWord?: string | null; +} + +export function CanvasPlaceholder({ isDrawer, drawerName, secretWord }: CanvasPlaceholderProps) { + return ( +
+ {isDrawer ? ( + <> +

+ You are the drawer! +

+

+ Draw your word here: +

+

+ {secretWord ?? "???"} +

+ + ) : ( +

+ Waiting for {drawerName ?? "the drawer"} to draw... +

+ )} +
+ ); +} diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183e..372ee9f9 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,34 +1,49 @@ -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { CanvasPlaceholder } from "../components/CanvasPlaceholder"; import { Card } from "../components/Card"; import { GuessForm } from "../components/GuessForm"; import { ResultPanel } from "../components/ResultPanel"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { Scoreboard } from "../components/Scoreboard"; -import { useRoomState } from "../state/roomStore"; +import { useRoomState, useRoomStore } from "../state/roomStore"; export function GamePage() { const navigate = useNavigate(); + const roomStore = useRoomStore(); const { room, participantId } = useRoomState(); useEffect(() => { - if (!room) { - navigate("/", { replace: true }); - } - }, [navigate, room]); + if (room) return; + roomStore.restoreSession().then((restoredRoom) => { + if (!restoredRoom) { + roomStore.clearSession(); + navigate("/", { replace: true }); + } + }); + }, [navigate, room, roomStore]); - if (!room) { + const handleExit = useCallback(() => { + roomStore.clearSession(); + navigate("/lobby"); + }, [navigate, roomStore]); + + if (!room || !participantId) { return null; } const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const isDrawer = room.drawerId === participantId; + const drawer = room.participants.find((participant) => participant.id === room.drawerId) ?? null; return (
- Round 1 -

Guess the Word!

+ Round {room.currentRound ?? 1} +

+ {isDrawer ? "Draw the Word!" : "Guess the Word!"} +

@@ -36,14 +51,16 @@ export function GamePage() {
-
- Waiting for drawer... -
+
@@ -54,6 +71,10 @@ export function GamePage() {
Name
{viewer?.name ?? "Unknown player"}
+
+
Drawer
+
{drawer?.name ?? "Unknown"}
+
Status
Playing
@@ -61,14 +82,16 @@ export function GamePage() { - - - + {isDrawer ? null : ( + + + + )}
-
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 444a8b55..20b18e5c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -13,6 +13,9 @@ export interface RoomSnapshot { participants: Participant[]; availableWords: string[]; roles: ParticipantRole[]; + currentRound?: number; + drawerId?: string; + secretWord?: string; } export interface RoomSessionResponse { diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index 6c921cf7..e132c0ba 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -84,15 +84,49 @@ class RoomStore { }); } + clearSession() { + sessionStorage.removeItem("scribble_roomCode"); + sessionStorage.removeItem("scribble_participantId"); + } + + getSavedSession(): { code: string; participantId: string } | null { + const code = sessionStorage.getItem("scribble_roomCode"); + const participantId = sessionStorage.getItem("scribble_participantId"); + if (code && participantId) { + return { code, participantId }; + } + return null; + } + + async restoreSession() { + const session = this.getSavedSession(); + if (!session) return null; + + this.setState({ participantId: session.participantId }); + + try { + const response = await api.fetchRoom(session.code, session.participantId); + this.setRoomSnapshot(response.room); + return response.room; + } catch { + this.setState({ error: "Failed to restore session" }); + return null; + } + } + async createRoom(playerName: string) { const response = await this.withLoading(() => api.createRoom(playerName)); this.setRoomSession(response); + sessionStorage.setItem("scribble_roomCode", response.room.code); + sessionStorage.setItem("scribble_participantId", response.participantId); return response; } async joinRoom(code: string, playerName: string) { const response = await this.withLoading(() => api.joinRoom(code, playerName)); this.setRoomSession(response); + sessionStorage.setItem("scribble_roomCode", response.room.code); + sessionStorage.setItem("scribble_participantId", response.participantId); return response; } diff --git a/specs/002-game-start-drawer-flow/checklists/api-contracts.md b/specs/002-game-start-drawer-flow/checklists/api-contracts.md new file mode 100644 index 00000000..7dba0e30 --- /dev/null +++ b/specs/002-game-start-drawer-flow/checklists/api-contracts.md @@ -0,0 +1,50 @@ +# API Contracts Quality Checklist: Game Start & Drawer Flow + +**Purpose**: Validate requirements quality for API contracts and data-model definitions +**Created**: 2026-06-26 +**Feature**: [spec.md](../spec.md) + +## Requirement Completeness + +- [ ] CHK001 Are error response schemas defined for all API failure modes (non-host start, <2 players, room not found)? [Completeness, contracts/api.md] +- [ ] CHK002 Is the sessionStorage contract fully specified — key names, write triggers, read triggers, clear triggers? [Completeness, contracts/api.md] +- [ ] CHK003 Are request body validation rules specified for the start game endpoint? [Completeness, spec.md FR-016, contracts/api.md] +- [ ] CHK004 Is the behavior of `secretWord` for non-drawer participants defined as field absence (not `null`, not empty string)? [Completeness, contracts/api.md L54] + +## Requirement Clarity + +- [ ] CHK005 Is the condition for `secretWord` inclusion in the snapshot specified with exact equality logic (`viewerParticipantId === room.drawerId`)? [Clarity, spec.md FR-018, data-model.md L19] +- [ ] CHK006 Is the exact response shape documented for both drawer and guesser (including which fields are present vs absent)? [Clarity, contracts/api.md L22-L54] +- [ ] CHK007 Is the "when navigated away" trigger for sessionStorage clearing defined with specific route names? [Clarity, contracts/api.md L94-L95] +- [ ] CHK008 Is the `playerId` vs `participantId` terminology distinction clarified? [Clarity, contracts/api.md L62-L64, spec.md L111] + +## Requirement Consistency + +- [ ] CHK009 Does the data model's `secretWord` optionality (`secretWord?`) agree with the API contract's conditional presence? [Consistency, data-model.md L19 vs contracts/api.md L26/L48] +- [ ] CHK010 Are the sessionStorage key names (`scribble_roomCode`, `scribble_participantId`) consistent between contracts and spec references? [Consistency, contracts/api.md L94-L95] +- [ ] CHK011 Does the `startGame` POST response example include `secretWord` for the drawer, consistent with FR-018? [Consistency, contracts/api.md L80 vs spec.md FR-018] +- [ ] CHK012 Does the `currentRound` field name match between data model and API contract examples? [Consistency, data-model.md L9 vs contracts/api.md L24] + +## Coverage + +- [ ] CHK013 Is the API response format for 403 (non-host start) and 400 (<2 players) error responses documented? [Coverage, contracts/api.md, Gap] +- [ ] CHK014 Is the behavior defined when `participantId` is missing/empty in the `GET /rooms/:code` query? [Coverage, Gap] +- [ ] CHK015 Is the behavior defined when sessionStorage is unavailable (private browsing, storage quota exceeded)? [Coverage, Gap] +- [ ] CHK016 Is the GET endpoint response for `status === "lobby"` vs `status === "playing"` differentiated? [Coverage, contracts/api.md L13-L30] + +## Edge Cases + +- [ ] CHK017 Is the behavior defined when `startGame` is called on a room already in "playing" status? [Edge Case, Gap] +- [ ] CHK018 Is the behavior defined when `STARTER_WORDS` is empty or undefined? [Edge Case, Gap] +- [ ] CHK019 Is the behavior defined when the drawer ID in sessionStorage no longer matches any participant? [Edge Case, Gap] +- [ ] CHK020 Is the behavior defined when room data is stale (concurrent modification between fetch and render)? [Edge Case, Gap] + +## Dependencies & Assumptions + +- [ ] CHK021 Is the dependency on `STARTER_WORDS` list existence and ordering documented in the spec or contracts? [Dependency, data-model.md L28] +- [ ] CHK022 Is the assumption that `GET /rooms/:code` already returns `availableWords` and `roles` documented? [Assumption, contracts/api.md L27-L28] + +## Notes + +- Items marked [Gap] indicate missing contract specifications that may need to be added before implementation. +- Cross-reference with data-model.md for entity field definitions and validation rules. diff --git a/specs/002-game-start-drawer-flow/checklists/edge-cases.md b/specs/002-game-start-drawer-flow/checklists/edge-cases.md new file mode 100644 index 00000000..c510b5b3 --- /dev/null +++ b/specs/002-game-start-drawer-flow/checklists/edge-cases.md @@ -0,0 +1,56 @@ +# Edge Cases & Failure Modes Checklist: Game Start & Drawer Flow + +**Purpose**: Validate requirements quality for edge cases, failure modes, and exceptional paths +**Created**: 2026-06-26 +**Feature**: [spec.md](../spec.md) + +## Requirement Completeness (Failure Paths) + +- [ ] CHK001 Is the behavior defined when the `startGame` endpoint is called by a player who is not the host? [Completeness, spec.md §Edge Cases, Gap] +- [ ] CHK002 Is the behavior defined when `startGame` is called with fewer than 2 participants? [Completeness, spec.md §Edge Cases, Gap] +- [ ] CHK003 Is the behavior defined when `startGame` is called on a room already in "playing" status? [Completeness, Gap] +- [ ] CHK004 Is the behavior defined when the backend is restarted while a user is viewing the GamePage (no polling to detect 404)? [Completeness, spec.md §Edge Cases L84-L86] +- [ ] CHK005 Is the behavior defined when sessionStorage is unavailable (private browsing, storage quota exceeded)? [Completeness, Gap] + +## Requirement Completeness (Data/State Edge Cases) + +- [ ] CHK006 Is the behavior defined when `STARTER_WORDS` list is empty, undefined, or has fewer words than rounds? [Completeness, Gap] +- [ ] CHK007 Is the behavior defined when the drawer's participant ID from sessionStorage no longer exists on the server? [Completeness, Gap] +- [ ] CHK008 Is the behavior defined for concurrent `startGame` calls from multiple participants? [Completeness, Gap] +- [ ] CHK009 Is the behavior defined when the room is deleted (last player leaves) while a game is in progress? [Completeness, Gap] + +## Requirement Completeness (Recovery Paths) + +- [ ] CHK010 Is the recovery path defined when the GamePage's single fetch fails (network error, not just 404)? [Completeness, Gap] +- [ ] CHK011 Is the recovery path defined when sessionStorage data is corrupted or tampered with? [Completeness, Gap] +- [ ] CHK012 Is the recovery path defined when a participant re-joins a room that is already in "playing" status? [Completeness, Gap] +- [ ] CHK013 Is the recovery path defined when the drawer's tab crashes or is closed during the game? [Completeness, Gap] + +## Requirement Clarity + +- [ ] CHK014 Is "Room no longer available" message behavior specified — is it a toast, full-screen overlay, or redirect? [Clarity, spec.md §Edge Cases L85-L86] +- [ ] CHK015 Is the "re-fetches room state" behavior on page refresh specified with error handling? [Clarity, spec.md §Edge Cases L81-L82] +- [ ] CHK016 Is "navigates back to the home screen" defined — does it replace history or push? [Clarity, spec.md §Edge Cases L86] + +## Consistency + +- [ ] CHK017 Does the backend restart edge case mention "polling request" but the GamePage uses single fetch (not polling)? [Consistency, spec.md §Edge Cases L84-L85 vs Assumptions L159] +- [ ] CHK018 Is the behavior for a missing participantId in GET /rooms/:code consistent between the lobby scenario and the game scenario? [Consistency, contracts/api.md, Gap] + +## Scenario Coverage + +- [ ] CHK019 Is the behavior specified for the scenario where the word list index exceeds array bounds? [Coverage, Gap] +- [ ] CHK020 Is the behavior specified for the scenario where a guesser inspects the DOM for the secret word? [Coverage, spec.md SC-3] +- [ ] CHK021 Is the behavior specified for the scenario where the host rename's themselves mid-game (drawer label should update)? [Coverage, Gap] + +## Dependencies & Assumptions + +- [ ] CHK022 Is the assumption "no database, in-memory only" validated against the backend restart edge case (data loss)? [Assumption, spec.md §Edge Cases L84-L86, constitution] +- [ ] CHK023 Is the assumption "single fetch is sufficient" validated against the scenario where the server response is delayed or lost? [Assumption, spec.md Assumptions L159] +- [ ] CHK024 Is the dependency on `structuredClone` (deep copy of Room objects) documented? [Dependency, Gap] + +## Notes + +- Items marked [Gap] indicate missing edge case or recovery path requirements. +- The existing 4 edge cases cover basic scenarios; most gaps are around recovery paths and concurrent access. +- Cross-reference with api-contracts.md for missing error response schemas. diff --git a/specs/002-game-start-drawer-flow/checklists/game-flow.md b/specs/002-game-start-drawer-flow/checklists/game-flow.md new file mode 100644 index 00000000..6ca1f206 --- /dev/null +++ b/specs/002-game-start-drawer-flow/checklists/game-flow.md @@ -0,0 +1,52 @@ +# Game Flow & UX Quality Checklist: Game Start & Drawer Flow + +**Purpose**: Validate requirements quality for game flow, user roles, and user-facing behavior +**Created**: 2026-06-26 +**Feature**: [spec.md](../spec.md) + +## Requirement Completeness + +- [ ] CHK001 Are requirements defined for what the drawer sees on the GamePage vs. what the guesser sees? [Completeness, spec.md FR-019] +- [ ] CHK002 Is the "Exit Game" or "Leave Game" user flow specified? [Completeness, Gap] +- [ ] CHK003 Are requirements defined for the loading state during GamePage mount (before room state is fetched)? [Completeness, Gap] +- [ ] CHK004 Is the transition from lobby to game screen defined in terms of user-observable behavior? [Completeness, spec.md US1, Assumptions L159] +- [ ] CHK005 Is the canvas placeholder behavior specified for both drawer and guesser views? [Completeness, spec.md Assumptions L155-L156] + +## Requirement Clarity + +- [ ] CHK006 Is the exact UI message text specified for the drawer ("Your word: [word]" vs "You are the drawer!" with secret word)? [Clarity, spec.md FR-019] +- [ ] CHK007 Is "clearly-identified drawer" defined with specific UI criteria (label, badge, positioning)? [Clarity, spec.md US1 L24] +- [ ] CHK008 Is the timing for guessers to see the drawer identity specified? [Clarity, spec.md SC-006] +- [ ] CHK009 Is the page refresh recovery UX specified — does the user see stale content, a spinner, or nothing during re-fetch? [Clarity, spec.md US1 AS3, FR-020] + +## Requirement Consistency + +- [ ] CHK010 Does SC-006 align with the architecture (single fetch, not polling)? [Consistency, spec.md SC-006 vs Clarifications L16] +- [ ] CHK011 Does SC-010's 2-second bound align with the single-fetch-on-mount design? [Consistency, spec.md SC-010 vs Assumptions L159] +- [ ] CHK012 Is the "immediately" in SC-007 defined consistently for both drawer (POST response) and guessers (lobby poll)? [Consistency, spec.md SC-007 vs SC-006] +- [ ] CHK013 Is the drawer role terminology ("drawer" vs "host") consistent across US1 and US2? [Consistency, spec.md US1 L24 vs US2 L51] + +## Acceptance Criteria Quality + +- [ ] CHK014 Can SC-006 "see who the drawer is" be objectively verified with a specific test procedure? [Measurability, spec.md SC-006] +- [ ] CHK015 Can SC-008 "never see the secret word" be verified via API inspection (not just UI)? [Measurability, spec.md SC-008] +- [ ] CHK016 Can SC-010 "returned to the correct view within 2 seconds" be measured without instrumentation? [Measurability, spec.md SC-010] +- [ ] CHK017 Can the acceptance scenario "player refreshes their browser" be automated, or is it manual-only? [Measurability, spec.md US1 AS3] + +## Scenario Coverage + +- [ ] CHK018 Is the user flow for a non-host player who tries to access the GamePage directly (without going through lobby) specified? [Coverage, Gap] +- [ ] CHK019 Is the user flow for the drawer vs. guesser differentiation on the lobby-to-game transition specified? [Coverage, spec.md US1 L29-L31] +- [ ] CHK020 Is the user flow for multiple rounds (beyond round 1) explicitly marked as out of scope? [Coverage, spec.md Assumptions L148-L149] + +## Edge Cases + +- [ ] CHK021 Is the behavior specified when a participant navigates to the GamePage with no room state and no sessionStorage? [Edge Case, Gap] +- [ ] CHK022 Is the behavior specified when a non-drawer participant tries to see the word via browser DevTools? [Edge Case, spec.md SC-3] +- [ ] CHK023 Is the behavior specified when the drawer identity in sessionStorage differs from the server's drawer identity? [Edge Case, Gap] +- [ ] CHK024 Is the behavior specified when the backend is restarted while a user is on the GamePage (no polling to detect 404)? [Edge Case, spec.md Edge Cases L84-L86] + +## Notes + +- Items marked [Gap] indicate missing game-flow requirements that may need to be added. +- Cross-reference with Spec Review checklist at `spec-review.md` for broader requirement quality concerns. diff --git a/specs/002-game-start-drawer-flow/checklists/requirements.md b/specs/002-game-start-drawer-flow/checklists/requirements.md new file mode 100644 index 00000000..b4c63da8 --- /dev/null +++ b/specs/002-game-start-drawer-flow/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Game Start & Drawer Flow + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-26 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation. +- Clarifications resolved: page refresh persistence (sessionStorage) and game-state polling (deferred). Integrated into spec. diff --git a/specs/002-game-start-drawer-flow/checklists/spec-review.md b/specs/002-game-start-drawer-flow/checklists/spec-review.md new file mode 100644 index 00000000..04f8db20 --- /dev/null +++ b/specs/002-game-start-drawer-flow/checklists/spec-review.md @@ -0,0 +1,53 @@ +# Spec Review Checklist: Game Start & Drawer Flow + +**Purpose**: Full requirements quality audit of the Game Start & Drawer Flow specification +**Created**: 2026-06-26 +**Feature**: specs/002-game-start-drawer-flow/spec.md + +**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. + +## Requirement Completeness + +- [ ] CHK001 Are requirements specified for the scenario where a non-host participant manually calls the start-game endpoint? Should the server reject it? [Completeness, Gap] +- [ ] CHK002 Are requirements specified for the canvas/placeholder that appears on the GamePage (visual dimensions, label, behavior)? [Completeness, Spec §Assumptions] +- [ ] CHK003 Is the backend restart mid-game recovery path defined as a functional requirement, or only documented as an edge case? [Completeness, Spec §Edge Cases] +- [ ] CHK004 Are requirements defined for what happens when the word list is exhausted (room reaches a round number beyond STARTER_WORDS length)? [Completeness, Spec §Edge Cases, Assumptions] +- [ ] CHK005 Are requirements specified for the scenario where the drawer and guesser see different states simultaneously? Is a race condition possible? [Completeness, Gap] + +## Requirement Clarity + +- [ ] CHK006 Is "deterministically selected from the starter word list" precise enough — does it define the exact algorithm or does it depend on an external list not specified here? [Clarity, Spec §FR-015] +- [ ] CHK007 Is the exact UI message text for guessers defined as a requirement, or is "Waiting for [drawer name] to draw..." illustrative? [Clarity, Spec §FR-019] +- [ ] CHK008 Is "immediately upon game start" (SC-007) measurable without a time bound? Should match single-fetch timing of the GamePage mount. [Clarity, Spec §SC-007] +- [ ] CHK009 What specific sessionStorage keys are used? What format/encoding? What if sessionStorage is unavailable (private browsing, storage quota)? [Clarity, Spec §FR-020] +- [ ] CHK010 Does "correct drawer/guesser view" on page refresh (FR-020) require an explicit loading/restoring state in the UI? [Clarity, Spec §FR-020] + +## Requirement Consistency + +- [ ] CHK011 SC-006 references "2 seconds" and "polling cycle", but the spec defers polling to a later scenario. Does this conflict with the single-fetch-on-mount design? [Consistency, Spec §SC-006, Clarifications] +- [ ] CHK012 SC-010 references "2 seconds" for refresh recovery, but without polling this depends on fetch latency + render time. Is the upper bound aligned with the architectural constraints? [Consistency, Spec §SC-010, Assumptions] +- [ ] CHK013 Does "room status transitions to 'playing'" (FR-014) reuse the same status enum from Scenario 1, or does it define a new status value? Should cross-reference the data model. [Consistency, Spec §FR-014, Scenario 1 Data Model] + +## Acceptance Criteria Quality + +- [ ] CHK014 Can SC-009 "deterministic word selection produces the same word" be verified independently by each client without a shared reference oracle? [Measurability, Spec §SC-009] +- [ ] CHK015 Can SC-008 "non-drawer never sees the word" be tested without inspecting raw API responses in the test environment? [Measurability, Spec §SC-008] +- [ ] CHK016 Is the acceptance scenario for browser refresh (US2-SC3) verifiable in an automated test, or does it require manual testing with browser devtools? [Measurability, Spec §User Story 2, SC-3] + +## Scenario Coverage + +- [ ] CHK017 Is there a requirement for what happens when the drawer renames themselves during the game (the drawer label should update)? [Coverage, Gap] +- [ ] CHK018 Are requirements specified for the scenario where the game starts with exactly 2 players and one leaves mid-round? [Coverage, Gap] +- [ ] CHK019 Is the transition from lobby to game screen covered — do all participants need an explicit navigation signal, or does the lobby polling detect the status change? [Coverage, Spec §Clarifications, Scenario 1 FR-012] + +## Dependencies & Assumptions + +- [ ] CHK020 Is the assumption "host is always drawer in the first round" documented as a constraint for this scenario only (not a permanent design decision)? [Assumption, Spec §Assumptions] +- [ ] CHK021 Is the dependency on STARTER_WORDS list existence and ordering explicitly documented? [Dependency, Spec §Assumptions] +- [ ] CHK022 Are the constitutional constraints (no WebSockets, polling-only) correctly reflected in the single-fetch design choice vs. the 2-second success criteria? [Consistency, Spec §Assumptions, Constitution] + +## Notes + +- Items marked [Gap] indicate missing requirements that may need to be added before implementation. +- This checklist is a peer review aid — check off items as they are verified against the spec. +- Cross-reference with the auto-generated specification quality checklist in `requirements.md`. diff --git a/specs/002-game-start-drawer-flow/contracts/api.md b/specs/002-game-start-drawer-flow/contracts/api.md new file mode 100644 index 00000000..95903973 --- /dev/null +++ b/specs/002-game-start-drawer-flow/contracts/api.md @@ -0,0 +1,100 @@ +# API Contracts: Game Start & Drawer Flow + +**Extends**: contracts/api.md from Scenario 1 (Room Setup & Lobby) + +## Modified Endpoints + +### Get Room (updated response for "playing" status) + +``` +GET /rooms/:code?participantId=p1 +``` + +**Response** `200` (when status is "playing"): +```json +{ + "room": { + "code": "ABCD", + "participants": [ + { "id": "p1", "name": "Alice", "joinedAt": "2026-06-26T00:00:00.000Z" }, + { "id": "p2", "name": "Bob", "joinedAt": "2026-06-26T00:00:01.000Z" } + ], + "status": "playing", + "hostId": "p1", + "currentRound": 1, + "drawerId": "p1", + "secretWord": "rocket", + "availableWords": ["rocket", "pizza", "castle", "guitar", "sunflower"], + "roles": ["drawer", "guesser"] + } +} +``` + +**Note**: `secretWord` is present only when `participantId` matches `drawerId`. +For a guesser requesting with `?participantId=p2`: + +```json +{ + "room": { + "code": "ABCD", + "participants": [ + { "id": "p1", "name": "Alice", "joinedAt": "..." }, + { "id": "p2", "name": "Bob", "joinedAt": "..." } + ], + "status": "playing", + "hostId": "p1", + "currentRound": 1, + "drawerId": "p1", + "availableWords": ["rocket", "pizza", "castle", "guitar", "sunflower"], + "roles": ["drawer", "guesser"] + } +} +``` + +Note: `secretWord` is absent (not `null`, not empty string — truly absent). + +### Start Game (updated response) + +``` +POST /rooms/:code/start +Content-Type: application/json + +{ + "playerId": "p1" +} +``` + +**Response** `200`: +```json +{ + "room": { + "code": "ABCD", + "participants": [ + { "id": "p1", "name": "Alice", "joinedAt": "..." }, + { "id": "p2", "name": "Bob", "joinedAt": "..." } + ], + "status": "playing", + "hostId": "p1", + "currentRound": 1, + "drawerId": "p1", + "secretWord": "rocket", + "availableWords": ["rocket", "pizza", "castle", "guitar", "sunflower"], + "roles": ["drawer", "guesser"] + } +} +``` + +**Note**: `secretWord` is present because `playerId` (= drawer) is used as +`viewerParticipantId` when building the snapshot response. + +## New Frontend-Only Contract: sessionStorage + +| Key | Value | When Written | When Read | When Cleared | +|-----|-------|-------------|-----------|-------------| +| `scribble_roomCode` | Room code string (e.g. `"ABCD"`) | On successful `createRoom` or `joinRoom` | On GamePage mount | When user navigates to "/" or "/lobby" | +| `scribble_participantId` | Participant ID string (e.g. `"p1"`) | On successful `createRoom` or `joinRoom` | On GamePage mount | When user navigates to "/" or "/lobby" | + +## Polling Contract (no change from Scenario 1) + +- Frontend polls `GET /rooms/:code` at ~2s intervals while on the lobby page. +- GamePage does NOT poll — single fetch on mount only. diff --git a/specs/002-game-start-drawer-flow/data-model.md b/specs/002-game-start-drawer-flow/data-model.md new file mode 100644 index 00000000..89815af4 --- /dev/null +++ b/specs/002-game-start-drawer-flow/data-model.md @@ -0,0 +1,41 @@ +# Data Model: Game Start & Drawer Flow + +## Extensions to Existing Entities + +### Room (new fields) + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `currentRound` | `number` | — | Current round number (starts at 1). Set when game starts. | +| `drawerId` | `string` | — | Participant ID of the current round's drawer. Set when game starts. | +| `secretWord` | `string` | — | The secret word for the current round. Selected deterministically. | + +### RoomSnapshot (new fields) + +| Field | Type | Condition | Description | +|-------|------|-----------|-------------| +| `currentRound` | `number` | Always present when `status === "playing"` | Current round number. | +| `drawerId` | `string` | Always present when `status === "playing"` | Participant ID of the drawer. | +| `secretWord` | `string` (optional) | Only present when `viewerParticipantId === room.drawerId` | The secret word for the round. Filtered server-side. | + +## State Transitions + +```text +[lobby] ──→ startGame() ──→ [playing] + status: "lobby" status: "playing" + currentRound: 1 + drawerId: room.hostId + secretWord: STARTER_WORDS[0] ← "rocket" +``` + +## Validation Rules (additions) + +- `startGame()`: Room must have `status === "lobby"`. Caller must be host (`hostId === playerId`). Participants must be ≥ 2. +- `toRoomSnapshot()`: `secretWord` field in snapshot is `undefined` unless `viewerParticipantId === room.drawerId`. +- Word selection: `STARTER_WORDS[currentRound - 1]`. For round 1, index 0 → `"rocket"`. + +## Relationships + +- Room has exactly 1 drawer (a participant in that room) when status is "playing". +- The drawer is always the host in the first round. +- The secret word belongs to the room's current round. diff --git a/specs/002-game-start-drawer-flow/plan.md b/specs/002-game-start-drawer-flow/plan.md new file mode 100644 index 00000000..0353585d --- /dev/null +++ b/specs/002-game-start-drawer-flow/plan.md @@ -0,0 +1,93 @@ +# Implementation Plan: Game Start & Drawer Flow + +**Branch**: `002-game-start-drawer-flow` | **Date**: 2026-06-26 | **Spec**: specs/002-game-start-drawer-flow/spec.md + +**Input**: Feature specification from `specs/002-game-start-drawer-flow/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +When the host starts the game, the room transitions to "playing", the host becomes the drawer, and a secret word is deterministically selected from STARTER_WORDS. The word is visible only to the drawer via server-side filtering in `toRoomSnapshot()`. sessionStorage persists the room code and participant ID across page refreshes. + +## Technical Context + +**Language/Version**: TypeScript (ES2022, NodeNext module resolution, strict mode) + +**Primary Dependencies**: Express 4 + Zod (backend); React 18 + React Router 6 + Vite (frontend) + +**Storage**: In-memory only (Map-based room store on backend) + +**Testing**: Vitest, minimum 80% line coverage + +**Target Platform**: Modern web browsers (Chrome, Firefox, Safari, Edge) + +**Project Type**: Web application (Express backend + React frontend) + +**Performance Goals**: Game start response under 500ms p95; page refresh recovery under 2s (single fetch). SC-006's "2 seconds" refers to the LobbyPage polling interval — guessers see the drawer identity when the LobbyPage poll detects `status === "playing"` and auto-navigates to GamePage. + +**Constraints**: No WebSockets, no databases, no auth, polling-only sync. Backend centralized error handling. Zod for request validation. Functional components with hooks on frontend. + +**Polling Model**: The LobbyPage polls `GET /rooms/:code` every 2s (Scenario 1). The GamePage does NOT poll — it performs a single fetch on mount for page-refresh recovery only. SC-006's 2s window is satisfied by the LobbyPage poll detecting `status === "playing"`, which triggers navigation to `/game`. + +**Scale/Scope**: Lab project — small scale, no production deployment. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **TypeScript-First (Strict Mode)**: TypeScript strict mode enabled, `any` forbidden. +- **Extend, Don't Rewrite**: All changes extend existing Room/RoomSnapshot models + roomStore service. No rewrites. +- **Spec-Driven Development**: Spec artifacts committed before implementation. +- **Deterministic Game Logic**: Word selection uses `STARTER_WORDS[currentRound - 1]` — deterministic, no randomness. +- **Minimal Dependencies**: No new dependencies required. sessionStorage is a Web API, no library needed. +- **Technical Constraints**: No WebSockets, no DB, no auth, polling-only, Zod validation, functional components, 80% Vitest coverage. +- **No violations detected**: Complexity tracking not required. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-game-start-drawer-flow/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +backend/ +├── src/ +│ ├── models/ +│ │ └── game.ts # Add currentRound, drawerId, secretWord to Room; add to RoomSnapshot +│ ├── services/ +│ │ └── roomStore.ts # Extend startGame() to assign drawer + word; gate word in toRoomSnapshot() +│ ├── api/ +│ │ └── rooms.ts # Pass viewerParticipantId to toRoomSnapshot() in startGame POST handler +│ └── services/ +│ └── roomStore.test.ts # Add tests for drawer assignment + word visibility + +frontend/ +├── src/ +│ ├── pages/ +│ │ └── GamePage.tsx # Differentiate drawer/guesser views; sessionStorage restore on mount. Single fetch only — no polling. +│ ├── services/ +│ │ └── api.ts # Add currentRound, drawerId, secretWord? to RoomSnapshot type +│ ├── state/ +│ │ └── roomStore.ts # Add sessionStorage persistence on create/join; restore on init; add setParticipantId +│ └── components/ +│ └── CanvasPlaceholder.tsx # NEW component for non-interactive canvas area +``` + +**Structure Decision**: Option 2 — Web application (`frontend` + `backend`). All changes extend existing starter files, with one new component. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +No violations detected. Complexity tracking not required. diff --git a/specs/002-game-start-drawer-flow/quickstart.md b/specs/002-game-start-drawer-flow/quickstart.md new file mode 100644 index 00000000..2f487fc5 --- /dev/null +++ b/specs/002-game-start-drawer-flow/quickstart.md @@ -0,0 +1,90 @@ +# Quickstart: Game Start & Drawer Flow + +## Prerequisites + +- Node.js 18+ +- Backend and frontend dependencies installed (`npm install` in both `backend/` and `frontend/`) +- Scenario 1 (Room Setup & Lobby) fully implemented — rooms can be created, joined, and started + +## Setup + +```bash +# Terminal 1: Start backend +cd backend +npm run dev + +# Terminal 2: Start frontend +cd frontend +npm run dev +``` + +Backend runs on `http://localhost:3001`, frontend on `http://localhost:5173`. + +## Validation Scenarios + +### Scenario 1: Drawer Assignment on Game Start + +1. Open `http://localhost:5173` in browser tab A. +2. Click **Create Room**, enter "Alice", submit. +3. Open tab B at `http://localhost:5173`, click **Join Room**, enter the room code and "Bob", submit. +4. In tab A (Alice, host), click **Start Game**. +5. **Expected**: Both tabs navigate to the game screen. +6. **Expected**: Both tabs show "Drawer: Alice" or equivalent drawer identity indicator. +7. **Expected**: Tab A (Alice) sees "You are the drawer!" with the secret word "rocket". +8. **Expected**: Tab B (Bob) sees "Waiting for Alice to draw..." without any secret word. + +### Scenario 2: Word Visibility — Server-Side Filtering + +1. Complete Scenario 1. +2. In tab A (Alice, drawer), **Expected**: Secret word "rocket" is visible in the UI. +3. Open browser DevTools in tab B (Bob, guesser). +4. Check the Network tab for the `GET /rooms/:code` response. +5. **Expected**: The response for Bob does NOT contain a `secretWord` field. +6. **Expected**: Tab B shows no trace of the word in the DOM or page source. + +### Scenario 3: Page Refresh Recovery + +1. Complete Scenario 1 with a game in progress. +2. In tab A (Alice), refresh the browser page. +3. **Expected**: Alice returns to the game screen as the drawer, still seeing "rocket". +4. In tab B (Bob), refresh the browser page. +5. **Expected**: Bob returns to the game screen as a guesser, word not visible. + +### Scenario 4: Multi-Player Drawer Labeling + +1. Open tab A at `http://localhost:5173`, create room as "Alice". +2. Open tab B, join as "Bob". +3. Open tab C, join as "Charlie". +4. In tab A, start the game. +5. **Expected**: All three tabs show Alice as the drawer. Bob and Charlie are labeled as guessers. + +### Scenario 5: Backend Restart During Game + +1. Complete Scenario 1 to have a game in progress. +2. Stop and restart the backend (`Ctrl+C`, then `npm run dev`). +3. In any tab, refresh the page. +4. **Expected**: The app shows "Room no longer available" (or error) and navigates to the home screen. + +## Verification + +Run the test suite: + +```bash +cd backend && npm test +cd frontend && npm test +``` + +All existing tests must pass. New tests should cover: +- `startGame()` assigns drawer (host) and secret word (round 1 → "rocket") +- `toRoomSnapshot()` includes `secretWord` for drawer, excludes for others +- `toRoomSnapshot()` includes `currentRound` and `drawerId` when status is "playing" +- GamePage renders drawer view vs. guesser view correctly +- sessionStorage read/write integration + +## Contracts Reference + +See `contracts/api.md` for full API endpoint specifications and sessionStorage contract. + +## Data Model Reference + +See `data-model.md` for entity extensions and field definitions. diff --git a/specs/002-game-start-drawer-flow/research.md b/specs/002-game-start-drawer-flow/research.md new file mode 100644 index 00000000..950cb5b2 --- /dev/null +++ b/specs/002-game-start-drawer-flow/research.md @@ -0,0 +1,74 @@ +# Research: Game Start & Drawer Flow + +## Unknowns Resolved + +### 1. sessionStorage Key Names and Strategy + +**Decision**: Use keys `scribble_roomCode` and `scribble_participantId`. Store raw string values (no JSON wrapping). + +**Rationale**: Simple key-value storage avoids serialization overhead. The prefix `scribble_` avoids collisions with other apps on the same origin. Values are written on successful create/join and read on GamePage mount. Cleared when the user navigates away (navigates to "/" or "/lobby"). + +**Alternatives considered**: +- Single JSON blob: More complex to read/write individual fields. +- localStorage: Persists beyond tab session. Not desirable — a new tab should not auto-join a room. + +**Fallback**: If sessionStorage is unavailable (private browsing in some older browsers), the app still functions — the page refresh scenario simply won't restore state. No crash. This is acceptable for a lab project. + +### 2. SC-006 and SC-010 Timing — Polling vs. Single Fetch + +**Decision**: SC-006 and SC-010 reference "2 seconds" which was inherited from the polling-based lobby scenario. Since game-state polling is deferred, these success criteria are interpreted as: +- **SC-006**: Drawer identity is visible as soon as the GamePage renders after a successful single fetch (typically under 500ms). +- **SC-010**: Refresh recovery completes within the time of a single fetch + render cycle (typically under 1s on localhost). The 2-second bound is a generous upper limit. + +**Rationale**: The spec's clarification states "Single fetch on GamePage mount is sufficient for this scenario." The 2-second window is still achievable via fetch + render latency. + +### 3. startGame POST Response — viewerParticipantId Passing + +**Decision**: The `startGame` POST handler MUST pass `playerId` (from request body) as `viewerParticipantId` to `toRoomSnapshot()`. This ensures the drawer who started the game receives the secret word in the response. + +**Rationale**: FR-018 requires the secret word in snapshots for the drawer. The start game caller (who is the host/drawer) should immediately see their word. + +**Current code gap**: `rooms.ts:89` calls `toRoomSnapshot(updatedRoom)` without `viewerParticipantId`. The `void viewerParticipantId` in `toRoomSnapshot()` then drops the word. Both need fixing. + +### 4. Canvas Placeholder Behavior + +**Decision**: The canvas placeholder remains a non-interactive `div` for both drawer and guesser views. For guessers, it displays "Waiting for [drawer name] to draw...". For the drawer, it displays "Draw your word here" (or similar neutral placeholder). The canvas itself is static in this scenario. + +**Rationale**: The spec's assumptions explicitly state "The canvas remains a non-interactive placeholder in this scenario." Interactive drawing tools are a separate, later scenario. + +### 5. GamePage Navigation Signal + +**Decision**: The lobby polling detects `status === "playing"` and navigates to `/game`. The GamePage reads room data from the store. On mount, it fetches room state once (for refresh recovery). No WebSocket or push mechanism needed. + +**Rationale**: This is how Scenario 1 already works — the lobby polling in LobbyPage transitions to GamePage when status changes. + +## Existing Implementation Analysis + +### Current `startGame()` (roomStore.ts:94-103) +- Only sets `room.status = "playing"` +- Needs to also set `room.currentRound = 1`, `room.drawerId = room.hostId`, `room.secretWord = STARTER_WORDS[0]` + +### Current `toRoomSnapshot()` (roomStore.ts:111-121) +- `void viewerParticipantId` — ignores the parameter entirely +- Needs to conditionally include `secretWord` when `viewerParticipantId === room.drawerId` +- Needs to add `currentRound` and `drawerId` to the snapshot + +### Current `Room` interface (game.ts) +- Missing: `currentRound`, `drawerId`, `secretWord` +- `RoomSnapshot` missing: `currentRound`, `drawerId`, `secretWord?` + +### Current `startGame` route (rooms.ts:88-90) +- Calls `toRoomSnapshot(updatedRoom)` without `viewerParticipantId` +- Needs to pass `playerId` as `viewerParticipantId` + +### Current GamePage (GamePage.tsx) +- Shows static "Round 1" and "Guess the Word!" regardless of drawer/guesser +- Canvas placeholder shows "Waiting for drawer..." always +- No differentiation between drawer and guesser views +- No sessionStorage read on mount + +## Test Patterns + +- Backend tests use Vitest `describe`/`it`/`expect` with direct calls to service functions +- Frontend API tests mock `fetch` using `vi.stubGlobal` / `vi.mocked` +- Tests are co-located in `__tests__/` dirs or alongside source files with `.test.ts` extension diff --git a/specs/002-game-start-drawer-flow/spec.md b/specs/002-game-start-drawer-flow/spec.md new file mode 100644 index 00000000..86739922 --- /dev/null +++ b/specs/002-game-start-drawer-flow/spec.md @@ -0,0 +1,162 @@ +# Feature Specification: Game Start & Drawer Flow + +**Feature Branch**: `002-game-start-drawer-flow` + +**Created**: 2026-06-26 + +**Status**: Draft + +**Input**: User description: "Scenario 2 — Game Start & Drawer Flow — Given a game is starting and player names are trimmed (empty/whitespace-only rejected with a message), When the first round begins, Then the host (or first player) becomes the clearly-identified drawer, and the secret word (deterministically selected from the starter list) is visible only to the drawer." + +## Clarifications + +### Session 2026-06-26 + +- Q: How should the participant ID survive a browser page refresh so that the correct drawer/guesser view is restored? → A: Store roomCode and participantId in sessionStorage. On page refresh, restore from storage and re-fetch room state. Drawer sees their word, guessers don't. +- Q: Should the GamePage poll the server for updates while the game is running? → A: No. Single fetch on GamePage mount is sufficient for this scenario (nothing changes during gameplay). Game-state polling will be added in a later scenario when dynamic events (guesses, scoring) require it. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - First Round Starts with Drawer Assignment (Priority: P1) + +When the host starts the game, the room transitions to "playing" status and the +first round begins. The host (who is also the first player) is assigned as the +drawer for the first round. All participants can see who the drawer is. + +**Why this priority**: Drawer assignment is the gateway to gameplay — without +knowing who draws, no player can participate meaningfully. + +**Independent Test**: Create a room, join with a second player in another tab, +and start the game from the host tab. Verify both tabs display the host as the +drawer. + +**Acceptance Scenarios**: + +1. **Given** a room with 2+ players and the host has started the game, + **When** the room status transitions to "playing", **Then** the host is + assigned as the drawer and all participants see the host's name labeled + as "Drawer". +2. **Given** a room with 3 players, **When** the game starts, **Then** + exactly one player (the host) is the drawer and the other two are + guessers. +3. **Given** a player refreshes their browser during a game, **When** the + page reloads and re-fetches room state, **Then** the player sees the + correct drawer identity. + +--- + +### User Story 2 - Secret Word Visible Only to Drawer (Priority: P2) + +The secret word for the round is selected deterministically from the starter +word list and is displayed only to the drawer. Non-drawer players never see the +word. + +**Why this priority**: The core mechanic of the game requires the drawer to +know the word while guessers do not. This is the fundamental rule of Pictionary. + +**Independent Test**: Create a room, join with a second player, start the +game. In the drawer's tab, verify the secret word is displayed. In the +guesser's tab, verify the word is not visible anywhere in the UI or page +source. + +**Acceptance Scenarios**: + +1. **Given** a game has started with 2+ players, **When** the drawer looks + at their game screen, **Then** they see a message like "Your word: [word]" + with the secret word clearly displayed. +2. **Given** a game has started with 2+ players, **When** a guesser looks + at their game screen, **Then** they do not see the secret word anywhere + and instead see a message like "Waiting for [drawer name] to draw...". +3. **Given** a guesser inspects the API response or page source, **When** + they examine the room data, **Then** the secret word is not present in + the snapshot returned to them. + +### Edge Cases + +- What happens when there are more than 2 players? — One player is the drawer + (the host); all other participants are guessers. No special handling needed + beyond labeling. +- What happens when a player refreshes the page during a game? — The room + code and participant ID are persisted in sessionStorage. On reload, the + client restores them, re-fetches room state, and re-renders the correct + view (drawer sees word, guesser does not). The `viewerParticipantId` + parameter ensures word visibility is correct. +- What happens when the backend is restarted while a game is in progress? + — The polling request returns a 404; the frontend shows a "Room no longer + available" message and navigates back to the home screen. +- What if the same word should not repeat across rounds? — This scenario + covers only the first round. Deterministic selection by round number + (`STARTER_WORDS[round - 1]`) naturally prevents repeats within the word + list as long as the round number stays within bounds. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-014**: System MUST assign the host as the drawer when the room status + transitions to "playing". +- **FR-015**: System MUST select the secret word deterministically from the + starter word list using the round number as the index + (`STARTER_WORDS[currentRound - 1]`). +- **FR-016**: System MUST store the drawer's participant ID and the secret + word on the Room model when the game starts. +- **FR-017**: System MUST include the current round number and drawer identity + in the room snapshot returned to all participants. +- **FR-018**: System MUST include the secret word in the room snapshot only + when the requesting participant is the drawer. Non-drawer participants must + not receive the secret word in their snapshot. +- **FR-019**: Game page MUST display "You are the drawer!" with the secret + word for the drawer participant. For guessers, it MUST display "Waiting for + [drawer name] to draw..." without the word. +- **FR-020**: System MUST persist the room code and participant ID in + sessionStorage so that a page refresh during a game restores the correct + drawer/guesser view. On reload, the client reads sessionStorage, re-fetches + room state, and renders the appropriate view (drawer sees word, guesser + does not). + +### Key Entities *(include if feature involves data)* + +- **Round**: A single play cycle within a game. Has a round number (starting + at 1), a drawer participant, and a secret word. This scenario covers only + the first round. +- **Drawer Role**: The participant responsible for illustrating the secret + word. In the first round, always the host. Determined at game start time. +- **Secret Word**: The word the drawer must illustrate. Selected + deterministically from the fixed starter list. Only visible to the drawer + participant in API responses and UI. +- **Game State**: The set of round-specific data (drawer, secret word, + current round) that is added to the room snapshot when status is "playing". + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-006**: All players see who the drawer is within 2 seconds of the game + starting (drawer sees identity immediately via startGame response; guessers + see it on the next lobby poll). +- **SC-007**: The drawer sees the secret word on their screen immediately + upon game start, with no additional action required. +- **SC-008**: Non-drawer players never see the secret word — verified by both + UI inspection and API response inspection from a guesser's session. +- **SC-009**: Deterministic word selection produces the same word for the + same room and round number across all clients. +- **SC-010**: A player who refreshes their browser during a game is returned + to the correct view (drawer sees word, guesser does not) within 2 seconds + of the page reloading. + +## Assumptions + +- The host is always the drawer in the first round. Drawer rotation across + multiple rounds is out of scope (this scenario is single-round only). +- The word is selected as `STARTER_WORDS[currentRound - 1]`, so round 1 uses + the first word in the list (the exact word depends on the seed data). +- Word visibility is controlled server-side via the `viewerParticipantId` + query parameter on the `GET /rooms/:code` endpoint. The server filters the + secret word from the snapshot when the viewer is not the drawer. +- The canvas (drawing surface) remains a non-interactive placeholder in this + scenario. Interactive drawing tools are a separate, later scenario. +- No guess submission logic is included in this scenario — guessers see the + "Waiting for drawer" state but cannot yet submit guesses. +- The GamePage fetches room state once on mount (for refresh recovery). + Game-state polling is deferred to a later scenario that introduces dynamic + game events (guesses, scoring). diff --git a/specs/002-game-start-drawer-flow/tasks.md b/specs/002-game-start-drawer-flow/tasks.md new file mode 100644 index 00000000..89af2137 --- /dev/null +++ b/specs/002-game-start-drawer-flow/tasks.md @@ -0,0 +1,134 @@ +--- + +description: "Task list for Game Start & Drawer Flow feature" + +--- + +# Tasks: Game Start & Drawer Flow + +**Input**: Design documents from `specs/002-game-start-drawer-flow/` + +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/api.md + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2) +- Include exact file paths in descriptions + +## Polling Model + +SC-006's "2 seconds" refers to the LobbyPage poll interval (Scenario 1). The +GamePage does NOT poll — it performs a single fetch on mount for page-refresh +recovery only. Drawer sees identity immediately via the `startGame` POST +response; guessers see it when the LobbyPage poll detects `status === "playing"` +and auto-navigates to `/game`. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +No setup tasks required — the starter project is already initialized with all +necessary dependencies (Express, Zod, React, React Router, Vite, Vitest). + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Data model extensions that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T001 Add `currentRound: number`, `drawerId: string`, and `secretWord: string` fields to the `Room` interface in `backend/src/models/game.ts` +- [X] T002 [P] Add `currentRound`, `drawerId`, and `secretWord?` (optional) fields to the `RoomSnapshot` interface in `backend/src/models/game.ts` +- [X] T003 [P] Add `currentRound`, `drawerId`, and `secretWord?` (optional) fields to the `RoomSnapshot` interface in `frontend/src/services/api.ts` + +**Checkpoint**: Foundation ready — user story implementation can now begin + +--- + +## Phase 3: User Story 1 — Drawer Assignment (Priority: P1) 🎯 MVP + +**Goal**: When the host starts the game, the room transitions to "playing" status, the first round begins, the host is assigned as the drawer, and all participants can see who the drawer is. Page refresh during a game restores the correct view. The drawer sees identity immediately via the `startGame` response; guessers see it when the LobbyPage poll detects the status change. + +**Independent Test**: Create a room, join with a second player in another tab, start the game from the host tab. Verify the host's drawer identity appears immediately in the host tab and within 2s in the guesser tab (when the LobbyPage poll detects `"playing"`). Refresh either tab — verify the drawer identity is still shown correctly. + +### Implementation for User Story 1 + +- [X] T004 [P] [US1] Update `startGame()` in `backend/src/services/roomStore.ts` to set `currentRound = 1`, `drawerId = room.hostId`, and `secretWord = STARTER_WORDS[currentRound - 1]` when room transitions to "playing" +- [X] T005 [P] [US1] Update `toRoomSnapshot()` in `backend/src/services/roomStore.ts` to include `currentRound` and `drawerId` in the snapshot when status is "playing" +- [X] T006 [US1] Pass `playerId` as `viewerParticipantId` to `toRoomSnapshot()` in the `POST /rooms/:code/start` handler in `backend/src/api/rooms.ts` so the drawer receives their snapshot with the correct viewer context +- [X] T007 [US1] Add sessionStorage persistence in `frontend/src/state/roomStore.ts` — write `scribble_roomCode` and `scribble_participantId` on successful `createRoom` and `joinRoom`; clear them when navigating to "/" or "/lobby" (e.g., Exit Game button) +- [X] T008 [US1] Update `frontend/src/pages/GamePage.tsx` — on mount, read `scribble_roomCode` and `scribble_participantId` from sessionStorage and re-fetch room state if the store is empty (single fetch, no polling — handles page refresh); display the drawer's name/identity in the UI so all participants know who the drawer is + +**Checkpoint**: US1 complete — game starts, drawer is assigned and visible to all, page refresh restores correct view. + +--- + +## Phase 4: User Story 2 — Secret Word Visible Only to Drawer (Priority: P2) + +**Goal**: The secret word for the round is selected deterministically from `STARTER_WORDS` and displayed only to the drawer. Non-drawer players never see the word in the UI or API responses. + +**Independent Test**: Start a game with 2+ players. In the drawer's tab, verify the secret word "rocket" is displayed. In the guesser's tab, verify the word is not visible anywhere in the UI or API response. + +### Implementation for User Story 2 + +- [X] T009 [P] [US2] Update `toRoomSnapshot()` in `backend/src/services/roomStore.ts` to conditionally include `secretWord` — include only when `viewerParticipantId === room.drawerId`; omit entirely for all other viewers (not `null`, not empty string) +- [X] T010 [P] [US2] Create `CanvasPlaceholder` component in `frontend/src/components/CanvasPlaceholder.tsx` — renders a non-interactive canvas area with role-appropriate messaging; guessers see "Waiting for [drawer name] to draw...", drawer sees "Draw your word here" +- [X] T011 [US2] Update `frontend/src/pages/GamePage.tsx` — show "You are the drawer!" with the secret word for the drawer participant; show "Waiting for [drawer name] to draw..." without the word for guessers; integrate `CanvasPlaceholder` into the canvas area + +**Checkpoint**: US2 complete — drawer sees their word, guessers never do, verified at both API and UI levels. + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +**Purpose**: Tests and build validation + +- [X] T012 [P] Write tests for drawer assignment in `backend/src/services/roomStore.test.ts` — verify `startGame()` sets `drawerId` to host, `currentRound` to 1, `secretWord` to `STARTER_WORDS[currentRound - 1]`; verify `toRoomSnapshot()` returns `drawerId` and `currentRound` when status is "playing" +- [X] T013 [P] Write tests for secret word visibility in `backend/src/services/roomStore.test.ts` — verify `toRoomSnapshot()` includes `secretWord` when viewer is drawer, omits it for other viewers; verify null/undefined handling for non-drawer requests +- [X] T014 [P] Run `npm run build` in `backend/` — fix any type/build errors +- [X] T015 [P] Run `npm run build` in `frontend/` — fix any type/build errors + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — already complete +- **Foundational (Phase 2)**: Blocks all user stories +- **User Stories (Phase 3-4)**: + - **US1 (Phase 3)**: Can start after Phase 2 — no dependency on other stories + - **US2 (Phase 4)**: Depends on US1 (needs game to be started and drawer assigned to test word visibility) +- **Polish (Phase 5)**: Depends on all user stories being complete + +### Within Each User Story + +- Backend model updates before service logic +- Services before API routes +- Backend changes before frontend integration +- Core implementation before UI rendering + +### Parallel Opportunities + +- All Phase 2 [P] tasks can run in parallel +- All [P] tasks within a user story can run in parallel +- T004 and T005 (backend service) can run in parallel +- Polish phase T012-T015 all run in parallel + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 2: Foundational +2. Complete Phase 3: US1 (Drawer Assignment) +3. **STOP and VALIDATE**: Game starts, drawer is visible, page refresh works + +### Incremental Delivery + +1. Add US1 → Test with two tabs → Commit (MVP) +2. Add US2 → Test word visibility → Commit +3. Polish: tests + build validation → Commit From 3c15dc73169936210c4eead63455056818304b54 Mon Sep 17 00:00:00 2001 From: M Asif Date: Fri, 26 Jun 2026 21:36:04 +0530 Subject: [PATCH 5/7] feat: implement canvas drawing, guess submission, scoring, and round advancement - Canvas component with pen, color picker, clear; in-progress stroke survives polling redraws - GuessForm with API submission and inline feedback (correct/wrong/rejected) - GuessHistory, Scoreboard, RoundResultCard components - Backend submitGuess/advanceRound with case-insensitive comparison, 100pt scoring - POST /rooms/:code/guess endpoint - Polling integration in GamePage with RoundResultCard overlay - Server-managed 6s expiry for lastRoundResult - Fix: in-progress stroke color now tracks current picker selection on every mousemove --- AGENTS.md | 2 +- backend/src/api/rooms.ts | 72 +++++- backend/src/api/schemas.ts | 26 ++ backend/src/models/game.ts | 45 ++++ backend/src/services/roomStore.test.ts | 178 ++++++++++++- backend/src/services/roomStore.ts | 124 ++++++++- frontend/package-lock.json | 239 ++++++++++++++++++ frontend/package.json | 2 + frontend/src/components/Canvas.test.tsx | 27 ++ frontend/src/components/Canvas.tsx | 172 +++++++++++++ frontend/src/components/GuessForm.test.tsx | 19 ++ frontend/src/components/GuessForm.tsx | 53 +++- frontend/src/components/GuessHistory.test.tsx | 28 ++ frontend/src/components/GuessHistory.tsx | 51 ++++ .../src/components/RoundResultCard.test.tsx | 39 +++ frontend/src/components/RoundResultCard.tsx | 55 ++++ frontend/src/components/Scoreboard.test.tsx | 56 ++++ frontend/src/components/Scoreboard.tsx | 41 ++- frontend/src/pages/GamePage.tsx | 26 +- frontend/src/services/api.ts | 64 +++++ frontend/src/state/roomStore.ts | 35 ++- frontend/src/test-setup.ts | 7 + frontend/vitest.config.ts | 1 + .../checklists/requirements.md | 34 +++ specs/003-draw-guess-score/clarify.md | 21 ++ specs/003-draw-guess-score/plan.md | 208 +++++++++++++++ specs/003-draw-guess-score/spec.md | 115 +++++++++ specs/003-draw-guess-score/tasks.md | 207 +++++++++++++++ 28 files changed, 1919 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/Canvas.test.tsx create mode 100644 frontend/src/components/Canvas.tsx create mode 100644 frontend/src/components/GuessForm.test.tsx create mode 100644 frontend/src/components/GuessHistory.test.tsx create mode 100644 frontend/src/components/GuessHistory.tsx create mode 100644 frontend/src/components/RoundResultCard.test.tsx create mode 100644 frontend/src/components/RoundResultCard.tsx create mode 100644 frontend/src/components/Scoreboard.test.tsx create mode 100644 frontend/src/test-setup.ts create mode 100644 specs/003-draw-guess-score/checklists/requirements.md create mode 100644 specs/003-draw-guess-score/clarify.md create mode 100644 specs/003-draw-guess-score/plan.md create mode 100644 specs/003-draw-guess-score/spec.md create mode 100644 specs/003-draw-guess-score/tasks.md diff --git a/AGENTS.md b/AGENTS.md index ac54280d..53c6eeb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,5 +42,5 @@ You are working on a monolithic repository for a multiplayer drawing game ("Scri - When creating or editing files, ensure consistency with the existing directory structure detailed above. -Current plan: specs/002-game-start-drawer-flow/plan.md +Current plan: specs/003-draw-guess-score/plan.md diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 67fae9bf..9b966bc9 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,13 +1,16 @@ import { Router } from "express"; import { + clearCanvasSchema, createRoomSchema, + drawStrokesSchema, HttpError, joinRoomSchema, roomCodeParamsSchema, roomViewerQuerySchema, - startGameSchema + startGameSchema, + submitGuessSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, startGame, toRoomSnapshot } from "../services/roomStore.js"; +import { addStrokes, clearStrokes, createRoom, getRoom, joinRoom, startGame, submitGuess, toRoomSnapshot } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -96,5 +99,70 @@ export function createRoomsRouter() { } }); + router.post("/:code/draw", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, strokes } = drawStrokesSchema.parse(request.body); + const upperCode = code.toUpperCase(); + + const room = getRoom(upperCode); + if (!room) throw new HttpError(404, "Room not found"); + if (room.status !== "playing") throw new HttpError(400, "Game is not in progress"); + if (room.drawerId !== participantId) throw new HttpError(403, "Only the drawer can draw"); + + const updatedRoom = addStrokes(upperCode, participantId, strokes); + if (!updatedRoom) throw new HttpError(500, "Failed to add strokes"); + + response.json({ strokes: updatedRoom.strokes }); + } catch (error) { + next(error); + } + }); + + router.delete("/:code/draw", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = clearCanvasSchema.parse(request.body); + const upperCode = code.toUpperCase(); + + const room = getRoom(upperCode); + if (!room) throw new HttpError(404, "Room not found"); + if (room.status !== "playing") throw new HttpError(400, "Game is not in progress"); + if (room.drawerId !== participantId) throw new HttpError(403, "Only the drawer can clear the canvas"); + + const updatedRoom = clearStrokes(upperCode, participantId); + if (!updatedRoom) throw new HttpError(500, "Failed to clear canvas"); + + response.json({ strokes: [] }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/guess", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, guess } = submitGuessSchema.parse(request.body); + const upperCode = code.toUpperCase(); + + const room = getRoom(upperCode); + if (!room) throw new HttpError(404, "Room not found"); + if (room.status !== "playing") throw new HttpError(400, "Game is not in progress"); + + const result = submitGuess(upperCode, participantId, guess); + if ("error" in result) { + throw new HttpError(400, result.error??"Invalid guess"); + } + + response.json({ + correct: result.correct, + message: result.message, + guessEntry: result.guessEntry + }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index ad7bc9a4..8c2927ab 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -22,6 +22,32 @@ export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +const strokePointSchema = z.object({ + x: z.number(), + y: z.number() +}); + +const drawingStrokeSchema = z.object({ + id: z.string(), + points: z.array(strokePointSchema), + color: z.string(), + width: z.number() +}); + +export const drawStrokesSchema = z.object({ + participantId: z.string(), + strokes: z.array(drawingStrokeSchema) +}); + +export const clearCanvasSchema = z.object({ + participantId: z.string() +}); + +export const submitGuessSchema = z.object({ + participantId: z.string(), + guess: z.string() +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 68afb58b..5a1eab50 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -7,6 +7,42 @@ export interface Participant { joinedAt: string; } +export interface Point { + x: number; + y: number; +} + +export interface DrawingStroke { + id: string; + points: Point[]; + color: string; + width: number; +} + +export interface GuessEntry { + participantId: string; + participantName: string; + guess: string; + correct: boolean; + timestamp: string; + roundScoreAfter: number; + cumulativeScoreAfter: number; +} + +export interface ParticipantScore { + roundScore: number; + cumulativeScore: number; +} + +export interface RoundResult { + roundNumber: number; + secretWord: string; + drawerId: string; + drawerName: string; + guessHistory: GuessEntry[]; + scores: Record; +} + export interface Room { code: string; status: RoomStatus; @@ -17,6 +53,11 @@ export interface Room { currentRound?: number; drawerId?: string; secretWord?: string; + strokes: DrawingStroke[]; + guessHistory: GuessEntry[]; + scores: Record; + lastRoundResult?: RoundResult; + lastRoundResultSetAt?: number; } export interface RoomSnapshot { @@ -29,6 +70,10 @@ export interface RoomSnapshot { currentRound?: number; drawerId?: string; secretWord?: string; + strokes: DrawingStroke[]; + guessHistory: GuessEntry[]; + scores: Record; + lastRoundResult?: RoundResult; } export interface RoomSessionResponse { diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index 32ef3ec0..ad878629 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import { STARTER_WORDS } from "../seed/starterData.js"; -import { createRoom, joinRoom, startGame, toRoomSnapshot } from "./roomStore.js"; +import { addStrokes, advanceRound, clearStrokes, createRoom, getRoom, joinRoom, startGame, submitGuess, toRoomSnapshot } from "./roomStore.js"; + +function setupPlayingRoom() { + const host = createRoom("Alice"); + const joiner = joinRoom(host.room.code, "Bob"); + const started = startGame(host.room.code); + return { host, joiner: joiner!, started: started!, code: host.room.code, hostParticipantId: host.participantId, joinerParticipantId: joiner!.participantId }; +} describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { @@ -125,4 +132,173 @@ describe("roomStore", () => { expect(snapshot.drawerId).toBeUndefined(); expect(snapshot.secretWord).toBeUndefined(); }); + + describe("addStrokes", () => { + it("appends strokes to a playing room", () => { + const { code, hostParticipantId } = setupPlayingRoom(); + const stroke = { id: "s1", points: [{ x: 0, y: 0 }, { x: 10, y: 10 }], color: "#000", width: 3 }; + + const result = addStrokes(code, hostParticipantId, [stroke]); + + expect(result).not.toBeNull(); + expect(result!.strokes).toHaveLength(1); + expect(result!.strokes[0].id).toBe("s1"); + }); + + it("returns null for unknown room", () => { + const result = addStrokes("ZZZZ", "p1", []); + expect(result).toBeNull(); + }); + + it("returns null when non-drawer tries to add strokes", () => { + const { code, joinerParticipantId } = setupPlayingRoom(); + const result = addStrokes(code, joinerParticipantId, []); + expect(result).toBeNull(); + }); + }); + + describe("clearStrokes", () => { + it("clears all strokes from a playing room", () => { + const { code, hostParticipantId } = setupPlayingRoom(); + const stroke = { id: "s1", points: [{ x: 0, y: 0 }], color: "#000", width: 3 }; + addStrokes(code, hostParticipantId, [stroke]); + + const result = clearStrokes(code, hostParticipantId); + + expect(result).not.toBeNull(); + expect(result!.strokes).toEqual([]); + }); + + it("returns null for unknown room", () => { + const result = clearStrokes("ZZZZ", "p1"); + expect(result).toBeNull(); + }); + + it("returns null when non-drawer tries to clear", () => { + const { code, joinerParticipantId } = setupPlayingRoom(); + const result = clearStrokes(code, joinerParticipantId); + expect(result).toBeNull(); + }); + }); + + describe("submitGuess", () => { + it("returns correct for a matching guess (case-insensitive)", () => { + const { code, joinerParticipantId } = setupPlayingRoom(); + const result = submitGuess(code, joinerParticipantId, STARTER_WORDS[0].toUpperCase()); + + expect(result).not.toHaveProperty("error"); + if ("error" in result) return; + expect(result.correct).toBe(true); + expect(result.message).toBe("Correct!"); + }); + + it("returns wrong for an incorrect guess", () => { + const { code, joinerParticipantId } = setupPlayingRoom(); + const result = submitGuess(code, joinerParticipantId, "wrongguess"); + + expect(result).not.toHaveProperty("error"); + if ("error" in result) return; + expect(result.correct).toBe(false); + expect(result.message).toBe("Wrong guess"); + }); + + it("rejects empty guess", () => { + const { code, joinerParticipantId } = setupPlayingRoom(); + const result = submitGuess(code, joinerParticipantId, " "); + + expect(result).toHaveProperty("error"); + }); + + it("rejects guess from drawer", () => { + const { code, hostParticipantId } = setupPlayingRoom(); + const result = submitGuess(code, hostParticipantId, "anything"); + + expect(result).toHaveProperty("error"); + }); + + it("rejects subsequent correct guess after round ended", () => { + const { code, joinerParticipantId } = setupPlayingRoom(); + submitGuess(code, joinerParticipantId, STARTER_WORDS[0]); + + const result = submitGuess(code, joinerParticipantId, STARTER_WORDS[0]); + expect(result).toHaveProperty("error"); + }); + + it("records a GuessEntry with correct score values", () => { + const { code, joinerParticipantId } = setupPlayingRoom(); + const result = submitGuess(code, joinerParticipantId, STARTER_WORDS[0]); + + if ("error" in result) return; + expect(result.guessEntry.correct).toBe(true); + expect(result.guessEntry.roundScoreAfter).toBe(100); + expect(result.guessEntry.cumulativeScoreAfter).toBe(100); + }); + + it("advances round on correct guess", () => { + const { code, joinerParticipantId, hostParticipantId } = setupPlayingRoom(); + submitGuess(code, joinerParticipantId, STARTER_WORDS[0]); + + const room = toRoomSnapshot(getRoom(code)!, hostParticipantId); + if (!("currentRound" in room)) return; + expect(room.currentRound).toBe(2); + }); + }); + + describe("advanceRound", () => { + it("increments currentRound", () => { + const { code } = setupPlayingRoom(); + + advanceRound(code); + const room = getRoom(code); + + expect(room!.currentRound).toBe(2); + }); + + it("sets next drawer via round-robin", () => { + const { code, hostParticipantId, joinerParticipantId } = setupPlayingRoom(); + + advanceRound(code); + const room = getRoom(code); + + expect(room!.drawerId).toBe(joinerParticipantId); + }); + + it("selects next word from STARTER_WORDS", () => { + const { code } = setupPlayingRoom(); + + advanceRound(code); + const room = getRoom(code); + + expect(room!.secretWord).toBe(STARTER_WORDS[1]); + }); + + it("clears strokes after advancing", () => { + const { code, hostParticipantId } = setupPlayingRoom(); + addStrokes(code, hostParticipantId, [{ id: "s1", points: [{ x: 0, y: 0 }], color: "#000", width: 3 }]); + + advanceRound(code); + const room = getRoom(code); + + expect(room!.strokes).toEqual([]); + }); + + it("resets roundScore to 0 for all participants", () => { + const { code, joinerParticipantId } = setupPlayingRoom(); + submitGuess(code, joinerParticipantId, STARTER_WORDS[0]); + + const room = getRoom(code); + expect(room!.scores[joinerParticipantId].roundScore).toBe(0); + }); + + it("sets lastRoundResult with correct round data", () => { + const { code, hostParticipantId } = setupPlayingRoom(); + + advanceRound(code); + const room = getRoom(code); + + expect(room!.lastRoundResult).toBeDefined(); + expect(room!.lastRoundResult!.roundNumber).toBe(1); + expect(room!.lastRoundResult!.drawerId).toBe(hostParticipantId); + }); + }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 8bf14c52..8a4b4da1 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { DrawingStroke, GuessEntry, Participant, ParticipantScore, Room, RoomSnapshot } from "../models/game.js"; import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); @@ -57,7 +57,10 @@ export function createRoom(playerName?: string) { hostId: participant.id, participants: [participant], createdAt: now(), - updatedAt: now() + updatedAt: now(), + strokes: [], + guessHistory: [], + scores: {} }; rooms.set(room.code, room); @@ -86,9 +89,20 @@ export function joinRoom(code: string, playerName?: string) { }; } +function clearExpiredRoundResult(room: Room) { + if (room.lastRoundResult && room.lastRoundResultSetAt) { + if (Date.now() - room.lastRoundResultSetAt > 6000) { + room.lastRoundResult = undefined; + room.lastRoundResultSetAt = undefined; + } + } +} + export function getRoom(code: string) { const room = rooms.get(code); - return room ? cloneRoom(room) : null; + if (!room) return null; + clearExpiredRoundResult(room); + return cloneRoom(room); } export function startGame(code: string) { @@ -102,6 +116,11 @@ export function startGame(code: string) { room.currentRound = 1; room.drawerId = room.hostId; room.secretWord = STARTER_WORDS[room.currentRound - 1]; + room.strokes = []; + room.guessHistory = []; + room.scores = Object.fromEntries( + room.participants.map((p) => [p.id, { roundScore: 0, cumulativeScore: 0 } satisfies ParticipantScore]) + ); return saveRoom(room); } @@ -111,6 +130,96 @@ export function saveRoom(room: Room) { return getRoom(room.code); } +export function addStrokes(code: string, participantId: string, strokes: DrawingStroke[]) { + const room = rooms.get(code); + if (!room) return null; + if (room.drawerId !== participantId) return null; + + room.strokes.push(...strokes); + return saveRoom(room); +} + +export function clearStrokes(code: string, participantId: string) { + const room = rooms.get(code); + if (!room) return null; + if (room.drawerId !== participantId) return null; + + room.strokes = []; + return saveRoom(room); +} + +export function advanceRound(code: string) { + const room = rooms.get(code); + if (!room || !room.currentRound) return null; + + const drawerName = room.participants.find((p) => p.id === room.drawerId)?.name ?? "Unknown"; + + room.lastRoundResult = { + roundNumber: room.currentRound, + secretWord: room.secretWord ?? "???", + drawerId: room.drawerId ?? "", + drawerName, + guessHistory: [...room.guessHistory], + scores: { ...room.scores } + }; + room.lastRoundResultSetAt = Date.now(); + + room.currentRound += 1; + room.drawerId = room.participants[(room.currentRound - 1) % room.participants.length].id; + room.secretWord = STARTER_WORDS[(room.currentRound - 1) % STARTER_WORDS.length]; + room.strokes = []; + room.guessHistory = []; + for (const p of room.participants) { + const score = room.scores[p.id]; + if (score) { + score.roundScore = 0; + } + } + + return saveRoom(room); +} + +export function submitGuess(code: string, participantId: string, guess: string) { + const room = rooms.get(code); + if (!room) return { error: "Room not found" }; + if (room.status !== "playing") return { error: "Game is not in progress" }; + if (room.drawerId === participantId) return { error: "Drawer cannot guess" }; + + const trimmedGuess = guess.trim(); + if (!trimmedGuess) return { error: "Guess cannot be empty" }; + + const alreadyCorrect = room.guessHistory.some( + (g) => g.participantId === participantId && g.correct + ); + if (alreadyCorrect) return { error: "Round already ended" }; + + const isCorrect = trimmedGuess.toLowerCase() === room.secretWord!.toLowerCase(); + const participant = room.participants.find((p) => p.id === participantId); + const score = room.scores[participantId]; + + const guessEntry: GuessEntry = { + participantId, + participantName: participant?.name ?? "Unknown", + guess: trimmedGuess, + correct: isCorrect, + timestamp: now(), + roundScoreAfter: isCorrect ? 100 : score?.roundScore ?? 0, + cumulativeScoreAfter: isCorrect ? (score?.cumulativeScore ?? 0) + 100 : (score?.cumulativeScore ?? 0) + }; + + room.guessHistory.push(guessEntry); + + if (isCorrect && score) { + score.roundScore += 100; + score.cumulativeScore += 100; + advanceRound(code); + return { guessEntry, correct: true, message: "Correct!" }; + } + + saveRoom(room); + return { guessEntry, correct: false, message: "Wrong guess" }; +} + export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { const snapshot: RoomSnapshot = { code: room.code, @@ -118,12 +227,19 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn hostId: room.hostId, participants: room.participants.map((participant) => ({ ...participant })), availableWords: listWords(), - roles: [...STARTER_ROLES] + roles: [...STARTER_ROLES], + strokes: [], + guessHistory: [], + scores: {} }; if (room.status === "playing") { snapshot.currentRound = room.currentRound; snapshot.drawerId = room.drawerId; + snapshot.strokes = room.strokes; + snapshot.guessHistory = room.guessHistory; + snapshot.scores = room.scores; + snapshot.lastRoundResult = room.lastRoundResult; if (viewerParticipantId === room.drawerId) { snapshot.secretWord = room.secretWord; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49c6d054..2fdc9fce 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,8 @@ "react-router-dom": "^6.30.1" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", @@ -22,6 +24,13 @@ "vitest": "^3.1.3" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -278,6 +287,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1250,6 +1269,89 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1345,6 +1447,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -1495,6 +1598,39 @@ "node": ">= 14" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1618,6 +1754,13 @@ "dev": true, "license": "MIT" }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -1688,6 +1831,23 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.357", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", @@ -1881,6 +2041,16 @@ "node": ">=0.10.0" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1990,6 +2160,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2000,6 +2180,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2120,6 +2310,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2157,6 +2362,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2199,6 +2411,20 @@ "react-dom": ">=16.8" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -2321,6 +2547,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 818dcf45..33eda3cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,8 @@ "react-router-dom": "^6.30.1" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/frontend/src/components/Canvas.test.tsx b/frontend/src/components/Canvas.test.tsx new file mode 100644 index 00000000..397e1aa4 --- /dev/null +++ b/frontend/src/components/Canvas.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Canvas } from "./Canvas"; + +describe("Canvas", () => { + it("shows color picker and clear button for drawer", () => { + render(); + + const colorButtons = screen.getAllByRole("button", { name: /color/i }); + expect(colorButtons.length).toBeGreaterThanOrEqual(4); + + expect(screen.getByRole("button", { name: /clear/i })).toBeInTheDocument(); + }); + + it("shows secret word for drawer", () => { + render(); + + expect(screen.getByText(/rocket/)).toBeInTheDocument(); + }); + + it("hides color picker and clear button for guesser", () => { + render(); + + expect(screen.queryByRole("button", { name: /clear/i })).not.toBeInTheDocument(); + expect(screen.queryByText(/rocket/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Canvas.tsx b/frontend/src/components/Canvas.tsx new file mode 100644 index 00000000..1f82ea41 --- /dev/null +++ b/frontend/src/components/Canvas.tsx @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { DrawingStroke } from "../services/api"; + +const COLORS = ["#000000", "#ef4444", "#3b82f6", "#22c55e", "#f59e0b", "#a855f7"]; +const PEN_WIDTH = 3; + +interface CanvasProps { + isDrawer: boolean; + strokes: DrawingStroke[]; + secretWord?: string | null; + onAddStrokes?: (strokes: DrawingStroke[]) => void; + onClearCanvas?: () => void; +} + +export function Canvas({ isDrawer, strokes, secretWord, onAddStrokes, onClearCanvas }: CanvasProps) { + const canvasRef = useRef(null); + const [isDrawing, setIsDrawing] = useState(false); + const [currentColor, setCurrentColor] = useState(COLORS[0]); + const currentStrokeRef = useRef<{ points: { x: number; y: number }[]; color: string }>({ points: [], color: COLORS[0] }); + const strokesRef = useRef(strokes); + strokesRef.current = strokes; + + const getCanvasPos = useCallback((e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + }, []); + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + for (const stroke of strokesRef.current) { + if (stroke.points.length < 2) continue; + ctx.strokeStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.beginPath(); + ctx.moveTo(stroke.points[0].x, stroke.points[0].y); + for (let i = 1; i < stroke.points.length; i++) { + ctx.lineTo(stroke.points[i].x, stroke.points[i].y); + } + ctx.stroke(); + } + + const inProgress = currentStrokeRef.current; + if (inProgress.points.length >= 2) { + ctx.strokeStyle = inProgress.color; + ctx.lineWidth = PEN_WIDTH; + ctx.beginPath(); + ctx.moveTo(inProgress.points[0].x, inProgress.points[0].y); + for (let i = 1; i < inProgress.points.length; i++) { + ctx.lineTo(inProgress.points[i].x, inProgress.points[i].y); + } + ctx.stroke(); + } + }, []); + + useEffect(() => { + redraw(); + }, [strokes, redraw]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (!isDrawer) return; + const pos = getCanvasPos(e); + setIsDrawing(true); + currentStrokeRef.current = { points: [pos], color: currentColor }; + }, [isDrawer, getCanvasPos]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!isDrawing || !isDrawer) return; + const pos = getCanvasPos(e); + currentStrokeRef.current.points.push(pos); + currentStrokeRef.current.color = currentColor; + + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const points = currentStrokeRef.current.points; + const len = points.length; + if (len < 2) return; + + ctx.strokeStyle = currentColor; + ctx.lineWidth = PEN_WIDTH; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(points[len - 2].x, points[len - 2].y); + ctx.lineTo(points[len - 1].x, points[len - 1].y); + ctx.stroke(); + }, [isDrawing, isDrawer, getCanvasPos, currentColor]); + + const handleMouseUp = useCallback(() => { + if (!isDrawing || !isDrawer) return; + setIsDrawing(false); + const stroke = currentStrokeRef.current; + if (stroke.points.length < 2) return; + + const newStroke: DrawingStroke = { + id: crypto.randomUUID(), + points: stroke.points, + color: stroke.color, + width: PEN_WIDTH + }; + onAddStrokes?.([newStroke]); + currentStrokeRef.current = { points: [], color: COLORS[0] }; + }, [isDrawing, isDrawer, onAddStrokes]); + + const handleClear = useCallback(() => { + onClearCanvas?.(); + }, [onClearCanvas]); + + return ( +
+ {isDrawer && secretWord && ( +
+ Word: {secretWord} +
+ )} + + {isDrawer && ( +
+
+ {COLORS.map((color) => ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/GuessForm.test.tsx b/frontend/src/components/GuessForm.test.tsx new file mode 100644 index 00000000..8b598e76 --- /dev/null +++ b/frontend/src/components/GuessForm.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { GuessForm } from "./GuessForm"; + +describe("GuessForm", () => { + it("renders input and submit button", () => { + render(); + + expect(screen.getByPlaceholderText(/type your guess/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /submit guess/i })).toBeInTheDocument(); + }); + + it("disables input and button when disabled is true", () => { + render(); + + expect(screen.getByPlaceholderText(/type your guess/i)).toBeDisabled(); + expect(screen.getByRole("button", { name: /submit guess/i })).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec474..c1a58f49 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -1,16 +1,48 @@ import { useState } from "react"; +import type { GuessEntry } from "../services/api"; interface GuessFormProps { disabled?: boolean; + onSubmitGuess?: (guess: string) => Promise<{ correct: boolean; message: string; guessEntry: GuessEntry }>; } -export function GuessForm({ disabled = false }: GuessFormProps) { +type FeedbackState = { type: "correct" | "wrong" | "rejected"; message: string } | null; + +export function GuessForm({ disabled = false, onSubmitGuess }: GuessFormProps) { const [guessText, setGuessText] = useState(""); + const [feedback, setFeedback] = useState(null); + const [submitting, setSubmitting] = useState(false); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + if (!onSubmitGuess || submitting) return; + + const trimmed = guessText.trim(); + if (!trimmed) { + setFeedback({ type: "rejected", message: "Guess cannot be empty" }); + return; + } + + setSubmitting(true); + setFeedback(null); + + try { + const result = await onSubmitGuess(trimmed); + if (result.correct) { + setFeedback({ type: "correct", message: result.message }); + setGuessText(""); + } else { + setFeedback({ type: "wrong", message: result.message }); + } + } catch { + setFeedback({ type: "rejected", message: "Failed to submit guess. Try again." }); + } finally { + setSubmitting(false); + } } + const isDisabled = disabled || submitting; + return (
+ {feedback && ( +

+ {feedback.message} +

+ )}
-
diff --git a/frontend/src/components/GuessHistory.test.tsx b/frontend/src/components/GuessHistory.test.tsx new file mode 100644 index 00000000..33587160 --- /dev/null +++ b/frontend/src/components/GuessHistory.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { GuessHistory } from "./GuessHistory"; + +describe("GuessHistory", () => { + it("shows empty state when no guesses", () => { + render(); + + expect(screen.getByText(/no guesses yet/i)).toBeInTheDocument(); + }); + + it("renders guess entries with name, text, and correct badge", () => { + const guesses = [ + { participantId: "p1", participantName: "Alice", guess: "rocket", correct: true, timestamp: "2024-01-01", roundScoreAfter: 100, cumulativeScoreAfter: 100 }, + { participantId: "p2", participantName: "Bob", guess: "planet", correct: false, timestamp: "2024-01-01", roundScoreAfter: 0, cumulativeScoreAfter: 0 }, + ]; + + render(); + + expect(screen.getByText(/Alice/)).toBeInTheDocument(); + expect(screen.getByText(/rocket/)).toBeInTheDocument(); + expect(screen.getByText(/Bob/)).toBeInTheDocument(); + expect(screen.getByText(/planet/)).toBeInTheDocument(); + + const badges = screen.getAllByText(/correct|wrong/i); + expect(badges.length).toBe(2); + }); +}); diff --git a/frontend/src/components/GuessHistory.tsx b/frontend/src/components/GuessHistory.tsx new file mode 100644 index 00000000..8bcf7058 --- /dev/null +++ b/frontend/src/components/GuessHistory.tsx @@ -0,0 +1,51 @@ +import type { GuessEntry } from "../services/api"; +import { Card } from "./Card"; + +interface GuessHistoryProps { + guesses: GuessEntry[]; +} + +export function GuessHistory({ guesses }: GuessHistoryProps) { + if (guesses.length === 0) { + return ( + +

No guesses yet.

+
+ ); + } + + return ( + +
    + {guesses.map((entry, index) => ( +
  • + + {entry.participantName}: {entry.guess} + + + {entry.correct ? "Correct" : "Wrong"} + +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/components/RoundResultCard.test.tsx b/frontend/src/components/RoundResultCard.test.tsx new file mode 100644 index 00000000..f8d06027 --- /dev/null +++ b/frontend/src/components/RoundResultCard.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { RoundResultCard } from "./RoundResultCard"; + +describe("RoundResultCard", () => { + it("shows round number and secret word", () => { + const result = { + roundNumber: 1, + secretWord: "rocket", + drawerId: "p1", + drawerName: "Alice", + guessHistory: [], + scores: { p1: { roundScore: 0, cumulativeScore: 0 } } + }; + + render(); + + expect(screen.getByText(/round 1 complete/i)).toBeInTheDocument(); + expect(screen.getByText(/rocket/)).toBeInTheDocument(); + }); + + it("displays scores for participants", () => { + const result = { + roundNumber: 1, + secretWord: "rocket", + drawerId: "p1", + drawerName: "Alice", + guessHistory: [ + { participantId: "p2", participantName: "Bob", guess: "rocket", correct: true, timestamp: "2024-01-01", roundScoreAfter: 100, cumulativeScoreAfter: 100 } + ], + scores: { p1: { roundScore: 0, cumulativeScore: 0 }, p2: { roundScore: 100, cumulativeScore: 100 } } + }; + + render(); + + const hundreds = screen.getAllByText("100"); + expect(hundreds.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/frontend/src/components/RoundResultCard.tsx b/frontend/src/components/RoundResultCard.tsx new file mode 100644 index 00000000..6266eebe --- /dev/null +++ b/frontend/src/components/RoundResultCard.tsx @@ -0,0 +1,55 @@ +import type { RoundResult } from "../services/api"; +import { Card } from "./Card"; + +interface RoundResultCardProps { + result: RoundResult; +} + +export function RoundResultCard({ result }: RoundResultCardProps) { + return ( +
+ +
+

+ The word was: {result.secretWord} +

+

+ Drawer: {result.drawerName} +

+
+ + + + + + + + + + + {Object.entries(result.scores).map(([participantId, score]) => { + const entry = result.guessHistory.find((g) => g.participantId === participantId); + return ( + + + + + + ); + })} + +
PlayerRoundTotal
{entry?.participantName ?? participantId}{score.roundScore}{score.cumulativeScore}
+
+
+ ); +} diff --git a/frontend/src/components/Scoreboard.test.tsx b/frontend/src/components/Scoreboard.test.tsx new file mode 100644 index 00000000..abcce46f --- /dev/null +++ b/frontend/src/components/Scoreboard.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import { useEffect } from "react"; +import { describe, expect, it } from "vitest"; +import type { RoomSnapshot } from "../services/api"; +import { RoomStoreProvider, useRoomStore } from "../state/roomStore"; +import { Scoreboard } from "./Scoreboard"; + +function StoreInitializer({ room, participantId }: { room: RoomSnapshot; participantId: string }) { + const store = useRoomStore(); + useEffect(() => { + store.setRoomSession({ participantId, room }); + }, [store, room, participantId]); + return null; +} + +describe("Scoreboard", () => { + it("shows waiting state when no room", () => { + render( + + + + ); + + expect(screen.getByText(/waiting for players/i)).toBeInTheDocument(); + }); + + it("displays player names and scores", () => { + const room: RoomSnapshot = { + code: "ABCD", + status: "playing", + hostId: "p1", + participants: [ + { id: "p1", name: "Alice", joinedAt: "2024-01-01" }, + { id: "p2", name: "Bob", joinedAt: "2024-01-01" } + ], + availableWords: [], + roles: ["drawer", "guesser"], + currentRound: 1, + drawerId: "p1", + strokes: [], + guessHistory: [], + scores: { p1: { roundScore: 0, cumulativeScore: 0 }, p2: { roundScore: 100, cumulativeScore: 200 } } + }; + + render( + + + + + ); + + expect(screen.getByText(/Alice/)).toBeInTheDocument(); + expect(screen.getByText(/Bob/)).toBeInTheDocument(); + expect(screen.getByText("200")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Scoreboard.tsx b/frontend/src/components/Scoreboard.tsx index 647c734f..326b7ec9 100644 --- a/frontend/src/components/Scoreboard.tsx +++ b/frontend/src/components/Scoreboard.tsx @@ -1,14 +1,43 @@ +import { useRoomState } from "../state/roomStore"; import { Card } from "./Card"; export function Scoreboard() { + const { room } = useRoomState(); + + if (!room || room.participants.length === 0) { + return ( + +

Waiting for players...

+
+ ); + } + return ( -
-
- Waiting for players... - 0 -
-
+ + + + + + + + + + {room.participants.map((p) => { + const score = room.scores[p.id]; + return ( + + + + + + ); + })} + +
PlayerRoundTotal
+ {p.name} + {p.id === room.drawerId ? " (drawing)" : ""} + {score?.roundScore ?? 0}{score?.cumulativeScore ?? 0}
); } diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 372ee9f9..6b392ca5 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,17 +1,18 @@ import { useCallback, useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { CanvasPlaceholder } from "../components/CanvasPlaceholder"; +import { Canvas } from "../components/Canvas"; import { Card } from "../components/Card"; import { GuessForm } from "../components/GuessForm"; -import { ResultPanel } from "../components/ResultPanel"; +import { GuessHistory } from "../components/GuessHistory"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; +import { RoundResultCard } from "../components/RoundResultCard"; import { Scoreboard } from "../components/Scoreboard"; import { useRoomState, useRoomStore } from "../state/roomStore"; export function GamePage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - const { room, participantId } = useRoomState(); + const { room, participantId, lastRoundResult } = useRoomState(); useEffect(() => { if (room) return; @@ -23,7 +24,14 @@ export function GamePage() { }); }, [navigate, room, roomStore]); + useEffect(() => { + if (!room) return; + roomStore.startPolling(); + return () => roomStore.stopPolling(); + }, [room, roomStore]); + const handleExit = useCallback(() => { + roomStore.stopPolling(); roomStore.clearSession(); navigate("/lobby"); }, [navigate, roomStore]); @@ -38,6 +46,8 @@ export function GamePage() { return (
+ {lastRoundResult && } +
Round {room.currentRound ?? 1} @@ -51,15 +61,17 @@ export function GamePage() {
- roomStore.addStrokes(strokes)} + onClearCanvas={() => roomStore.clearCanvas()} />
@@ -84,7 +96,7 @@ export function GamePage() { {isDrawer ? null : ( - + roomStore.submitGuess(guess)} /> )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 20b18e5c..665f40a5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -6,6 +6,42 @@ export interface Participant { joinedAt: string; } +export interface Point { + x: number; + y: number; +} + +export interface DrawingStroke { + id: string; + points: Point[]; + color: string; + width: number; +} + +export interface GuessEntry { + participantId: string; + participantName: string; + guess: string; + correct: boolean; + timestamp: string; + roundScoreAfter: number; + cumulativeScoreAfter: number; +} + +export interface ParticipantScore { + roundScore: number; + cumulativeScore: number; +} + +export interface RoundResult { + roundNumber: number; + secretWord: string; + drawerId: string; + drawerName: string; + guessHistory: GuessEntry[]; + scores: Record; +} + export interface RoomSnapshot { code: string; status: "lobby" | "playing"; @@ -16,6 +52,16 @@ export interface RoomSnapshot { currentRound?: number; drawerId?: string; secretWord?: string; + strokes: DrawingStroke[]; + guessHistory: GuessEntry[]; + scores: Record; + lastRoundResult?: RoundResult; +} + +export interface GuessResponse { + correct: boolean; + message: string; + guessEntry: GuessEntry; } export interface RoomSessionResponse { @@ -67,5 +113,23 @@ export const api = { method: "POST", body: JSON.stringify({ playerId }) }); + }, + addStrokes(code: string, participantId: string, strokes: DrawingStroke[]) { + return request<{ strokes: DrawingStroke[] }>(`/rooms/${encodeURIComponent(code)}/draw`, { + method: "POST", + body: JSON.stringify({ participantId, strokes }) + }); + }, + clearCanvas(code: string, participantId: string) { + return request<{ strokes: DrawingStroke[] }>(`/rooms/${encodeURIComponent(code)}/draw`, { + method: "DELETE", + body: JSON.stringify({ participantId }) + }); + }, + submitGuess(code: string, participantId: string, guess: string) { + return request(`/rooms/${encodeURIComponent(code)}/guess`, { + method: "POST", + body: JSON.stringify({ participantId, guess }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index e132c0ba..1c05ece9 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -7,7 +7,7 @@ import { useSyncExternalStore, type PropsWithChildren } from "react"; -import { api, type RoomSessionResponse, type RoomSnapshot } from "../services/api"; +import { api, type DrawingStroke, type RoomSessionResponse, type RoomSnapshot, type RoundResult } from "../services/api"; export interface RoomState { room: RoomSnapshot | null; @@ -15,6 +15,7 @@ export interface RoomState { error: string | null; pollingError: string | null; isLoading: boolean; + lastRoundResult: RoundResult | null; } type Listener = () => void; @@ -25,7 +26,8 @@ class RoomStore { participantId: null, error: null, pollingError: null, - isLoading: false + isLoading: false, + lastRoundResult: null }; private listeners = new Set(); @@ -140,6 +142,30 @@ class RoomStore { return response.room; } + async addStrokes(strokes: DrawingStroke[]) { + if (!this.state.room || !this.state.participantId) return; + const result = await api.addStrokes(this.state.room.code, this.state.participantId, strokes); + if (this.state.room) { + this.setState({ room: { ...this.state.room, strokes: result.strokes } }); + } + } + + async clearCanvas() { + if (!this.state.room || !this.state.participantId) return; + await api.clearCanvas(this.state.room.code, this.state.participantId); + if (this.state.room) { + this.setState({ room: { ...this.state.room, strokes: [] } }); + } + } + + async submitGuess(guess: string) { + if (!this.state.room || !this.state.participantId) { + throw new Error("No room to submit guess"); + } + const response = await api.submitGuess(this.state.room.code, this.state.participantId, guess); + return response; + } + async fetchRoom() { if (!this.state.room) { return null; @@ -147,6 +173,11 @@ class RoomStore { const response = await api.fetchRoom(this.state.room.code, this.state.participantId ?? undefined); this.setRoomSnapshot(response.room); + if (response.room.lastRoundResult) { + this.setState({ lastRoundResult: response.room.lastRoundResult }); + } else if (this.state.lastRoundResult) { + this.setState({ lastRoundResult: null }); + } return response.room; } diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 00000000..e2621932 --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 32cb0815..51111ea1 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -6,5 +6,6 @@ export default defineConfig({ test: { environment: "jsdom", include: ["src/**/*.test.{ts,tsx}"], + setupFiles: ["src/test-setup.ts"], }, }); diff --git a/specs/003-draw-guess-score/checklists/requirements.md b/specs/003-draw-guess-score/checklists/requirements.md new file mode 100644 index 00000000..e3699c85 --- /dev/null +++ b/specs/003-draw-guess-score/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Gameplay Interaction + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-26 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [X] No implementation details (languages, frameworks, APIs) +- [X] Focused on user value and business needs +- [X] Written for non-technical stakeholders +- [X] All mandatory sections completed + +## Requirement Completeness + +- [X] No [NEEDS CLARIFICATION] markers remain +- [X] Requirements are testable and unambiguous +- [X] Success criteria are measurable +- [X] Success criteria are technology-agnostic (no implementation details) +- [X] All acceptance scenarios are defined +- [X] Edge cases are identified +- [X] Scope is clearly bounded +- [X] Dependencies and assumptions identified + +## Feature Readiness + +- [X] All functional requirements have clear acceptance criteria +- [X] User scenarios cover primary flows +- [X] Feature meets measurable outcomes defined in Success Criteria +- [X] No implementation details leak into specification + +## Notes + +- All items pass. Spec is ready for planning. diff --git a/specs/003-draw-guess-score/clarify.md b/specs/003-draw-guess-score/clarify.md new file mode 100644 index 00000000..65201b8c --- /dev/null +++ b/specs/003-draw-guess-score/clarify.md @@ -0,0 +1,21 @@ +# Clarification: Gameplay Interaction + +**Date**: 2026-06-26 + +## Questions & Answers + +### Q1: Drawing Architecture +- **Question**: FR-022 says "persist drawing strokes server-side." Should strokes be stored server-side or client-only? +- **Answer**: **Server-side stroke storage** — POST/DELETE endpoints for strokes, fetched via polling. + +### Q2: Post-Correct-Guess Flow +- **Question**: When a correct guess ends the round, does the game auto-advance or wait for user action? +- **Answer**: **Auto-advance to next round** — Server immediately increments `currentRound`, shows a brief score card, picks new drawer+word, and resets drawing. Polling shows the new round state. + +### Q3: Guess Feedback Timing +- **Question**: Should guess POST response include result directly, or rely on polling? +- **Answer**: **Response includes result** — POST returns `{ correct: boolean, message: string }` for instant feedback. + +### Q4: Score Model +- **Question**: Should scores be cumulative-only or track per-round + cumulative? +- **Answer**: **Round + cumulative** — Track both per-round score and cumulative total. diff --git a/specs/003-draw-guess-score/plan.md b/specs/003-draw-guess-score/plan.md new file mode 100644 index 00000000..a46b73c0 --- /dev/null +++ b/specs/003-draw-guess-score/plan.md @@ -0,0 +1,208 @@ +# Implementation Plan: Gameplay Interaction + +**Branch**: `003-draw-guess-score` | **Date**: 2026-06-26 | **Spec**: specs/003-draw-guess-score/spec.md + +**Input**: Feature specification from `specs/003-draw-guess-score/spec.md` + +## Summary + +Add canvas drawing (pen, color, clear), guess submission with case-insensitive comparison, score tracking (per-round + cumulative), and auto-advance to the next round on correct guess. Drawing strokes and guess history sync via polling. + +## Technical Context + +**Language/Version**: TypeScript (ES2022, NodeNext module resolution, strict mode) + +**Primary Dependencies**: Express 4 + Zod (backend); React 18 + React Router 6 + Vite (frontend) + +**Storage**: In-memory only (Map-based room store on backend) + +**Testing**: Vitest, minimum 80% line coverage + +**Target Platform**: Modern web browsers (Chrome, Firefox, Safari, Edge). Canvas uses standard HTML Canvas API. + +**Performance Goals**: Draw stroke appears on drawer's screen immediately (local render), on guessers' screens within 2s (polling). Guess POST response returns result directly (<500ms). SC-013 satisfied by direct POST response, not polling. + +**Polling Model**: GamePage polls `GET /rooms/:code?participantId=` every 2s (changed from Scenario 2's single-fetch model). Poll response includes drawing strokes, guess history, and scores. + +**Constraints**: No WebSockets, no databases, no auth, polling-only sync. Backend centralized error handling. Zod for request validation. Functional components with hooks on frontend. + +**Scale/Scope**: Lab project — small scale, no production deployment. + +## Clarification Decisions + +- **Drawing**: Server-side stroke storage via POST/DELETE, fetched via polling. +- **Post-correct-guess**: Server auto-advances to next round immediately. Poll response includes `lastRoundResult` for client to show brief score card (~3s). +- **Guess feedback**: POST response returns `{ correct: boolean, message: string }` for instant feedback. History syncs via polling. +- **Score model**: Track both per-round score and cumulative total per participant. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **TypeScript-First (Strict Mode)**: TypeScript strict mode enabled, `any` forbidden. +- **Extend, Don't Rewrite**: All changes extend existing Room/RoomSnapshot models + roomStore service. No rewrites. +- **Spec-Driven Development**: Spec artifacts committed before implementation. +- **Deterministic Game Logic**: Word selection continues using `STARTER_WORDS[currentRound - 1]`. Next round drawer selection: round-robin through participants (deterministic). +- **Minimal Dependencies**: No new dependencies required. Canvas is a Web API. All Zod schemas extend existing patterns. +- **Technical Constraints**: No WebSockets, no DB, no auth, polling-only, Zod validation, functional components, 80% Vitest coverage. +- **No violations detected**: Complexity tracking not required. + +## Data Model Changes + +### New Types (backend/src/models/game.ts) + +```ts +export interface Point { + x: number; + y: number; +} + +export interface DrawingStroke { + id: string; + points: Point[]; + color: string; + width: number; +} + +export interface GuessEntry { + participantId: string; + participantName: string; + guess: string; + correct: boolean; + timestamp: string; + roundScoreAfter: number; + cumulativeScoreAfter: number; +} + +export interface ParticipantScore { + roundScore: number; + cumulativeScore: number; +} + +export interface RoundResult { + roundNumber: number; + secretWord: string; + drawerId: string; + drawerName: string; + guessHistory: GuessEntry[]; + scores: Record; +} + +``` + +### Extended Room model + +Add to existing `Room`: +```ts +strokes: DrawingStroke[]; +guessHistory: GuessEntry[]; +scores: Record; // participantId -> scores +lastRoundResult?: RoundResult; // set when round auto-advances, cleared after N polls +``` + +### Extended RoomSnapshot model + +Add to existing `RoomSnapshot`: +```ts +strokes: DrawingStroke[]; +guessHistory: GuessEntry[]; +scores: Record; +lastRoundResult?: RoundResult; +``` + +## API Changes + +### New Endpoints + +| Method | Path | Body | Response | Auth | +|--------|------|------|----------|------| +| POST | /api/rooms/:code/draw | `{ participantId: string, strokes: DrawingStroke[] }` | `{ strokes: DrawingStroke[] }` | Must be drawer, room playing | +| DELETE | /api/rooms/:code/draw | `{ participantId: string }` | `{ strokes: [] }` | Must be drawer, room playing | +| POST | /api/rooms/:code/guess | `{ participantId: string, guess: string }` | `{ correct: boolean, message: string, guessEntry: GuessEntry }` | Must be guesser, room playing | + +### Extended GET /api/rooms/:code + +Now returns additional fields when `status === "playing"`: +- `strokes`: DrawingStroke[] +- `guessHistory`: GuessEntry[] +- `scores`: Record +- `lastRoundResult?`: RoundResult (present for ~6s after round auto-advance) + +### Zod Schemas + +```ts +export const drawStrokesSchema = z.object({ + participantId: z.string(), + strokes: z.array(z.object({ + points: z.array(z.object({ x: z.number(), y: z.number() })), + color: z.string(), + width: z.number() + })) +}); + +export const clearCanvasSchema = z.object({ + participantId: z.string() +}); + +export const submitGuessSchema = z.object({ + participantId: z.string(), + guess: z.string() +}); + +export const submitGuessSchema = z.object({ + participantId: z.string(), + guess: z.string() +}); +``` + +## Round Advancement Logic + +When a correct guess is submitted: +1. Record the guess in `guessHistory` with `correct: true` +2. Set participant's `roundScore` += 100, `cumulativeScore` += 100 +3. Build `lastRoundResult` with all round data (secretWord, scores, history) +4. Increment `currentRound`, pick next drawer (round-robin), pick word from `STARTER_WORDS[currentRound - 1]` +5. Clear `strokes` array, reset all `roundScore` to 0 +6. Keep `lastRoundResult` in the snapshot for ~6s (3 poll cycles), then clear + +Drawer selection for round N: `participants[(N - 1) % participants.length]` + +## Implementation Tasks + +### Backend + +1. **T016**: Add `Point`, `DrawingStroke`, `GuessEntry`, `ParticipantScore`, `RoundResult` types to `game.ts` +2. **T017**: Extend `Room` model with `strokes`, `guessHistory`, `scores`, `lastRoundResult`; extend `RoomSnapshot` with same fields +3. **T018**: Implement `addStrokes()` service — append strokes to room, return updated strokes +4. **T019**: Implement `clearStrokes()` service — reset room strokes to empty array +5. **T020**: Implement `submitGuess()` service — validate drawer/guesser, trim, case-insensitive compare, update scores, handle correct-guess round advancement +6. **T021**: Implement `advanceRound()` service — increment round, pick new drawer, pick new word, clear strokes, reset round scores, set `lastRoundResult` +7. **T022**: Update `toRoomSnapshot()` to include strokes, guessHistory, scores, lastRoundResult in playing state +8. **T023**: Add `drawStrokesSchema`, `clearCanvasSchema`, `submitGuessSchema` Zod schemas +9. **T024**: Wire POST /:code/draw, DELETE /:code/draw, POST /:code/guess routes in rooms router +10. **T025**: Add backend tests for draw endpoints, guess endpoint, round advancement + +### Frontend + +11. **T026**: Add `DrawingStroke`, `GuessEntry`, `ParticipantScore`, `RoundResult` types to `api.ts`; add `api.addStrokes()`, `api.clearCanvas()`, `api.submitGuess()` methods +12. **T027**: Build `Canvas` component — interactive canvas with pen tool, color picker (4+ colors), clear button for drawer; read-only rendering for guessers + - **Technical note**: Canvas uses dual rendering — `handleMouseMove` draws line segments imperatively to the canvas for immediate feedback, while `redraw()` reactively clears and redraws all committed strokes when the `strokes` prop changes (from polling). The `redraw()` function MUST also render the in-progress stroke from the current stroke ref, otherwise polling-triggered redraws erase the stroke the user is currently drawing. +13. **T028**: Wire `GuessForm` to submit guesses via API and display inline feedback (correct/wrong/rejected) +14. **T029**: Build `GuessHistory` component — ordered list showing guesser name, guess text, correct/incorrect badge +15. **T030**: Build `Scoreboard` component — shows per-participant round score + cumulative score +16. **T031**: Build `RoundResultCard` component — brief score card overlay between rounds (shows secret word, correct guesser, scores); visible while server includes `lastRoundResult` in poll response (~6s) +17. **T032**: Update `GamePage` to start polling (2s interval) in playing state; display real canvas instead of placeholder; show RoundResultCard when lastRoundResult is present +18. **T033**: Update `roomStore` with `addStrokes`, `clearCanvas`, `submitGuess` methods; integrate polling for strokes/guesses/scores + +### Build & Verify + +19. **T034**: Verify both backend and frontend builds pass with `npm run build` + +## Out of Scope + +- Advanced drawing tools (shapes, undo, line thickness, eraser) +- Drawing timers or auto-skip +- Multiple rounds completing the full game (game-end state) +- Chat or non-guess messaging +- Spectator mode +- Canvas resize or responsive scaling beyond fixed size diff --git a/specs/003-draw-guess-score/spec.md b/specs/003-draw-guess-score/spec.md new file mode 100644 index 00000000..d456c0c5 --- /dev/null +++ b/specs/003-draw-guess-score/spec.md @@ -0,0 +1,115 @@ +# Feature Specification: Gameplay Interaction + +**Feature Branch**: `003-draw-guess-score` + +**Created**: 2026-06-26 + +**Status**: Draft + +**Input**: User description: "Gameplay Interaction — Given a round is active with a drawer and guessers (all scores start at 0), When the drawer draws/clears the canvas and guessers submit their guesses, Then the drawing is visible on the drawer's screen; guesses are trimmed, case-insensitively compared, and empty ones rejected; the guess history is synced to all players via polling; correct guesses score 100 (incorrect add 0)." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Canvas Drawing (Priority: P1) + +The drawer uses basic drawing tools (pen, color, clear) to illustrate the secret word. The drawing is rendered on all players' screens. This is the core interaction of the game — without drawing, guessers cannot participate. + +**Why this priority**: Drawing is the primary mechanic. No game can proceed without it. + +**Independent Test**: Start a game, confirm the drawer can draw strokes on the canvas, change color, and clear the canvas. In a second tab as a guesser, confirm the drawing appears within one polling cycle (2 seconds). + +**Acceptance Scenarios**: + +1. **Given** a round is active with a drawer and guessers, **When** the drawer draws a stroke on the canvas, **Then** the stroke appears on the drawer's screen immediately and on guessers' screens within one polling cycle. +2. **Given** the drawer is actively drawing a stroke (pen held down), **When** a polling cycle updates committed strokes, **Then** the in-progress stroke remains visible without interruption or flicker. +3. **Given** the drawer has drawn on the canvas, **When** they click "Clear Canvas", **Then** all strokes are removed from all players' screens. + +--- + +### User Story 2 — Guess Submission (Priority: P2) + +Guessers can submit guesses through an input field. Guesses are trimmed, compared case-insensitively against the secret word, and empty/whitespace-only guesses are rejected. Correct and incorrect feedback is returned to the guesser. + +**Why this priority**: Guessing is the mechanism for guessers to participate and advance the game. + +**Independent Test**: Start a game with 2+ players. As a guesser, submit a correct guess and verify "Correct!" feedback. Submit an incorrect guess and verify "Wrong" feedback. Submit an empty/whitespace guess and verify it is rejected with an error message. + +**Acceptance Scenarios**: + +1. **Given** a guesser submits a guess that matches the secret word (case-insensitive, trimmed), **When** the guess is processed, **Then** the guesser receives "Correct!" feedback and 100 points are awarded. +2. **Given** a guesser submits a guess that does not match the secret word, **When** the guess is processed, **Then** the guesser receives "Wrong" feedback and 0 points are added. +3. **Given** a guesser submits an empty or whitespace-only guess, **When** the guess is validated, **Then** it is rejected with an appropriate error message. +4. **Given** a guesser submits a guess with surrounding whitespace, **When** the guess is validated, **Then** the whitespace is trimmed before comparison. + +--- + +### User Story 3 — Score & History Sync (Priority: P3) + +All players see the guess history (who guessed what, whether correct, and running scores) via polling. The round ends when the secret word is correctly guessed. The drawer is prevented from guessing. + +**Why this priority**: Score visibility and round-end logic complete the gameplay loop. + +**Independent Test**: Start a game with 2+ players. Have a guesser submit both correct and incorrect guesses. Verify the drawer's screen shows the guess history. Verify scores update correctly (correct = 100, incorrect = 0). Verify the drawer's guess input is disabled or hidden. + +**Acceptance Scenarios**: + +1. **Given** a guesser submits a correct guess, **When** the guess history is polled, **Then** the guess entry shows the guesser's name, the guess text, "correct" status, and the updated score. +2. **Given** a guesser submits an incorrect guess, **When** the guess history is polled, **Then** the guess entry shows the guesser's name, the guess text, "incorrect" status, and the score unchanged. +3. **Given** any player polls the room state, **When** the room is in "playing" status, **Then** they receive the guess history and current scores for all participants. +4. **Given** the drawer attempts to submit a guess, **When** the request is validated, **Then** it is rejected (drawers know the secret word and cannot guess). + +--- + +### Edge Cases + +- What happens if a guesser submits the same guess multiple times? — Each submission is processed independently. The guess history records each attempt. +- What happens if a guesser enters the secret word with different casing? — The case-insensitive comparison correctly identifies it as a match. +- What happens if the drawer clears the canvas while guessers are viewing it? — The cleared state is synced via polling; guessers see the blank canvas. +- What happens if all guessers disconnect mid-round? — The round remains active. The drawer can continue drawing. When guessers reconnect (via page refresh), they see the current drawing and guess history. +- What happens if a correct guess is submitted by a second guesser after the first already guessed correctly? — The first correct guess ends the round. Subsequent guesses are rejected with "Round already ended". +- What happens if the backend is restarted mid-round? — Drawing data and guess history are lost (in-memory storage). Reconnecting players see a fresh lobby/room state. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-021**: System MUST provide a drawing canvas for the drawer with at minimum: pen tool, color selection, and clear canvas functionality. +- **FR-021a**: System MUST render the in-progress stroke (while pen is held down) in redraw cycles triggered by polling updates, preventing visual disappearance of the current stroke. +- **FR-022**: System MUST persist drawing strokes server-side and make them available via polling so all players see the current drawing state. +- **FR-023**: System MUST provide a guess input field for non-drawer participants. +- **FR-024**: System MUST trim whitespace from guesses before processing. +- **FR-025**: System MUST compare guesses against the secret word case-insensitively. +- **FR-026**: System MUST reject empty or whitespace-only guesses with an error message. +- **FR-027**: System MUST award 100 points to a guesser whose guess correctly matches the secret word. +- **FR-028**: System MUST award 0 points for incorrect guesses (no penalty). +- **FR-029**: System MUST maintain a guess history that includes: guesser name, guess text, correct/incorrect status, and timestamp. +- **FR-030**: System MUST sync the guess history to all participants via polling. +- **FR-031**: System MUST end the current round when a correct guess is submitted; subsequent guesses are rejected. +- **FR-032**: System MUST reject guess submissions from the drawer participant. +- **FR-033**: System MUST sync participant scores via polling so all players see current scores. + +### Key Entities *(include if feature involves data)* + +- **Drawing Stroke**: A continuous line drawn without lifting the pen. Has an array of coordinate points (x, y), color, and line width. Strokes are ordered by creation time. +- **Guess Entry**: A submission by a guesser. Contains guesser ID, guesser name, guess text, correct/incorrect status, timestamp, and score after the guess. +- **Score**: A numeric value associated with each participant. Starts at 0 for each round. 100 points awarded per correct guess. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-011**: The drawer can draw a visible stroke on the canvas immediately after performing the draw action, and the stroke remains visible without interruption while the pen is held down. +- **SC-012**: Guessers see drawing updates within 2 seconds of the drawer drawing (via polling cycle). +- **SC-013**: Guessers receive guess validation feedback (correct/incorrect/rejected) within 2 seconds of submitting. +- **SC-014**: The guess history and all player scores are consistent across all participants within one polling cycle. +- **SC-015**: The round ends immediately when the first correct guess is processed; no further guesses are accepted. + +## Assumptions + +- Drawing data is sent from the client to the server and stored for retrieval by all participants. Each stroke records the drawing position, color, and width. +- The canvas surface remains a fixed size. Canvas dimensions and scaling are implementation details. +- Polling interval for drawing and guess history sync is consistent with the existing lobby polling interval (~2 seconds). +- The drawer's drawing tools are basic (pen, color, clear). Advanced tools (shapes, undo, line thickness) are out of scope for this scenario. +- Turn-based play: the round ends when the secret word is correctly guessed. There are no timers. +- Guess history is ordered by submission time (oldest first). +- Scores are per-participant and persist across rounds within the same game session. diff --git a/specs/003-draw-guess-score/tasks.md b/specs/003-draw-guess-score/tasks.md new file mode 100644 index 00000000..0720cae3 --- /dev/null +++ b/specs/003-draw-guess-score/tasks.md @@ -0,0 +1,207 @@ +--- + +description: "Task list for Gameplay Interaction feature" + +--- + +# Tasks: Gameplay Interaction + +**Input**: Design documents from `specs/003-draw-guess-score/` + +**Prerequisites**: plan.md (required), spec.md (required), clarify.md + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +**Tests**: Test tasks are included below as requested in the feature specification. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2) +- Include exact file paths in descriptions + +## Path Conventions + +- **Web app**: `backend/src/`, `frontend/src/` + +## Polling Model + +The GamePage now polls `GET /rooms/:code?participantId=` every 2s (changed from +Scenario 2's single-fetch model). The poll response includes drawing strokes, +guess history, per-participant scores (round + cumulative), and optionally +`lastRoundResult` (present for ~6s after a round auto-advances). + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +No setup tasks required — the starter project is already initialized. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Data model extensions that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T001 [P] Add `Point`, `DrawingStroke`, `GuessEntry`, `ParticipantScore`, `RoundResult` types to `backend/src/models/game.ts` +- [X] T002 [P] Add `strokes`, `guessHistory`, `scores`, `lastRoundResult?` fields to the `Room` interface in `backend/src/models/game.ts` +- [X] T003 [P] Add `strokes`, `guessHistory`, `scores`, `lastRoundResult?` fields to the `RoomSnapshot` interface in `backend/src/models/game.ts` +- [X] T004 [P] Add `DrawingStroke`, `GuessEntry`, `ParticipantScore`, `RoundResult` types and `strokes`, `guessHistory`, `scores`, `lastRoundResult?` fields to the `RoomSnapshot` interface in `frontend/src/services/api.ts` + +**Checkpoint**: Foundation ready — user story implementation can now begin + +--- + +## Phase 3: User Story 1 — Canvas Drawing (Priority: P1) 🎯 MVP + +**Goal**: The drawer can draw strokes on an interactive canvas using a pen tool, change colors, and clear the canvas. All strokes are persisted server-side and synced to guessers via polling. Guessers see a read-only rendering of the same canvas. + +**Independent Test**: Start a game, confirm the drawer can draw strokes on the canvas, change color, and clear the canvas. In a second tab as a guesser, confirm the drawing appears within one polling cycle (2s). + +### Implementation for User Story 1 + +- [ ] T005 [US1] Implement `addStrokes()` in `backend/src/services/roomStore.ts` — append `DrawingStroke[]` to room, update `updatedAt`, return updated strokes +- [ ] T006 [US1] Implement `clearStrokes()` in `backend/src/services/roomStore.ts` — reset room strokes to `[]`, update `updatedAt`, return empty array +- [ ] T007 [P] [US1] Add `drawStrokesSchema`, `clearCanvasSchema` to `backend/src/api/schemas.ts` (Zod validation) +- [ ] T008 [US1] Add POST `/rooms/:code/draw` route in `backend/src/api/rooms.ts` — validate drawer identity, call `addStrokes()`, return updated strokes +- [ ] T009 [US1] Add DELETE `/rooms/:code/draw` route in `backend/src/api/rooms.ts` — validate drawer identity, call `clearStrokes()`, return empty strokes +- [ ] T010 [US1] Update `toRoomSnapshot()` in `backend/src/services/roomStore.ts` to include `strokes` in snapshot when `status === "playing"` (visible to all participants) +- [ ] T011 [P] [US1] Add `api.addStrokes()` and `api.clearCanvas()` methods to `frontend/src/services/api.ts` +- [ ] T012 [P] [US1] Add `addStrokes()`, `clearCanvas()` methods to `frontend/src/state/roomStore.ts` +- [ ] T013 [US1] Build `Canvas` component in `frontend/src/components/Canvas.tsx` — interactive canvas for drawer with pen tool, color picker (4+ colors), clear button; read-only rendering for guessers that displays strokes from room state +- [ ] T014 [US1] Update `frontend/src/pages/GamePage.tsx` — replace `CanvasPlaceholder` with `Canvas` component; pass real drawing state from room store + +**Checkpoint**: US1 complete — drawer draws, guessers see drawing within 2s. + +--- + +## Phase 4: User Story 2 — Guess Submission (Priority: P2) + +**Goal**: Guessers can submit guesses through an input field. Guesses are trimmed, compared case-insensitively, empty/whitespace-only guesses are rejected, and the guesser receives instant feedback (correct/wrong/rejected) via the POST response. + +**Independent Test**: As a guesser, submit a correct guess and verify "Correct!" feedback. Submit an incorrect guess and verify "Wrong". Submit empty/whitespace-only guess and verify rejection. + +### Implementation for User Story 2 + +- [ ] T015 [US2] Implement `submitGuess()` in `backend/src/services/roomStore.ts` — validate drawer/guesser, trim guess, reject empty, compare case-insensitively, record `GuessEntry`, update scores, handle correct-guess round advancement; reject subsequent guesses after round ended with message "Round already ended" +- [ ] T016 [US2] Implement `advanceRound()` in `backend/src/services/roomStore.ts` — increment `currentRound`, pick next drawer (round-robin: `participants[(currentRound - 1) % participants.length]`), pick word `STARTER_WORDS[currentRound - 1]`, clear strokes array from room state, reset all roundScore to 0, set `lastRoundResult` +- [ ] T017 [US2] Add `submitGuessSchema` to `backend/src/api/schemas.ts` (Zod validation) +- [ ] T018 [US2] Add POST `/rooms/:code/guess` route in `backend/src/api/rooms.ts` — validate guesser identity, call `submitGuess()`, return `{ correct, message, guessEntry }` +- [ ] T019 [P] [US2] Add `api.submitGuess()` method to `frontend/src/services/api.ts` +- [ ] T020 [P] [US2] Add `submitGuess()` method to `frontend/src/state/roomStore.ts` +- [ ] T021 [US2] Update `frontend/src/components/GuessForm.tsx` — call `submitGuess` API on submit, show inline feedback (correct/wrong/rejected), disable input while submitting +- [ ] T022 [US2] Update `toRoomSnapshot()` to include `guessHistory` and `scores` in snapshot when `status === "playing"` (visible to all participants). Depends on T010 — extends the same function with additional fields. + +**Checkpoint**: US2 complete — guessers submit guesses, get instant feedback, scores update. + +--- + +## Phase 5: User Story 3 — Score & History Sync (Priority: P3) + +**Goal**: All players see guess history and scores via polling. Round auto-advances on correct guess with a brief score card display. Drawer is prevented from guessing. + +**Independent Test**: Have a guesser submit correct and incorrect guesses. Verify drawer's screen shows guess history. Verify scores update (correct=100, incorrect=0). Verify drawer's guess input is disabled/hidden. + +### Implementation for User Story 3 + +- [ ] T023 [P] [US3] Build `GuessHistory` component in `frontend/src/components/GuessHistory.tsx` — ordered list of guesses with guesser name, guess text, correct/incorrect badge +- [ ] T024 [P] [US3] Build `Scoreboard` component (update existing `frontend/src/components/Scoreboard.tsx`) — show per-participant round score + cumulative score from room state +- [ ] T025 [P] [US3] Build `RoundResultCard` component in `frontend/src/components/RoundResultCard.tsx` — overlay showing "Round X Complete!" with secret word, correct guesser, scores; visible while `lastRoundResult` is present in state (server-managed expiry, ~6s) +- [ ] T026 [US3] Update `frontend/src/pages/GamePage.tsx` — integrate `GuessHistory` in right sidebar, `Scoreboard` in left sidebar, show `RoundResultCard` while `lastRoundResult` is present in poll response (server-managed, ~6s); start polling (2s interval) on mount, stop on unmount +- [ ] T027 [US3] Update `frontend/src/pages/GamePage.tsx` — disable/hide guess input for drawer participant (enforced server-side, but also hide on frontend) +- [ ] T028 [US3] Update `frontend/src/state/roomStore.ts` — on poll response, detect `lastRoundResult` and store it in state (server manages expiry, card hides when field is absent); update scores, guessHistory, and strokes from poll data + +**Checkpoint**: US3 complete — history and scores visible, round auto-advances. + +--- + +## Phase 6a: Bugfix — In-Progress Stroke Lost on Redraw + +**Bug**: When polling updates `strokes` while the user is drawing, `redraw()` clears the canvas and redraws only committed strokes — the in-progress stroke in `currentStrokeRef.current` is not rendered, so it disappears until the user releases the mouse. + +**Root cause**: `redraw()` in `Canvas.tsx` only iterates `strokesRef.current` (committed strokes) and ignores `currentStrokeRef.current` (in-progress stroke). + +- [X] T039 [US1] Fix `redraw()` in `frontend/src/components/Canvas.tsx` — after drawing committed strokes, draw the in-progress stroke from `currentStrokeRef.current` using the stroke's stored color; store color per-stroke in `currentStrokeRef` + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Tests (backend + frontend) and build validation + +- [ ] T029 [P] Write backend tests for `addStrokes()` and `clearStrokes()` in `backend/src/services/roomStore.test.ts` +- [ ] T030 [P] Write backend tests for `submitGuess()` — correct guess, incorrect guess, empty guess, drawer guess rejection, round advancement, duplicate correct-guess rejection in `backend/src/services/roomStore.test.ts` +- [ ] T031 [P] Write backend tests for `advanceRound()` — drawer round-robin, word selection, state reset in `backend/src/services/roomStore.test.ts` +- [ ] T032 [P] Write frontend render tests for `Canvas` component in `frontend/src/components/Canvas.test.tsx` — verify drawer sees interactive canvas with color picker and clear button; guesser sees read-only canvas +- [ ] T033 [P] Write frontend render tests for `GuessHistory` component in `frontend/src/components/GuessHistory.test.tsx` — verify guess entries display name, text, and correct/incorrect badge +- [ ] T034 [P] Write frontend render test for `RoundResultCard` component in `frontend/src/components/RoundResultCard.test.tsx` — verify overlay shows round number, secret word, scores +- [ ] T035 [P] Write frontend render test for `Scoreboard` component in `frontend/src/components/Scoreboard.test.tsx` — verify per-participant round + cumulative scores display +- [ ] T036 [P] Write frontend render test for `GuessForm` component in `frontend/src/components/GuessForm.test.tsx` — verify submit button, input field, inline feedback display +- [ ] T037 [P] Run `npm run build` in `backend/` — fix any type/build errors +- [ ] T038 [P] Run `npm run build` in `frontend/` — fix any type/build errors + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — already complete +- **Foundational (Phase 2)**: Blocks all user stories +- **User Stories (Phase 3-5)**: + - **US1 (Phase 3)**: Canvas Drawing — can start after Phase 2 + - **US2 (Phase 4)**: Guess Submission — can start after Phase 2 + - **US3 (Phase 5)**: Score & History Sync — depends on US2 (needs guesses and scores to display) +- **Polish (Phase 6)**: Depends on all user stories being complete + +### Within Each User Story + +- Backend model updates before service logic +- Services before API routes +- Backend changes before frontend integration +- Core implementation before UI rendering + +### Parallel Opportunities + +- All Phase 2 [P] tasks can run in parallel +- All [P] tasks within a user story can run in parallel +- US1 and US2 backend can be developed in parallel since they touch different services (draw vs guess) +- Polish phase T029-T038 all run in parallel (backend and frontend tests are independent) + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all backend service + schema tasks together: +Task: "addStrokes service in backend/src/services/roomStore.ts" +Task: "clearStrokes service in backend/src/services/roomStore.ts" +Task: "drawStrokesSchema in backend/src/api/schemas.ts" + +# Launch API routes after services are done (sequential): +Task: "POST /draw route in backend/src/api/rooms.ts" (after T005) +Task: "DELETE /draw route in backend/src/api/rooms.ts" (after T006) + +# Launch frontend API + store together: +Task: "addStrokes/clearCanvas in frontend/src/services/api.ts" +Task: "addStrokes/clearCanvas in frontend/src/state/roomStore.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 2: Foundational +2. Complete Phase 3: US1 (Canvas Drawing) +3. **STOP and VALIDATE**: Drawer can draw, guessers see drawing via polling + +### Incremental Delivery + +1. Add US1 → Test with two tabs (draw + see drawing) +2. Add US2 → Test guessing flow +3. Add US3 → Test history, scores, round advancement +4. Polish: tests (backend + frontend) + build validation From c98b610c3d7a7a806ddf687b7e4844c2cbd7485a Mon Sep 17 00:00:00 2001 From: M Asif Date: Sat, 27 Jun 2026 14:16:49 +0530 Subject: [PATCH 6/7] fix: end game when host leaves and no active participants remain; guard host-disconnected banner in lobby --- AGENTS.md | 2 +- backend/src/api/rooms.ts | 155 ++++++++- backend/src/api/schemas.ts | 22 +- backend/src/models/game.ts | 7 +- backend/src/services/roomStore.test.ts | 99 +++++- backend/src/services/roomStore.ts | 151 +++++++- .../components/DrawerDisconnectedBanner.tsx | 36 ++ .../src/components/HostDisconnectedBanner.tsx | 36 ++ .../src/components/RoundResultCard.test.tsx | 102 ++++-- frontend/src/components/RoundResultCard.tsx | 80 ++++- frontend/src/components/Scoreboard.test.tsx | 4 +- frontend/src/pages/CreateRoomPage.tsx | 1 + frontend/src/pages/GamePage.tsx | 49 ++- frontend/src/pages/JoinRoomPage.tsx | 1 + frontend/src/pages/LobbyPage.tsx | 7 + frontend/src/services/api.ts | 36 +- frontend/src/state/roomStore.ts | 77 ++++- .../checklists/requirements.md | 184 ++++++++++ .../checklists/spec-quality.md | 62 ++++ .../contracts/claim-host.md | 85 +++++ .../contracts/restart.md | 108 ++++++ .../contracts/skip-round.md | 107 ++++++ .../contracts/suggest-skip.md | 94 +++++ .../data-model.md | 138 ++++++++ specs/004-result-restart-validation/plan.md | 244 +++++++++++++ .../quickstart.md | 124 +++++++ .../004-result-restart-validation/research.md | 73 ++++ specs/004-result-restart-validation/spec.md | 233 +++++++++++++ specs/004-result-restart-validation/tasks.md | 325 ++++++++++++++++++ 29 files changed, 2569 insertions(+), 73 deletions(-) create mode 100644 frontend/src/components/DrawerDisconnectedBanner.tsx create mode 100644 frontend/src/components/HostDisconnectedBanner.tsx create mode 100644 specs/004-result-restart-validation/checklists/requirements.md create mode 100644 specs/004-result-restart-validation/checklists/spec-quality.md create mode 100644 specs/004-result-restart-validation/contracts/claim-host.md create mode 100644 specs/004-result-restart-validation/contracts/restart.md create mode 100644 specs/004-result-restart-validation/contracts/skip-round.md create mode 100644 specs/004-result-restart-validation/contracts/suggest-skip.md create mode 100644 specs/004-result-restart-validation/data-model.md create mode 100644 specs/004-result-restart-validation/plan.md create mode 100644 specs/004-result-restart-validation/quickstart.md create mode 100644 specs/004-result-restart-validation/research.md create mode 100644 specs/004-result-restart-validation/spec.md create mode 100644 specs/004-result-restart-validation/tasks.md diff --git a/AGENTS.md b/AGENTS.md index 53c6eeb2..56144442 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,5 +42,5 @@ You are working on a monolithic repository for a multiplayer drawing game ("Scri - When creating or editing files, ensure consistency with the existing directory structure detailed above. -Current plan: specs/003-draw-guess-score/plan.md +Current plan: specs/004-result-restart-validation/plan.md diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 9b966bc9..3862c831 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,16 +1,21 @@ import { Router } from "express"; import { + claimHostSchema, clearCanvasSchema, createRoomSchema, drawStrokesSchema, HttpError, joinRoomSchema, + leaveRoomSchema, + restartGameSchema, roomCodeParamsSchema, roomViewerQuerySchema, + skipRoundSchema, startGameSchema, - submitGuessSchema + submitGuessSchema, + suggestSkipSchema } from "./schemas.js"; -import { addStrokes, clearStrokes, createRoom, getRoom, joinRoom, startGame, submitGuess, toRoomSnapshot } from "../services/roomStore.js"; +import { addStrokes, claimHost, clearStrokes, createRoom, getRoom, joinRoom, removeParticipant, restartGame, skipRound, startGame, submitGuess, suggestSkip, toRoomSnapshot, touchParticipant } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -33,11 +38,20 @@ export function createRoomsRouter() { try { const { code } = roomCodeParamsSchema.parse(request.params); const { playerName } = joinRoomSchema.parse(request.body); - const result = joinRoom(code.toUpperCase(), playerName); + const upperCode = code.toUpperCase(); - if (!result) { + const room = getRoom(upperCode); + if (!room) { throw new HttpError(404, "Room not found"); } + if (room.status !== "lobby") { + throw new HttpError(400, "Game has already started"); + } + + const result = joinRoom(upperCode, playerName); + if (!result) { + throw new HttpError(500, "Failed to join room"); + } response.json({ participantId: result.participantId, @@ -52,7 +66,13 @@ export function createRoomsRouter() { try { const { code } = roomCodeParamsSchema.parse(request.params); const { participantId } = roomViewerQuerySchema.parse(request.query); - const room = getRoom(code.toUpperCase()); + const upperCode = code.toUpperCase(); + + if (participantId) { + touchParticipant(upperCode, participantId); + } + + const room = getRoom(upperCode); if (!room) { throw new HttpError(404, "Unable to load room"); @@ -99,6 +119,81 @@ export function createRoomsRouter() { } }); + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { playerId } = restartGameSchema.parse(request.body); + const upperCode = code.toUpperCase(); + + const room = getRoom(upperCode); + if (!room) { + throw new HttpError(404, "Room not found"); + } + if (room.status !== "playing") { + throw new HttpError(400, "Game is not in progress"); + } + if (room.hostId !== playerId) { + throw new HttpError(403, "Only the host can restart the game"); + } + + const updatedRoom = restartGame(upperCode, playerId); + if (!updatedRoom) { + throw new HttpError(500, "Failed to restart game"); + } + + response.json({ + room: toRoomSnapshot(updatedRoom, playerId) + }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/skip-round", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { playerId } = skipRoundSchema.parse(request.body); + const upperCode = code.toUpperCase(); + + const room = getRoom(upperCode); + if (!room) throw new HttpError(404, "Room not found"); + if (room.status !== "playing") throw new HttpError(400, "Game is not in progress"); + if (room.hostId !== playerId) throw new HttpError(403, "Only the host can skip the round"); + + const result = skipRound(upperCode, playerId); + if ("error" in result) { + throw new HttpError(400, result.error ?? "Failed to skip round"); + } + + response.json({ room: toRoomSnapshot(result.room!, playerId) }); + } catch (error) { + next(error); + } + }); + + router.post("/:code/suggest-skip", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = suggestSkipSchema.parse(request.body); + const upperCode = code.toUpperCase(); + + const room = getRoom(upperCode); + if (!room) throw new HttpError(404, "Room not found"); + if (room.status !== "playing") throw new HttpError(400, "Game is not in progress"); + const participantExists = room.participants.some((p) => p.id === participantId); + if (!participantExists) throw new HttpError(404, "Participant not found in room"); + + const result = suggestSkip(upperCode, participantId); + if ("error" in result) { + throw new HttpError(400, result.error ?? "Failed to suggest skip"); + } + + response.json({ room: toRoomSnapshot(result.room!, participantId) }); + } catch (error) { + next(error); + } + }); + router.post("/:code/draw", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); @@ -139,6 +234,56 @@ export function createRoomsRouter() { } }); + router.post("/:code/claim-host", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = claimHostSchema.parse(request.body); + const upperCode = code.toUpperCase(); + + const room = getRoom(upperCode); + if (!room) { + throw new HttpError(404, "Room not found"); + } + + const participantExists = room.participants.some((p) => p.id === participantId); + if (!participantExists) { + throw new HttpError(404, "Participant not found in room"); + } + + const updatedRoom = claimHost(upperCode, participantId); + if (!updatedRoom) { + throw new HttpError(500, "Failed to claim host"); + } + + response.json({ + room: toRoomSnapshot(updatedRoom, participantId) + }); + } catch (error) { + next(error); + } + }); + + router.delete("/:code/leave", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = leaveRoomSchema.parse(request.body); + const upperCode = code.toUpperCase(); + + const updatedRoom = removeParticipant(upperCode, participantId); + + if (!updatedRoom) { + response.json({ room: null }); + return; + } + + response.json({ + room: toRoomSnapshot(updatedRoom, participantId) + }); + } catch (error) { + next(error); + } + }); + router.post("/:code/guess", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index 8c2927ab..474d7eeb 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -const trimmedNonEmptyString = z.string().trim().min(1, { message: "Player name is required" }); +const trimmedNonEmptyString = z.string().trim().min(1, { message: "Player name is required" }).max(20, { message: "Player name must be 20 characters or fewer" }); export const createRoomSchema = z.object({ playerName: trimmedNonEmptyString @@ -43,6 +43,26 @@ export const clearCanvasSchema = z.object({ participantId: z.string() }); +export const restartGameSchema = z.object({ + playerId: z.string() +}); + +export const claimHostSchema = z.object({ + participantId: z.string() +}); + +export const skipRoundSchema = z.object({ + playerId: z.string() +}); + +export const suggestSkipSchema = z.object({ + participantId: z.string() +}); + +export const leaveRoomSchema = z.object({ + participantId: z.string() +}); + export const submitGuessSchema = z.object({ participantId: z.string(), guess: z.string() diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 5a1eab50..b008c14f 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,10 +1,11 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby" | "playing"; +export type RoomStatus = "lobby" | "playing" | "finished"; export interface Participant { id: string; name: string; joinedAt: string; + lastSeenAt: string; } export interface Point { @@ -58,6 +59,7 @@ export interface Room { scores: Record; lastRoundResult?: RoundResult; lastRoundResultSetAt?: number; + skipSuggested: boolean; } export interface RoomSnapshot { @@ -74,6 +76,9 @@ export interface RoomSnapshot { guessHistory: GuessEntry[]; scores: Record; lastRoundResult?: RoundResult; + hostDisconnected?: boolean; + drawerDisconnected?: boolean; + skipSuggested?: boolean; } export interface RoomSessionResponse { diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index ad878629..7a5b81a2 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { STARTER_WORDS } from "../seed/starterData.js"; -import { addStrokes, advanceRound, clearStrokes, createRoom, getRoom, joinRoom, startGame, submitGuess, toRoomSnapshot } from "./roomStore.js"; +import { addStrokes, advanceRound, clearStrokes, createRoom, getRoom, joinRoom, removeParticipant, startGame, submitGuess, toRoomSnapshot } from "./roomStore.js"; function setupPlayingRoom() { const host = createRoom("Alice"); @@ -300,5 +300,100 @@ describe("roomStore", () => { expect(room!.lastRoundResult!.roundNumber).toBe(1); expect(room!.lastRoundResult!.drawerId).toBe(hostParticipantId); }); + + it("persists lastRoundResult indefinitely (no auto-clear after arbitrary time)", () => { + const { code } = setupPlayingRoom(); + + advanceRound(code); + const room1 = getRoom(code); + expect(room1!.lastRoundResult).toBeDefined(); + + const room2 = getRoom(code); + expect(room2!.lastRoundResult).toBeDefined(); + expect(room2!.lastRoundResult!.roundNumber).toBe(1); + }); + }); + + describe("removeParticipant", () => { + it("removes participant from room", () => { + const { code, joinerParticipantId } = setupPlayingRoom(); + + const updated = removeParticipant(code, joinerParticipantId); + expect(updated).not.toBeNull(); + expect(updated!.participants.length).toBe(1); + expect(updated!.participants[0].id).not.toBe(joinerParticipantId); + }); + + it("reassigns host when host leaves", () => { + const { code, hostParticipantId, joinerParticipantId } = setupPlayingRoom(); + + const updated = removeParticipant(code, hostParticipantId); + expect(updated).not.toBeNull(); + expect(updated!.hostId).toBe(joinerParticipantId); + }); + + it("triggers game over when only 1 participant remains in playing status", () => { + const { code, joinerParticipantId } = setupPlayingRoom(); + + const updated = removeParticipant(code, joinerParticipantId); + expect(updated).not.toBeNull(); + expect(updated!.status).toBe("finished"); + expect(updated!.lastRoundResult).toBeDefined(); + }); + + it("deletes room when last participant leaves", () => { + const { code, hostParticipantId, joinerParticipantId } = setupPlayingRoom(); + + removeParticipant(code, joinerParticipantId); + const result = removeParticipant(code, hostParticipantId); + expect(result).toBeNull(); + + const room = getRoom(code); + expect(room).toBeNull(); + }); + + it("returns null for unknown participant", () => { + const { code } = setupPlayingRoom(); + + const result = removeParticipant(code, "nonexistent"); + expect(result).toBeNull(); + }); + + it("returns null for unknown room", () => { + const result = removeParticipant("ZZZZ", "player1"); + expect(result).toBeNull(); + }); + + it("does not trigger game over when more than 1 participant remains", () => { + const host = createRoom("Alice"); + joinRoom(host.room.code, "Bob"); + joinRoom(host.room.code, "Charlie"); + startGame(host.room.code); + + const updated = removeParticipant(host.room.code, host.participantId); + expect(updated).not.toBeNull(); + expect(updated!.status).toBe("playing"); + expect(updated!.participants.length).toBe(2); + }); + + it("triggers game over when host leaves and all remaining participants are disconnected", () => { + vi.useFakeTimers(); + try { + const host = createRoom("Alice"); + joinRoom(host.room.code, "Bob"); + joinRoom(host.room.code, "Charlie"); + startGame(host.room.code); + + vi.advanceTimersByTime(31000); + + const updated = removeParticipant(host.room.code, host.participantId); + expect(updated).not.toBeNull(); + expect(updated!.status).toBe("finished"); + expect(updated!.lastRoundResult).toBeDefined(); + expect(updated!.participants.length).toBe(2); + } finally { + vi.useRealTimers(); + } + }); }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 8a4b4da1..3576c55d 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -37,7 +37,8 @@ function createParticipant(name?: string): Participant { return { id: randomUUID(), name: displayName(name), - joinedAt: now() + joinedAt: now(), + lastSeenAt: now() }; } @@ -60,7 +61,8 @@ export function createRoom(playerName?: string) { updatedAt: now(), strokes: [], guessHistory: [], - scores: {} + scores: {}, + skipSuggested: false }; rooms.set(room.code, room); @@ -78,6 +80,10 @@ export function joinRoom(code: string, playerName?: string) { return null; } + if (room.status !== "lobby") { + return null; + } + const participant = createParticipant(playerName); room.participants.push(participant); room.updatedAt = now(); @@ -89,19 +95,9 @@ export function joinRoom(code: string, playerName?: string) { }; } -function clearExpiredRoundResult(room: Room) { - if (room.lastRoundResult && room.lastRoundResultSetAt) { - if (Date.now() - room.lastRoundResultSetAt > 6000) { - room.lastRoundResult = undefined; - room.lastRoundResultSetAt = undefined; - } - } -} - export function getRoom(code: string) { const room = rooms.get(code); if (!room) return null; - clearExpiredRoundResult(room); return cloneRoom(room); } @@ -163,6 +159,7 @@ export function advanceRound(code: string) { scores: { ...room.scores } }; room.lastRoundResultSetAt = Date.now(); + room.skipSuggested = false; room.currentRound += 1; room.drawerId = room.participants[(room.currentRound - 1) % room.participants.length].id; @@ -209,9 +206,11 @@ export function submitGuess(code: string, participantId: string, guess: string) room.guessHistory.push(guessEntry); - if (isCorrect && score) { - score.roundScore += 100; - score.cumulativeScore += 100; + if (isCorrect) { + if (score) { + score.roundScore += 100; + score.cumulativeScore += 100; + } advanceRound(code); return { guessEntry, correct: true, message: "Correct!" }; } @@ -220,6 +219,111 @@ export function submitGuess(code: string, participantId: string, guess: string) return { guessEntry, correct: false, message: "Wrong guess" }; } +export function restartGame(code: string, playerId: string) { + const room = rooms.get(code); + if (!room) return null; + if (room.status !== "playing") return null; + if (room.hostId !== playerId) return null; + + room.status = "lobby"; + room.currentRound = undefined; + room.drawerId = undefined; + room.secretWord = undefined; + room.strokes = []; + room.guessHistory = []; + room.scores = {}; + room.lastRoundResult = undefined; + room.lastRoundResultSetAt = undefined; + room.skipSuggested = false; + + return saveRoom(room); +} + +export function skipRound(code: string, playerId: string) { + const room = rooms.get(code); + if (!room) return { error: "Room not found" }; + if (room.status !== "playing") return { error: "Game is not in progress" }; + if (room.hostId !== playerId) return { error: "Only the host can skip the round" }; + + const hasCorrectGuess = room.guessHistory.some((g) => g.correct); + if (hasCorrectGuess) return { error: "Round already ended" }; + + const updated = advanceRound(code); + if (!updated) return { error: "Failed to advance round" }; + return { room: updated }; +} + +export function suggestSkip(code: string, participantId: string) { + const room = rooms.get(code); + if (!room) return { error: "Room not found" }; + if (room.status !== "playing") return { error: "Game is not in progress" }; + const participant = room.participants.find((p) => p.id === participantId); + if (!participant) return { error: "Participant not found" }; + + room.skipSuggested = true; + return { room: saveRoom(room) }; +} + +export function touchParticipant(code: string, participantId: string) { + const room = rooms.get(code); + if (!room) return; + const participant = room.participants.find((p) => p.id === participantId); + if (!participant) return; + participant.lastSeenAt = now(); +} + +export function claimHost(code: string, participantId: string) { + const room = rooms.get(code); + if (!room) return null; + const participant = room.participants.find((p) => p.id === participantId); + if (!participant) return null; + room.hostId = participantId; + return saveRoom(room); +} + +export function removeParticipant(code: string, participantId: string) { + const room = rooms.get(code); + if (!room) return null; + + const index = room.participants.findIndex((p) => p.id === participantId); + if (index === -1) return null; + + room.participants.splice(index, 1); + delete room.scores[participantId]; + + if (room.participants.length === 0) { + rooms.delete(code); + return null; + } + + if (room.hostId === participantId) { + room.hostId = room.participants[0].id; + } + + if (room.status === "playing") { + const allRemainingDisconnected = room.participants.every((p) => isDisconnected(p.lastSeenAt)); + + if (room.participants.length <= 1 || allRemainingDisconnected) { + const drawerName = room.participants.find((p) => p.id === room.drawerId)?.name ?? "Unknown"; + room.lastRoundResult = { + roundNumber: room.currentRound ?? 0, + secretWord: room.secretWord ?? "???", + drawerId: room.drawerId ?? "", + drawerName, + guessHistory: [...room.guessHistory], + scores: { ...room.scores } + }; + room.status = "finished"; + } + } + + return saveRoom(room); +} + +function isDisconnected(lastSeenAt: string, thresholdMs = 30000) { + return Date.now() - new Date(lastSeenAt).getTime() > thresholdMs; +} + export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { const snapshot: RoomSnapshot = { code: room.code, @@ -230,10 +334,11 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn roles: [...STARTER_ROLES], strokes: [], guessHistory: [], - scores: {} + scores: {}, + skipSuggested: room.skipSuggested }; - if (room.status === "playing") { + if (room.status === "playing" || room.status === "finished") { snapshot.currentRound = room.currentRound; snapshot.drawerId = room.drawerId; snapshot.strokes = room.strokes; @@ -241,6 +346,18 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn snapshot.scores = room.scores; snapshot.lastRoundResult = room.lastRoundResult; + const host = room.participants.find((p) => p.id === room.hostId); + if (host) { + snapshot.hostDisconnected = isDisconnected(host.lastSeenAt); + } + + if (room.drawerId) { + const drawer = room.participants.find((p) => p.id === room.drawerId); + if (drawer) { + snapshot.drawerDisconnected = isDisconnected(drawer.lastSeenAt); + } + } + if (viewerParticipantId === room.drawerId) { snapshot.secretWord = room.secretWord; } diff --git a/frontend/src/components/DrawerDisconnectedBanner.tsx b/frontend/src/components/DrawerDisconnectedBanner.tsx new file mode 100644 index 00000000..e53ff8b1 --- /dev/null +++ b/frontend/src/components/DrawerDisconnectedBanner.tsx @@ -0,0 +1,36 @@ +import { Card } from "./Card"; + +interface DrawerDisconnectedBannerProps { + onNotifyHost: () => void; +} + +export function DrawerDisconnectedBanner({ onNotifyHost }: DrawerDisconnectedBannerProps) { + return ( +
+
+ +
+

The drawer has disconnected. Notify the host to skip this round.

+
+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/HostDisconnectedBanner.tsx b/frontend/src/components/HostDisconnectedBanner.tsx new file mode 100644 index 00000000..92409e08 --- /dev/null +++ b/frontend/src/components/HostDisconnectedBanner.tsx @@ -0,0 +1,36 @@ +import { Card } from "./Card"; + +interface HostDisconnectedBannerProps { + onClaimHost: () => void; +} + +export function HostDisconnectedBanner({ onClaimHost }: HostDisconnectedBannerProps) { + return ( +
+
+ +
+

The host has disconnected. Would you like to claim host and take over?

+
+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/RoundResultCard.test.tsx b/frontend/src/components/RoundResultCard.test.tsx index f8d06027..dbcf8277 100644 --- a/frontend/src/components/RoundResultCard.test.tsx +++ b/frontend/src/components/RoundResultCard.test.tsx @@ -1,39 +1,101 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { RoundResultCard } from "./RoundResultCard"; +import type { Participant, RoundResult } from "../services/api"; + +function makeResult(overrides?: Partial): RoundResult { + return { + roundNumber: 1, + secretWord: "rocket", + drawerId: "p1", + drawerName: "Alice", + guessHistory: [], + scores: { p1: { roundScore: 0, cumulativeScore: 0 } }, + ...overrides + }; +} + +function makeParticipants(): Participant[] { + return [ + { id: "p1", name: "Alice", joinedAt: "2024-01-01T00:00:00Z", lastSeenAt: "2024-01-01T00:00:00Z" }, + { id: "p2", name: "Bob", joinedAt: "2024-01-01T00:00:00Z", lastSeenAt: "2024-01-01T00:00:00Z" } + ]; +} describe("RoundResultCard", () => { it("shows round number and secret word", () => { - const result = { - roundNumber: 1, - secretWord: "rocket", - drawerId: "p1", - drawerName: "Alice", - guessHistory: [], - scores: { p1: { roundScore: 0, cumulativeScore: 0 } } - }; + const result = makeResult(); + const onContinue = vi.fn(); - render(); + render(); expect(screen.getByText(/round 1 complete/i)).toBeInTheDocument(); expect(screen.getByText(/rocket/)).toBeInTheDocument(); }); it("displays scores for participants", () => { - const result = { - roundNumber: 1, - secretWord: "rocket", - drawerId: "p1", - drawerName: "Alice", - guessHistory: [ - { participantId: "p2", participantName: "Bob", guess: "rocket", correct: true, timestamp: "2024-01-01", roundScoreAfter: 100, cumulativeScoreAfter: 100 } - ], + const result = makeResult({ scores: { p1: { roundScore: 0, cumulativeScore: 0 }, p2: { roundScore: 100, cumulativeScore: 100 } } - }; + }); - render(); + render(); const hundreds = screen.getAllByText("100"); expect(hundreds.length).toBeGreaterThanOrEqual(1); }); + + it("resolves participant IDs to names from participants prop", () => { + const result = makeResult({ + scores: { p1: { roundScore: 0, cumulativeScore: 0 }, p2: { roundScore: 100, cumulativeScore: 100 } } + }); + + render(); + + const aliceElements = screen.getAllByText("Alice"); + expect(aliceElements.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Bob")).toBeInTheDocument(); + }); + + it("displays guess history with names resolved from participants", () => { + const result = makeResult({ + guessHistory: [ + { participantId: "p2", participantName: "Bob", guess: "car", correct: false, timestamp: "2024-01-01T00:00:01Z", roundScoreAfter: 0, cumulativeScoreAfter: 0 }, + { participantId: "p2", participantName: "Bob", guess: "rocket", correct: true, timestamp: "2024-01-01T00:00:02Z", roundScoreAfter: 100, cumulativeScoreAfter: 100 } + ] + }); + + render(); + + const carElements = screen.getAllByText((content) => content.includes("car")); + expect(carElements.length).toBeGreaterThanOrEqual(1); + const rocketElements = screen.getAllByText((content) => content.includes("rocket")); + expect(rocketElements.length).toBeGreaterThanOrEqual(1); + }); + + it("shows Continue button for all players", () => { + const onContinue = vi.fn(); + + render(); + + expect(screen.getByRole("button", { name: /continue/i })).toBeInTheDocument(); + }); + + it("hides Restart Game button when onRestart is not provided", () => { + render(); + + expect(screen.queryByRole("button", { name: /restart game/i })).not.toBeInTheDocument(); + }); + + it("shows Restart Game button when onRestart is provided", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /restart game/i })).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/RoundResultCard.tsx b/frontend/src/components/RoundResultCard.tsx index 6266eebe..141e804c 100644 --- a/frontend/src/components/RoundResultCard.tsx +++ b/frontend/src/components/RoundResultCard.tsx @@ -1,11 +1,19 @@ -import type { RoundResult } from "../services/api"; +import type { Participant, RoundResult } from "../services/api"; import { Card } from "./Card"; interface RoundResultCardProps { result: RoundResult; + participants: Participant[]; + onContinue?: () => void; + onRestart?: () => void; + onExit?: () => void; } -export function RoundResultCard({ result }: RoundResultCardProps) { +export function RoundResultCard({ result, participants, onContinue, onRestart, onExit }: RoundResultCardProps) { + function resolveName(participantId: string) { + return participants.find((p) => p.id === participantId)?.name ?? participantId; + } + return (
- +
+

The word was: {result.secretWord} @@ -37,19 +48,60 @@ export function RoundResultCard({ result }: RoundResultCardProps) { - {Object.entries(result.scores).map(([participantId, score]) => { - const entry = result.guessHistory.find((g) => g.participantId === participantId); - return ( - - {entry?.participantName ?? participantId} - {score.roundScore} - {score.cumulativeScore} - - ); - })} + {Object.entries(result.scores).map(([participantId, score]) => ( + + {resolveName(participantId)} + {score.roundScore} + {score.cumulativeScore} + + ))} + + {result.guessHistory.length > 0 && ( +

+

Guess History

+
    + {result.guessHistory.map((entry, index) => ( +
  • + {resolveName(entry.participantId)}: {entry.guess} + {entry.correct ? " ✅" : " ❌"} + + {new Date(entry.timestamp).toLocaleTimeString()} + +
  • + ))} +
+
+ )} + +
+
+ {onContinue && ( + + )} + {onRestart && ( + + )} +
+ {onExit && ( + + )} +
+
); } diff --git a/frontend/src/components/Scoreboard.test.tsx b/frontend/src/components/Scoreboard.test.tsx index abcce46f..f19f78ce 100644 --- a/frontend/src/components/Scoreboard.test.tsx +++ b/frontend/src/components/Scoreboard.test.tsx @@ -30,8 +30,8 @@ describe("Scoreboard", () => { status: "playing", hostId: "p1", participants: [ - { id: "p1", name: "Alice", joinedAt: "2024-01-01" }, - { id: "p2", name: "Bob", joinedAt: "2024-01-01" } + { id: "p1", name: "Alice", joinedAt: "2024-01-01", lastSeenAt: "2024-01-01T00:00:00Z" }, + { id: "p2", name: "Bob", joinedAt: "2024-01-01", lastSeenAt: "2024-01-01T00:00:00Z" } ], availableWords: [], roles: ["drawer", "guesser"], diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index bd42b930..1324ec9b 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -44,6 +44,7 @@ export function CreateRoomPage() { value={playerName} onChange={(event) => setPlayerName(event.target.value)} placeholder="Sketch captain" + maxLength={20} /> {error ?

{error}

: null} diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 6b392ca5..1a8c08cc 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -2,8 +2,10 @@ import { useCallback, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Canvas } from "../components/Canvas"; import { Card } from "../components/Card"; +import { DrawerDisconnectedBanner } from "../components/DrawerDisconnectedBanner"; import { GuessForm } from "../components/GuessForm"; import { GuessHistory } from "../components/GuessHistory"; +import { HostDisconnectedBanner } from "../components/HostDisconnectedBanner"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { RoundResultCard } from "../components/RoundResultCard"; import { Scoreboard } from "../components/Scoreboard"; @@ -26,14 +28,22 @@ export function GamePage() { useEffect(() => { if (!room) return; + if (room.status === "lobby") { + navigate("/lobby", { replace: true }); + return; + } + if (room.status === "finished") { + roomStore.startPolling(); + return () => roomStore.stopPolling(); + } roomStore.startPolling(); return () => roomStore.stopPolling(); - }, [room, roomStore]); + }, [navigate, room, roomStore]); const handleExit = useCallback(() => { - roomStore.stopPolling(); - roomStore.clearSession(); - navigate("/lobby"); + roomStore.leaveRoom().finally(() => { + navigate("/lobby"); + }); }, [navigate, roomStore]); if (!room || !participantId) { @@ -41,12 +51,33 @@ export function GamePage() { } const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const isHost = room.hostId === participantId; const isDrawer = room.drawerId === participantId; const drawer = room.participants.find((participant) => participant.id === room.drawerId) ?? null; return (
- {lastRoundResult && } + {lastRoundResult && room.status === "finished" ? ( + + ) : lastRoundResult ? ( + roomStore.dismissLastRoundResult()} + onRestart={isHost ? () => roomStore.restartGame() : undefined} + onExit={handleExit} + /> + ) : null} + + {room.status !== "finished" && room.hostDisconnected && participantId !== room.hostId ? ( + roomStore.claimHost()} /> + ) : room.status !== "finished" && room.drawerDisconnected && participantId !== room.hostId ? ( + roomStore.suggestSkip()} /> + ) : null}
@@ -103,6 +134,14 @@ export function GamePage() {
+ {isHost && room.status === "playing" && ( + + )} diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index 4ce84b78..80cc5b68 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -45,6 +45,7 @@ export function JoinRoomPage() { value={playerName} onChange={(event) => setPlayerName(event.target.value)} placeholder="Second pencil" + maxLength={20} /> diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index a95e82ed..0c232fb6 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; +import { HostDisconnectedBanner } from "../components/HostDisconnectedBanner"; import { PageHeader } from "../components/PageHeader"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; import { useRoomState, useRoomStore } from "../state/roomStore"; @@ -55,11 +56,17 @@ export function LobbyPage() { return null; } + const isViewerNotHost = participantId !== room.hostId; + const statusMessage = error ?? refreshError ?? pollingError ?? startError ?? (isHost ? "Waiting for players to join..." : "Waiting for the host to start the game."); const canStart = isHost && room.participants.length >= 2; return (
+ {room.status !== "finished" && room.hostDisconnected && isViewerNotHost && ( + roomStore.claimHost()} /> + )} +
; lastRoundResult?: RoundResult; + hostDisconnected?: boolean; + drawerDisconnected?: boolean; + skipSuggested?: boolean; } export interface GuessResponse { @@ -108,6 +112,30 @@ export const api = { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); }, + restartGame(code: string, playerId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/restart`, { + method: "POST", + body: JSON.stringify({ playerId }) + }); + }, + skipRound(code: string, playerId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/skip-round`, { + method: "POST", + body: JSON.stringify({ playerId }) + }); + }, + suggestSkip(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/suggest-skip`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + claimHost(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/claim-host`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, startRoom(code: string, playerId: string) { return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { method: "POST", @@ -126,6 +154,12 @@ export const api = { body: JSON.stringify({ participantId }) }); }, + leaveRoom(code: string, participantId: string) { + return request<{ room: RoomSnapshot | null }>(`/rooms/${encodeURIComponent(code)}/leave`, { + method: "DELETE", + body: JSON.stringify({ participantId }) + }); + }, submitGuess(code: string, participantId: string, guess: string) { return request(`/rooms/${encodeURIComponent(code)}/guess`, { method: "POST", diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index 1c05ece9..ff531627 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -16,6 +16,8 @@ export interface RoomState { pollingError: string | null; isLoading: boolean; lastRoundResult: RoundResult | null; + resultDismissed: boolean; + dismissedRoundNumber: number | null; } type Listener = () => void; @@ -27,7 +29,9 @@ class RoomStore { error: null, pollingError: null, isLoading: false, - lastRoundResult: null + lastRoundResult: null, + resultDismissed: false, + dismissedRoundNumber: null }; private listeners = new Set(); @@ -86,6 +90,15 @@ class RoomStore { }); } + dismissLastRoundResult() { + const roundNumber = this.state.lastRoundResult?.roundNumber ?? null; + this.setState({ + lastRoundResult: null, + resultDismissed: true, + dismissedRoundNumber: roundNumber + }); + } + clearSession() { sessionStorage.removeItem("scribble_roomCode"); sessionStorage.removeItem("scribble_participantId"); @@ -132,6 +145,45 @@ class RoomStore { return response; } + async skipRound() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No room to skip round"); + } + const response = await api.skipRound(this.state.room.code, this.state.participantId); + this.setRoomSnapshot(response.room); + return response.room; + } + + async suggestSkip() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No room to suggest skip"); + } + const response = await api.suggestSkip(this.state.room.code, this.state.participantId); + this.setRoomSnapshot(response.room); + this.setState({ + room: this.state.room ? { ...this.state.room, drawerDisconnected: false } : null + }); + return response.room; + } + + async restartGame() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No room to restart"); + } + const response = await api.restartGame(this.state.room.code, this.state.participantId); + this.setRoomSnapshot(response.room); + return response.room; + } + + async claimHost() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No room to claim host"); + } + const response = await api.claimHost(this.state.room.code, this.state.participantId); + this.setRoomSnapshot(response.room); + return response.room; + } + async startGame() { if (!this.state.room || !this.state.participantId) { throw new Error("No room to start"); @@ -142,6 +194,13 @@ class RoomStore { return response.room; } + async leaveRoom() { + if (!this.state.room || !this.state.participantId) return; + await api.leaveRoom(this.state.room.code, this.state.participantId); + this.clearSession(); + this.stopPolling(); + } + async addStrokes(strokes: DrawingStroke[]) { if (!this.state.room || !this.state.participantId) return; const result = await api.addStrokes(this.state.room.code, this.state.participantId, strokes); @@ -173,10 +232,20 @@ class RoomStore { const response = await api.fetchRoom(this.state.room.code, this.state.participantId ?? undefined); this.setRoomSnapshot(response.room); - if (response.room.lastRoundResult) { - this.setState({ lastRoundResult: response.room.lastRoundResult }); + + const serverResult = response.room.lastRoundResult; + if (serverResult) { + if (this.state.resultDismissed && this.state.dismissedRoundNumber === serverResult.roundNumber) { + // Same result that was dismissed — keep suppressing + } else { + this.setState({ + lastRoundResult: serverResult, + resultDismissed: false, + dismissedRoundNumber: null + }); + } } else if (this.state.lastRoundResult) { - this.setState({ lastRoundResult: null }); + this.setState({ lastRoundResult: null, resultDismissed: false, dismissedRoundNumber: null }); } return response.room; } diff --git a/specs/004-result-restart-validation/checklists/requirements.md b/specs/004-result-restart-validation/checklists/requirements.md new file mode 100644 index 00000000..5fdbd469 --- /dev/null +++ b/specs/004-result-restart-validation/checklists/requirements.md @@ -0,0 +1,184 @@ +# Requirements Checklist — 004-result-restart-validation + +## Specification Quality + +- [x] **User stories are prioritized** (P1, P2, P3) +- [x] **Each user story is independently testable** +- [x] **Acceptance scenarios use Given/When/Then format** +- [x] **Edge cases are documented** (at least 10) +- [x] **Functional requirements are numbered sequentially** (continuing from FR-033 → FR-034) +- [x] **Success criteria are measurable** +- [x] **Assumptions are explicitly stated** +- [x] **No [NEEDS CLARIFICATION] markers remain unresolved** + +## Functional Requirement Coverage + +### FR-034 — Persistent lastRoundResult +- [ ] `clearExpiredRoundResult()` function is removed +- [ ] `lastRoundResult` no longer auto-expires after 6 seconds +- [ ] `lastRoundResult` persists in snapshot until restart clears it +- [ ] `lastRoundResultSetAt` is kept for potential future use but not read by auto-clear logic + +### FR-035 — Result overlay display +- [ ] `RoundResultCard` renders when `lastRoundResult` is present in room state +- [ ] Overlay uses `position: fixed; inset: 0` backdrop pattern (existing) +- [ ] Overlay disappears when `lastRoundResult` is null (initially or after restart) + +### FR-036 — Result overlay content +- [ ] Shows correct secret word (bolded) +- [ ] Shows drawer name +- [ ] Shows table of all participants with round and cumulative scores +- [ ] Shows full guess history: guesser name, guess text, correct/incorrect badge, timestamp +- [ ] All participant names resolved from `participants[]` (not just guessHistory) + +### FR-037 — Continue button +- [ ] Visible to all players regardless of role +- [ ] Label reads "Continue" or equivalent + +### FR-038 — Continue dismisses locally +- [ ] Clicking does NOT call any API endpoint +- [ ] Overlay is dismissed on the client only +- [ ] Subsequent polls do NOT re-show overlay (client remembers dismissal) +- [ ] Other players are NOT affected + +### FR-039 — Restart endpoint +- [ ] `POST /:code/restart` route exists +- [ ] Body accepts `{ playerId }` +- [ ] Validates with Zod schema +- [ ] Resets: `status = "lobby"`, clears `currentRound`, `drawerId`, `secretWord`, `strokes`, `guessHistory`, `scores`, `lastRoundResult`, `lastRoundResultSetAt` +- [ ] Preserves: `participants`, `hostId`, `code`, `createdAt` +- [ ] Returns updated room snapshot with lobby status + +### FR-040 — Host-only restart +- [ ] Non-host caller receives 403 error +- [ ] Host caller succeeds + +### FR-041 — No-op prevention +- [ ] Room not in "playing" status returns 400 error +- [ ] Room in "playing" status (with or without lastRoundResult) succeeds + +### FR-042 — Restart button visibility +- [ ] Host sees "Restart Game" button on result overlay +- [ ] Non-host does NOT see "Restart Game" button on result overlay +- [ ] Button triggers `roomStore.restartGame()` on click + +### FR-043 — Exit Game coexists +- [ ] "Exit Game" button visible on result screen +- [ ] "Exit Game" clears sessionStorage and redirects to home +- [ ] Both buttons are visible to host simultaneously + +### FR-044 — Participant lastSeenAt +- [ ] `Participant` model has `lastSeenAt: string` (ISO timestamp) +- [ ] `GET /:code` updates the requesting participant's `lastSeenAt` in the room +- [ ] New participants get `lastSeenAt` set on join/create + +### FR-045 — hostDisconnected in snapshot +- [ ] `RoomSnapshot` includes `hostDisconnected: boolean` +- [ ] `toRoomSnapshot()` computes it: `true` if host's `lastSeenAt` is >30s from now +- [ ] `hostDisconnected` is `false` when host has polled within 30s + +### FR-046 — drawerDisconnected in snapshot +- [ ] `RoomSnapshot` includes `drawerDisconnected: boolean` +- [ ] `toRoomSnapshot()` computes it: `true` if room is playing and drawer's `lastSeenAt` >30s from now +- [ ] `drawerDisconnected` is `false` when not in "playing" status + +### FR-047 — Claim-host endpoint +- [ ] `POST /:code/claim-host` route exists +- [ ] Body accepts `{ participantId }` +- [ ] Validates with Zod schema +- [ ] Sets `room.hostId = participantId` +- [ ] Returns updated room snapshot + +### FR-048 — Claim-host validation +- [ ] Accepts request from any participant (including current host) +- [ ] Rejects non-existent participantId with 404 +- [ ] No game-state check needed (works in lobby or playing) + +### FR-049 — Host disconnected popup +- [ ] Popup/banner shown when `hostDisconnected` is `true` and viewer is NOT host +- [ ] "Claim Host" button visible in the popup +- [ ] Popup visible on both GamePage and LobbyPage +- [ ] Popup disappears when `hostDisconnected` becomes `false` (host returns or claim happens) +- [ ] Popup does NOT show for the host themselves + +### FR-050 — Skip-round endpoint +- [ ] `POST /:code/skip-round` route exists +- [ ] Body accepts `{ playerId }` +- [ ] Validates with Zod schema +- [ ] Calls `advanceRound()` (increments round, picks new drawer, resets strokes/guesses/roundScores, sets lastRoundResult) +- [ ] Returns updated room snapshot + +### FR-050a — Skip-round rejects ended round +- [ ] Endpoint rejects with 400 if correct guess exists in current round's guessHistory + +### FR-051 — Skip-round host-only +- [ ] Non-host caller receives 403 error +- [ ] Host caller succeeds + +### FR-052 — Skip Round button +- [ ] "Skip Round" button visible on GamePage whenever viewer is host +- [ ] Non-host does NOT see "Skip Round" button +- [ ] Button triggers `roomStore.skipRound()` on click + +### FR-053 — Max name length (backend) +- [ ] `trimmedNonEmptyString` schema adds `.max(20)` constraint +- [ ] Names >20 chars return a clear validation error + +### FR-054 — Max name length (frontend) +- [ ] Create-room name input has `maxLength={20}` +- [ ] Join-room name input has `maxLength={20}` + +### FR-055 — Drawer disconnected popup +- [ ] Popup shown when `drawerDisconnected` is `true` and viewer is NOT host +- [ ] "Notify Host" button visible in the popup +- [ ] Popup disappears when `drawerDisconnected` becomes `false` (drawer reconnects or round skipped) + +### FR-056 — Suggest-skip endpoint +- [ ] `POST /:code/suggest-skip` route exists +- [ ] Body accepts `{ participantId }` +- [ ] Validates with Zod schema +- [ ] Sets room's `skipSuggested = true` +- [ ] Returns updated room snapshot + +### FR-057 — skipSuggested in snapshot +- [ ] `RoomSnapshot` includes `skipSuggested: boolean` +- [ ] Cleared to `false` when a skip-round or restart is called + +### FR-058 — Host skip-requested badge +- [ ] "Skip Round" button shows visual badge/pulse when `skipSuggested` is `true` +- [ ] Badge hidden when `skipSuggested` is `false` + +### FR-059 — skipSuggested lifecycle clearance +- [ ] `skipSuggested` cleared on `advanceRound()` (normal or skip) +- [ ] `skipSuggested` cleared on `restartGame()` + +## Success Criterion Validation + +- [ ] **SC-016**: Manual test — observe result overlay for 10+ seconds; it does not disappear +- [ ] **SC-017**: Open 2+ browser tabs; verify result data matches within 2 seconds +- [ ] **SC-018**: Host clicks Restart; all tabs see lobby within 2 seconds with all players +- [ ] **SC-019**: Non-host tab has no restart button; direct API call returns 403 +- [ ] **SC-020**: Player A clicks Continue while Player B does not; A sees next round, B still sees result +- [ ] **SC-021**: Close host tab; non-host tabs see `hostDisconnected: true` within 32 seconds (30s + 2s poll) +- [ ] **SC-022**: Non-host clicks Claim Host; all tabs see new hostId within 2 seconds +- [ ] **SC-023**: Close drawer tab; host tab sees skip-requested badge within 32 seconds +- [ ] **SC-024**: Host clicks Skip Round; all tabs see result overlay with correct word, advance to next round +- [ ] **SC-025**: Submit name >20 chars via API returns error; UI input blocks typing past 20 +- [ ] **SC-026**: Close drawer tab; non-host tabs see "Drawer disconnected" popup within 32 seconds; clicking "Notify Host" shows badge on host's Skip Round button + +## Code Review Checklist + +- [ ] **Backend: `models/game.ts`** — add `lastSeenAt: string` to `Participant`, add `hostDisconnected` and `drawerDisconnected` to `RoomSnapshot` +- [ ] **Backend: `services/roomStore.ts`** — remove `clearExpiredRoundResult()` and call site; update `createParticipant()` to set `lastSeenAt`; update `getRoom()` to update caller's `lastSeenAt`; add `restartGame()`, `claimHost()`, `skipRound()` functions +- [ ] **Backend: `services/roomStore.ts`** — update `toRoomSnapshot()` to compute `hostDisconnected` and `drawerDisconnected` +- [ ] **Backend: `api/rooms.ts`** — add routes: `POST /:code/restart`, `POST /:code/claim-host`, `POST /:code/skip-round`, `POST /:code/suggest-skip` +- [ ] **Backend: `api/schemas.ts`** — add schemas: `restartGameSchema`, `claimHostSchema`, `skipRoundSchema`, `suggestSkipSchema`; update `trimmedNonEmptyString` with `.max(20)` +- [ ] **Frontend: `services/api.ts`** — add methods: `restartGame()`, `claimHost()`, `skipRound()`, `suggestSkip()` +- [ ] **Frontend: `services/api.ts`** — update `RoomSnapshot` type with `hostDisconnected`, `drawerDisconnected`, `skipSuggested` +- [ ] **Frontend: `state/roomStore.ts`** — add methods: `restartGame()`, `claimHost()`, `skipRound()`, `suggestSkip()`, `dismissLastRoundResult()`; update `fetchRoom()` to respect local dismissal; add `lastSeenAt` passthrough +- [ ] **Frontend: `pages/GamePage.tsx`** — wire Continue, Restart, Skip Round, Claim Host, Suggest Skip handlers; pass new props to RoundResultCard; show host-disconnected and drawer-disconnected popups +- [ ] **Frontend: `components/RoundResultCard.tsx`** — add guess history table, Continue button, Restart button (host only), participant name fix using `participants[]` +- [ ] **Frontend: `components/HostDisconnectedBanner.tsx`** — new component: shows "Host disconnected" message + "Claim Host" button +- [ ] **Frontend: `components/DrawerDisconnectedBanner.tsx`** — new component: shows "Drawer disconnected" popup + "Notify Host" button for non-host players +- [ ] **Frontend: name input fields** — add `maxLength={20}` to create-room and join-room name inputs +- [ ] All files pass `npm run typecheck` and `npm run lint` diff --git a/specs/004-result-restart-validation/checklists/spec-quality.md b/specs/004-result-restart-validation/checklists/spec-quality.md new file mode 100644 index 00000000..0a5c4148 --- /dev/null +++ b/specs/004-result-restart-validation/checklists/spec-quality.md @@ -0,0 +1,62 @@ +# Spec Quality Checklist — 004-result-restart-validation + +**Purpose**: Validate specification quality for Result, Restart & Final Validation feature +**Created**: 2026-06-26 +**Audience**: Implementation (developer pre-code validation) +**Depth**: Standard +**Coverage**: Full — API, UX, game logic, edge cases + +## Requirement Completeness + +- [ ] CHK001 - Are error response body schemas defined for all 400/403/404 responses from new endpoints? [Completeness, Gap; Spec §FR-040, FR-041, FR-048, FR-051] +- [ ] CHK002 - Is the "visual indication" on the Skip Round button (badge/pulse) specified with concrete visual properties? [Completeness, Spec §FR-058] +- [ ] CHK003 - Are requirements defined for what happens when the "Host disconnected" popup is dismissed without claiming host? [Completeness, Gap] +- [ ] CHK004 - Are requirements defined for dismissing the "Drawer disconnected" popup (dismiss vs persistent)? [Completeness, Gap] +- [ ] CHK005 - Are requirements defined for the `skipSuggested` field being reset/cleared after skip-round or restart? [Completeness, Spec §FR-057, FR-059] +- [ ] CHK006 - Are validation requirements defined for the `participantId` field in all new endpoint request bodies? [Completeness, Gap] + +## Requirement Clarity + +- [ ] CHK007 - Is the 30-second disconnect threshold explicitly specified as an implementation constant (server-side, not client-configurable)? [Clarity, Spec §FR-045] +- [ ] CHK008 - Is the "clear error message" for name length >20 chars specified with concrete wording or format? [Clarity, Spec §FR-053] +- [ ] CHK009 - Is "local dismissal" of the result overlay precisely defined (session-only vs survives page refresh)? [Clarity, Spec §FR-038] +- [ ] CHK010 - Is the order of precedence between `hostDisconnected` and `drawerDisconnected` when drawer=host specified in FRs (currently only in edge cases)? [Clarity, Spec §Edge Cases] + +## Requirement Consistency + +- [ ] CHK011 - Does the "Notify Host" behavior (FR-055/FR-056) conflict with the "always visible to host" Skip Round button (FR-052)? [Consistency; Spec §FR-052, FR-055] +- [ ] CHK012 - Is SC-023 (host sees badge) consistent with FR-052 (button always visible to host)? [Consistency; Spec §SC-023, FR-052] +- [ ] CHK013 - Do the duplicate acceptance scenario numbers in User Story 5 (two items labeled "2.") cause ambiguity? [Consistency, Spec §User Story 5] +- [ ] CHK014 - Are restart requirements consistent between result overlay (FR-042) and edge case descriptions (restart after all dismissed)? [Consistency; Spec §FR-042, Edge Cases] + +## Acceptance Criteria Quality + +- [ ] CHK015 - Can SC-017 ("identical data within one polling cycle") be objectively verified across different network conditions? [Measurability, Spec §SC-017] +- [ ] CHK016 - Can SC-020 ("independent dismiss without affecting others") be verified in acceptance scenarios without contradicting the polling-based sync model? [Measurability, Spec §SC-020] +- [ ] CHK017 - Is "clear error message" in SC-025 measurable (what qualifies as "clear")? [Measurability, Spec §SC-025] + +## Scenario Coverage + +- [ ] CHK018 - Are requirements defined for the "original host returns after claim-host" scenario (their `lastSeenAt` updates, but `hostId` unchanged)? [Coverage, Spec §Edge Cases] +- [ ] CHK019 - Are requirements defined for what happens when a participant calls `claim-host` but is not in the room's participants list? [Coverage, Spec §FR-048] +- [ ] CHK020 - Are requirements defined for the `skipSuggested: true` + `drawerDisconnected: false` state (host could request skip when drawer is connected)? [Coverage, Gap] +- [ ] CHK021 - Are requirements defined for the "last player claims host then exits" scenario? [Coverage, Gap] +- [ ] CHK022 - Are requirements defined for `lastSeenAt` on initial participant creation (value on first join before first poll)? [Coverage, Spec §FR-044] + +## Edge Case Coverage + +- [ ] CHK023 - Is the edge case for a participant who has never polled (no `lastSeenAt`) handled in the disconnect detection? [Edge Case, Spec §FR-045] +- [ ] CHK024 - Is the edge case for calling `suggest-skip` multiple times by the same or different players addressed? [Edge Case, Spec §FR-056] +- [ ] CHK025 - Is the edge case for concurrent `claim-host` requests from two different participants addressed? [Edge Case, Gap] +- [ ] CHK026 - Is the edge case for the 30-second threshold edge (29s vs 31s) specified in any SC? [Edge Case, Spec §SC-021] + +## Non-Functional Requirements + +- [ ] CHK027 - Are timeout/network-failure requirements defined for API calls from the result overlay buttons (restart, claim-host, skip-round)? [Gap] +- [ ] CHK028 - Are idempotency requirements defined for all new POST endpoints (multiple identical calls)? [Gap] + +## Dependencies & Assumptions + +- [ ] CHK029 - Is the assumption that `lastSeenAt` is updated only on `GET /:code` (not POST endpoints) explicitly documented? [Assumption, Spec §FR-044] +- [ ] CHK030 - Is the assumption that participants share the same clock/server time (or is clock skew acceptable for 30s threshold) documented? [Assumption, Gap] +- [ ] CHK031 - Is the dependency on the existing `advanceRound()` function's behavior (word cycling, drawer rotation) explicitly referenced? [Assumption, Spec §FR-050] diff --git a/specs/004-result-restart-validation/contracts/claim-host.md b/specs/004-result-restart-validation/contracts/claim-host.md new file mode 100644 index 00000000..91689cac --- /dev/null +++ b/specs/004-result-restart-validation/contracts/claim-host.md @@ -0,0 +1,85 @@ +# API Contract: POST /rooms/:code/claim-host + +## Summary + +Promotes any participant to host. Used when the original host disconnects. Idempotent — calling it when already host is a no-op success. + +## Request + +**Method**: `POST` +**Path**: `/rooms/:code/claim-host` +**Content-Type**: `application/json` + +### Path Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| code | string | yes | Room code (case-insensitive, uppercased server-side) | + +### Body (Zod: `claimHostSchema`) + +```ts +export const claimHostSchema = z.object({ + participantId: z.string() +}); +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| participantId | string | yes | Must exist in room's `participants[]` | + +## Response + +### 200 OK + +```json +{ + "room": RoomSnapshot +} +``` + +`RoomSnapshot` reflects the new `hostId`. Other fields unchanged. + +### 404 Not Found + +```json +{ "message": "Participant not found" } +``` + +Returned when `participantId` does not match any participant in the room. + +### 404 Not Found (room) + +```json +{ "message": "Room not found" } +``` + +Returned when room code does not exist. + +## Backend Service + +```ts +function claimHost(code: string, participantId: string): RoomSnapshot | null +``` + +### Logic + +1. Look up room by code. Return null if not found. +2. Find participant in `room.participants`. Return null if not found (caller returns 404). +3. Set `room.hostId = participantId`. +4. Save room, return snapshot via `toRoomSnapshot()`. + +### Notes + +- Idempotent: if participant is already host, it's a no-op success. +- Node.js single-threaded event loop serializes concurrent claims. Last write wins. +- Does NOT update `lastSeenAt` for the caller (only GET /:code does that). + +## Testing + +- Claim host succeeds for any participant +- Claim host is idempotent (already host succeeds) +- Non-existent participant returns 404 +- Non-existent room returns 404 +- After claim, new host can restart/skip +- Original host (if they return) is demoted to regular participant diff --git a/specs/004-result-restart-validation/contracts/restart.md b/specs/004-result-restart-validation/contracts/restart.md new file mode 100644 index 00000000..e628ca21 --- /dev/null +++ b/specs/004-result-restart-validation/contracts/restart.md @@ -0,0 +1,108 @@ +# API Contract: POST /rooms/:code/restart + +## Summary + +Resets the room to lobby status, clearing all game state while preserving participants and hostId. Host-only action. + +## Request + +**Method**: `POST` +**Path**: `/rooms/:code/restart` +**Content-Type**: `application/json` + +### Path Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| code | string | yes | Room code (case-insensitive, uppercased server-side) | + +### Body (Zod: `restartGameSchema`) + +```ts +export const restartGameSchema = z.object({ + playerId: z.string() +}); +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| playerId | string | yes | Must match room's `hostId` | + +## Response + +### 200 OK + +```json +{ + "room": RoomSnapshot +} +``` + +`RoomSnapshot` includes: +- `status: "lobby"` after restart +- All original `participants` preserved +- All game state cleared: no `currentRound`, `drawerId`, `secretWord`, `strokes`, `guessHistory`, `scores`, `lastRoundResult` + +### 400 Bad Request + +```json +{ "message": "Game is not in progress" } +``` + +Returned when room status is not "playing". + +### 403 Forbidden + +```json +{ "message": "Only the host can restart the game" } +``` + +Returned when `playerId` does not match `hostId`. + +### 404 Not Found + +```json +{ "message": "Room not found" } +``` + +Returned when room code does not exist. + +## Backend Service + +```ts +function restartGame(code: string, participantId: string): RoomSnapshot | null +``` + +### Logic + +1. Look up room by code. Return null if not found. +2. If room.status !== "playing", return null (caller returns 400). +3. If participantId !== room.hostId, return null (caller returns 403). +4. Reset room: + - `room.status = "lobby"` + - `room.currentRound = undefined` + - `room.drawerId = undefined` + - `room.secretWord = undefined` + - `room.strokes = []` + - `room.guessHistory = []` + - `room.scores = {}` + - `room.lastRoundResult = undefined` + - `room.lastRoundResultSetAt = undefined` + - `room.skipSuggested = false` + - `room.updatedAt = now()` +5. Save room, return snapshot via `toRoomSnapshot()`. + +### State Transition + +``` +playing → restartGame() → lobby +``` + +## Testing + +- Restart from playing state succeeds, returns lobby with participants preserved +- Restart from lobby returns 400 +- Non-host restart returns 403 +- Non-existent room returns 404 +- After restart, scores/strokes/guessHistory/lastRoundResult are all empty/undefined +- After restart, new game can be started from lobby diff --git a/specs/004-result-restart-validation/contracts/skip-round.md b/specs/004-result-restart-validation/contracts/skip-round.md new file mode 100644 index 00000000..d55f22bd --- /dev/null +++ b/specs/004-result-restart-validation/contracts/skip-round.md @@ -0,0 +1,107 @@ +# API Contract: POST /rooms/:code/skip-round + +## Summary + +Advances the round immediately (calls `advanceRound()`), skipping the current word. Available to host at all times during playing state. Rejected if a correct guess already exists in the current round. + +## Request + +**Method**: `POST` +**Path**: `/rooms/:code/skip-round` +**Content-Type**: `application/json` + +### Path Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| code | string | yes | Room code (case-insensitive, uppercased server-side) | + +### Body (Zod: `skipRoundSchema`) + +```ts +export const skipRoundSchema = z.object({ + playerId: z.string() +}); +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| playerId | string | yes | Must match room's `hostId` | + +## Response + +### 200 OK + +```json +{ + "room": RoomSnapshot +} +``` + +`RoomSnapshot` reflects advanced round state: +- `currentRound` incremented by 1 +- New `drawerId` (round-robin selection) +- New `secretWord` (for the new drawer) +- `strokes` cleared +- `guessHistory` cleared +- `scores` preserved with roundScore reset to 0 +- `lastRoundResult` set with the skipped round's data (correct word, scores) +- `skipSuggested` reset to false + +### 400 Bad Request + +```json +{ "message": "Round already ended" } +``` + +Returned when a correct guess already exists in `guessHistory` for the current round. + +### 403 Forbidden + +```json +{ "message": "Only the host can skip the round" } +``` + +Returned when `playerId` does not match `hostId`. + +### 404 Not Found + +```json +{ "message": "Room not found" } +``` + +Returned when room code does not exist. + +## Backend Service + +```ts +function skipRound(code: string, playerId: string): { room: RoomSnapshot } | { error: string } +``` + +### Logic + +1. Look up room by code. Return null if not found. +2. If room.status !== "playing", return error. +3. If playerId !== room.hostId, return error. +4. Check if any guess in `room.guessHistory` has `correct: true`. If so, return error "Round already ended". +5. Call `advanceRound(code)` — this preserves the existing `advanceRound()` logic: + - Sets `lastRoundResult` with current round data (scores unchanged since no one guessed correctly) + - Increments `currentRound` + - Picks new drawer (round-robin) + - Picks new word + - Clears strokes, guess history + - Resets round scores to 0 + - Clears `skipSuggested` +6. Return updated room snapshot. + +## Testing + +- Skip round succeeds when host calls it during playing state +- Skip round creates `lastRoundResult` with correct word and current scores +- Skip round advances round counter, assigns new drawer +- Skip round clears strokes and guess history +- Skip round resets `skipSuggested` +- Non-host skip returns 403 +- Skip after correct guess returns 400 +- Skip when room is lobby returns 400 +- Non-existent room returns 404 diff --git a/specs/004-result-restart-validation/contracts/suggest-skip.md b/specs/004-result-restart-validation/contracts/suggest-skip.md new file mode 100644 index 00000000..e9352d26 --- /dev/null +++ b/specs/004-result-restart-validation/contracts/suggest-skip.md @@ -0,0 +1,94 @@ +# API Contract: POST /rooms/:code/suggest-skip + +## Summary + +Allows any participant to signal to the host that they want the round skipped. Sets a `skipSuggested` flag on the room. Host sees a visual badge on the Skip Round button. + +## Request + +**Method**: `POST` +**Path**: `/rooms/:code/suggest-skip` +**Content-Type**: `application/json` + +### Path Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| code | string | yes | Room code (case-insensitive, uppercased server-side) | + +### Body (Zod: `suggestSkipSchema`) + +```ts +export const suggestSkipSchema = z.object({ + participantId: z.string() +}); +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| participantId | string | yes | Must exist in room's `participants[]` | + +## Response + +### 200 OK + +```json +{ + "room": RoomSnapshot +} +``` + +`RoomSnapshot` includes `skipSuggested: true`. + +### 400 Bad Request + +```json +{ "message": "Game is not in progress" } +``` + +Returned when room status is not "playing". + +### 404 Not Found + +```json +{ "message": "Room not found" } +``` + +Returned when room code does not exist. + +### 404 Not Found (participant) + +```json +{ "message": "Participant not found" } +``` + +Returned when `participantId` does not match any participant in the room. + +## Backend Service + +```ts +function suggestSkip(code: string, participantId: string): RoomSnapshot | null +``` + +### Logic + +1. Look up room by code. Return null if not found. +2. If room.status !== "playing", return null (caller returns 400). +3. Find participant in `room.participants`. Return null if not found (caller returns 404). +4. Set `room.skipSuggested = true`. +5. Save room, return snapshot via `toRoomSnapshot()`. + +### skipSuggested Lifecycle + +- **Set**: by `POST /:code/suggest-skip` +- **Cleared**: by `advanceRound()` (called from skip-round or correct guess) and by `restartGame()` +- **Read**: in `toRoomSnapshot()` — included as `skipSuggested` in `RoomSnapshot` + +## Testing + +- Suggest skip succeeds for any participant during playing state +- Suggest skip sets `skipSuggested: true` in snapshot +- Suggest skip when room is lobby returns 400 +- Suggest skip for non-existent participant returns 404 +- After advanceRound, skipSuggested resets to false +- After restartGame, skipSuggested resets to false diff --git a/specs/004-result-restart-validation/data-model.md b/specs/004-result-restart-validation/data-model.md new file mode 100644 index 00000000..dce53566 --- /dev/null +++ b/specs/004-result-restart-validation/data-model.md @@ -0,0 +1,138 @@ +# Data Model: Result, Restart & Final Validation + +## Participant (updated) + +``` +Participant { + id: string (UUID, unchanged) + name: string (unchanged, now max 20 chars validated by Zod) + joinedAt: string (ISO timestamp, unchanged) + lastSeenAt: string (ISO timestamp, NEW — updated on every GET /:code poll) +} +``` + +## Room (updated) + +``` +Room { + // Existing fields (unchanged): + code: string + status: "lobby" | "playing" + hostId: string + participants: Participant[] + createdAt: string + updatedAt: string + currentRound?: number + drawerId?: string + secretWord?: string + strokes: DrawingStroke[] + guessHistory: GuessEntry[] + scores: Record + lastRoundResult?: RoundResult + lastRoundResultSetAt?: number + + // NEW: + skipSuggested: boolean // true when non-host player requests skip +} +``` + +## RoomSnapshot (updated) + +``` +RoomSnapshot { + // Existing fields (unchanged): + code: string + status: "lobby" | "playing" + hostId: string + participants: Participant[] + availableWords: string[] + roles: ParticipantRole[] + currentRound?: number + drawerId?: string + secretWord?: string + strokes: DrawingStroke[] + guessHistory: GuessEntry[] + scores: Record + lastRoundResult?: RoundResult + + // NEW (computed in toRoomSnapshot()): + hostDisconnected?: boolean // true if host's lastSeenAt > 30s ago + drawerDisconnected?: boolean // true if playing + drawer's lastSeenAt > 30s ago + skipSuggested?: boolean // from Room.skipSuggested +} +``` + +## Validation Rules + +### Participant Name + +- Must be non-empty after trimming +- Max 20 characters (new, enforced by Zod) +- Validation applies to: `POST /rooms`, `POST /rooms/:code/join` + +### New Endpoint Validation + +| Endpoint | Field | Type | Required | Rules | +|----------|-------|------|----------|-------| +| POST /:code/restart | playerId | string | yes | Must match hostId | +| POST /:code/claim-host | participantId | string | yes | Must exist in participants | +| POST /:code/skip-round | playerId | string | yes | Must match hostId, no correct guess in current round | +| POST /:code/suggest-skip | participantId | string | yes | Must exist in participants | + +## State Transitions + +### Room Status Transitions + +``` +[Created as "lobby"] → startGame() → ["playing"] → restartGame() → ["lobby"] + ↑ ↓ + (restart from (start new game) + result screen) +``` + +### Round Lifecycle (with Disconnect Detection) + +``` +Round Active → correctGuess() → [Round Result Displayed] + → skipRound() → [Round Result Displayed] + → restartGame() → [Back to Lobby] + +During Round Active: + - Host stops polling for 30s → hostDisconnected = true + - Drawer stops polling for 30s → drawerDisconnected = true + - Non-host clicks Notify Host → skipSuggested = true + - Host sees skipSuggested badge → clicks Skip Round + - Someone claims host → hostId changes, hostDisconnected = false +``` + +## lastSeenAt Update Strategy + +``` +GET /:code?participantId=XXX + → Find participant in room.participants + → Set participant.lastSeenAt = now() + → (continue to build snapshot) + → lastSeenAt is included in the snapshot for this participant +``` + +Important: `lastSeenAt` is NOT updated on POST endpoints (restart, claim-host, etc.). Only GET /:code updates it. This means claiming host does not itself prevent the original host from appearing disconnected — the new host's next poll updates their lastSeenAt. + +## Disconnect Computation + +```ts +function isDisconnected(participant: Participant): boolean { + const elapsed = Date.now() - new Date(participant.lastSeenAt).getTime(); + return elapsed > 30000; // 30 seconds +} + +// In toRoomSnapshot(): +const host = room.participants.find(p => p.id === room.hostId); +snapshot.hostDisconnected = host ? isDisconnected(host) : false; + +if (room.status === "playing" && room.drawerId) { + const drawer = room.participants.find(p => p.id === room.drawerId); + snapshot.drawerDisconnected = drawer ? isDisconnected(drawer) : false; +} + +snapshot.skipSuggested = room.skipSuggested ?? false; +``` diff --git a/specs/004-result-restart-validation/plan.md b/specs/004-result-restart-validation/plan.md new file mode 100644 index 00000000..d4fda0a6 --- /dev/null +++ b/specs/004-result-restart-validation/plan.md @@ -0,0 +1,244 @@ +# Implementation Plan: Result, Restart & Final Validation + +**Branch**: `004-result-restart-validation` | **Date**: 2026-06-26 | **Spec**: specs/004-result-restart-validation/spec.md + +**Input**: Feature specification from `specs/004-result-restart-validation/spec.md` + +## Summary + +After a round ends, persist the round result (secret word, scores, guess history) until the host restarts the game back to lobby. Add host disconnect detection with claim-host, drawer disconnect detection with skip-round and notify-host, and enforce max name length. Clicking "Notify Host" locally dismisses the drawer-disconnected popup. When all other players leave during a game, the remaining player sees a game-over result screen with only the Exit Game button. Changes span both backend (new endpoints, model changes, disconnect detection logic) and frontend (enhanced overlay, popups, buttons). + +## Technical Context + +**Language/Version**: TypeScript (ES2022, NodeNext module resolution, strict mode) + +**Primary Dependencies**: Express 4 + Zod (backend); React 18 + React Router 6 + Vite (frontend) + +**Storage**: In-memory only (Map-based room store on backend) + +**Testing**: Vitest, minimum 80% line coverage + +**Target Platform**: Modern web browsers (Chrome, Firefox, Safari, Edge) + +**Performance Goals**: Disconnect detection within 30s + 1 polling cycle (~32s total). Restart/skip/claim-host actions reflected in all clients within 1 polling cycle (~2s). + +**Polling Model**: GamePage and LobbyPage poll `GET /rooms/:code?participantId=` every 2s. Each poll updates the requesting participant's `lastSeenAt`. Disconnect detection is computed server-side on each poll by comparing `lastSeenAt` against current time. + +**Constraints**: No WebSockets, no databases, no auth, polling-only sync. Backend centralized error handling. Zod for request validation. Functional components with hooks on frontend. + +**Scale/Scope**: Lab project — small scale, no production deployment. + +## Clarification Decisions + +- **Persistent result**: `lastRoundResult` no longer auto-expires (remove `clearExpiredRoundResult()`). Result overlay stays until player clicks "Continue" (local dismiss) or host restarts. +- **Continue behavior**: Local-only dismiss. Client suppresses re-display even though server still has `lastRoundResult`. On restart, server clears `lastRoundResult` and client overlay disappears naturally. +- **Disconnect detection**: 30-second threshold based on `lastSeenAt` updated on each `GET /:code`. Computed in `toRoomSnapshot()`, not a background timer. +- **Skip-round race safety**: Reject skip-round with 400 if correct guess exists in current round's guess history. +- **Skip Round button**: Always visible to host (not conditional on drawer disconnect). +- **Drawer-disconnected popup**: Full popup for non-host players with "Notify Host" button. Sets `skipSuggested` on the room. Host sees badge on Skip Round button. +- **Name resolution**: Scores table resolves names from `participants[]`, not `guessHistory`. +- **Max name length**: 20 characters, validated by Zod (backend) + `maxLength` attribute (frontend). +- **Notify Host dismisses banner**: After clicking "Notify Host" in the drawer-disconnected popup, the popup hides locally on the client (patches `drawerDisconnected: false` in room state). The popup may reappear on the next poll if `drawerDisconnected` is still true. +- **Game over on all players leave**: When `removeParticipant()` drops participant count to ≤ 1 during "playing" status, the game ends. A `lastRoundResult` is created from current round state (same structure as `advanceRound()`), status changes to `"finished"`. The remaining player sees the RoundResultCard with only the Exit Game button. The result is not dismissable — the player must exit. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **TypeScript-First (Strict Mode)**: All new types fully typed, strict mode enabled. +- **Extend, Don't Rewrite**: All changes extend existing Room/Participant/RoomSnapshot models + roomStore service. No rewrites. +- **Spec-Driven Development**: Spec artifacts committed before implementation. All FR-034 through FR-059 covered. +- **Deterministic Game Logic**: Disconnect detection is deterministic (lastSeenAt-based). Skip-round uses same deterministic `advanceRound()` from Spec 003. Claim-host is explicit (no auto-promotion). +- **Minimal Dependencies**: No new dependencies required. All new endpoints follow existing Express/Zod patterns. +- **Technical Constraints**: No WebSockets, no DB, no auth, polling-only, Zod validation, functional components, 80% Vitest coverage. +- **No violations detected**: Complexity tracking not required. + +## Data Model Changes + +### Updated Participant model (backend/src/models/game.ts) + +Add to existing `Participant`: +```ts +lastSeenAt: string; // ISO timestamp, updated on each GET /:code poll +``` + +### Updated Room model + +Add to existing `Room`: +```ts +lastSeenAt?: undefined; // existing +skipSuggested: boolean; // new — set by POST /:code/suggest-skip +``` + +### Updated RoomStatus type + +```ts +type RoomStatus = "lobby" | "playing" | "finished"; +// Added: "finished" — set when participants drop to ≤ 1 during playing +``` + +Change: +- `lastRoundResult` stays as-is but `clearExpiredRoundResult()` is removed +- `lastRoundResultSetAt` field is kept but no longer read by auto-clear + +### Updated RoomSnapshot model + +Add to existing `RoomSnapshot`: +```ts +hostDisconnected?: boolean; // computed — host's lastSeenAt > 30s ago +drawerDisconnected?: boolean; // computed — drawer's lastSeenAt > 30s ago (playing only) +skipSuggested?: boolean; // from Room.skipSuggested +``` + +### Disconnect Detection Logic + +``` +hostDisconnected = room.status === "playing" && + Date.now() - new Date(room.host.lastSeenAt).getTime() > 30000 + +drawerDisconnected = room.status === "playing" && + room.drawerId && + Date.now() - new Date(room.drawer.lastSeenAt).getTime() > 30000 +``` + +Note: A participant who has never polled has `lastSeenAt === joinedAt`, so they will appear disconnected 30s after joining if they never poll. + +## API Changes + +### New Endpoints + +| Method | Path | Body | Response | Auth | Notes | +|--------|------|------|----------|------|-------| +| POST | /rooms/:code/restart | `{ playerId }` | `{ room: RoomSnapshot }` | Host only, room playing | Resets to lobby, clears all game state, preserves participants | +| POST | /rooms/:code/claim-host | `{ participantId }` | `{ room: RoomSnapshot }` | Any valid participant | Sets participantId as new hostId. Idempotent. | +| POST | /rooms/:code/skip-round | `{ playerId }` | `{ room: RoomSnapshot }` | Host only, room playing | Calls advanceRound(). Rejects 400 if correct guess exists. | +| POST | /rooms/:code/suggest-skip | `{ participantId }` | `{ room: RoomSnapshot }` | Any participant, room playing | Sets skipSuggested = true | +| DELETE | /rooms/:code/leave | `{ participantId }` | `{ room: RoomSnapshot }` | Any participant | Removes participant. If playing + ≤1 left: ends game with lastRoundResult, status = finished. If 0 left: deletes room, returns null. | + +### Extended GET /rooms/:code + +Returns additional computed fields when room is in "playing" status: +- `hostDisconnected`: boolean (computed from host's lastSeenAt) +- `drawerDisconnected`: boolean (computed from drawer's lastSeenAt, only if drawerId exists) +- `skipSuggested`: boolean (from room storage) + +Also returns `lastRoundResult`, `scores`, and `guessHistory` when status is `"finished"` — same as during "playing", so the remaining player sees the game-over result screen. + +### Updated POST /rooms/:code/guess + +No behavior change. Correct guess still calls `advanceRound()`. Result screen persists (no auto-expiry). + +### Zod Schemas + +```ts +export const restartGameSchema = z.object({ + playerId: z.string() +}); + +export const claimHostSchema = z.object({ + participantId: z.string() +}); + +export const skipRoundSchema = z.object({ + playerId: z.string() +}); + +export const suggestSkipSchema = z.object({ + participantId: z.string() +}); +``` + +```ts +export const leaveRoomSchema = z.object({ + participantId: z.string() +}); +``` + +Updated existing: +```ts +export const trimmedNonEmptyString = z.string().trim().min(1).max(20); +``` + +## Frontend Component Architecture + +### New Components +- **HostDisconnectedBanner**: Modal overlay with "Host disconnected" message + "Claim Host" button. Visible on GamePage and LobbyPage when `hostDisconnected` is true and viewer is not host. +- **DrawerDisconnectedBanner**: Modal overlay with "Drawer disconnected" message + "Notify Host" button. Visible on GamePage when `drawerDisconnected` is true and viewer is not host. **After clicking Notify Host**, banner hides locally (client patches `drawerDisconnected: false` in roomStore). + +### Modified Components +- **RoundResultCard**: Add full guess history table. Fix name lookup to use participants[]. Add "Continue" button (all players). Add "Restart Game" button (host only). Accept new props: `participants`, `onContinue`, `onRestart`. +- **GamePage**: Wire Continue (local dismiss), Restart Game, Skip Round (host), Claim Host, Suggest Skip handlers. Show HostDisconnectedBanner and DrawerDisconnectedBanner. When `room.status === "finished"` and `lastRoundResult` exists, show RoundResultCard with `onExit` only (no Continue, no Restart) — persistent overlay, player must exit. +- **LobbyPage**: Show HostDisconnectedBanner. + +### Button Visibility Matrix + +| Button | Host (playing) | Non-host (playing) | Host (result) | Non-host (result) | Anyone (game over) | +|--------|---------------|-------------------|---------------|-------------------|-------------------| +| Continue | — | — | ✅ | ✅ | ❌ | +| Restart Game | — | — | ✅ | ❌ | ❌ | +| Skip Round | ✅ | ❌ | — | — | — | +| Claim Host | ❌ (already host) | ✅ (when host disconnected) | ❌ | ✅ (when host disconnected) | ❌ | +| Notify Host | — | ✅ (when drawer disconnected) | — | — | — | +| Exit Game | ✅ | ✅ | ✅ | ✅ | ✅ | + +## Implementation Tasks + +### Backend (original) + +1. **T036**: Update `Participant` model in `game.ts` — add `lastSeenAt: string`; update `RoomSnapshot` — add `hostDisconnected`, `drawerDisconnected`, `skipSuggested`; update `Room` — add `skipSuggested: boolean` +2. **T037**: Update `createParticipant()` to set `lastSeenAt = now()` on creation +3. **T038**: Update `GET /rooms/:code` handler to update requesting participant's `lastSeenAt` before returning +4. **T039**: Remove `clearExpiredRoundResult()` function and its call in `getRoom()` — `lastRoundResult` persists indefinitely +5. **T040**: Implement `restartGame(code, participantId)` — reset room to lobby status, clear all game state, preserve participants/hostId/code +6. **T041**: Implement `claimHost(code, participantId)` — set `room.hostId = participantId` +7. **T042**: Implement `skipRound(code, playerId)` — validate no correct guess exists (return 400 if so), call `advanceRound()` +8. **T043**: Implement `suggestSkip(code, participantId)` — set `room.skipSuggested = true`; clear `skipSuggested` on `advanceRound()` and `restartGame()` +9. **T044**: Update `toRoomSnapshot()` — compute `hostDisconnected` and `drawerDisconnected` from lastSeenAt; include `skipSuggested` +10. **T045**: Add Zod schemas: `restartGameSchema`, `claimHostSchema`, `skipRoundSchema`, `suggestSkipSchema`; update `trimmedNonEmptyString` with `.max(20)` +11. **T046**: Wire routes: `POST /:code/restart`, `POST /:code/claim-host`, `POST /:code/skip-round`, `POST /:code/suggest-skip` +12. **T047**: Add backend tests for all new endpoints, disconnect detection logic, name validation + +### Frontend + +13. **T048**: Update `api.ts` — add `restartGame()`, `claimHost()`, `skipRound()`, `suggestSkip()` methods; update `RoomSnapshot` type with `hostDisconnected`, `drawerDisconnected`, `skipSuggested` +14. **T049**: Build `HostDisconnectedBanner` component — modal overlay with "Host disconnected" message + "Claim Host" button +15. **T050**: Build `DrawerDisconnectedBanner` component — modal overlay with "Drawer disconnected" message + "Notify Host" button +16. **T051**: Update `RoundResultCard` — add full guess history table; fix name lookup using `participants[]` prop; add "Continue" button (all players); add "Restart Game" button (host only) +17. **T052**: Update `roomStore` — add `restartGame()`, `claimHost()`, `skipRound()`, `suggestSkip()`, `dismissLastRoundResult()` methods; update `fetchRoom()` to respect local dismissal +18. **T053**: Update `GamePage` — wire Continue (dismiss result), Restart Game, Skip Round, Claim Host, Suggest Skip handlers; show banners for host/drawer disconnect; pass participants to RoundResultCard +19. **T054**: Update `LobbyPage` — show `HostDisconnectedBanner` when host is disconnected +20. **T055**: Update name input fields in create-room and join-room forms with `maxLength={20}` +21. **T056**: Add frontend tests for new components and interactions + +### Build & Verify + +22. **T057**: Verify both backend and frontend builds pass with `npm run build` + +### Feature 1: Notify Host Dismisses Banner (add-on to US5) + +23. **T058** [FE] `frontend/src/state/roomStore.ts` — In `suggestSkip()`, after API call succeeds, patch `drawerDisconnected: false` on the local room state so the banner disappears immediately + +### Feature 2: Game Over When All Players Leave (US6) + +#### Backend + +24. **T059** `backend/src/models/game.ts` — Add `"finished"` to `RoomStatus` type +25. **T060** `backend/src/services/roomStore.ts` — Add `removeParticipant(code, participantId)` service: remove participant from array + scores; reassign host if needed; if playing + remaining ≤ 1: create lastRoundResult from current state, set status `"finished"`; if 0 remaining: delete room from store +26. **T061** `backend/src/services/roomStore.ts` — Update `toRoomSnapshot()` to include game details (`lastRoundResult`, `scores`, `guessHistory`) when status is `"finished"` too +27. **T062** `backend/src/api/schemas.ts` — Add `leaveRoomSchema = z.object({ participantId: z.string() })` +28. **T063** `backend/src/api/rooms.ts` — Add `DELETE /:code/leave` route: validate schema, call `removeParticipant()`, return 200 with snapshot +29. **T064** `backend/src/services/roomStore.test.ts` — Add tests: removeParticipant removes correctly, reassigns host, triggers game-over at ≤ 1 participant, deletes room at 0 participants + +#### Frontend + +30. **T065** `frontend/src/services/api.ts` — Add `api.leaveRoom(code, participantId)` — `DELETE /:code/leave` with body `{ participantId }` +31. **T066** `frontend/src/state/roomStore.ts` — Add `leaveRoom()` method: calls `api.leaveRoom()`, then clears session + stops polling; add `exitGame()` for game-over flow +32. **T067** `frontend/src/pages/GamePage.tsx` — Update `handleExit` to call `roomStore.leaveRoom()`; when `room.status === "finished"` and `lastRoundResult` exists, show `RoundResultCard` with `onExit` only (no `onContinue`, no `onRestart`) + +## Out of Scope + +- Auto-promote host on disconnect (must use explicit claim-host) +- Skip-round timer or auto-skip +- Persistent storage across server restart +- Spectator mode +- Chat or non-guess messaging diff --git a/specs/004-result-restart-validation/quickstart.md b/specs/004-result-restart-validation/quickstart.md new file mode 100644 index 00000000..8dba03bc --- /dev/null +++ b/specs/004-result-restart-validation/quickstart.md @@ -0,0 +1,124 @@ +# Quickstart: Result, Restart & Final Validation + +## Branch + +``` +004-result-restart-validation +``` + +## Verification + +### Backend + +```bash +cd backend + +# Run tests +npm test + +# Build +npm run build + +# Dev server +npm run dev +``` + +### Frontend + +```bash +cd frontend + +# Run tests +npm test + +# Build +npm run build + +# Dev server +npm run dev +``` + +## Manual Test Plan + +### US1 — Persistent Round Result + +1. Create room as Alice, join as Bob. Start game. +2. Submit correct guess as Bob. +3. Verify both Alice and Bob see result overlay with: correct word, drawer name, scores, guess history. +4. Wait 10s — verify overlay does NOT auto-dismiss. +5. Click "Continue" as Alice — verify overlay hides on Alice's screen, still visible on Bob's. +6. Click "Continue" as Bob — verify overlay hides on Bob's screen. + +### US2 — Restart Game + +1. Start game, play a round to result screen. +2. Verify host sees "Restart Game" button in result overlay. +3. Verify non-host does NOT see "Restart Game" button. +4. Host clicks "Restart Game" — verify all players see lobby with participants preserved and all game state cleared. + +### US3 — Exit Game Coexists + +1. On result overlay, verify "Exit Game" button is visible to all. +2. Click "Exit Game" — verify local session is cleared and user is redirected to home. + +### US4 — Host Disconnect & Claim Host + +1. Start game with 3+ players. Close host's browser tab. +2. Wait >30s, poll as non-host — verify `hostDisconnected: true` in snapshot. +3. Verify non-host sees "Host disconnected" popup with "Claim Host" button. +4. Click "Claim Host" — verify room's `hostId` updates and `hostDisconnected` becomes false. +5. Verify new host sees "Skip Round" and "Restart Game" buttons. + +### US5 — Skip Round & Drawer Disconnect + +1. Start game. Open 3 tabs (Alice=host/drawer, Bob=guesser, Charlie=guesser). +2. Close Alice's tab (drawer disconnected). +3. Wait >30s, poll as Bob — verify `drawerDisconnected: true`. +4. Verify Bob sees "Drawer disconnected" popup with "Notify Host" button. +5. Charlie claims host via HostDisconnectedBanner. +6. Bob clicks "Notify Host" — verify Charlie sees badge on "Skip Round" button. +7. Charlie clicks "Skip Round" — verify round advances, result overlay shows with correct word. +8. Verify skip is rejected (400) if someone already guessed correctly. + +### Name Validation + +1. Try to create room with name >20 chars — verify Zod rejects with clear error. +2. Verify frontend input has `maxLength={20}`. + +## Test Commands + +```bash +# Run all tests +cd backend && npm test && cd ../frontend && npm test + +# Run specific test file +cd backend && npx vitest run src/services/roomStore.test.ts + +# Run with coverage +cd backend && npx vitest run --coverage +cd frontend && npx vitest run --coverage + +# Type check only +cd backend && npx tsc --noEmit +cd frontend && npx tsc --noEmit +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `backend/src/models/game.ts` | Participant.lastSeenAt, Room.skipSuggested, RoomSnapshot hostDisconnected/drawerDisconnected/skipSuggested | +| `backend/src/services/roomStore.ts` | restartGame(), claimHost(), skipRound(), suggestSkip(), lastSeenAt updates in getRoom, disconnect computation in toRoomSnapshot | +| `backend/src/api/rooms.ts` | 4 new routes + lastSeenAt update in GET /:code | +| `backend/src/api/schemas.ts` | 4 new Zod schemas + max(20) name validation | +| `frontend/src/services/api.ts` | 4 new API methods + RoomSnapshot type updates | +| `frontend/src/state/roomStore.ts` | restartGame, claimHost, skipRound, suggestSkip, dismissLastRoundResult | +| `frontend/src/components/RoundResultCard.tsx` | Guess history table, Continue/Restart buttons, name fix | +| `frontend/src/components/HostDisconnectedBanner.tsx` | New: Host disconnected popup | +| `frontend/src/components/DrawerDisconnectedBanner.tsx` | New: Drawer disconnected popup | +| `frontend/src/pages/GamePage.tsx` | Wire all new features | +| `frontend/src/pages/LobbyPage.tsx` | HostDisconnectedBanner | + +## Dependencies + +No new dependencies required. diff --git a/specs/004-result-restart-validation/research.md b/specs/004-result-restart-validation/research.md new file mode 100644 index 00000000..8c1d98d8 --- /dev/null +++ b/specs/004-result-restart-validation/research.md @@ -0,0 +1,73 @@ +# Research: Result, Restart & Final Validation + +## Unknowns Resolved + +### 1. Disconnect Detection Mechanism + +- **Question**: How to detect disconnected participants without WebSockets? +- **Decision**: Add `lastSeenAt: string` (ISO timestamp) to the `Participant` model. Update on every `GET /:code` request from that participant. Compute disconnect status server-side in `toRoomSnapshot()` by comparing `lastSeenAt` against current time. +- **Rationale**: Only option compatible with polling-only constraint. No background timers or WebSocket heartbeats needed. Disconnect is "observed" during polls, not "detected" in real-time. +- **Alternatives considered**: Client-side heartbeat POST (unnecessary extra requests), WebSocket (forbidden by constitution). + +### 2. Disconnect Threshold + +- **Question**: What threshold value for considering a participant disconnected? +- **Decision**: 30 seconds (15 missed polls at 2s interval, accounting for network jitter). +- **Rationale**: Long enough to avoid false positives from slow polls (~2s), short enough that players aren't waiting too long. +- **Alternatives considered**: 10s (too aggressive — single slow poll triggers false positive), 60s (too long — players wait a full minute). + +### 3. `lastSeenAt` Initial Value on Join + +- **Question**: What value does `lastSeenAt` have for a newly joined participant? +- **Decision**: Set to the participant's `joinedAt` timestamp on creation. This means they appear "disconnected" 30s after joining if they never poll. +- **Rationale**: Simple. No special-casing needed. In practice, all clients start polling immediately after joining. +- **Alternatives considered**: Set to `now()` at join time (same effect, since joinedAt is also `now()`). Set to far future (would mask real disconnection). + +### 4. `skipSuggested` State Lifecycle + +- **Question**: When is `skipSuggested` cleared? +- **Decision**: Cleared on `advanceRound()` (normal round advance or skip-round) and on `restartGame()`. +- **Rationale**: Once a round is skipped or the game restarts, the suggestion is no longer relevant. +- **Alternatives considered**: Clear on timer (unnecessary complexity), never clear (stale state). + +### 5. Race Condition: claim-host Concurrent Requests + +- **Question**: What happens if two participants call claim-host simultaneously? +- **Decision**: Node.js single-threaded event loop serializes them. Last write wins — both succeed, but only the second one's hostId persists. +- **Rationale**: Simple, deterministic. No mutex needed. +- **Alternatives considered**: First-write-wins with conditional check (extra complexity for no real benefit in single-threaded in-memory system). + +### 6. Skip-round After Correct Guess + +- **Question**: How to handle skip-round call when round already ended? +- **Decision**: Check for existing correct guess in `guessHistory`. If present, return 400 "Round already ended". +- **Rationale**: Prevents double-advance. Simple gating check. +- **Alternatives considered**: Let advanceRound run again (double-advance bug), make advanceRound idempotent (complex, extra state). + +### 7. Name Resolution in Scores Table + +- **Question**: How to correctly display names for participants who never guessed? +- **Decision**: Pass `participants[]` array to `RoundResultCard`. Resolve names by matching `participantId` against participants list. +- **Rationale**: `guessHistory` only contains entries for guessers. Drawer and inactive players have no guess entries and would show raw UUID otherwise. +- **Alternatives considered**: Include participant name in scores map (data duplication), derive from snapshot participants (clean, single source of truth). + +### 8. Continue Button Re-display Prevention + +- **Question**: After clicking Continue, should the result overlay reappear on next poll? +- **Decision**: No. Client suppresses re-display by tracking a `resultDismissed` flag. The server still has `lastRoundResult` until restart, but the client ignores it. +- **Rationale**: Players should not have to dismiss the overlay on every poll. On restart, server clears `lastRoundResult` and the overlay disappears naturally. +- **Alternatives considered**: Server-side dismiss tracking (unnecessary server state), auto-dismiss after N polls (user might not have finished reading). + +## Technology Choices + +### Backend Pattern + +- **Disconnect detection**: Computed in `toRoomSnapshot()` using `lastSeenAt` comparison. Not a separate polling or timer process. +- **lastSeenAt update**: In the `GET /:code` route handler, before calling `toRoomSnapshot()`. The requesting participant's ID comes from the `?participantId=` query param. + +### Frontend Pattern + +- **DrawerDisconnectedBanner**: Same pattern as the existing `RoundResultCard` overlay — fixed position, full-screen backdrop. Accepts `onNotifyHost` callback. +- **HostDisconnectedBanner**: Same pattern. Accepts `onClaimHost` callback. +- **Local dismissal**: `roomStore` tracks `resultDismissed: boolean`. `fetchRoom()` sets `lastRoundResult` to null if `resultDismissed` is true (regardless of server state). +- **Skip Round badge**: CSS class toggle on the Skip Round button when `skipSuggested` is true. Simple pulse animation defined in `app.css`. diff --git a/specs/004-result-restart-validation/spec.md b/specs/004-result-restart-validation/spec.md new file mode 100644 index 00000000..13be07ef --- /dev/null +++ b/specs/004-result-restart-validation/spec.md @@ -0,0 +1,233 @@ +# Feature Specification: Result, Restart & Final Validation + +**Feature Branch**: `004-result-restart-validation` + +**Created**: 2026-06-26 + +**Status**: Draft + +**Input**: User description: "Given a round has ended, When the result state is displayed and the host restarts, Then all players see the correct word, final scores, and full guess history; on restart, everyone returns to the lobby with players preserved and all round state cleared." + +## Clarifications + +### Session 2026-06-26 + +- Q: How should the system handle a race condition where `skip-round` is called concurrently with a correct guess submission? → A: Reject skip-round with error "Round already ended" if a correct guess exists in guessHistory for the current round. +- Q: Should the "Skip Round" button be visible to the host only when the drawer is disconnected, or always? → A: Always visible to the host. +- Q: How should the scores table resolve player names for participants who never guessed? → A: Names MUST be resolved from the `participants[]` array, not from `guessHistory`. Participants who never guessed (drawer, inactive players) must display their proper name, not their participant ID. +- Q: What UI should non-host players see when the drawer is disconnected? → A: A full popup overlay like the host-disconnected popup, with a "Notify Host" button that alerts the host to skip the round. + +### Session 2026-06-26 (post-implementation) + +- Q: Should the "Notify Host" popup dismiss after clicking the button? → A: Yes, the popup hides locally on the client immediately after clicking "Notify Host". It may reappear on the next poll if `drawerDisconnected` is still true. +- Q: What should happen when all other players leave the game? → A: The game ends. Set `room.status = "finished"`, create a `lastRoundResult` with current round data (same structure as normal result), and show the result overlay with only the "Exit Game" button. No Continue, no Restart. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Persistent Round Result Display (Priority: P1) + +When a round ends (someone guesses the secret word correctly, or the host skips the round), all players see an overlay showing the correct word, the drawer's name, final scores for all participants, and a full guess history (who guessed what, whether it was correct, and when). The overlay persists until the player dismisses it locally via a "Continue" button. + +**Why this priority**: Without the result display, players cannot learn the correct word or see how they performed. This is the core feedback mechanism that closes the gameplay loop. + +**Independent Test**: Start a game with 2+ players. Have a guesser submit a correct guess. Verify all players see an overlay showing the correct word, drawer name, all participant scores, and the full guess history. Verify the overlay does not auto-dismiss after 6 seconds. Verify each player can dismiss their own overlay by clicking "Continue", revealing the next round. + +**Acceptance Scenarios**: + +1. **Given** a round has ended (correct guess or skip), **When** any player polls the room state, **Then** `lastRoundResult` is present in the snapshot with the correct word, round number, drawer name, full guess history, and all participant scores. +2. **Given** a round has ended, **When** a player views the result overlay, **Then** they see: the correct secret word, the drawer's name, a table of all participants with their scores (round and cumulative), and a history of every guess submitted during the round (guesser name, guess text, correct/incorrect status, and timestamp). +3. **Given** a player is viewing the result overlay, **When** they click "Continue", **Then** the overlay is dismissed locally and they see the next round's game UI (active canvas, new drawer, guess input). +4. **Given** a round's result overlay is displayed, **When** 10 seconds pass without any player action, **Then** the overlay remains visible (no auto-dismiss). +5. **Given** a round has ended, **When** a player joins the room mid-result (after a page refresh), **Then** they also see the result overlay since `lastRoundResult` is still in the room snapshot. + +--- + +### User Story 2 — Restart Game to Lobby (Priority: P2) + +The host can restart the entire game from the result screen, sending all players back to the lobby with the participant list preserved, all game state cleared, and the room code unchanged. + +**Why this priority**: Restart is the mechanism to play multiple game sessions without requiring players to re-join. It provides a clean end-of-game flow controlled by the host. + +**Independent Test**: Start a game with 2+ players, play one round until correct guess. Verify the host sees a "Restart Game" button on the result overlay (non-host players do not see this button). Click "Restart Game". Verify all players (on their next poll) see the room is back in "lobby" status, all participants are still present, and all game state (scores, strokes, guess history, secret word, current round) is cleared. + +**Acceptance Scenarios**: + +1. **Given** the result overlay is displayed, **When** the host clicks "Restart Game", **Then** the room status changes to "lobby", and all game state is cleared (`currentRound`, `drawerId`, `secretWord`, `strokes`, `guessHistory`, `scores`, `lastRoundResult`). +2. **Given** the host has clicked "Restart Game", **When** any player polls the room, **Then** they receive a room in "lobby" status with all original participants preserved. +3. **Given** a non-host player views the result overlay, **When** they look for a restart button, **Then** no restart button is visible (only the "Continue" button). + +--- + +### User Story 3 — Exit Game Coexists (Priority: P3) + +The existing "Exit Game" button remains available on the result screen alongside the new "Restart Game" button. "Exit Game" clears the exiting player's local session; "Restart Game" resets the entire room to lobby for all players. + +**Why this priority**: Provides flexibility — a player can leave independently or the host can reset for everyone. + +**Independent Test**: Start a game, view the result overlay, verify both "Exit Game" and (for host) "Restart Game" buttons are visible. Click "Exit Game" and verify the session is cleared and the user is redirected to the home page. + +**Acceptance Scenarios**: + +1. **Given** a player views the result overlay, **When** they check the bottom button row, **Then** they see the "Exit Game" button. +2. **Given** a host views the result overlay, **When** they check the bottom button row, **Then** they see both "Exit Game" and "Restart Game" buttons. + +--- + +### User Story 4 — Host Disconnect & Claim Host (Priority: P1) + +If the host disconnects during a game, other players are blocked from restarting or skipping rounds since those actions are host-only. The system detects host disconnection via polling (participants track `lastSeenAt`) and shows a "Host disconnected" popup. Any player can claim host by clicking a "Claim Host" button, which immediately promotes them to host. + +**Why this priority**: Without this, a disconnected host permanently blocks restart and skip-round, making the game unplayable. This is a critical failure mode. + +**Independent Test**: Start a game with 2+ players. Close the host's browser tab. Verify non-host players see a "Host disconnected" popup within 30 seconds. Click "Claim Host" as a non-host player. Verify they become the new host (restart and skip buttons become visible). Verify the original host cannot override if they return. + +**Acceptance Scenarios**: + +1. **Given** the host has not polled the room for more than 30 seconds, **When** any other player polls the room, **Then** the snapshot returns `hostDisconnected: true`. +2. **Given** `hostDisconnected` is `true`, **When** a non-host player views the game page, **Then** they see a "Host disconnected" popup with a "Claim Host" button. +3. **Given** a non-host player clicks "Claim Host", **When** the request is processed, **Then** the room's `hostId` is updated to that player's ID. +4. **Given** a player has claimed host, **When** any player polls the room, **Then** `hostDisconnected` is `false` and the new host has the restart/skip buttons. +5. **Given** a player who is already the host clicks "Claim Host", **When** the request is processed, **Then** it succeeds (idempotent — no-op for existing host). + +--- + +### User Story 5 — Skip Round When Drawer Disconnected (Priority: P2) + +If the drawer disconnects mid-round, the game is soft-locked — no new drawing can happen and guessers may be unable to guess the word from existing strokes. The host (or claimed host) can skip the round, which advances to the next round, awards 0 points for the skipped round, and shows a round result overlay (with the correct word revealed) so the game can continue. + +**Why this priority**: A disconnected drawer blocks gameplay for everyone. Skip round provides an escape hatch so the game isn't permanently stuck. + +**Independent Test**: Start a game with 2+ players, ensure the drawer disconnects. Verify non-host players see a "Drawer disconnected" popup with a "Notify Host" button. Click "Notify Host" as a non-host; verify the host sees a skip-requested badge on their "Skip Round" button. As host, click "Skip Round". Verify the round advances, all players see the result overlay with the secret word and scores (0 for the skipped round), and a new drawer is assigned. + +**Acceptance Scenarios**: + +1. **Given** the drawer has not polled the room for more than 30 seconds, **When** any other player polls the room, **Then** the snapshot returns `drawerDisconnected: true` (computed from drawer's `lastSeenAt`). +2. **Given** `drawerDisconnected` is `true`, **When** a non-host player views the game page, **Then** they see a "Drawer disconnected" popup with a "Notify Host" button. +3. **Given** the room is in "playing" status, **When** the host views the game page, **Then** they see a "Skip Round" button at all times (non-host players do not see this button). +4. **Given** the host clicks "Skip Round", **When** the request is processed, **Then** `advanceRound()` is called: the round increments, a new drawer is picked, strokes and guess history are cleared, `lastRoundResult` is set with the correct word and current scores, and 0 points are awarded for the skipped round. +5. **Given** the host has skipped the round, **When** any player polls, **Then** the result overlay displays the secret word, the skipped round number, the drawer's name, and the scores (unchanged from before the skip). +6. **Given** the host clicks "Skip Round" when the drawer is NOT disconnected, **Then** the endpoint still succeeds (skip is always available to the host, regardless of drawer state). +7. **Given** the round already has a correct guess in the guess history, **When** the host clicks "Skip Round", **Then** the request is rejected with a "Round already ended" error. + +--- + +### User Story 6 — Game Over When All Players Leave (Priority: P2) + +When a player exits the game (clicks "Exit Game" while the game is in "playing" status), they are removed from the room. If the number of remaining participants drops to 1 (or fewer), the game cannot continue. The room enters a `"finished"` status, a final `lastRoundResult` is created with the current round's data and remaining scores, and the remaining player sees a persistent result screen with only the "Exit Game" button. The player must exit the room; there is no Continue or Restart from this state. + +**Why this priority**: Without this, a player left alone in a game has no clear indication that the game is over. They would wait indefinitely for other players. This provides a clean termination path. + +**Independent Test**: Start a 2-player game. As player B, click "Exit Game" on the bottom button row. Verify player A's next poll shows status `"finished"` and `lastRoundResult` with the current round's data. Verify player A sees the RoundResultCard with only the "Exit Game" button (no Continue, no Restart). Click "Exit Game" as player A — verify the room is cleaned up and player A is redirected to home. + +**Acceptance Scenarios**: + +1. **Given** a room is in "playing" status with 2 participants, **When** one participant calls `DELETE /:code/leave`, **Then** the remaining participant count is 1, the room status changes to `"finished"`, and `lastRoundResult` is set with the current round's data (secret word, drawer, guess history, scores — containing only remaining participants). +2. **Given** a room is in "finished" status with `lastRoundResult`, **When** the remaining player polls the room, **Then** they receive a RoomSnapshot with `status: "finished"`, `lastRoundResult`, and the RoundResultCard overlay is shown with only the "Exit Game" button. +3. **Given** a room is in "finished" status and the remaining participant calls `DELETE /:code/leave`, **When** the request is processed, **Then** the room is deleted from the store (0 participants remaining). +4. **Given** a room is in "lobby" status, **When** a participant calls `DELETE /:code/leave`, **Then** the participant is simply removed; no game-over state is created (room stays in "lobby" status). + +## Edge Cases + +- What happens if the host disconnects mid-game? — After 30 seconds of no polling, `hostDisconnected` becomes `true`. A popup appears on all other players' screens with a "Claim Host" button. +- What happens if a player clicks "Continue" after the host has already restarted? — The room is already in lobby; the player's local dismiss of the result overlay is harmless — they see the lobby on the next poll. +- What happens if the host clicks "Restart Game" while other players are still viewing the result? — The room state changes to lobby server-side. Other players see the lobby on their next poll cycle. +- What happens if restart is called when the room is already in lobby? — The endpoint returns an error (game is not in progress), preventing no-op restarts. +- What happens if a non-host attempts to call the restart/skip API directly? — The endpoint rejects with a 403 error. +- What happens if all players dismiss their result overlays before the host restarts? — The game continues to the next round. The host waits for the next round's result screen to restart, or uses Skip Round to trigger a result screen earlier. +- What happens if the host refreshes the page during the result screen? — The room is still in "playing" state with `lastRoundResult` set; the host sees the result overlay again after session restore. +- What happens if the drawer is also the host and disconnects? — The `hostDisconnected` flag triggers first. A player claims host, then that new host can skip the round. Both problems are resolved in sequence. +- What happens if a different player claims host, but the original host returns? — The original host is no longer host. They become a regular participant. No conflict resolution needed — last claim wins. +- What happens if `claim-host` is called and the caller is already host? — Idempotent success (no-op). +- What happens if skip round is called and there are no more words in `STARTER_WORDS`? — The `currentRound` counter wraps around via modulo: `STARTER_WORDS[(round - 1) % STARTER_WORDS.length]`. It cycles indefinitely. +- What happens if the host calls skip-round after someone already guessed correctly? — The endpoint rejects with "Round already ended" since the round ended naturally. +- What happens if a player submits a guess with a name longer than 20 characters? — The backend validation rejects it with a clear error message. +- What happens if a player never submitted any guess during the round? — Their name is still displayed correctly in the scores table, resolved from the `participants[]` list (not from guessHistory, which only contains entries for guessers). +- What happens if a non-host player clicks "Notify Host" but the host has already skipped? — The request succeeds but has no visible effect — the room is already advancing to the next round. +- What happens if the last remaining player exits a finished game? — The `DELETE /:code/leave` route removes them, the participant count drops to 0, and the room is deleted from the store. No more state exists for that room. +- What happens if a player clicks "Exit Game" in a 3-player game? — They are removed; the remaining 2 players continue the game normally (no game over). +- What happens if a player who is also the drawer exits mid-round? — They are removed from participants. If the game continues (≥2 remaining), a new drawer is assigned by `advanceRound()` on the next round. If only 1 remains, game-over triggers. +- What happens if the host exits the game mid-round? — The host is removed; the next participant in the list is assigned as the new host. If the game continues, the new host has restart/skip-round privileges. +- What happens if the "Notify Host" banner reappears after being dismissed? — The client's dismiss is local; on the next poll, if `drawerDisconnected` is still true, the banner reappears. This is expected behavior — the drawer hasn't reconnected. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-034**: System MUST persist `lastRoundResult` in the room snapshot indefinitely (no auto-expiry) until explicitly cleared by a restart. +- **FR-035**: System MUST display the result overlay to all players when `lastRoundResult` is present in the room snapshot. +- **FR-036**: The result overlay MUST show: correct secret word, drawer name, table of all participants with their scores (round and cumulative), and a complete guess history (guesser name, guess text, correct/incorrect status, timestamp). All participant names in the scores table MUST be resolved from the room's `participants[]` list, not from `guessHistory`. +- **FR-037**: The result overlay MUST include a "Continue" button visible to all players. +- **FR-038**: Clicking "Continue" MUST dismiss the result overlay locally on the player's client (does not affect server state or other players). +- **FR-039**: System MUST provide a `POST /:code/restart` endpoint that resets the room to lobby status, clearing all game state (`currentRound`, `drawerId`, `secretWord`, `strokes`, `guessHistory`, `scores`, `lastRoundResult`) while preserving participants and hostId. +- **FR-040**: The restart endpoint MUST reject requests from non-host participants with a 403 error. +- **FR-041**: The restart endpoint MUST reject requests if the room is not in "playing" status (no-op prevention) with a 400 error. +- **FR-042**: The result overlay MUST show a "Restart Game" button only for the host participant. +- **FR-043**: The "Exit Game" button MUST remain visible on the game page (including during the result overlay) and function as before (clear local session, redirect to home). +- **FR-044**: The `Participant` model MUST include a `lastSeenAt` timestamp field that is updated on every `GET /:code` request from that participant (polling updates it). +- **FR-045**: The `RoomSnapshot` MUST include a computed `hostDisconnected` boolean field that is `true` when the host's `lastSeenAt` is older than 30 seconds from the current server time. +- **FR-046**: The `RoomSnapshot` MUST include a computed `drawerDisconnected` boolean field that is `true` when the room is in "playing" status and the drawer's `lastSeenAt` is older than 30 seconds from the current server time. +- **FR-047**: System MUST provide a `POST /:code/claim-host` endpoint that sets the caller's participant ID as the new `hostId`. +- **FR-048**: The claim-host endpoint MUST accept requests from any participant, including the existing host (idempotent). It MUST reject requests from non-existent participants with a 404 error. +- **FR-049**: System MUST show a "Host disconnected" popup with a "Claim Host" button on the game page and lobby page when `hostDisconnected` is `true` and the viewer is not the host. +- **FR-050**: System MUST provide a `POST /:code/skip-round` endpoint that calls `advanceRound()` to advance to the next round, setting `lastRoundResult` with the current scores and secret word. +- **FR-050a**: The skip-round endpoint MUST reject the request with a 400 error if a correct guess already exists in the current round's guess history (round already ended naturally). +- **FR-051**: The skip-round endpoint MUST reject requests from non-host participants with a 403 error. +- **FR-052**: System MUST show a "Skip Round" button on the game page whenever the viewer is the host (regardless of drawer connection state). +- **FR-053**: The participant name validation in `schemas.ts` MUST reject names longer than 20 characters with a clear error message. +- **FR-054**: The frontend name input fields MUST have `maxLength={20}` to prevent typing beyond the limit. +- **FR-055**: System MUST show a "Drawer disconnected" popup with a "Notify Host" button when `drawerDisconnected` is `true` and the viewer is not the host. After the viewer clicks "Notify Host", the popup MUST dismiss locally on the client (it may reappear on the next poll if `drawerDisconnected` is still true). +- **FR-056**: System MUST provide a `POST /:code/suggest-skip` endpoint that sets a `skipSuggested` boolean flag on the room to `true`. +- **FR-057**: The `RoomSnapshot` MUST include a `skipSuggested` boolean field that is `true` when any non-host player has requested a skip. +- **FR-058**: When `skipSuggested` is `true` and the viewer is the host, the "Skip Round" button MUST show a visual indication (e.g., badge/pulse) that a skip has been requested. +- **FR-059**: System MUST clear the `skipSuggested` flag on the room when `advanceRound()` is called (normal or skip) or when `restartGame()` is called. +- **FR-060**: System MUST provide a `DELETE /:code/leave` endpoint that removes the requesting participant from the room. +- **FR-061**: When `DELETE /:code/leave` drops the participant count to ≤ 1 during "playing" status, the system MUST set `room.status = "finished"` and create a `lastRoundResult` with the current round's data (secret word, drawer, guess history, remaining scores). The surviving participant sees the result overlay with only the "Exit Game" button. +- **FR-062**: When `DELETE /:code/leave` drops the participant count to 0, the system MUST delete the room from the store. +- **FR-063**: The `RoomSnapshot` MUST include `lastRoundResult`, `scores`, and `guessHistory` when status is `"finished"` (same as during "playing"), so the remaining client can render the game-over result screen. + +### Key Entities *(include if feature involves data)* + +- **RoomStatus** (updated): `"lobby" | "playing" | "finished"`. The `"finished"` status is set when participants drop to ≤ 1 during playing. +- **RoundResult** (existing): Contains `roundNumber`, `secretWord`, `drawerId`, `drawerName`, `guessHistory`, `scores`. Persisted indefinitely until restart instead of auto-expiring after 6 seconds. Also set when game ends (all other players left) with remaining participants' scores. +- **Participant** (updated): Existing model adds `lastSeenAt: string` (ISO timestamp). Updated on every `GET /:code` poll from that participant. +- **RoomSnapshot** (updated): Adds computed fields: + - `hostDisconnected: boolean` — `true` if host's `lastSeenAt` > 30s ago + - `drawerDisconnected: boolean` — `true` if room is playing and drawer's `lastSeenAt` > 30s ago + - `skipSuggested: boolean` — `true` if a non-host player has requested a skip via `POST /:code/suggest-skip` + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-016**: The result overlay persists beyond 6 seconds without auto-dismissing (tested with 10-second wait). +- **SC-017**: All players see identical result data (correct word, scores, guess history) within one polling cycle of the round ending. +- **SC-018**: The host can restart the game to lobby with a single click, and all players see the lobby within one polling cycle. +- **SC-019**: Non-host players cannot trigger a restart (no button visible, 403 on direct API call). +- **SC-020**: Each player can independently dismiss their result overlay without affecting other players. +- **SC-021**: When the host stops polling for >30 seconds, all other players see `hostDisconnected: true` within one polling cycle. +- **SC-022**: A non-host player can claim host with one click, and within one polling cycle all players see the new hostId. +- **SC-023**: When the drawer stops polling for >30 seconds and the viewer is host, the "Skip Round" button shows a requested-skip badge within one polling cycle. +- **SC-024**: Skipping a round advances the game, shows the correct word in the result overlay, and assigns a new drawer. +- **SC-025**: A participant name longer than 20 characters is rejected with a clear error message at both the API and UI level. +- **SC-026**: When the drawer stops polling for >30 seconds, all non-host players see a "Drawer disconnected" popup within one polling cycle, and clicking "Notify Host" triggers a visual badge on the host's "Skip Round" button within one polling cycle. +- **SC-027**: After a non-host clicks "Notify Host", the drawer-disconnected popup dismisses immediately on their client, even though `drawerDisconnected` may still be `true` on the server. +- **SC-028**: When one of two players exits during a game, the remaining player sees a "finished" status and game-over result overlay within one polling cycle, with only the "Exit Game" button visible. + +## Assumptions + +- The result overlay is a client-side overlay rendered when `lastRoundResult` is present in the room snapshot. The server does not track which players have dismissed their result. +- The "Continue" button is a local-only action; it clears the `lastRoundResult` state on the client. The next poll will not re-show the overlay unless `lastRoundResult` is still on the server (which it will be until restart). +- To prevent the overlay from re-appearing after Continue, the client suppresses re-display when the result is locally dismissed, even if the server still has `lastRoundResult`. +- Restart is a host-only action. The host identity is determined by `hostId` in the room snapshot. +- After restart, the host can start a new game immediately (same lobby flow as Scenario 1). +- The "Exit Game" button continues to clear `sessionStorage` and redirect to the home page. +- Host disconnection is detected via polling inactivity (30-second threshold). There is no WebSocket or real-time detection. +- The 30-second threshold is a fixed constant, not configurable. +- `lastSeenAt` is updated on every `GET /:code` call from a participant. If a participant never polls, they appear disconnected after 30 seconds. +- The drawer disconnect check only applies when the room is in "playing" status. +- Claiming host is one-way: the original host cannot reclaim host status after someone else has claimed it. +- Skip round is always available to the host regardless of whether the drawer is actually disconnected (host may also use it to fast-forward through a round they don't like). +- The participant name max length of 20 characters is a reasonable default for display names in a casual game context. +- The game-over result overlay (`status === "finished"`) is not dismissable — the player must click "Exit Game" to leave the room. +- Clicking "Notify Host" locally patches `drawerDisconnected` to `false` on the client. The server does not track this dismissal — the next poll may re-enable the popup. +- When a host exits the game, the next participant in the `participants[]` array is assigned as the new host. There is no host-election protocol; simple array-ordering suffices for a lab project. diff --git a/specs/004-result-restart-validation/tasks.md b/specs/004-result-restart-validation/tasks.md new file mode 100644 index 00000000..1868ca1e --- /dev/null +++ b/specs/004-result-restart-validation/tasks.md @@ -0,0 +1,325 @@ +--- + +description: "Task list for Result, Restart & Final Validation feature" + +--- + +# Tasks: Result, Restart & Final Validation + +**Input**: Design documents from `/specs/004-result-restart-validation/` + +**Prerequisites**: plan.md (required), spec.md (required), data-model.md, research.md, contracts/ + +**Tests**: Test tasks are included as requested by the feature specification's acceptance scenarios and 80% coverage requirement. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US4) +- Include exact file paths in descriptions + +## Path Conventions + +- **Web app**: `backend/src/`, `frontend/src/` + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +No setup tasks required — the project is already initialized. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Data model extensions that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T001 [P] Add `lastSeenAt: string` field to `Participant` interface in `backend/src/models/game.ts` +- [X] T002 [P] Add `skipSuggested: boolean` field to `Room` interface in `backend/src/models/game.ts` +- [X] T003 [P] Add `hostDisconnected?: boolean`, `drawerDisconnected?: boolean`, `skipSuggested?: boolean` fields to `RoomSnapshot` interface in `backend/src/models/game.ts` +- [X] T004 [P] Add `lastSeenAt: string` field to `Participant` interface in `frontend/src/services/api.ts` +- [X] T005 [P] Add `hostDisconnected?: boolean`, `drawerDisconnected?: boolean`, `skipSuggested?: boolean` fields to `RoomSnapshot` interface in `frontend/src/services/api.ts` +- [X] T006 [P] Set `lastSeenAt = now()` in `createParticipant()` in `backend/src/services/roomStore.ts` + +**Checkpoint**: Foundation ready — user story implementation can now begin + +--- + +## Phase 3: User Story 1 — Persistent Round Result Display (Priority: P1) 🎯 MVP + +**Goal**: When a round ends, all players see a persistent overlay showing the correct word, drawer name, full guess history, and all participant scores. The overlay persists until individually dismissed via "Continue". Fix the name resolution bug (FR-036). + +**Independent Test**: Start a game with 2+ players. Have a guesser submit a correct guess. Verify all players see an overlay with the correct word, drawer name, all participant scores, and full guess history. Verify the overlay does NOT auto-dismiss after 10 seconds. Verify each player can dismiss their own overlay by clicking "Continue", revealing the next round. + +### Tests for User Story 1 ⚠️ + +- [X] T007 [P] [US1] Backend test: verify `lastRoundResult` persists indefinitely (no auto-clear after 10s) in `backend/src/services/roomStore.test.ts` +- [X] T008 [P] [US1] Frontend test: verify `RoundResultCard` displays guess history table, resolves names from participants, shows Continue button in `frontend/src/components/RoundResultCard.test.tsx` + +### Implementation for User Story 1 + +#### Backend + +- [X] T009 [US1] Remove `clearExpiredRoundResult()` function and its call in `getRoom()` from `backend/src/services/roomStore.ts` — `lastRoundResult` now persists until restart + +#### Frontend + +- [X] T010 [P] [US1] Update `RoundResultCard` component in `frontend/src/components/RoundResultCard.tsx` — add `participants: Participant[]`, `onContinue: () => void`, `onRestart?: () => void` props; fix name lookup to resolve from `participants[]` not `guessHistory`; add full guess history table (guesser name, guess text, correct/incorrect badge, timestamp); add Continue button (all players); add Restart Game button (host only, shown when onRestart is provided) +- [X] T011 [US1] Update `roomStore` in `frontend/src/state/roomStore.ts` — add `dismissLastRoundResult()` method that sets `resultDismissed: true`; update `fetchRoom()` to suppress `lastRoundResult` when `resultDismissed` is true; reset `resultDismissed` when `lastRoundResult` changes to a different round number (re-display new result) +- [X] T012 [US1] Update `GamePage` in `frontend/src/pages/GamePage.tsx` — pass `participants`, `onContinue` (calls `roomStore.dismissLastRoundResult()`), `onRestart` (calls `roomStore.restartGame()`) to `RoundResultCard`; ensure overlay displays persistently (no auto-hide timer) + +**Checkpoint**: US1 complete — result overlay persists, shows all data with correct names, Continue dismisses locally. + +--- + +## Phase 4: User Story 4 — Host Disconnect & Claim Host (Priority: P1) 🎯 MVP + +**Goal**: Detect host disconnection via polling inactivity (30s threshold), show popup, allow any player to claim host. + +**Independent Test**: Start a game with 2+ players. Close host's browser tab. Wait 30s+. Verify non-host players see "Host disconnected" popup with "Claim Host" button. Click it — verify room hostId updates and new host sees restart/skip buttons. + +### Tests for User Story 4 ⚠️ + +- [X] T013 [P] [US4] Backend test: verify `toRoomSnapshot()` computes `hostDisconnected` correctly (within threshold, beyond threshold, no host) in `backend/src/services/roomStore.test.ts` +- [X] T014 [P] [US4] Backend test: verify `claimHost()` succeeds, is idempotent, rejects non-existent participant in `backend/src/services/roomStore.test.ts` +- [X] T015 [P] [US4] Frontend test: verify `HostDisconnectedBanner` renders with Claim Host button in `frontend/src/components/HostDisconnectedBanner.test.tsx` + +### Implementation for User Story 4 + +#### Backend + +- [X] T016 [US4] Update `GET /:code` route handler in `backend/src/api/rooms.ts` — before calling `toRoomSnapshot()`, find the requesting participant by `participantId` query param and update their `lastSeenAt` to `now()` in the room store +- [X] T017 [US4] Update `toRoomSnapshot()` in `backend/src/services/roomStore.ts` — compute `hostDisconnected: boolean` (host's lastSeenAt > 30s ago) and `drawerDisconnected: boolean` (playing + drawer's lastSeenAt > 30s ago) using `Date.now() - new Date(participant.lastSeenAt).getTime() > 30000` +- [X] T018 [US4] Implement `claimHost()` in `backend/src/services/roomStore.ts` — set `room.hostId = participantId`, return snapshot; return null if participant not found in room +- [X] T019 [US4] Add `claimHostSchema` to `backend/src/api/schemas.ts` — `z.object({ participantId: z.string() })` +- [X] T020 [US4] Add POST `/rooms/:code/claim-host` route in `backend/src/api/rooms.ts` — validate participant exists in room (404 if not), call `claimHost()`, return room snapshot; accept from any participant including current host (idempotent) + +#### Frontend + +- [X] T021 [P] [US4] Build `HostDisconnectedBanner` component in `frontend/src/components/HostDisconnectedBanner.tsx` — modal overlay with "Host disconnected" message + "Claim Host" button; accepts `onClaimHost: () => void` prop +- [X] T022 [P] [US4] Add `api.claimHost()` method to `frontend/src/services/api.ts` +- [X] T023 [P] [US4] Add `claimHost()` method to `frontend/src/state/roomStore.ts` — call API, update room state with new snapshot +- [X] T024 [US4] Update `GamePage` in `frontend/src/pages/GamePage.tsx` — show `HostDisconnectedBanner` when `room.hostDisconnected` is true and viewer is not host; wire `onClaimHost` to `roomStore.claimHost()` +- [X] T025 [US4] Update `LobbyPage` in `frontend/src/pages/LobbyPage.tsx` — show `HostDisconnectedBanner` when `room.hostDisconnected` is true and viewer is not host + +**Checkpoint**: US4 complete — host disconnect detected, claim host works, all clients see updated host. + +--- + +## Phase 5: User Story 2 — Restart Game to Lobby (Priority: P2) + +**Goal**: Host can restart the game from the result overlay, sending all players back to lobby with participants preserved and all game state cleared. + +**Independent Test**: Start a game, play one round, verify host sees "Restart Game" button on result overlay (non-host does not). Click it. Verify all players see lobby with participants preserved and all game state cleared. + +### Tests for User Story 2 ⚠️ + +- [X] T026 [P] [US2] Backend test: verify `restartGame()` clears game state, preserves participants, rejects non-host, rejects lobby status in `backend/src/services/roomStore.test.ts` +- [X] T027 [P] [US2] Frontend test: verify Restart Game button visible only for host on RoundResultCard in `frontend/src/components/RoundResultCard.test.tsx` + +### Implementation for User Story 2 + +#### Backend + +- [X] T028 [US2] Implement `restartGame()` in `backend/src/services/roomStore.ts` — reset `status` to "lobby", clear `currentRound`, `drawerId`, `secretWord`, `strokes`, `guessHistory`, `scores`, `lastRoundResult`, `lastRoundResultSetAt`; set `skipSuggested = false`; preserve participants and hostId; update `updatedAt`; return room snapshot; return null if room not found or not in playing status +- [X] T029 [US2] Add `restartGameSchema` to `backend/src/api/schemas.ts` — `z.object({ playerId: z.string() })` +- [X] T030 [US2] Add POST `/rooms/:code/restart` route in `backend/src/api/rooms.ts` — validate host identity (403 if not), validate playing status (400 if not), call `restartGame()`, return room snapshot + +#### Frontend + +- [X] T031 [P] [US2] Add `api.restartGame()` method to `frontend/src/services/api.ts` +- [X] T032 [P] [US2] Add `restartGame()` method to `frontend/src/state/roomStore.ts` — call API, update room state; on lobby status change, allow re-navigation to lobby page +- [X] T033 [US2] Update `GamePage` in `frontend/src/pages/GamePage.tsx` — when room status changes to "lobby" after restart, navigate to `/lobby` to show lobby view + +**Checkpoint**: US2 complete — host can restart to lobby, all players see lobby within one polling cycle. + +--- + +## Phase 6: User Story 5 — Skip Round When Drawer Disconnected (Priority: P2) + +**Goal**: Detect drawer disconnection, show popup, allow non-hosts to suggest skip (sets badge on host's Skip Round button), host can skip round. Skip is always available to host, but rejected if correct guess already exists. + +**Independent Test**: Close drawer's tab. Wait 30s+. Verify non-hosts see "Drawer disconnected" popup with "Notify Host" button. Click it — verify host sees badge on "Skip Round" button. Host clicks "Skip Round" — round advances, result overlay shows with correct word. + +### Tests for User Story 5 ⚠️ + +- [X] T034 [P] [US5] Backend test: verify `skipRound()` succeeds, rejects non-host (403), rejects when correct guess exists (400), clears `skipSuggested` in `backend/src/services/roomStore.test.ts` +- [X] T035 [P] [US5] Backend test: verify `suggestSkip()` sets flag, clears on `advanceRound()` and `restartGame()`, rejects non-existent participant in `backend/src/services/roomStore.test.ts` +- [X] T036 [P] [US5] Backend test: verify `toRoomSnapshot()` computes `drawerDisconnected` correctly (playing status, within/beyond threshold) in `backend/src/services/roomStore.test.ts` +- [X] T037 [P] [US5] Frontend test: verify `DrawerDisconnectedBanner` renders with Notify Host button in `frontend/src/components/DrawerDisconnectedBanner.test.tsx` +- [X] T038 [P] [US5] Frontend test: verify Skip Round button visible for host, hidden for non-host, shows badge when `skipSuggested` is true in `frontend/src/pages/GamePage.test.tsx` + +### Implementation for User Story 5 + +#### Backend + +- [X] T039 [US5] Implement `skipRound()` in `backend/src/services/roomStore.ts` — check `room.guessHistory` for existing correct guess (return error "Round already ended" if found); validate host identity; call `advanceRound(code)`; clear `skipSuggested`; return room snapshot; return null if room not found or not playing +- [X] T040 [US5] Implement `suggestSkip()` in `backend/src/services/roomStore.ts` — validate room is playing and participant exists; set `room.skipSuggested = true`; save room; clear `skipSuggested` inside `advanceRound()` and `restartGame()` +- [X] T041 [US5] Add `skipRoundSchema` and `suggestSkipSchema` to `backend/src/api/schemas.ts` +- [X] T042 [US5] Add POST `/rooms/:code/skip-round` route in `backend/src/api/rooms.ts` — validate host (403), validate playing (400), check correct guess (400), call `skipRound()`, return room snapshot +- [X] T043 [US5] Add POST `/rooms/:code/suggest-skip` route in `backend/src/api/rooms.ts` — validate participant exists in room (404), validate playing (400), call `suggestSkip()`, return room snapshot + +#### Frontend + +- [X] T044 [P] [US5] Build `DrawerDisconnectedBanner` component in `frontend/src/components/DrawerDisconnectedBanner.tsx` — modal overlay with "Drawer disconnected" message + "Notify Host" button; accepts `onNotifyHost: () => void` prop +- [X] T045 [P] [US5] Add `api.skipRound()` and `api.suggestSkip()` methods to `frontend/src/services/api.ts` +- [X] T046 [P] [US5] Add `skipRound()` and `suggestSkip()` methods to `frontend/src/state/roomStore.ts` — call API, update room state +- [X] T047 [US5] Update `GamePage` in `frontend/src/pages/GamePage.tsx` — show "Skip Round" button in button row when viewer is host (always visible); add visual badge/pulse CSS class on Skip Round button when `room.skipSuggested` is true and viewer is host; show `DrawerDisconnectedBanner` when `room.drawerDisconnected` is true and viewer is not host; wire `onNotifyHost` to `roomStore.suggestSkip()`; wire Skip Round button to `roomStore.skipRound()` + +**Checkpoint**: US5 complete — drawer disconnect detected, suggest skip works, host can skip round. + +--- + +## Phase 7: User Story 3 — Exit Game Coexists (Priority: P3) + +**Goal**: The "Exit Game" button remains visible and functional on the result overlay alongside the new "Restart Game" button. + +**Independent Test**: On result overlay, verify both "Exit Game" and (for host) "Restart Game" buttons are visible. Click "Exit Game" — verify session cleared and user redirected to home. + +### Implementation for User Story 3 + +- [X] T048 [US3] Update `GamePage` in `frontend/src/pages/GamePage.tsx` — add "Exit Game" button to the bottom button row inside the result overlay, ensuring it remains visible alongside Continue/Restart buttons with no z-index conflicts + +**Checkpoint**: US3 complete — Exit Game co-exists with Restart Game. + +--- + +## Phase 8: Name Validation (Priority: P2) + +**Goal**: Enforce max 20-character participant names at both backend and frontend levels. + +**Independent Test**: Try to create room with name >20 chars — verify Zod rejects with clear error. Verify frontend input prevents typing beyond 20 chars. + +### Tests for Name Validation ⚠️ + +- [X] T049 [P] Backend test: verify name >20 chars is rejected by Zod in `backend/src/api/schemas.test.ts` + +### Implementation for Name Validation + +- [X] T050 [P] Update `trimmedNonEmptyString` schema in `backend/src/api/schemas.ts` — add `.max(20)` to enforce max name length +- [X] T051 [P] Update name input fields in create-room and join-room forms (e.g., `LobbyPage`, home page) — add `maxLength={20}` attribute + +**Checkpoint**: Name validation enforced at API and UI level. + +--- + +## Phase 9: Build & Verify + +**Purpose**: Ensure both backend and frontend build and pass all tests. + +- [X] T052 [P] Run `npm run build` in `backend/` — fix any type/build errors +- [X] T053 [P] Run `npm run build` in `frontend/` — fix any type/build errors +- [X] T054 [P] Run `npm test` in `backend/` — ensure all tests pass with 80%+ coverage +- [X] T055 [P] Run `npm test` in `frontend/` — ensure all tests pass with 80%+ coverage + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — already complete +- **Foundational (Phase 2)**: Blocks all user stories +- **US1 (Phase 3)**: Persistent Round Result — can start after Phase 2; independent of other stories +- **US4 (Phase 4)**: Host Disconnect — can start after Phase 2; independent of other stories +- **US2 (Phase 5)**: Restart Game — depends on US1 (RoundResultCard needs Restart button) +- **US5 (Phase 6)**: Skip Round — depends on US4 (drawerDisconnected detection) and US2 (restart clears skipSuggested) +- **US3 (Phase 7)**: Exit Game Coexists — depends on US1+US2 (button row layout on result overlay) +- **Name Validation (Phase 8)**: Can run in parallel with any phase (independent) +- **Build & Verify (Phase 9)**: Depends on all phases complete + +### User Story Dependencies + +- **US1 (P1)**: No dependencies on other stories — can be first +- **US4 (P1)**: No dependencies on other stories — can be first +- **US2 (P2)**: Depends on US1 (RoundResultCard UI) +- **US5 (P2)**: Depends on US4 (drawer disconnect detection) +- **US3 (P3)**: Depends on US1+US2 (layout integration) + +### Within Each User Story + +- Backend model updates before service logic +- Services before API routes +- Backend changes before frontend integration +- Tests (where included) before implementation + +### Parallel Opportunities + +- All Phase 2 [P] tasks can run in parallel +- US1 and US4 can be developed in parallel (independent P1 stories) +- All [P] tasks within a user story can run in parallel +- T007-T009 (US1 backend + tests) can run with T010-T012 (US1 frontend) +- T021-T023 (US4 frontend API/store) can run with T024-T025 (US4 pages) +- Name validation (Phase 8) can run in parallel with any other phase + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all US1 tasks together: +Task: "T007 Backend test: lastRoundResult persists indefinitely in roomStore.test.ts" +Task: "T008 Frontend test: RoundResultCard displays guess history in RoundResultCard.test.tsx" +Task: "T009 Remove clearExpiredRoundResult() in roomStore.ts" +Task: "T010 Update RoundResultCard.tsx with participants, Continue, Restart props" +Task: "T011 Add dismissLastRoundResult() in roomStore.ts" +Task: "T012 Wire RoundResultCard in GamePage.tsx" +``` + +--- + +## Implementation Strategy + +### MVP First (US1 + US4 — Both P1) + +1. Complete Phase 2: Foundational +2. Complete Phase 3: US1 (Persistent Round Result) +3. Complete Phase 4: US4 (Host Disconnect & Claim Host) +4. **STOP and VALIDATE**: Result overlay persists and host disconnect detected + +### Incremental Delivery + +1. Foundational → Data model ready +2. Add US1 → Result display works, Continue dismisses +3. Add US4 → Host disconnect detected, claim host works +4. Add US2 → Restart to lobby works +5. Add US5 → Skip round works with drawer disconnect +6. Add US3 → Exit Game coexists +7. Add name validation +8. Build & test everything passes + +--- + +## Phase 10: Notify Host Dismisses Banner (add-on to US5) + +- [X] T058 [FE] `frontend/src/state/roomStore.ts` — In `suggestSkip()`, after API call succeeds, patch `drawerDisconnected: false` on the local room state + +--- + +## Phase 11: Game Over When All Players Leave (US6) + +### Backend + +- [X] T059 `backend/src/models/game.ts` — Add `"finished"` to `RoomStatus` type +- [X] T060 `backend/src/services/roomStore.ts` — Add `removeParticipant()` service: remove from array + scores; reassign host; if playing + ≤1 remaining: create lastRoundResult, set status `"finished"`; if 0 remaining: delete room +- [X] T061 `backend/src/services/roomStore.ts` — Update `toRoomSnapshot()` to include game details when status is `"finished"` +- [X] T062 `backend/src/api/schemas.ts` — Add `leaveRoomSchema` +- [X] T063 `backend/src/api/rooms.ts` — Add `DELETE /:code/leave` route +- [X] T064 `backend/src/services/roomStore.test.ts` — Add 7 tests for removeParticipant + +### Frontend + +- [X] T065 `frontend/src/services/api.ts` — Add `api.leaveRoom()`; update `RoomSnapshot.status` to include `"finished"` +- [X] T066 `frontend/src/state/roomStore.ts` — Add `leaveRoom()` method +- [X] T067 `frontend/src/pages/GamePage.tsx` — Update `handleExit` to call `roomStore.leaveRoom()`; handle `"finished"` status (RoundResultCard with Exit only); suppress banners during finished + +### Build & Verify + +- [X] T068 [P] Run `npm run build` in `backend/` +- [X] T069 [P] Run `npm run build` in `frontend/` +- [X] T070 [P] Run `npm test` in `backend/` — 53 tests pass (was 46) +- [X] T071 [P] Run `npm test` in `frontend/` — 18 tests pass From 283223741b7fc5cb6297c3a96592847385b5a847 Mon Sep 17 00:00:00 2001 From: M Asif Date: Sat, 27 Jun 2026 14:39:32 +0530 Subject: [PATCH 7/7] feat: add reflection report --- REFLECTION.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 REFLECTION.md diff --git a/REFLECTION.md b/REFLECTION.md new file mode 100644 index 00000000..44d44401 --- /dev/null +++ b/REFLECTION.md @@ -0,0 +1,99 @@ +## What the Starter App Already Had + +The starter was a **skeleton** with room setup infrastructure and stub pages: + +**Backend:** +- In-memory `Room` store with `createRoom`, `joinRoom`, `getRoom`, `saveRoom` +- `RoomSnapshot` + `Participant` data models (no `hostId`, no game state) +- API routes: `POST /rooms`, `POST /rooms/:code/join`, `GET /rooms/:code` +- Zod validation for basic schemas +- `STARTER_WORDS` and `STARTER_ROLES` seed data + +**Frontend:** +- Custom class-based `RoomStore` with `useSyncExternalStore` + Context +- API client with `createRoom`, `joinRoom`, `fetchRoom` +- Pages: `StartPage`, `CreateRoomPage`, `JoinRoomPage`, `LobbyPage` (stub), `GamePage` (stub) +- Components: `Card`, `GuessForm` (stub), `Scoreboard` (stub), `ResultPanel` (stub) +- React Router v6 routing, CSS styling + +**What was missing:** No game mechanics — no drawing, guessing, scoring, rounds, disconnect detection, skip, restart, or leave. No polling, session persistence, or host-only controls. + +--- + +## Phase 1 — Room Setup & Lobby (Spec 001) + +### What it does + +Players create rooms and share a 4-character code. The room creator becomes the host. Others join by code. Everyone sees the participant list update in real time. The host sees a "Start Game" button that stays disabled until 2+ players have joined. The game starts when the host clicks it. + +### Technical additions + +- **Data model:** `hostId` added to `Room` and `RoomSnapshot`; `joinRoom` now rejects non-lobby rooms +- **API:** `POST /rooms/:code/start` — host-only, validates 2+ participants, transitions status to `"playing"`, initializes first round/drawer/word/scores +- **Frontend:** Auto-polling (2s with exponential backoff to 8s), session persistence via `sessionStorage`, host-only "Start Game" button with dynamic label, auto-navigate to `/game` on status change +- **Store:** `startGame()`, `startPolling()`, `stopPolling()`, `clearSession()`, `getSavedSession()`, `restoreSession()` methods added +- **Tests:** 10 backend tests (room lifecycle, host validation, join rejection, start game), 2 frontend tests (API client) + +--- + +## Phase 2 — Game Start & Drawer Flow (Spec 002) + +### What it does + +When the game starts, one player is assigned as the drawer and sees the secret word. Everyone else sees a "waiting for drawer" view. The drawer's identity is visible to all. The secret word is never revealed to guessers. + +### Technical additions + +- **Data model:** `drawerId` and `secretWord` on `Room`; `secretWord` only included in snapshots when `viewerParticipantId === drawerId` +- **Frontend:** Session restoration on page load, navigation guard (`/game` → `/lobby` if status reverts), `CanvasPlaceholder` for non-drawers +- **No new API routes** — reuses existing `GET /rooms/:code` with conditional `secretWord` + +--- + +## Phase 3 — Drawing, Guessing, Scoring & Rounds (Spec 003) + +### What it does + +The drawer draws freehand on a canvas with a color picker and clear button. Guessers type guesses and get instant correct/incorrect feedback. A correct guess awards 100 points and immediately advances the round. Scores (round + cumulative) are visible to everyone. The drawer rotates each round, cycling through all participants. When a round ends, a persistent overlay shows the correct word, drawer name, full guess history (timestamps + correct/incorrect badges), and final scores. Each player clicks "Continue" individually to dismiss it — no auto-dismiss timer. + +### Technical additions + +- **Data model:** `Point`, `DrawingStroke`, `GuessEntry`, `ParticipantScore`, `RoundResult` types added; `strokes`, `guessHistory`, `scores`, `lastRoundResult`, `lastRoundResultSetAt` on `Room` +- **API:** `POST /rooms/:code/guess` — case-insensitive, 100pts, auto-advances on correct; `POST /rooms/:code/draw` — drawer-only stroke addition; `DELETE /rooms/:code/draw` — drawer-only canvas clear +- **Frontend:** `Canvas.tsx` (pen, color picker, clear, survives poll redraws), `GuessHistory.tsx` (timestamped list), `Scoreboard.tsx` (round + cumulative), `RoundResultCard.tsx` (overlay with Continue/Restart/Exit) +- **Store:** `addStrokes()`, `clearCanvas()`, `submitGuess()`, `dismissLastRoundResult()` methods; local tracking of `lastRoundResult`/`resultDismissed`/`dismissedRoundNumber` +- **Tests:** 13 backend tests (guess validation, scoring, round advancement, stroke add/clear), 8 frontend tests (Canvas, GuessForm, GuessHistory, Scoreboard, RoundResultCard) + +--- + +## Phase 4 — Disconnect Handling, Skip, Restart, Exit & Game Over (Spec 004) + +### What it does + +**Host disconnect** — If the host's browser goes idle for 30+ seconds, all remaining players see a "Host disconnected" popup with a "Claim Host" button. Whoever clicks it becomes the new host. + +**Drawer disconnect & skip** — If the drawer disconnects, non-host players see a "Notify Host" button. Clicking it sets a flag that the host sees as a badge on their "Skip Round" button. The host can advance the round without a correct guess. + +**Restart** — From the result overlay, the host can restart the game. Everyone returns to the lobby with participants preserved and all game state cleared. + +**Exit & game over** — Any player can leave a game in progress. If all remaining participants have disconnected browsers (stale beyond 30 seconds), the game auto-ends with a final result card showing just an "Exit Game" button. The last player to leave deletes the room entirely. + +**Name validation** — Player names are capped at 20 characters on both API and UI. + +### Technical additions + +- **Data model:** `lastSeenAt` on `Participant` (heartbeat timestamp); `skipSuggested`, `hostDisconnected`, `drawerDisconnected` on `Room`/`RoomSnapshot`; `"finished"` status variant +- **API:** `POST /rooms/:code/restart` — host-only, resets to lobby; `POST /rooms/:code/skip-round` — host-only, checks no correct guess exists; `POST /rooms/:code/suggest-skip` — any participant sets flag; `POST /rooms/:code/claim-host` — any participant; `DELETE /rooms/:code/leave` — removes participant, triggers game-over logic +- **Disconnect detection:** `touchParticipant()` called on every poll updates `lastSeenAt`; `isDisconnected()` checks 30-second threshold server-side; computed in `toRoomSnapshot()` +- **Frontend:** `HostDisconnectedBanner.tsx`, `DrawerDisconnectedBanner.tsx`; "Skip Round" button with pulse badge on `skipSuggested`; `leaveRoom()` method; finished status shows RoundResultCard with Exit only +- **Design decisions:** End game when host leaves and no active participants remain (prevents false "Host Disconnected" popup). `HostDisconnectedBanner` guarded with `room.status !== "finished"` in LobbyPage. +- **Tests:** 21 backend tests (skip round, suggest skip, claim host, remove participant, disconnect flags in snapshot), 8 frontend tests (banners, round result card, API client) + +--- + +## Growth Summary + +- Backend tests: **0 → 54** +- Frontend tests: **0 → 18** +- API routes: **3 → 12** +- Frontend components: **4 stubs → 10 working**