diff --git a/.gitignore b/.gitignore index 75a55bc19..eb31d874d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ precheck-results/ .vscode/ .vite/ +.claude +.idea +.specify +package*.json +*/package-lock.json \ No newline at end of file diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 000000000..85d92c8d6 --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,129 @@ + + +# Scribble Constitution + +## Core Principles + +### I. Test-Driven Development (NON-NEGOTIABLE) + +Tests MUST be written and confirmed failing before any implementation begins. +The Red-Green-Refactor cycle is strictly enforced on every task. +Minimum code coverage across both `backend` and `frontend` packages MUST be ≥ 80%. +Coverage is measured per package using Vitest's built-in reporter. + +**Rationale**: Prevents regressions and ensures acceptance criteria are encoded as executable +contracts before code is written. Coverage floor prevents coverage theatre on trivial paths. + +### II. TypeScript Strict Mode + +All code in `backend/` and `frontend/` MUST compile under TypeScript strict mode with zero errors. +`any` is forbidden; use `unknown` for genuinely dynamic types and narrow with guards. +Type definitions belong in `src/models/` (backend) or co-located with the module (frontend). + +**Rationale**: Strict typing catches entire classes of bugs at compile time and serves as +lightweight documentation of contracts between layers. + +### III. Architecture Integrity + +The system MUST conform to this two-package layout — no additional apps, servers, or runtimes: + +- **Frontend**: Vite + React 18 + TypeScript (port 5173) +- **Backend**: Node.js + Express + TypeScript REST API (port 3001) + +Hard constraints that MUST NOT be violated regardless of feature scope: +- All client–server sync MUST use HTTP polling (`GET /api/rooms/:code`). WebSockets, SSE, and + Socket.io are forbidden. +- All state MUST be stored in-memory (`Map` in `backend/src/services/roomStore.ts`). Databases, + file storage, and external caches are forbidden. +- No authentication, sessions, JWT, or OAuth. + +- Never remove existing tests without justification. +- Preserve backward compatibility unless explicitly requested. +- Generate complete implementations, not TODO placeholders. +- Explain architectural trade-offs before introducing new dependencies. +- Ask for clarification when requirements are ambiguous. + +**Rationale**: Constraints are load-bearing, Violating them produces a different system than specified. + +### IV. Lean Dependency Management + +New npm packages MUST NOT be introduced without explicit justification documented in the PR +description. Existing project patterns MUST be preferred over new libraries. +- Frontend state: follow the `useSyncExternalStore` pattern in `frontend/src/state/roomStore.ts`. +- Backend validation: extend Zod schemas in `backend/src/api/schemas.ts`. +- API calls: route through `frontend/src/services/api.ts`; no inline `fetch` in components. + +**Rationale**: Every new dependency is a long-term maintenance cost. The scaffold already +covers routing, state, validation, and HTTP — additional frameworks add complexity without value. + +### V. Production-Ready, Self-Documenting Code + +Code MUST be readable without inline comments explaining *what* it does; names and types carry +that weight. Comments are permitted only for non-obvious *why* (hidden constraint, workaround). +React components MUST avoid unnecessary re-renders: prefer stable references, memoize only when +profiling shows measurable impact, and keep component trees shallow. +All new code MUST work correctly in both the happy path and the documented edge cases from the spec. + +**Rationale**: Production-ready means the code could go into a real product without shame. +Self-documenting code reduces the overhead of onboarding and code review. + +## Architecture Constraints + +| Layer | Technology | Entry point | +|-------|-----------|-------------| +| Frontend | Vite + React + TypeScript | `frontend/src/main.tsx` | +| Backend | Node.js + Express + TypeScript | `backend/src/server.ts` | +| State (server) | In-memory `Map` | `backend/src/services/roomStore.ts` | +| State (client) | `useSyncExternalStore` store | `frontend/src/state/roomStore.ts` | +| Validation | Zod | `backend/src/api/schemas.ts` | +| Testing | Vitest (both packages) | `*.test.ts` / `*.test.tsx` | + +Backend route structure: business logic in `src/services/`, HTTP handling in `src/api/`, +types in `src/models/`. Frontend: API calls in `src/services/api.ts`, CSS in `src/styles/app.css`. + +## Development Workflow + +- PRs MUST be small and focused: one logical change per PR, reviewable in a single sitting. +- Commits MUST be granular and traceable to a specific task from `tasks.md`. +- Task execution order: write failing tests → implement → verify tests pass → commit. +- Both `npm run build` (backend and frontend) MUST succeed before a PR is opened. +- The Constitution Check in `plan.md` MUST be completed before Phase 0 research begins and + re-checked after Phase 1 design. + +## Governance + +This constitution supersedes all other practice documents for this project. +Amendments require: a written rationale, a version bump per the semantic versioning policy below, +and a re-run of the consistency propagation checklist across all templates. + +**Versioning policy**: +- MAJOR: Principle removed, fundamentally redefined, or hard constraint overturned. +- MINOR: New principle or section added; materially expanded guidance. +- PATCH: Clarifications, wording, or non-semantic refinements. + +All PRs and reviews MUST verify compliance with this constitution before merge. +Complexity violations (e.g., adding a framework, bypassing a hard constraint) MUST be justified +in a Complexity Tracking table in `plan.md` before work begins. + +**Version**: 1.0.0 | **Ratified**: 2026-06-12 | **Last Amended**: 2026-06-12 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1aa2d798c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,116 @@ +# CLAUDE.md + + +For additional context about technologies to be used, project structure, +shell commands, and other important information, read the current plan at +specs/004-result-restart/plan.md + + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Multiplayer drawing game ("Scribble") — a brownfield enhancement project. The scaffold has basic room creation/joining and UI; the remaining gameplay logic (host controls, lobby polling, game start, drawing, guessing, scoring, results) is left to be implemented. + +## Commands + +Run from each sub-directory — there is no root-level script runner. + +**Backend** (`cd backend`): +``` +npm run dev # tsx watch src/server.ts (port 3001) +npm run build # tsc -p tsconfig.json → dist/ +npm run start # node dist/server.js +npm run test # vitest run +``` + +**Frontend** (`cd frontend`): +``` +npm run dev # vite dev server (port 5173) +npm run build # tsc -b && vite build +npm run preview # vite preview +npm run test # vitest run +``` + +**Run a single test file:** +``` +cd backend && npx vitest run src/services/roomStore.test.ts +cd frontend && npx vitest run src/services/api.test.ts +``` + +Node version: v24.13.0 (see `.nvmrc`). No environment variables required for local development; `VITE_API_URL` defaults to `http://localhost:3001`. + +## Architecture + +Two independent apps sharing conventions but with separate builds and `node_modules`. + +### Backend (`backend/src/`) + +``` +api/ # Express routes + Zod request validation + router.ts # Route definitions (GET /health, POST /rooms, etc.) + rooms.ts # Handler logic + schemas.ts # Zod schemas for request/response +services/ + roomStore.ts # In-memory Map — all game state lives here +models/ + game.ts # Core TypeScript types: Room, Participant, RoundState, etc. +seed/ + starterData.ts # Default word list and role definitions +``` + +Entry point: `src/server.ts` (binds port) → `src/app.ts` (Express setup + CORS). + +**API surface:** +- `GET /health` +- `GET /api/` +- `POST /api/rooms` — create room; returns `{ participantId, room: RoomSnapshot }` +- `POST /api/rooms/:code/join` — join room; returns same shape +- `GET /api/rooms/:code?participantId=` — poll room state + +### Frontend (`frontend/src/`) + +``` +routes/index.tsx # React Router v6 route tree (5 routes) +pages/ # One component per route +components/ # Reusable UI pieces (AppShell, Card, GuessForm, Scoreboard, etc.) +state/roomStore.ts # Custom store using useSyncExternalStore — source of truth for room state +services/api.ts # fetch-based HTTP client; all backend calls go here +styles/app.css # All CSS lives in one file +``` + +State flows from API → `roomStore.ts` → components via the store's `subscribe`/`getSnapshot` pattern. Polling is done with `setInterval` in `useEffect` hooks — **no WebSockets**. + +## Coding Guidelines + +### TypeScript +- Strict mode is on in both packages. Avoid `any`; use `unknown` for truly dynamic types. +- Backend uses `NodeNext` module resolution; frontend uses `Bundler`. + +### Backend +- All request payloads must be validated with Zod schemas in `api/schemas.ts`. +- Business logic belongs in `services/`, not in route handlers. +- Keep `roomStore` footprint small; clean up inactive rooms explicitly. + +### Frontend +- Functional components with hooks only. +- New state belongs in `state/`; follow the `useSyncExternalStore` pattern in `roomStore.ts`. +- All API calls go through `services/api.ts` — no inline `fetch` in components. +- CSS changes go in `app.css`. + +## Hard Constraints + +These are strictly off-limits regardless of task: +- **No WebSockets** — all sync must use HTTP polling (`setInterval` + `GET /api/rooms/:code`). +- **No databases** — in-memory only (`Map` in `roomStore.ts`). +- **No authentication** — no sessions, JWT, or OAuth. + +## Spec Kit Artifacts + +When using Spec Kit skills, artifacts are stored in `.specify/`: +- `constitution.md` — engineering principles +- `spec.md` — acceptance criteria (4 scenarios) +- `plan.md` — state model, file-level changes, data flow +- `tasks.md` — ordered, testable work items + +When CLAUDE.md says "read the current plan", check `.specify/plan.md`. diff --git a/REFLECTION.md b/REFLECTION.md new file mode 100644 index 000000000..aad65821a --- /dev/null +++ b/REFLECTION.md @@ -0,0 +1,84 @@ +# Reflection Report — Scribble Assignment + +## What Did the Starter App Already Have? + +The scaffold provided a working skeleton with the bare minimum to run the app: + +**Backend** +- Express server with three routes: `POST /api/rooms`, `POST /api/rooms/:code/join`, `GET /api/rooms/:code` +- In-memory `Map` store with `createRoom` and `joinRoom` functions +- A minimal `Room` model with only `"lobby"` as the room status, and a `Participant` type with no host flag and no score +- `RoomSnapshot` that exposed `availableWords` and `roles` arrays but had no concept of a round, drawer, or guesses + +**Frontend** +- React Router v6 with five routes wired up (`/`, `/create`, `/join`, `/lobby`, `/game`) +- `CreateRoomPage` and `JoinRoomPage` with form markup but no client-side validation +- `LobbyPage` that rendered participants but had no polling loop and no "Start Game" button +- `GamePage` that displayed a static `"Drawing canvas placeholder"` string — completely non-interactive +- `Scoreboard` and `ResultPanel` rendered as empty shell components (no props, no data) +- `GuessForm` with an input field that did nothing on submit +- A `roomStore` using `useSyncExternalStore` for state management and a `fetchRoom` action, but no `startGame`, `submitGuess`, `updateCanvas`, `endRound`, or `restartGame` actions +- All CSS layout and design system tokens already in `app.css` + +In short: the scaffold could create and join rooms and navigate between pages, but contained no gameplay logic whatsoever. + +--- + +## What Did I Add? + +All four requirements were implemented incrementally using the **Spec Kit** workflow (`speckit-specify` → `speckit-clarify` → `speckit-plan` → `speckit-tasks` → `speckit-implement`), producing a spec, data model, API contract, checklist, and ordered task list for each feature before writing any code. + +### Requirement 1 — Room Setup & Lobby + +- **Host assignment**: `createParticipant` gained an `isHost` flag; the room creator is automatically the host. +- **Room code format**: Changed to 4 uppercase letters only (removed digits from the alphabet). +- **Participant model**: Added `score: number` field. +- **Input validation**: Empty player name is caught client-side before any fetch; invalid/missing room code shows a descriptive error. +- **Case-insensitive join**: `joinRoom` normalises the code to uppercase. +- **Lobby polling**: `LobbyPage` runs a `setInterval` every 2 seconds via `useEffect`; failures surface an inline error banner that clears on the next successful poll. +- **Host-only Start Game button**: Visible only to the host; disabled when fewer than 2 participants are present. + +### Requirement 2 — Game Start & Drawer + +- **`startGame()` service**: Validates that the caller is the host, that ≥ 2 players are present, and that words are available. Sets `room.status = "in-game"` and creates the first `Round` (drawer = host, word = first word in list, empty guesses/canvas). +- **Expanded type model**: `RoomStatus` widened to `"lobby" | "in-game" | "results"`; new `Round` interface added with `drawerId`, `word`, `guesses`, `canvasData`. +- **`toRoomSnapshot`**: Hides `secretWord` from guessers; only the drawer (and later the results screen) receives it. +- **Frontend navigation**: `LobbyPage` watches `room.status === "in-game"` and redirects to `/game`; `GamePage` redirects back to `/` if room is absent. + +### Requirement 3 — Gameplay Interaction + +- **Canvas drawing**: `GamePage` attaches `pointerdown/pointermove/pointerup` listeners on the `` element only for the drawer; on `pointerup` it serialises the canvas as a `data:image/png` and calls `store.updateCanvas()`. +- **Canvas sync for guessers**: Non-drawer participants see an `` whose `src` is polled from `room.canvasData` every 2 seconds. +- **`updateCanvas()` backend**: Validates the caller is the active drawer, then writes `canvasData` to the round. +- **Guess submission**: `submitGuess()` checks correctness case-insensitively, awards +100 points to the first correct guess, and records every guess with name, text, `isCorrect`, and timestamp. The drawer is blocked from guessing. +- **`GuessForm`** wired to the real API; shows "Correct!" feedback. +- **`Scoreboard`** and **`ResultPanel`** now render live participant scores and the running guess log. +- **Host "End Round" button**: Calls `endRound()`, which sets `room.status = "results"`. + +### Requirement 4 — Results, Restart & Final Validation + +- **`endRound()` backend**: Validates active round and host caller, then flips status to `"results"`. `toRoomSnapshot` exposes `secretWord` to all viewers when status is `"results"`. +- **`restartGame()` backend**: Validates host caller and `"results"` status, then resets to `"lobby"` and clears `currentRound`. +- **New `ResultsPage`**: Shows the secret word reveal, final `Scoreboard`, full `ResultPanel` of guesses, and a host "Back to Lobby" button (guests see a waiting message). Polls every 2 seconds and navigates away when status changes. +- **Navigation guards**: `GamePage` redirects to `/results` when status becomes `"results"`; `ResultsPage` redirects to `/lobby` or `/game` on status change. +- **New route**: `/results` added to `routes/index.tsx`. + +--- + +## Testing + +All logic is covered by automated tests run with Vitest: + +| Layer | Test files | Tests | +|---|---|---| +| Backend (services + schema) | 2 | 83 passed | +| Frontend (pages + components) | 9 | 56 passed | +| **Total** | **11** | **139 passed** | + +Key test areas: room creation/joining, host validation, guess scoring, canvas update guards, end-round/restart state transitions, lobby polling error banner, `ResultsPage` redirect logic, and `GuessForm` correct/incorrect feedback. + +--- + +## How Spec Kit Shaped the Process + +Using Spec Kit forced a structured order: clarify ambiguities first, agree on a data model and API contract before touching code, then generate a dependency-ordered task list. This meant implementation decisions (e.g. _"drawer gets word; guessers don't"_, _"restartGame requires results status"_) were resolved in the spec rather than discovered mid-implementation. The generated `tasks.md` for each feature broke work into small, independently testable chunks, which made it easy to verify progress incrementally and keep commits atomic. diff --git a/backend/package-lock.json b/backend/package-lock.json index 38f3d3c86..2bbc7c16a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -954,15 +954,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -971,13 +971,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -998,9 +998,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", "dev": true, "license": "MIT", "dependencies": { @@ -1011,13 +1011,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -1026,13 +1026,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -1041,9 +1041,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1054,13 +1054,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -1879,7 +1879,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2298,7 +2297,6 @@ "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -2379,7 +2377,6 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2982,20 +2979,20 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -3025,8 +3022,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c970..fb63e6b6f 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,12 +1,17 @@ import { Router } from "express"; import { createRoomSchema, + endRoundSchema, HttpError, joinRoomSchema, + restartGameSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startGameSchema, + submitGuessSchema, + updateDrawingSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, endRound, getRoom, joinRoom, restartGame, startGame, submitGuess, toRoomSnapshot, updateCanvas } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -44,6 +49,99 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startGameSchema.parse(request.body); + const snapshot = startGame(code, participantId); + response.json({ room: snapshot }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("403")) { + next(new HttpError(403, error.message.replace(/^403:\s*/, ""))); + } else if (error instanceof Error && error.message.startsWith("400")) { + next(new HttpError(400, error.message.replace(/^400:\s*/, ""))); + } else { + next(error); + } + } + }); + + router.post("/:code/guess", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, guess } = submitGuessSchema.parse(request.body); + const { snapshot, isCorrect } = submitGuess(code, participantId, guess); + response.json({ isCorrect, room: snapshot }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("404")) { + next(new HttpError(404, error.message.replace(/^404:\s*/, ""))); + } else if (error instanceof Error && error.message.startsWith("400")) { + next(new HttpError(400, error.message.replace(/^400:\s*/, ""))); + } else if (error instanceof Error && error.message.startsWith("403")) { + next(new HttpError(403, error.message.replace(/^403:\s*/, ""))); + } else { + next(error); + } + } + }); + + router.post("/:code/drawing", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, canvasData } = updateDrawingSchema.parse(request.body); + updateCanvas(code, participantId, canvasData); + response.json({ ok: true }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("404")) { + next(new HttpError(404, error.message.replace(/^404:\s*/, ""))); + } else if (error instanceof Error && error.message.startsWith("400")) { + next(new HttpError(400, error.message.replace(/^400:\s*/, ""))); + } else if (error instanceof Error && error.message.startsWith("403")) { + next(new HttpError(403, error.message.replace(/^403:\s*/, ""))); + } else { + next(error); + } + } + }); + + router.post("/:code/end", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = endRoundSchema.parse(request.body); + const snapshot = endRound(code, participantId); + response.json({ room: snapshot }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("404")) { + next(new HttpError(404, error.message.replace(/^404:\s*/, ""))); + } else if (error instanceof Error && error.message.startsWith("400")) { + next(new HttpError(400, error.message.replace(/^400:\s*/, ""))); + } else if (error instanceof Error && error.message.startsWith("403")) { + next(new HttpError(403, error.message.replace(/^403:\s*/, ""))); + } else { + next(error); + } + } + }); + + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = restartGameSchema.parse(request.body); + const snapshot = restartGame(code, participantId); + response.json({ room: snapshot }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("404")) { + next(new HttpError(404, error.message.replace(/^404:\s*/, ""))); + } else if (error instanceof Error && error.message.startsWith("400")) { + next(new HttpError(400, error.message.replace(/^400:\s*/, ""))); + } else if (error instanceof Error && error.message.startsWith("403")) { + next(new HttpError(403, error.message.replace(/^403:\s*/, ""))); + } else { + next(error); + } + } + }); + router.get("/:code", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); diff --git a/backend/src/api/schemas.test.ts b/backend/src/api/schemas.test.ts index 641efea35..420eab722 100644 --- a/backend/src/api/schemas.test.ts +++ b/backend/src/api/schemas.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoomSchema, roomCodeParamsSchema } from "./schemas.js"; +import { createRoomSchema, endRoundSchema, joinRoomSchema, restartGameSchema, roomCodeParamsSchema, submitGuessSchema, updateDrawingSchema } from "./schemas.js"; describe("schemas", () => { it("createRoomSchema accepts a valid body with playerName", () => { @@ -8,7 +8,121 @@ describe("schemas", () => { expect(result.playerName).toBe("Alice"); }); + it("createRoomSchema rejects empty playerName", () => { + expect(() => createRoomSchema.parse({ playerName: "" })).toThrow(); + }); + + it("createRoomSchema rejects missing playerName", () => { + expect(() => createRoomSchema.parse({})).toThrow(); + }); + + it("joinRoomSchema rejects empty playerName", () => { + expect(() => joinRoomSchema.parse({ playerName: "" })).toThrow(); + }); + + it("joinRoomSchema rejects missing playerName", () => { + expect(() => joinRoomSchema.parse({})).toThrow(); + }); + it("roomCodeParamsSchema rejects missing code", () => { expect(() => roomCodeParamsSchema.parse({})).toThrow(); }); + + // ── US1: Player Name Validation ───────────────────────────────────────────── + + it("createRoomSchema trims surrounding whitespace from playerName", () => { + const result = createRoomSchema.parse({ playerName: " Alice " }); + + expect(result.playerName).toBe("Alice"); + }); + + it("createRoomSchema rejects whitespace-only playerName", () => { + expect(() => createRoomSchema.parse({ playerName: " " })).toThrow(); + }); + + it("createRoomSchema rejects playerName longer than 20 characters", () => { + expect(() => createRoomSchema.parse({ playerName: "A".repeat(21) })).toThrow(); + }); + + it("createRoomSchema accepts playerName of exactly 20 characters", () => { + const result = createRoomSchema.parse({ playerName: "A".repeat(20) }); + + expect(result.playerName).toHaveLength(20); + }); + + // ── US2: updateDrawingSchema ───────────────────────────────────────────────── + + it("updateDrawingSchema accepts valid participantId and canvasData", () => { + const result = updateDrawingSchema.parse({ participantId: "uuid-1", canvasData: "data:image/png;base64,abc" }); + expect(result.participantId).toBe("uuid-1"); + expect(result.canvasData).toBe("data:image/png;base64,abc"); + }); + + it("updateDrawingSchema accepts empty string canvasData (clear)", () => { + const result = updateDrawingSchema.parse({ participantId: "uuid-1", canvasData: "" }); + expect(result.canvasData).toBe(""); + }); + + it("updateDrawingSchema rejects missing participantId", () => { + expect(() => updateDrawingSchema.parse({ canvasData: "data:image/png;base64,abc" })).toThrow(); + }); + + it("updateDrawingSchema rejects empty participantId", () => { + expect(() => updateDrawingSchema.parse({ participantId: "", canvasData: "" })).toThrow(); + }); + + // ── US3: submitGuessSchema ─────────────────────────────────────────────────── + + it("submitGuessSchema accepts valid participantId and guess", () => { + const result = submitGuessSchema.parse({ participantId: "uuid-1", guess: "rocket" }); + expect(result.participantId).toBe("uuid-1"); + expect(result.guess).toBe("rocket"); + }); + + it("submitGuessSchema trims whitespace from guess", () => { + const result = submitGuessSchema.parse({ participantId: "uuid-1", guess: " rocket " }); + expect(result.guess).toBe("rocket"); + }); + + it("submitGuessSchema rejects empty guess after trim", () => { + expect(() => submitGuessSchema.parse({ participantId: "uuid-1", guess: " " })).toThrow(); + }); + + it("submitGuessSchema rejects missing participantId", () => { + expect(() => submitGuessSchema.parse({ guess: "rocket" })).toThrow(); + }); + + it("submitGuessSchema rejects empty participantId", () => { + expect(() => submitGuessSchema.parse({ participantId: "", guess: "rocket" })).toThrow(); + }); + + // ── US1: endRoundSchema ────────────────────────────────────────────────────── + + it("endRoundSchema accepts valid participantId", () => { + const result = endRoundSchema.parse({ participantId: "uuid-1" }); + expect(result.participantId).toBe("uuid-1"); + }); + + it("endRoundSchema rejects empty participantId", () => { + expect(() => endRoundSchema.parse({ participantId: "" })).toThrow(); + }); + + it("endRoundSchema rejects missing participantId", () => { + expect(() => endRoundSchema.parse({})).toThrow(); + }); + + // ── US2: restartGameSchema ──────────────────────────────────────────────────── + + it("restartGameSchema accepts valid participantId", () => { + const result = restartGameSchema.parse({ participantId: "uuid-2" }); + expect(result.participantId).toBe("uuid-2"); + }); + + it("restartGameSchema rejects empty participantId", () => { + expect(() => restartGameSchema.parse({ participantId: "" })).toThrow(); + }); + + it("restartGameSchema rejects missing participantId", () => { + expect(() => restartGameSchema.parse({})).toThrow(); + }); }); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba086..a8aee3093 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,11 +1,17 @@ import { z } from "zod"; +export const playerNameSchema = z + .string() + .trim() + .min(1, "Name cannot be empty") + .max(20, "Name must be 20 characters or fewer"); + export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: playerNameSchema }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: playerNameSchema }); export const roomCodeParamsSchema = z.object({ @@ -16,6 +22,28 @@ export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const startGameSchema = z.object({ + participantId: z.string().min(1) +}); + +export const submitGuessSchema = z.object({ + participantId: z.string().min(1), + guess: z.string().trim().min(1, "Guess cannot be empty") +}); + +export const updateDrawingSchema = z.object({ + participantId: z.string().min(1), + canvasData: z.string() +}); + +export const endRoundSchema = z.object({ + participantId: z.string().min(1) +}); + +export const restartGameSchema = z.object({ + participantId: z.string().min(1) +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce9466e..8619e4ff5 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,10 +1,27 @@ -export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "in-game" | "results"; + +export interface Guess { + participantId: string; + participantName: string; + text: string; + isCorrect: boolean; + timestamp: string; +} export interface Participant { id: string; name: string; joinedAt: string; + isHost: boolean; + score: number; +} + +export interface Round { + drawerId: string; + word: string; + status: "active"; + guesses: Guess[]; + canvasData: string; } export interface Room { @@ -13,14 +30,17 @@ export interface Room { participants: Participant[]; createdAt: string; updatedAt: string; + currentRound?: Round; } export interface RoomSnapshot { code: string; status: RoomStatus; participants: Participant[]; - availableWords: string[]; - roles: ParticipantRole[]; + drawerId: string | null; + secretWord?: string; + guesses: Guess[]; + canvasData: string; } export interface RoomSessionResponse { diff --git a/backend/src/seed/starterData.ts b/backend/src/seed/starterData.ts index eeb269c9e..3594698db 100644 --- a/backend/src/seed/starterData.ts +++ b/backend/src/seed/starterData.ts @@ -1,5 +1,3 @@ -import type { ParticipantRole } from "../models/game.js"; - export const STARTER_WORDS = [ "rocket", "pizza", @@ -7,5 +5,3 @@ export const STARTER_WORDS = [ "guitar", "sunflower" ] as const; - -export const STARTER_ROLES: ParticipantRole[] = ["drawer", "guesser"]; diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index b70ef77b9..37c037a5e 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,19 +1,486 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom } from "./roomStore.js"; +import { createRoom, joinRoom, startGame, getRoom, toRoomSnapshot, updateCanvas, submitGuess, endRound, restartGame } from "./roomStore.js"; describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { const result = createRoom("Alice"); - expect(result.room.code).toMatch(/^[A-Z0-9]{4}$/); + expect(result.room.code).toMatch(/^[A-Z]{4}$/); expect(result.room.participants).toHaveLength(1); expect(result.room.participants[0].name).toBe("Alice"); expect(result.participantId).toBeDefined(); }); + it("createRoom marks the creator as host (isHost: true)", () => { + const result = createRoom("Alice"); + + expect(result.room.participants[0].isHost).toBe(true); + }); + it("joinRoom returns null for an unknown room code", () => { const result = joinRoom("ZZZZ", "Bob"); expect(result).toBeNull(); }); + + it("joinRoom marks the joiner as non-host (isHost: false)", () => { + const { room } = createRoom("Alice"); + const result = joinRoom(room.code, "Bob"); + + expect(result).not.toBeNull(); + expect(result!.room.participants[1].isHost).toBe(false); + }); + + it("joinRoom succeeds with lowercase code (case-insensitive)", () => { + const { room } = createRoom("Alice"); + const lowerCode = room.code.toLowerCase(); + const result = joinRoom(lowerCode, "Bob"); + + expect(result).not.toBeNull(); + expect(result!.room.participants).toHaveLength(2); + }); + + it("rooms are isolated — joining one room does not affect another", () => { + const { room: roomA } = createRoom("Alice"); + const { room: roomB } = createRoom("Charlie"); + joinRoom(roomA.code, "Bob"); + + const freshB = joinRoom(roomB.code + "NOPE", "Dave"); + expect(freshB).toBeNull(); + + const roomBState = joinRoom(roomB.code, "Eve"); + expect(roomBState!.room.participants.some((p) => p.name === "Bob")).toBe(false); + }); + + // ── US4: startGame ────────────────────────────────────────────────────────── + + it("startGame throws 403 when caller is not the host", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + const guestId = "not-the-host-id"; + void hostId; + + expect(() => startGame(room.code, guestId)).toThrow(/403|forbidden|not.*host/i); + }); + + it("startGame throws 400 when fewer than 2 participants are present", () => { + const { room, participantId: hostId } = createRoom("Alice"); + + expect(() => startGame(room.code, hostId)).toThrow(/400|need.*2|not enough|insufficient/i); + }); + + it("startGame returns a snapshot with status in-game when called by host with ≥2 participants", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + + const snapshot = startGame(room.code, hostId); + + expect(snapshot.status).toBe("in-game"); + }); + + // ── US2: Host as drawer at round start ────────────────────────────────────── + + it("startGame sets currentRound.drawerId to host id", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + + expect(getRoom(room.code)!.currentRound?.drawerId).toBe(hostId); + }); + + it("startGame sets currentRound.word to first starter word (rocket)", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + + expect(getRoom(room.code)!.currentRound?.word).toBe("rocket"); + }); + + it("startGame sets currentRound.status to active", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + + expect(getRoom(room.code)!.currentRound?.status).toBe("active"); + }); + + it("toRoomSnapshot returns drawerId matching host when in-game", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + + const inGameRoom = getRoom(room.code)!; + expect(toRoomSnapshot(inGameRoom, hostId).drawerId).toBe(hostId); + }); + + it("toRoomSnapshot returns drawerId null when room is in lobby", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const lobbyRoom = getRoom(room.code)!; + + expect(toRoomSnapshot(lobbyRoom, hostId).drawerId).toBeNull(); + }); + + it("toRoomSnapshot returns a drawerId that is not the guesser's id", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const joinResult = joinRoom(room.code, "Bob")!; + const guesserId = joinResult.participantId; + void hostId; + startGame(room.code, hostId); + + const inGameRoom = getRoom(room.code)!; + expect(toRoomSnapshot(inGameRoom, guesserId).drawerId).not.toBe(guesserId); + }); + + // ── US3: Secret word visible only to drawer ────────────────────────────────── + + it("toRoomSnapshot includes secretWord for the drawer", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + + const inGameRoom = getRoom(room.code)!; + expect(toRoomSnapshot(inGameRoom, hostId).secretWord).toBe("rocket"); + }); + + it("toRoomSnapshot omits secretWord for a guesser", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const joinResult = joinRoom(room.code, "Bob")!; + const guesserId = joinResult.participantId; + startGame(room.code, hostId); + + const inGameRoom = getRoom(room.code)!; + expect(toRoomSnapshot(inGameRoom, guesserId).secretWord).toBeUndefined(); + }); + + it("toRoomSnapshot omits secretWord when no viewerParticipantId provided", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + + const inGameRoom = getRoom(room.code)!; + expect(toRoomSnapshot(inGameRoom).secretWord).toBeUndefined(); + }); + + it("toRoomSnapshot omits secretWord in lobby (no active round)", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const lobbyRoom = getRoom(room.code)!; + + expect(toRoomSnapshot(lobbyRoom, hostId).secretWord).toBeUndefined(); + }); + + // ── Foundational: score, guesses, canvasData ───────────────────────────────── + + it("createRoom gives the host participant score of 0", () => { + const { room } = createRoom("Alice"); + expect(room.participants[0].score).toBe(0); + }); + + it("joinRoom gives the joining participant score of 0", () => { + const { room } = createRoom("Alice"); + const result = joinRoom(room.code, "Bob"); + expect(result!.room.participants[1].score).toBe(0); + }); + + it("startGame initialises currentRound.guesses as empty array", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + expect(getRoom(room.code)!.currentRound?.guesses).toEqual([]); + }); + + it("startGame initialises currentRound.canvasData as empty string", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + expect(getRoom(room.code)!.currentRound?.canvasData).toBe(""); + }); + + it("toRoomSnapshot includes guesses array in snapshot", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + const snapshot = toRoomSnapshot(getRoom(room.code)!, hostId); + expect(snapshot.guesses).toEqual([]); + }); + + it("toRoomSnapshot includes canvasData string in snapshot", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + const snapshot = toRoomSnapshot(getRoom(room.code)!, hostId); + expect(snapshot.canvasData).toBe(""); + }); + + it("toRoomSnapshot returns guesses: [] and canvasData: '' for lobby snapshot", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const lobbyRoom = getRoom(room.code)!; + const snapshot = toRoomSnapshot(lobbyRoom, hostId); + expect(snapshot.guesses).toEqual([]); + expect(snapshot.canvasData).toBe(""); + }); + + // ── US2: updateCanvas ──────────────────────────────────────────────────────── + + it("updateCanvas stores canvasData on currentRound for the drawer", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + updateCanvas(room.code, hostId, "data:image/png;base64,abc"); + expect(getRoom(room.code)!.currentRound?.canvasData).toBe("data:image/png;base64,abc"); + }); + + it("updateCanvas throws 403 when caller is not the drawer", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + expect(() => updateCanvas(room.code, guestResult.participantId, "data:image/png;base64,abc")).toThrow(/403/); + }); + + it("updateCanvas throws 400 when there is no active round", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + expect(() => updateCanvas(room.code, hostId, "data:image/png;base64,abc")).toThrow(/400/); + }); + + it("updateCanvas throws 404 when room does not exist", () => { + expect(() => updateCanvas("ZZZZ", "any-id", "")).toThrow(/404/); + }); + + it("toRoomSnapshot returns updated canvasData after updateCanvas call", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + updateCanvas(room.code, hostId, "data:image/png;base64,xyz"); + const snapshot = toRoomSnapshot(getRoom(room.code)!, hostId); + expect(snapshot.canvasData).toBe("data:image/png;base64,xyz"); + }); + + it("updateCanvas stores empty string when canvas is cleared", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + updateCanvas(room.code, hostId, "data:image/png;base64,abc"); + updateCanvas(room.code, hostId, ""); + expect(getRoom(room.code)!.currentRound?.canvasData).toBe(""); + }); + + // ── US3: submitGuess ───────────────────────────────────────────────────────── + + it("submitGuess returns isCorrect: true for case-insensitive match", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + const { isCorrect } = submitGuess(room.code, guestResult.participantId, "ROCKET"); + expect(isCorrect).toBe(true); + }); + + it("submitGuess returns isCorrect: false for non-matching guess", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + const { isCorrect } = submitGuess(room.code, guestResult.participantId, "banana"); + expect(isCorrect).toBe(false); + }); + + it("submitGuess throws 403 when drawer tries to guess", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + expect(() => submitGuess(room.code, hostId, "rocket")).toThrow(/403/); + }); + + it("submitGuess throws 400 when there is no active round", () => { + const { room } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + expect(() => submitGuess(room.code, guestResult.participantId, "rocket")).toThrow(/400/); + }); + + it("submitGuess throws 404 when room does not exist", () => { + expect(() => submitGuess("ZZZZ", "any-id", "rocket")).toThrow(/404/); + }); + + it("submitGuess increments guesser score by 100 on first correct guess", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + submitGuess(room.code, guestResult.participantId, "rocket"); + const guesser = getRoom(room.code)!.participants.find((p) => p.id === guestResult.participantId)!; + expect(guesser.score).toBe(100); + }); + + it("submitGuess does not increment score on second correct guess by same player", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + submitGuess(room.code, guestResult.participantId, "rocket"); + submitGuess(room.code, guestResult.participantId, "ROCKET"); + const guesser = getRoom(room.code)!.participants.find((p) => p.id === guestResult.participantId)!; + expect(guesser.score).toBe(100); + }); + + it("submitGuess does not change score for incorrect guess", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + submitGuess(room.code, guestResult.participantId, "banana"); + const guesser = getRoom(room.code)!.participants.find((p) => p.id === guestResult.participantId)!; + expect(guesser.score).toBe(0); + }); + + it("submitGuess appends the guess to currentRound.guesses", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + submitGuess(room.code, guestResult.participantId, "banana"); + expect(getRoom(room.code)!.currentRound?.guesses).toHaveLength(1); + expect(getRoom(room.code)!.currentRound?.guesses[0].text).toBe("banana"); + }); + + it("submitGuess snapshot includes the new guess in guesses array", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + const { snapshot } = submitGuess(room.code, guestResult.participantId, "banana"); + expect(snapshot.guesses).toHaveLength(1); + expect(snapshot.guesses[0].isCorrect).toBe(false); + }); + + it("submitGuess marks second correct guess as isCorrect: true but does not score", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + submitGuess(room.code, guestResult.participantId, "rocket"); + const { isCorrect, snapshot } = submitGuess(room.code, guestResult.participantId, "rocket"); + expect(isCorrect).toBe(true); + const guesser = snapshot.participants.find((p) => p.id === guestResult.participantId)!; + expect(guesser.score).toBe(100); + }); + + // ── US1: endRound ───────────────────────────────────────────────────────────── + + it("endRound throws 404 when room does not exist", () => { + expect(() => endRound("ZZZZ", "any-id")).toThrow(/404/); + }); + + it("endRound throws 400 when room status is not in-game", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + expect(() => endRound(room.code, hostId)).toThrow(/400/); + }); + + it("endRound throws 403 when caller is not the host", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + expect(() => endRound(room.code, guestResult.participantId)).toThrow(/403/); + }); + + it("endRound transitions room status to results", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + endRound(room.code, hostId); + expect(getRoom(room.code)!.status).toBe("results"); + }); + + it("endRound preserves round data (word, guesses, canvasData)", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + submitGuess(room.code, guestResult.participantId, "banana"); + updateCanvas(room.code, hostId, "data:image/png;base64,abc"); + endRound(room.code, hostId); + const stored = getRoom(room.code)!; + expect(stored.currentRound?.word).toBe("rocket"); + expect(stored.currentRound?.guesses).toHaveLength(1); + expect(stored.currentRound?.canvasData).toBe("data:image/png;base64,abc"); + }); + + it("endRound snapshot exposes secretWord to all viewers", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + endRound(room.code, hostId); + const roomAfter = getRoom(room.code)!; + expect(toRoomSnapshot(roomAfter, guestResult.participantId).secretWord).toBe("rocket"); + expect(toRoomSnapshot(roomAfter).secretWord).toBe("rocket"); + }); + + it("endRound snapshot status is results", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + const snapshot = endRound(room.code, hostId); + expect(snapshot.status).toBe("results"); + }); + + // ── US2: restartGame ────────────────────────────────────────────────────────── + + it("restartGame throws 404 when room does not exist", () => { + expect(() => restartGame("ZZZZ", "any-id")).toThrow(/404/); + }); + + it("restartGame throws 400 when room status is not results", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + expect(() => restartGame(room.code, hostId)).toThrow(/400/); + }); + + it("restartGame throws 403 when caller is not the host", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + endRound(room.code, hostId); + expect(() => restartGame(room.code, guestResult.participantId)).toThrow(/403/); + }); + + it("restartGame transitions room status to lobby", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + endRound(room.code, hostId); + restartGame(room.code, hostId); + expect(getRoom(room.code)!.status).toBe("lobby"); + }); + + it("restartGame clears currentRound", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + endRound(room.code, hostId); + restartGame(room.code, hostId); + expect(getRoom(room.code)!.currentRound).toBeUndefined(); + }); + + it("restartGame preserves all participants", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + endRound(room.code, hostId); + restartGame(room.code, hostId); + expect(getRoom(room.code)!.participants).toHaveLength(2); + }); + + it("restartGame preserves participant scores", () => { + const { room, participantId: hostId } = createRoom("Alice"); + const guestResult = joinRoom(room.code, "Bob")!; + startGame(room.code, hostId); + submitGuess(room.code, guestResult.participantId, "rocket"); + endRound(room.code, hostId); + restartGame(room.code, hostId); + const guesser = getRoom(room.code)!.participants.find((p) => p.id === guestResult.participantId)!; + expect(guesser.score).toBe(100); + }); + + it("restartGame snapshot returns empty guesses and canvasData", () => { + const { room, participantId: hostId } = createRoom("Alice"); + joinRoom(room.code, "Bob"); + startGame(room.code, hostId); + endRound(room.code, hostId); + const snapshot = restartGame(room.code, hostId); + expect(snapshot.guesses).toEqual([]); + expect(snapshot.canvasData).toBe(""); + expect(snapshot.drawerId).toBeNull(); + expect(snapshot.secretWord).toBeUndefined(); + }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a40..27f186396 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; -import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; +import type { Guess, Participant, Room, RoomSnapshot } from "../models/game.js"; +import { STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); @@ -9,7 +9,7 @@ function now() { } function generateCode() { - const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ"; let code = ""; for (let index = 0; index < 4; index += 1) { @@ -33,11 +33,13 @@ function displayName(name?: string) { return name || "Player"; } -function createParticipant(name?: string): Participant { +function createParticipant(name?: string, isHost = false): Participant { return { id: randomUUID(), name: displayName(name), - joinedAt: now() + joinedAt: now(), + isHost, + score: 0 }; } @@ -50,7 +52,7 @@ export function listWords() { } export function createRoom(playerName?: string) { - const participant = createParticipant(playerName); + const participant = createParticipant(playerName, true); const room: Room = { code: generateUniqueCode(), status: "lobby", @@ -68,13 +70,13 @@ export function createRoom(playerName?: string) { } export function joinRoom(code: string, playerName?: string) { - const room = rooms.get(code); + const room = rooms.get(code.toUpperCase()); if (!room) { return null; } - const participant = createParticipant(playerName); + const participant = createParticipant(playerName, false); room.participants.push(participant); room.updatedAt = now(); rooms.set(room.code, room); @@ -85,6 +87,35 @@ export function joinRoom(code: string, playerName?: string) { }; } +export function startGame(code: string, participantId: string): RoomSnapshot { + const room = rooms.get(code.toUpperCase()); + + if (!room) { + throw new Error("404: Room not found"); + } + + const caller = room.participants.find((p) => p.id === participantId); + + if (!caller || !caller.isHost) { + throw new Error("403: Forbidden — only the host can start the game"); + } + + if (room.participants.length < 2) { + throw new Error("400: Need at least 2 players to start the game"); + } + + if ((STARTER_WORDS as readonly string[]).length === 0) { + throw new Error("400: No words available to start the game"); + } + + room.currentRound = { drawerId: caller.id, word: STARTER_WORDS[0], status: "active", guesses: [], canvasData: "" }; + room.status = "in-game"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return toRoomSnapshot(cloneRoom(room), participantId); +} + export function getRoom(code: string) { const room = rooms.get(code); return room ? cloneRoom(room) : null; @@ -97,13 +128,101 @@ export function saveRoom(room: Room) { } export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; + const drawerId = room.currentRound?.drawerId ?? null; + const secretWord = + room.status === "results" + ? room.currentRound?.word + : viewerParticipantId && viewerParticipantId === drawerId + ? room.currentRound!.word + : undefined; return { code: room.code, status: room.status, participants: room.participants.map((participant) => ({ ...participant })), - availableWords: listWords(), - roles: [...STARTER_ROLES] + drawerId, + guesses: room.currentRound?.guesses ?? [], + canvasData: room.currentRound?.canvasData ?? "", + ...(secretWord !== undefined && { secretWord }) + }; +} + +export function endRound(code: string, participantId: string): RoomSnapshot { + const room = rooms.get(code.toUpperCase()); + if (!room) throw new Error("404: Room not found"); + if (room.status !== "in-game" || !room.currentRound) throw new Error("400: No active round"); + + const caller = room.participants.find((p) => p.id === participantId); + if (!caller || !caller.isHost) throw new Error("403: Forbidden — only the host can end the round"); + + room.status = "results"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return toRoomSnapshot(cloneRoom(room)); +} + +export function restartGame(code: string, participantId: string): RoomSnapshot { + const room = rooms.get(code.toUpperCase()); + if (!room) throw new Error("404: Room not found"); + if (room.status !== "results") throw new Error("400: Room is not in results state"); + + const caller = room.participants.find((p) => p.id === participantId); + if (!caller || !caller.isHost) throw new Error("403: Forbidden — only the host can restart"); + + room.status = "lobby"; + room.currentRound = undefined; + room.updatedAt = now(); + rooms.set(room.code, room); + + return toRoomSnapshot(cloneRoom(room)); +} + +export function submitGuess( + code: string, + participantId: string, + guessText: string +): { snapshot: RoomSnapshot; isCorrect: boolean } { + const room = rooms.get(code.toUpperCase()); + if (!room) throw new Error("404: Room not found"); + if (!room.currentRound) throw new Error("400: No active round"); + if (participantId === room.currentRound.drawerId) throw new Error("403: Drawer cannot guess"); + + const isCorrect = guessText.toLowerCase() === room.currentRound.word.toLowerCase(); + const hasAlreadyScored = room.currentRound.guesses.some( + (g) => g.participantId === participantId && g.isCorrect + ); + + if (isCorrect && !hasAlreadyScored) { + const participant = room.participants.find((p) => p.id === participantId); + if (participant) participant.score += 100; + } + + const submitter = room.participants.find((p) => p.id === participantId); + const guess: Guess = { + participantId, + participantName: submitter?.name ?? "Unknown", + text: guessText, + isCorrect, + timestamp: now() + }; + room.currentRound.guesses.push(guess); + room.updatedAt = now(); + rooms.set(room.code, room); + + return { + snapshot: toRoomSnapshot(cloneRoom(room), participantId), + isCorrect }; } + +export function updateCanvas(code: string, participantId: string, canvasData: string): void { + const room = rooms.get(code.toUpperCase()); + if (!room) throw new Error("404: Room not found"); + if (!room.currentRound) throw new Error("400: No active round"); + if (participantId !== room.currentRound.drawerId) throw new Error("403: Only the drawer can update the canvas"); + + room.currentRound.canvasData = canvasData; + room.updatedAt = now(); + rooms.set(room.code, room); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49c6d054e..2c3b7674f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,7 +18,7 @@ "@vitejs/plugin-react": "^4.3.1", "jsdom": "^26.1.0", "typescript": "^5.6.3", - "vite": "^5.4.10", + "vite": "^6.4.3", "vitest": "^3.1.3" } }, @@ -74,7 +74,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -414,7 +413,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -438,15 +436,14 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -457,13 +454,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -474,13 +471,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -491,13 +488,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -508,13 +505,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -525,13 +522,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -542,13 +539,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -559,13 +556,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -576,13 +573,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -593,13 +590,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -610,13 +607,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -627,13 +624,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -644,13 +641,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -661,13 +658,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -678,13 +675,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -695,13 +692,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -712,13 +709,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -729,13 +726,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -746,13 +760,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -763,13 +794,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -780,13 +828,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -797,13 +845,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -814,13 +862,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -831,7 +879,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@jridgewell/gen-mapping": { @@ -885,9 +933,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -1333,7 +1381,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1371,15 +1418,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -1388,13 +1435,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -1415,9 +1462,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", "dev": true, "license": "MIT", "dependencies": { @@ -1428,13 +1475,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -1443,13 +1490,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -1458,9 +1505,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1471,13 +1518,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -1538,7 +1585,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1716,9 +1762,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1726,32 +1772,35 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escalade": { @@ -1900,7 +1949,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -2083,7 +2131,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2135,7 +2182,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2148,7 +2194,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2168,12 +2213,12 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" @@ -2183,13 +2228,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" @@ -2501,22 +2546,24 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -2525,19 +2572,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -2558,6 +2611,12 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, @@ -2585,20 +2644,20 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -2628,8 +2687,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, diff --git a/frontend/package.json b/frontend/package.json index 818dcf457..6e8d05101 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,7 @@ "@vitejs/plugin-react": "^4.3.1", "jsdom": "^26.1.0", "typescript": "^5.6.3", - "vite": "^5.4.10", + "vite": "^6.4.3", "vitest": "^3.1.3" } } diff --git a/frontend/src/components/GuessForm.test.tsx b/frontend/src/components/GuessForm.test.tsx new file mode 100644 index 000000000..17a5ac670 --- /dev/null +++ b/frontend/src/components/GuessForm.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; + +const mockSubmitGuess = vi.hoisted(() => vi.fn()); + +vi.mock("../state/roomStore.js", () => ({ + useRoomStore: () => ({ submitGuess: mockSubmitGuess }), + useRoomState: () => ({ room: null, participantId: null, error: null, isLoading: false }) +})); + +import { GuessForm } from "./GuessForm.js"; + +describe("GuessForm", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + mockSubmitGuess.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + try { + await act(async () => root.unmount()); + } catch { + // already unmounted + } + container.remove(); + vi.clearAllMocks(); + }); + + function render(props = { roomCode: "ABCD", participantId: "guest-id" }) { + return act(async () => { root.render(createElement(GuessForm, props)); }); + } + + function getInput() { + return container.querySelector("input") as HTMLInputElement; + } + + function getSubmitButton() { + return container.querySelector("button[type='submit']") as HTMLButtonElement; + } + + async function typeInInput(value: string) { + const input = getInput(); + await act(async () => { + Object.defineProperty(input, "value", { writable: true, value }); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + // Use React synthetic event via change event + await act(async () => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )!.set!; + nativeInputValueSetter.call(input, value); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + } + + async function submitForm() { + const form = container.querySelector("form") as HTMLFormElement; + await act(async () => { + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + } + + it("shows 'Guess cannot be empty' error when empty input is submitted", async () => { + await render(); + await submitForm(); + expect(container.textContent).toContain("Guess cannot be empty"); + }); + + it("does not call store.submitGuess when input is empty", async () => { + await render(); + await submitForm(); + expect(mockSubmitGuess).not.toHaveBeenCalled(); + }); + + it("calls store.submitGuess with roomCode, participantId, and trimmed guess", async () => { + mockSubmitGuess.mockResolvedValue(false); + await render(); + await typeInInput(" rocket "); + await submitForm(); + expect(mockSubmitGuess).toHaveBeenCalledWith("ABCD", "guest-id", "rocket"); + }); + + it("shows 'Correct!' feedback after a correct guess", async () => { + mockSubmitGuess.mockResolvedValue(true); + await render(); + await typeInInput("rocket"); + await submitForm(); + expect(container.textContent).toContain("Correct!"); + }); + + it("clears the input after a correct guess", async () => { + mockSubmitGuess.mockResolvedValue(true); + await render(); + await typeInInput("rocket"); + await submitForm(); + expect(getInput().value).toBe(""); + }); + + it("clears the input after an incorrect guess", async () => { + mockSubmitGuess.mockResolvedValue(false); + await render(); + await typeInInput("banana"); + await submitForm(); + expect(getInput().value).toBe(""); + }); + + it("does not show 'Correct!' for an incorrect guess", async () => { + mockSubmitGuess.mockResolvedValue(false); + await render(); + await typeInInput("banana"); + await submitForm(); + expect(container.textContent).not.toContain("Correct!"); + }); +}); diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec474a..a21f02121 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -1,29 +1,56 @@ import { useState } from "react"; +import { useRoomStore } from "../state/roomStore"; interface GuessFormProps { - disabled?: boolean; + roomCode: string; + participantId: string; } -export function GuessForm({ disabled = false }: GuessFormProps) { +export function GuessForm({ roomCode, participantId }: GuessFormProps) { + const store = useRoomStore(); const [guessText, setGuessText] = useState(""); + const [error, setError] = useState(null); + const [feedback, setFeedback] = useState<"correct" | null>(null); + const [submitting, setSubmitting] = useState(false); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const trimmed = guessText.trim(); + if (!trimmed) { + setError("Guess cannot be empty"); + return; + } + setError(null); + setSubmitting(true); + try { + const isCorrect = await store.submitGuess(roomCode, participantId, trimmed); + setGuessText(""); + setFeedback(isCorrect ? "correct" : null); + } finally { + setSubmitting(false); + } } return ( -
+ + {feedback === "correct" && ( +

Correct!

+ )} + {error &&

{error}

}
-
diff --git a/frontend/src/components/ResultPanel.test.tsx b/frontend/src/components/ResultPanel.test.tsx new file mode 100644 index 000000000..e693ee3c9 --- /dev/null +++ b/frontend/src/components/ResultPanel.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import type { Guess } from "../services/api.js"; +import { ResultPanel } from "./ResultPanel.js"; + +function makeGuess(name: string, text: string, isCorrect: boolean): Guess { + return { participantId: "pid", participantName: name, text, isCorrect, timestamp: "2024-01-01T00:00:00Z" }; +} + +describe("ResultPanel", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + try { + await act(async () => root.unmount()); + } catch { + // already unmounted + } + container.remove(); + }); + + it("renders each guess's submitter name", async () => { + const guesses = [makeGuess("Alice", "banana", false), makeGuess("Bob", "rocket", true)]; + await act(async () => { root.render(createElement(ResultPanel, { guesses })); }); + expect(container.textContent).toContain("Alice"); + expect(container.textContent).toContain("Bob"); + }); + + it("renders each guess's text", async () => { + const guesses = [makeGuess("Alice", "banana", false)]; + await act(async () => { root.render(createElement(ResultPanel, { guesses })); }); + expect(container.textContent).toContain("banana"); + }); + + it("renders most recent guess first (reversed from input order)", async () => { + const guesses = [makeGuess("Alice", "first", false), makeGuess("Bob", "second", false)]; + await act(async () => { root.render(createElement(ResultPanel, { guesses })); }); + const names = Array.from(container.querySelectorAll(".result-panel__name")).map((el) => el.textContent); + expect(names[0]).toBe("Bob"); + expect(names[1]).toBe("Alice"); + }); + + it("applies correct CSS class to correct guesses", async () => { + const guesses = [makeGuess("Alice", "rocket", true)]; + await act(async () => { root.render(createElement(ResultPanel, { guesses })); }); + const item = container.querySelector(".result-panel__item"); + expect(item?.classList.contains("result-panel__item--correct")).toBe(true); + }); + + it("does not apply correct CSS class to incorrect guesses", async () => { + const guesses = [makeGuess("Alice", "banana", false)]; + await act(async () => { root.render(createElement(ResultPanel, { guesses })); }); + const item = container.querySelector(".result-panel__item"); + expect(item?.classList.contains("result-panel__item--correct")).toBe(false); + }); + + it("renders empty state gracefully when guesses array is empty", async () => { + await act(async () => { root.render(createElement(ResultPanel, { guesses: [] })); }); + expect(container.querySelector(".result-panel__empty")).not.toBeNull(); + expect(container.querySelector(".result-panel__item")).toBeNull(); + }); +}); diff --git a/frontend/src/components/ResultPanel.tsx b/frontend/src/components/ResultPanel.tsx index 447be42e0..ce0712c95 100644 --- a/frontend/src/components/ResultPanel.tsx +++ b/frontend/src/components/ResultPanel.tsx @@ -1,11 +1,30 @@ +import type { Guess } from "../services/api"; import { Card } from "./Card"; -export function ResultPanel() { +interface ResultPanelProps { + guesses: Guess[]; +} + +export function ResultPanel({ guesses }: ResultPanelProps) { + const ordered = [...guesses].reverse(); + return ( -
-

Game activity and guesses will appear here.

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

No guesses yet.

+ ) : ( +
    + {ordered.map((g, i) => ( +
  • + {g.participantName} + {g.text} +
  • + ))} +
+ )}
); } diff --git a/frontend/src/components/Scoreboard.test.tsx b/frontend/src/components/Scoreboard.test.tsx new file mode 100644 index 000000000..c4ccc284b --- /dev/null +++ b/frontend/src/components/Scoreboard.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import type { Participant } from "../services/api.js"; +import { Scoreboard } from "./Scoreboard.js"; + +function makeParticipant(id: string, name: string, score: number): Participant { + return { id, name, joinedAt: "2024-01-01T00:00:00Z", isHost: false, score }; +} + +describe("Scoreboard", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + try { + await act(async () => root.unmount()); + } catch { + // already unmounted + } + container.remove(); + }); + + it("renders each participant's name", async () => { + const participants = [makeParticipant("a", "Alice", 100), makeParticipant("b", "Bob", 0)]; + await act(async () => { root.render(createElement(Scoreboard, { participants })); }); + expect(container.textContent).toContain("Alice"); + expect(container.textContent).toContain("Bob"); + }); + + it("renders each participant's score", async () => { + const participants = [makeParticipant("a", "Alice", 100), makeParticipant("b", "Bob", 0)]; + await act(async () => { root.render(createElement(Scoreboard, { participants })); }); + expect(container.textContent).toContain("100"); + expect(container.textContent).toContain("0"); + }); + + it("renders the participant with the highest score first", async () => { + const participants = [makeParticipant("a", "Alice", 0), makeParticipant("b", "Bob", 100)]; + await act(async () => { root.render(createElement(Scoreboard, { participants })); }); + const names = Array.from(container.querySelectorAll(".scoreboard__name")).map((el) => el.textContent); + expect(names[0]).toBe("Bob"); + expect(names[1]).toBe("Alice"); + }); + + it("sorts participants by score descending", async () => { + const participants = [ + makeParticipant("a", "Alice", 200), + makeParticipant("b", "Bob", 100), + makeParticipant("c", "Carol", 300) + ]; + await act(async () => { root.render(createElement(Scoreboard, { participants })); }); + const scores = Array.from(container.querySelectorAll(".scoreboard__score")).map( + (el) => Number(el.textContent) + ); + expect(scores).toEqual([300, 200, 100]); + }); + + it("renders '0' for participants with no score", async () => { + const participants = [makeParticipant("a", "Alice", 0)]; + await act(async () => { root.render(createElement(Scoreboard, { participants })); }); + expect(container.querySelector(".scoreboard__score")?.textContent).toBe("0"); + }); +}); diff --git a/frontend/src/components/Scoreboard.tsx b/frontend/src/components/Scoreboard.tsx index 647c734f4..b89ce65d3 100644 --- a/frontend/src/components/Scoreboard.tsx +++ b/frontend/src/components/Scoreboard.tsx @@ -1,14 +1,23 @@ +import type { Participant } from "../services/api"; import { Card } from "./Card"; -export function Scoreboard() { +interface ScoreboardProps { + participants: Participant[]; +} + +export function Scoreboard({ participants }: ScoreboardProps) { + const sorted = [...participants].sort((a, b) => b.score - a.score); + return ( -
-
- Waiting for players... - 0 -
-
+
    + {sorted.map((p) => ( +
  • + {p.name} + {p.score} +
  • + ))} +
); } diff --git a/frontend/src/pages/CreateRoomPage.test.tsx b/frontend/src/pages/CreateRoomPage.test.tsx new file mode 100644 index 000000000..f3ed7f4c8 --- /dev/null +++ b/frontend/src/pages/CreateRoomPage.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +// Required for React 18 act() to work correctly in test environments +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; + +const mockNavigate = vi.hoisted(() => vi.fn()); +const mockCreateRoom = vi.hoisted(() => vi.fn()); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => mockNavigate +})); + +vi.mock("../state/roomStore.js", () => ({ + useRoomStore: () => ({ createRoom: mockCreateRoom }) +})); + +import { CreateRoomPage } from "./CreateRoomPage.js"; + +function setInputValue(input: HTMLInputElement, value: string) { + const nativeValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + nativeValueSetter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("CreateRoomPage", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + mockNavigate.mockClear(); + mockCreateRoom.mockClear(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + try { + await act(async () => root.unmount()); + } catch { + // already unmounted + } + container.remove(); + vi.clearAllMocks(); + }); + + // ── US1: Player Name Validation ───────────────────────────────────────────── + + it("shows error and does not call createRoom when name exceeds 20 characters", async () => { + await act(async () => { root.render(createElement(CreateRoomPage)); }); + + const input = container.querySelector("input"); + expect(input).not.toBeNull(); + + await act(async () => { setInputValue(input!, "A".repeat(21)); }); + + const form = container.querySelector("form")!; + await act(async () => { form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); }); + + expect(container.textContent).toContain("Name must be 20 characters or fewer"); + expect(mockCreateRoom).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee34..6d55c2874 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -12,9 +12,19 @@ export function CreateRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + if (!playerName.trim()) { + setError("Player name is required"); + return; + } + + if (playerName.trim().length > 20) { + setError("Name must be 20 characters or fewer"); + return; + } + try { setError(null); - await roomStore.createRoom(playerName); + await roomStore.createRoom(playerName.trim()); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to create room"); @@ -36,6 +46,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.test.tsx b/frontend/src/pages/GamePage.test.tsx new file mode 100644 index 000000000..aaf22c204 --- /dev/null +++ b/frontend/src/pages/GamePage.test.tsx @@ -0,0 +1,224 @@ +// @vitest-environment jsdom +// Required for React 18 act() to work correctly in test environments +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import type { RoomSnapshot } from "../services/api.js"; + +const mockNavigate = vi.hoisted(() => vi.fn()); + +const roomStateRef = vi.hoisted(() => ({ + room: null as RoomSnapshot | null, + participantId: null as string | null, + error: null as string | null, + isLoading: false +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => mockNavigate +})); + +const mockStore = vi.hoisted(() => ({ + fetchRoom: vi.fn().mockResolvedValue(undefined), + endRound: vi.fn().mockResolvedValue(undefined), + updateCanvas: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock("../state/roomStore.js", () => ({ + useRoomStore: () => mockStore, + useRoomState: () => roomStateRef +})); + +import { GamePage } from "./GamePage.js"; + +function makeInGameRoom(overrides: Partial = {}): RoomSnapshot { + return { + code: "ABCD", + status: "in-game", + participants: [ + { id: "host-id", name: "Alice", joinedAt: "2024-01-01T00:00:00Z", isHost: true, score: 0 }, + { id: "guest-id", name: "Bob", joinedAt: "2024-01-01T00:00:00Z", isHost: false, score: 0 } + ], + drawerId: "host-id", + guesses: [], + canvasData: "", + ...overrides + }; +} + +describe("GamePage", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + mockNavigate.mockClear(); + roomStateRef.room = makeInGameRoom(); + roomStateRef.participantId = "host-id"; + roomStateRef.error = null; + roomStateRef.isLoading = false; + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + try { + await act(async () => root.unmount()); + } catch { + // already unmounted + } + container.remove(); + vi.clearAllMocks(); + }); + + // ── US2: Drawer role indicator ─────────────────────────────────────────────── + + it("shows 'You are drawing!' when participantId matches drawerId", async () => { + roomStateRef.participantId = "host-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + expect(container.textContent).toContain("You are drawing!"); + }); + + it("shows drawer's name when participantId does not match drawerId", async () => { + roomStateRef.participantId = "guest-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + expect(container.textContent).toContain("Alice"); + }); + + it("does not show 'You are drawing!' when participantId does not match drawerId", async () => { + roomStateRef.participantId = "guest-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + expect(container.textContent).not.toContain("You are drawing!"); + }); + + // ── US3: Secret word display ───────────────────────────────────────────────── + + it("shows the secret word when room.secretWord is set and viewer is drawer", async () => { + roomStateRef.participantId = "host-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id", secretWord: "rocket" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + expect(container.textContent).toContain("rocket"); + }); + + it("does not show the secret word when viewer is a guesser (no secretWord)", async () => { + roomStateRef.participantId = "guest-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + expect(container.textContent).not.toContain("rocket"); + }); + + // ── US1: Drawer canvas ─────────────────────────────────────────────────────── + + it("renders a canvas element (not placeholder div) for the drawer", async () => { + roomStateRef.participantId = "host-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + expect(container.querySelector("canvas")).not.toBeNull(); + expect(container.querySelector(".canvas-placeholder")).toBeNull(); + }); + + it("shows a Clear button in the drawer view", async () => { + roomStateRef.participantId = "host-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons.some((b) => b.textContent?.toLowerCase().includes("clear"))).toBe(true); + }); + + it("does not render a canvas element for the guesser", async () => { + roomStateRef.participantId = "guest-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + expect(container.querySelector("canvas")).toBeNull(); + }); + + // ── US2: Guesser canvas display ────────────────────────────────────────────── + + it("guesser view renders an img element with src from room.canvasData", async () => { + roomStateRef.participantId = "guest-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id", canvasData: "data:image/png;base64,abc" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + const img = container.querySelector("img.canvas-display") as HTMLImageElement | null; + expect(img).not.toBeNull(); + expect(img!.src).toContain("data:image/png;base64,abc"); + }); + + it("guesser view renders img with no src when canvasData is empty", async () => { + roomStateRef.participantId = "guest-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id", canvasData: "" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + const img = container.querySelector("img.canvas-display") as HTMLImageElement | null; + expect(img).not.toBeNull(); + expect(img!.getAttribute("src")).toBeNull(); + }); + + // ── US3: GuessForm not shown for drawer ────────────────────────────────────── + + it("does not render GuessForm for the drawer", async () => { + roomStateRef.participantId = "host-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + expect(container.querySelector(".guess-form")).toBeNull(); + }); + + // ── US1: End Round button + /results redirect ───────────────────────────────── + + it("shows End Round button for host", async () => { + roomStateRef.participantId = "host-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons.some((b) => b.textContent?.toLowerCase().includes("end round"))).toBe(true); + }); + + it("does not show End Round button for non-host", async () => { + roomStateRef.participantId = "guest-id"; + roomStateRef.room = makeInGameRoom({ drawerId: "host-id" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons.some((b) => b.textContent?.toLowerCase().includes("end round"))).toBe(false); + }); + + it("navigates to /results when room status changes to results", async () => { + roomStateRef.participantId = "host-id"; + roomStateRef.room = makeInGameRoom({ status: "results" }); + + await act(async () => { root.render(createElement(GamePage)); }); + + expect(mockNavigate).toHaveBeenCalledWith("/results", { replace: true }); + }); +}); diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183e3..890689532 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,15 +1,17 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; 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 store = useRoomStore(); const { room, participantId } = useRoomState(); + const canvasRef = useRef(null); useEffect(() => { if (!room) { @@ -17,33 +19,127 @@ export function GamePage() { } }, [navigate, room]); + useEffect(() => { + if (room?.status === "results") { + navigate("/results", { replace: true }); + } + }, [navigate, room?.status]); + + useEffect(() => { + const intervalId = setInterval(() => { + store.fetchRoom(); + }, 2000); + return () => clearInterval(intervalId); + }, [store]); + + const isDrawer = participantId !== null && participantId === room?.drawerId; + const isHost = room?.participants.some((p) => p.id === participantId && p.isHost) ?? false; + + const handleClear = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || !room || !participantId) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + store.updateCanvas(room.code, participantId, ""); + }, [room, participantId, store]); + + useEffect(() => { + if (!isDrawer || !room || !participantId) return; + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.lineWidth = 3; + ctx.strokeStyle = "#000000"; + ctx.lineCap = "round"; + + let drawing = false; + + const onPointerDown = (e: PointerEvent) => { + drawing = true; + ctx.beginPath(); + ctx.moveTo(e.offsetX, e.offsetY); + }; + + const onPointerMove = (e: PointerEvent) => { + if (!drawing) return; + ctx.lineTo(e.offsetX, e.offsetY); + ctx.stroke(); + }; + + const onPointerUp = () => { + if (!drawing) return; + drawing = false; + store.updateCanvas(room.code, participantId, canvas.toDataURL("image/png")); + }; + + canvas.addEventListener("pointerdown", onPointerDown); + canvas.addEventListener("pointermove", onPointerMove); + canvas.addEventListener("pointerup", onPointerUp); + + return () => { + canvas.removeEventListener("pointerdown", onPointerDown); + canvas.removeEventListener("pointermove", onPointerMove); + canvas.removeEventListener("pointerup", onPointerUp); + }; + }, [isDrawer, room, participantId, store]); + if (!room) { return null; } const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const drawer = room.participants.find((p) => p.id === room.drawerId) ?? null; return (
Round 1 -

Guess the Word!

+

+ {isDrawer ? "You are drawing!" : `Drawing: ${drawer?.name ?? "Unknown"}`} +

+ {isDrawer && room.secretWord ? ( + +

{room.secretWord}

+
+ ) : null} + -
- Waiting for drawer... -
+ {isDrawer ? ( +
+ +
+ +
+
+ ) : ( + Drawing canvas + )}
@@ -61,13 +157,23 @@ export function GamePage() { - - - + {!isDrawer && ( + + + + )}
+ {isHost && ( + + )} diff --git a/frontend/src/pages/JoinRoomPage.test.tsx b/frontend/src/pages/JoinRoomPage.test.tsx new file mode 100644 index 000000000..abbe2ac24 --- /dev/null +++ b/frontend/src/pages/JoinRoomPage.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +// Required for React 18 act() to work correctly in test environments +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; + +const mockNavigate = vi.hoisted(() => vi.fn()); +const mockJoinRoom = vi.hoisted(() => vi.fn()); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => mockNavigate +})); + +vi.mock("../state/roomStore.js", () => ({ + useRoomStore: () => ({ joinRoom: mockJoinRoom }) +})); + +import { JoinRoomPage } from "./JoinRoomPage.js"; + +function setInputValue(input: HTMLInputElement, value: string) { + const nativeValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + nativeValueSetter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("JoinRoomPage", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + mockNavigate.mockClear(); + mockJoinRoom.mockClear(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + try { + await act(async () => root.unmount()); + } catch { + // already unmounted + } + container.remove(); + vi.clearAllMocks(); + }); + + // ── US1: Player Name Validation ───────────────────────────────────────────── + + it("shows error and does not call joinRoom when name exceeds 20 characters", async () => { + await act(async () => { root.render(createElement(JoinRoomPage)); }); + + const inputs = container.querySelectorAll("input"); + const nameInput = inputs[0]; + const codeInput = inputs[1]; + expect(nameInput).not.toBeNull(); + expect(codeInput).not.toBeNull(); + + await act(async () => { setInputValue(nameInput, "A".repeat(21)); }); + await act(async () => { setInputValue(codeInput, "ABCD"); }); + + const form = container.querySelector("form")!; + await act(async () => { form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); }); + + expect(container.textContent).toContain("Name must be 20 characters or fewer"); + expect(mockJoinRoom).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f53046..0b2da5293 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -13,9 +13,24 @@ export function JoinRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + if (!playerName.trim()) { + setError("Player name is required"); + return; + } + + if (playerName.trim().length > 20) { + setError("Name must be 20 characters or fewer"); + return; + } + + if (!roomCode.trim()) { + setError("Room code is required"); + return; + } + try { setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + await roomStore.joinRoom(roomCode.toUpperCase(), playerName.trim()); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to join room"); @@ -37,6 +52,7 @@ export function JoinRoomPage() { value={playerName} onChange={(event) => setPlayerName(event.target.value)} placeholder="Second pencil" + maxLength={20} /> diff --git a/frontend/src/pages/LobbyPage.test.tsx b/frontend/src/pages/LobbyPage.test.tsx new file mode 100644 index 000000000..809c5a6f2 --- /dev/null +++ b/frontend/src/pages/LobbyPage.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +// Required for React 18 act() to work correctly in test environments +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import type { RoomSnapshot } from "../services/api.js"; + +const mockNavigate = vi.hoisted(() => vi.fn()); +const mockFetchRoom = vi.hoisted(() => vi.fn()); +const mockStartGame = vi.hoisted(() => vi.fn()); + +// Mutable snapshot — mutate .room / .participantId per test in beforeEach +const roomStateRef = vi.hoisted(() => ({ + room: null as RoomSnapshot | null, + participantId: null as string | null, + error: null as string | null, + isLoading: false +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => mockNavigate +})); + +vi.mock("../state/roomStore.js", () => ({ + useRoomStore: () => ({ fetchRoom: mockFetchRoom, startGame: mockStartGame }), + useRoomState: () => roomStateRef +})); + +import { LobbyPage } from "./LobbyPage.js"; + +function makeRoom(overrides: Partial = {}): RoomSnapshot { + return { + code: "ABCD", + status: "lobby", + participants: [ + { id: "host-id", name: "Alice", joinedAt: "2024-01-01T00:00:00Z", isHost: true, score: 0 } + ], + drawerId: null, + guesses: [], + canvasData: "", + ...overrides + }; +} + +describe("LobbyPage", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockFetchRoom.mockResolvedValue(makeRoom()); + mockStartGame.mockResolvedValue(undefined); + mockNavigate.mockClear(); + mockFetchRoom.mockClear(); + mockStartGame.mockClear(); + roomStateRef.room = makeRoom(); + roomStateRef.participantId = "host-id"; + roomStateRef.error = null; + roomStateRef.isLoading = false; + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + try { + await act(async () => root.unmount()); + } catch { + // already unmounted in test body + } + container.remove(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + // ── US3: Auto-polling ────────────────────────────────────────────────────── + + it("polls fetchRoom every 2000ms after mount", async () => { + await act(async () => { root.render(createElement(LobbyPage)); }); + + expect(mockFetchRoom).not.toHaveBeenCalled(); + + await act(async () => { vi.advanceTimersByTime(2000); }); + expect(mockFetchRoom).toHaveBeenCalledTimes(1); + + await act(async () => { vi.advanceTimersByTime(2000); }); + expect(mockFetchRoom).toHaveBeenCalledTimes(2); + }); + + it("stops polling after unmount", async () => { + await act(async () => { root.render(createElement(LobbyPage)); }); + await act(async () => root.unmount()); + + await act(async () => { vi.advanceTimersByTime(6000); }); + expect(mockFetchRoom).not.toHaveBeenCalled(); + }); + + it("shows inline error banner when fetchRoom rejects", async () => { + mockFetchRoom.mockRejectedValue(new Error("Network error")); + + await act(async () => { root.render(createElement(LobbyPage)); }); + await act(async () => { vi.advanceTimersByTime(2000); }); + + expect(container.querySelector('[data-testid="poll-error"]')).not.toBeNull(); + }); + + it("clears error banner when fetchRoom succeeds after a rejection", async () => { + mockFetchRoom + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValue(makeRoom()); + + await act(async () => { root.render(createElement(LobbyPage)); }); + + await act(async () => { vi.advanceTimersByTime(2000); }); + expect(container.querySelector('[data-testid="poll-error"]')).not.toBeNull(); + + await act(async () => { vi.advanceTimersByTime(2000); }); + expect(container.querySelector('[data-testid="poll-error"]')).toBeNull(); + }); + + // ── US4: Host controls ───────────────────────────────────────────────────── + + it("shows Start Game button only when participant is the host", async () => { + roomStateRef.participantId = "host-id"; + await act(async () => { root.render(createElement(LobbyPage)); }); + + expect(container.querySelector('[data-testid="start-game"]')).not.toBeNull(); + }); + + it("hides Start Game button when participant is not the host", async () => { + roomStateRef.participantId = "guest-id"; + await act(async () => { root.render(createElement(LobbyPage)); }); + + expect(container.querySelector('[data-testid="start-game"]')).toBeNull(); + }); + + it("disables Start Game button when fewer than 2 participants are present", async () => { + roomStateRef.participantId = "host-id"; + // room has only 1 participant (host alone) + await act(async () => { root.render(createElement(LobbyPage)); }); + + const btn = container.querySelector('[data-testid="start-game"]'); + expect(btn).not.toBeNull(); + expect(btn!.disabled).toBe(true); + }); + + it("navigates to /game when room status is in-game", async () => { + roomStateRef.room = makeRoom({ status: "in-game" }); + await act(async () => { root.render(createElement(LobbyPage)); }); + + expect(mockNavigate).toHaveBeenCalledWith("/game", expect.objectContaining({ replace: true })); + }); +}); diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd284..e20b5c2d8 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -3,13 +3,14 @@ import { useNavigate } from "react-router-dom"; import { Card } from "../components/Card"; import { PageHeader } from "../components/PageHeader"; import { RoomCodeBadge } from "../components/RoomCodeBadge"; +import { ApiError } from "../services/api"; import { useRoomState, useRoomStore } from "../state/roomStore"; export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - const { room, error, isLoading } = useRoomState(); - const [refreshError, setRefreshError] = useState(null); + const { room, participantId, isLoading } = useRoomState(); + const [pollError, setPollError] = useState(null); useEffect(() => { if (!room) { @@ -17,19 +18,49 @@ export function LobbyPage() { } }, [navigate, room]); - async function handleRefresh() { - try { - setRefreshError(null); - await roomStore.fetchRoom(); - } catch (caughtError) { - setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); + useEffect(() => { + if (room?.status === "in-game") { + navigate("/game", { replace: true }); } - } + }, [navigate, room?.status]); + + useEffect(() => { + const intervalId = setInterval(() => { + roomStore.fetchRoom().then(() => { + setPollError(null); + }).catch((err: unknown) => { + // 404 means the server is back up but the room is gone (e.g. server + // restarted and wiped in-memory state). Treat this as "recovered" — + // clear the banner so the lobby stays visible rather than navigating away. + if (err instanceof ApiError && err.status === 404) { + setPollError(null); + return; + } + setPollError(err instanceof Error ? err.message : "Unable to refresh room"); + }); + }, 2000); + + return () => clearInterval(intervalId); + }, [roomStore]); if (!room) { return null; } + const isHost = room.participants.some( + (p) => p.id === participantId && p.isHost + ); + const canStart = room.participants.length >= 2; + + async function handleStartGame() { + if (!participantId) return; + try { + await roomStore.startGame(room!.code, participantId); + } catch (err) { + setPollError(err instanceof Error ? err.message : "Unable to start game"); + } + } + return (
@@ -41,6 +72,12 @@ export function LobbyPage() {
+ {pollError ? ( +

+ {pollError} +

+ ) : null} +
{room.participants.length === 0 ? ( @@ -50,7 +87,9 @@ export function LobbyPage() { {room.participants.map((participant) => (
  • {participant.name} - joined + + {participant.isHost ? "host" : "joined"} +
  • ))} @@ -58,21 +97,31 @@ export function LobbyPage() {
    -

    +

    {isLoading ? "Refreshing players..." : "Ready to play"}

    -

    {error ?? refreshError ?? "Waiting for the host to start the game."}

    +

    Waiting for the host to start the game.

    -
    - - -
    + {isHost ? ( +
    + +
    + ) : null}
    ); } diff --git a/frontend/src/pages/ResultsPage.test.tsx b/frontend/src/pages/ResultsPage.test.tsx new file mode 100644 index 000000000..1233c254d --- /dev/null +++ b/frontend/src/pages/ResultsPage.test.tsx @@ -0,0 +1,202 @@ +// @vitest-environment jsdom +// Required for React 18 act() to work correctly in test environments +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import type { RoomSnapshot } from "../services/api.js"; + +const mockNavigate = vi.hoisted(() => vi.fn()); + +const roomStateRef = vi.hoisted(() => ({ + room: null as RoomSnapshot | null, + participantId: null as string | null, + error: null as string | null, + isLoading: false +})); + +const mockStore = vi.hoisted(() => ({ + fetchRoom: vi.fn().mockResolvedValue(undefined), + restartGame: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => mockNavigate +})); + +vi.mock("../state/roomStore.js", () => ({ + useRoomStore: () => mockStore, + useRoomState: () => roomStateRef +})); + +import { ResultsPage } from "./ResultsPage.js"; + +function makeResultsRoom(overrides: Partial = {}): RoomSnapshot { + return { + code: "ABCD", + status: "results", + participants: [ + { id: "host-id", name: "Alice", joinedAt: "2024-01-01T00:00:00Z", isHost: true, score: 100 }, + { id: "guest-id", name: "Bob", joinedAt: "2024-01-01T00:00:00Z", isHost: false, score: 0 } + ], + drawerId: "host-id", + secretWord: "rocket", + guesses: [], + canvasData: "", + ...overrides + }; +} + +describe("ResultsPage", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + mockNavigate.mockClear(); + mockStore.fetchRoom.mockClear(); + mockStore.restartGame.mockClear(); + roomStateRef.room = makeResultsRoom(); + roomStateRef.participantId = "host-id"; + roomStateRef.error = null; + roomStateRef.isLoading = false; + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + try { + await act(async () => root.unmount()); + } catch { + // already unmounted + } + container.remove(); + vi.clearAllMocks(); + }); + + // ── Guard: no room ─────────────────────────────────────────────────────────── + + it("navigates to / when room is null", async () => { + roomStateRef.room = null; + + await act(async () => { root.render(createElement(ResultsPage)); }); + + expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); + }); + + // ── Stay on results ────────────────────────────────────────────────────────── + + it("does not navigate away when room status is results", async () => { + roomStateRef.room = makeResultsRoom({ status: "results" }); + + await act(async () => { root.render(createElement(ResultsPage)); }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + // ── Status redirect: in-game ───────────────────────────────────────────────── + + it("navigates to /game when room status is in-game", async () => { + roomStateRef.room = makeResultsRoom({ status: "in-game" }); + + await act(async () => { root.render(createElement(ResultsPage)); }); + + expect(mockNavigate).toHaveBeenCalledWith("/game", { replace: true }); + }); + + // ── Status redirect: lobby ─────────────────────────────────────────────────── + + it("navigates to /lobby when room status is lobby", async () => { + roomStateRef.room = makeResultsRoom({ status: "lobby" }); + + await act(async () => { root.render(createElement(ResultsPage)); }); + + expect(mockNavigate).toHaveBeenCalledWith("/lobby", { replace: true }); + }); + + // ── Secret word display ────────────────────────────────────────────────────── + + it("renders the secret word", async () => { + roomStateRef.room = makeResultsRoom({ secretWord: "rocket" }); + + await act(async () => { root.render(createElement(ResultsPage)); }); + + expect(container.textContent).toContain("rocket"); + }); + + // ── Scoreboard rendered ────────────────────────────────────────────────────── + + it("renders participant names in the scoreboard", async () => { + await act(async () => { root.render(createElement(ResultsPage)); }); + + expect(container.textContent).toContain("Alice"); + expect(container.textContent).toContain("Bob"); + }); + + // ── ResultPanel rendered ───────────────────────────────────────────────────── + + it("renders the guess history panel", async () => { + await act(async () => { root.render(createElement(ResultsPage)); }); + + expect(container.querySelector(".result-panel, .result-panel__empty")).not.toBeNull(); + }); + + // ── Polling ────────────────────────────────────────────────────────────────── + + it("starts a polling interval that calls store.fetchRoom", async () => { + vi.useFakeTimers(); + + await act(async () => { root.render(createElement(ResultsPage)); }); + await act(async () => { vi.advanceTimersByTime(2000); }); + + expect(mockStore.fetchRoom).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + // ── US2: Host restart button ───────────────────────────────────────────────── + + it("shows Back to Lobby button for host", async () => { + roomStateRef.participantId = "host-id"; + + await act(async () => { root.render(createElement(ResultsPage)); }); + + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons.some((b) => b.textContent?.toLowerCase().includes("lobby"))).toBe(true); + }); + + it("hides Back to Lobby button for non-host", async () => { + roomStateRef.participantId = "guest-id"; + + await act(async () => { root.render(createElement(ResultsPage)); }); + + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons.some((b) => b.textContent?.toLowerCase().includes("lobby"))).toBe(false); + }); + + it("shows waiting message for non-host", async () => { + roomStateRef.participantId = "guest-id"; + + await act(async () => { root.render(createElement(ResultsPage)); }); + + expect(container.textContent?.toLowerCase()).toContain("waiting"); + }); + + it("calls store.restartGame when host clicks Back to Lobby", async () => { + roomStateRef.participantId = "host-id"; + + await act(async () => { root.render(createElement(ResultsPage)); }); + + const buttons = Array.from(container.querySelectorAll("button")); + const lobbyBtn = buttons.find((b) => b.textContent?.toLowerCase().includes("lobby")); + expect(lobbyBtn).toBeDefined(); + + await act(async () => { lobbyBtn!.click(); }); + + expect(mockStore.restartGame).toHaveBeenCalledWith("ABCD", "host-id"); + }); +}); diff --git a/frontend/src/pages/ResultsPage.tsx b/frontend/src/pages/ResultsPage.tsx new file mode 100644 index 000000000..2c61ed2bd --- /dev/null +++ b/frontend/src/pages/ResultsPage.tsx @@ -0,0 +1,74 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { Card } from "../components/Card"; +import { ResultPanel } from "../components/ResultPanel"; +import { RoomCodeBadge } from "../components/RoomCodeBadge"; +import { Scoreboard } from "../components/Scoreboard"; +import { useRoomState, useRoomStore } from "../state/roomStore"; + +export function ResultsPage() { + const navigate = useNavigate(); + const store = useRoomStore(); + const { room, participantId } = useRoomState(); + + useEffect(() => { + if (!room) { + navigate("/", { replace: true }); + } + }, [navigate, room]); + + useEffect(() => { + if (room?.status === "lobby") { + navigate("/lobby", { replace: true }); + } else if (room?.status === "in-game") { + navigate("/game", { replace: true }); + } + }, [navigate, room?.status]); + + useEffect(() => { + const intervalId = setInterval(() => { + store.fetchRoom(); + }, 2000); + return () => clearInterval(intervalId); + }, [store]); + + if (!room) { + return null; + } + + const isHost = room.participants.some((p) => p.id === participantId && p.isHost); + + function handleRestart() { + if (!room || !participantId) return; + store.restartGame(room.code, participantId); + } + + return ( +
    +
    + Round Complete +

    Results

    + +
    + + +

    {room.secretWord}

    +
    + +
    + + +
    + +
    + {isHost ? ( + + ) : ( +

    Waiting for host to restart…

    + )} +
    +
    + ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 1d15a3f26..67dc3858c 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -4,6 +4,7 @@ import { CreateRoomPage } from "../pages/CreateRoomPage"; import { GamePage } from "../pages/GamePage"; import { JoinRoomPage } from "../pages/JoinRoomPage"; import { LobbyPage } from "../pages/LobbyPage"; +import { ResultsPage } from "../pages/ResultsPage"; import { StartPage } from "../pages/StartPage"; export function AppRoutes() { @@ -16,6 +17,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d86..5ddb16b14 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,17 +1,27 @@ -export type ParticipantRole = "drawer" | "guesser"; +export interface Guess { + participantId: string; + participantName: string; + text: string; + isCorrect: boolean; + timestamp: string; +} export interface Participant { id: string; name: string; joinedAt: string; + isHost: boolean; + score: number; } export interface RoomSnapshot { code: string; - status: "lobby"; + status: "lobby" | "in-game" | "results"; participants: Participant[]; - availableWords: string[]; - roles: ParticipantRole[]; + drawerId: string | null; + secretWord?: string; + guesses: Guess[]; + canvasData: string; } export interface RoomSessionResponse { @@ -19,7 +29,14 @@ 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"; + +export class ApiError extends Error { + constructor(public readonly status: number, message: string) { + super(message); + this.name = "ApiError"; + } +} async function request(path: string, init?: RequestInit) { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -35,7 +52,7 @@ async function request(path: string, init?: RequestInit) { message?: string; }; - throw new Error(errorBody.message ?? "Request failed"); + throw new ApiError(response.status, errorBody.message ?? "Request failed"); } return (await response.json()) as T; @@ -57,5 +74,41 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + startGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + submitGuess(code: string, participantId: string, guess: string) { + return request<{ isCorrect: boolean; room: RoomSnapshot }>( + `/rooms/${encodeURIComponent(code)}/guess`, + { + method: "POST", + body: JSON.stringify({ participantId, guess }) + } + ); + }, + updateCanvas(code: string, participantId: string, canvasData: string) { + return request<{ ok: true }>( + `/rooms/${encodeURIComponent(code)}/drawing`, + { + method: "POST", + body: JSON.stringify({ participantId, canvasData }) + } + ); + }, + endRound(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/end`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + restartGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/restart`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd37396..760807a90 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, ApiError, type RoomSessionResponse, type RoomSnapshot } from "../services/api"; export interface RoomState { room: RoomSnapshot | null; @@ -98,6 +98,32 @@ class RoomStore { this.setRoomSnapshot(response.room); return response.room; } + + async startGame(code: string, participantId: string) { + const response = await this.withLoading(() => api.startGame(code, participantId)); + this.setRoomSnapshot(response.room); + return response.room; + } + + async submitGuess(code: string, participantId: string, guess: string) { + const response = await api.submitGuess(code, participantId, guess); + this.setRoomSnapshot(response.room); + return response.isCorrect; + } + + async updateCanvas(code: string, participantId: string, canvasData: string) { + await api.updateCanvas(code, participantId, canvasData); + } + + async endRound(code: string, participantId: string) { + const response = await api.endRound(code, participantId); + this.setRoomSnapshot(response.room); + } + + async restartGame(code: string, participantId: string) { + const response = await api.restartGame(code, participantId); + this.setRoomSnapshot(response.room); + } } const RoomStoreContext = createContext(null); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index c929a6ddf..21424319e 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -451,6 +451,118 @@ input { font-weight: 500; } +.drawing-canvas { + display: block; + background: #ffffff; + border: 1px solid var(--line); + border-radius: 8px; + cursor: crosshair; + max-width: 100%; + touch-action: none; +} + +.canvas-display { + display: block; + max-width: 100%; + min-height: 300px; + background: #ffffff; + border: 1px solid var(--line); + border-radius: 8px; +} + +.canvas-wrapper { + display: flex; + flex-direction: column; + gap: 8px; +} + +.scoreboard { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.scoreboard__row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid var(--line); + font-size: 0.875rem; +} + +.scoreboard__row:last-child { + border-bottom: none; +} + +.scoreboard__name { + color: var(--ink); +} + +.scoreboard__score { + color: var(--ink); + font-variant-numeric: tabular-nums; +} + +.result-panel { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 240px; + overflow-y: auto; +} + +.result-panel__item { + display: flex; + gap: 6px; + font-size: 0.8125rem; + padding: 4px 0; + border-bottom: 1px solid var(--line); +} + +.result-panel__item:last-child { + border-bottom: none; +} + +.result-panel__item--correct { + color: #16a34a; + font-weight: 600; +} + +.result-panel__name { + font-weight: 500; + flex-shrink: 0; +} + +.result-panel__text { + color: inherit; +} + +.result-panel__empty { + font-size: 0.875rem; + color: var(--ink-soft); + margin: 0; +} + +.guess-form__feedback--correct { + color: #16a34a; + font-weight: 600; + font-size: 0.9375rem; + margin: 0 0 8px; +} + +.guess-form__error { + color: #dc2626; + font-size: 0.875rem; + margin: 0 0 8px; +} + /* --- GAME PAGE LAYOUT --- */ /* Changed layout structure to be much wider and utilize screen space */ .game-page { @@ -543,6 +655,48 @@ input { font-weight: 500; } +/* Results page */ +.results-page { + display: grid; + gap: 32px; +} + +.results-page__header { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.results-page__title { + font-size: 2rem; + font-weight: 700; + color: var(--ink); + margin: 0; +} + +.results-page__word { + font-size: 1.5rem; + font-weight: 700; + color: var(--ink); + margin: 0; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.results-page__columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + align-items: start; +} + +.results-page__waiting { + color: var(--ink-soft); + font-size: 1rem; + margin: 0; +} + @media (max-width: 720px) { .app-shell { padding: 24px 16px; @@ -554,10 +708,15 @@ input { .hero, .placeholder-page, - .game-page { + .game-page, + .results-page { padding: 24px 16px; } + .results-page__columns { + grid-template-columns: 1fr; + } + .button { width: 100%; } 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 000000000..8c6145f53 --- /dev/null +++ b/specs/001-room-setup-lobby/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Room Setup & Lobby + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-12 +**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 `/speckit-plan`. +- Clarification session 2026-06-14: 3 questions answered — polling failure UX (inline banner), room code format (4 uppercase letters), player disconnect behaviour (no removal until game starts). +- FR-011 added for polling failure banner; FR-002 updated with 4-letter code format; Assumptions updated with disconnect/no-leave behaviour. +- Edge cases documented: in-game room join, case-insensitive codes, polling failure (resolved). +- Assumptions section explicitly bounds scope (no max room size, no auth, case normalisation, no disconnect detection). 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 000000000..e3a59098c --- /dev/null +++ b/specs/001-room-setup-lobby/contracts/api.md @@ -0,0 +1,178 @@ +# API Contracts: Room Setup & Lobby + +**Branch**: `001-room-setup-lobby` | **Date**: 2026-06-14 +**Base URL**: `http://localhost:3001` + +--- + +## POST /api/rooms + +Create a new room. The caller becomes the host. + +### Request + +``` +Content-Type: application/json + +{ + "playerName": string // required; non-empty +} +``` + +### Response — 201 Created + +```json +{ + "participantId": "uuid-string", + "room": { + "code": "KART", + "status": "lobby", + "participants": [ + { + "id": "uuid-string", + "name": "Alice", + "joinedAt": "2026-06-14T10:00:00.000Z", + "isHost": true + } + ], + "availableWords": ["..."], + "roles": ["drawer", "guesser"] + } +} +``` + +### Error responses + +| Status | Condition | +|--------|-----------| +| 400 | `playerName` missing or empty | + +--- + +## POST /api/rooms/:code/join + +Join an existing room by its 4-letter code. + +### Request + +``` +Content-Type: application/json + +{ + "playerName": string // required; non-empty +} +``` + +### Path parameter + +| Param | Description | +|-------|-------------| +| `code` | 4-letter room code; case-insensitive (server normalises to uppercase) | + +### Response — 200 OK + +```json +{ + "participantId": "uuid-string", + "room": { + "code": "KART", + "status": "lobby", + "participants": [ + { "id": "...", "name": "Alice", "joinedAt": "...", "isHost": true }, + { "id": "...", "name": "Bob", "joinedAt": "...", "isHost": false } + ], + "availableWords": ["..."], + "roles": ["drawer", "guesser"] + } +} +``` + +### Error responses + +| Status | Condition | +|--------|-----------| +| 400 | `playerName` missing or empty | +| 404 | Room with `code` not found | + +--- + +## GET /api/rooms/:code + +Poll for the current room snapshot. Called every ~2 s by the lobby. + +### Query parameters + +| Param | Required | Description | +|-------|----------|-------------| +| `participantId` | No | Caller's participant UUID (used for viewer-specific projections in future) | + +### Response — 200 OK + +```json +{ + "room": { + "code": "KART", + "status": "lobby", + "participants": [ + { "id": "...", "name": "Alice", "joinedAt": "...", "isHost": true }, + { "id": "...", "name": "Bob", "joinedAt": "...", "isHost": false } + ], + "availableWords": ["..."], + "roles": ["drawer", "guesser"] + } +} +``` + +### Error responses + +| Status | Condition | +|--------|-----------| +| 404 | Room not found | + +--- + +## POST /api/rooms/:code/start + +Transition a room from `"lobby"` to `"in-game"`. Host only. + +### Request + +``` +Content-Type: application/json + +{ + "participantId": string // required; must match the host participant's id +} +``` + +### Response — 200 OK + +```json +{ + "room": { + "code": "KART", + "status": "in-game", + "participants": [ ... ], + "availableWords": ["..."], + "roles": ["drawer", "guesser"] + } +} +``` + +### Error responses + +| Status | Condition | +|--------|-----------| +| 400 | `participantId` missing, or fewer than 2 participants in room | +| 403 | `participantId` does not match the host | +| 404 | Room not found | + +--- + +## Common error shape + +All error responses use: + +```json +{ "message": "Human-readable description of the error" } +``` 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 000000000..b20ade599 --- /dev/null +++ b/specs/001-room-setup-lobby/data-model.md @@ -0,0 +1,121 @@ +# Data Model: Room Setup & Lobby + +**Branch**: `001-room-setup-lobby` | **Date**: 2026-06-14 + +## Entities + +### Participant + +Represents one player inside a room. + +``` +Participant { + id: string // UUID — assigned by server on create/join + name: string // Display name; non-empty, not unique within room + joinedAt: string // ISO 8601 timestamp + isHost: boolean // true only for the room creator; false for all joiners +} +``` + +**Validation rules**: +- `name` MUST NOT be empty (enforced client-side before request; backend rejects with 400 if empty) +- `isHost` is immutable after assignment; only one participant per room can have `isHost: true` + +**State transitions**: Participants are write-once — they join and remain until the game starts +or the server restarts. There is no leave/disconnect detection. + +--- + +### Room + +Represents an active game session. + +``` +Room { + code: string // 4 uppercase letters, unique across all active rooms + status: RoomStatus // "lobby" | "in-game" + participants: Participant[] // ordered by joinedAt; index 0 is always the host + createdAt: string // ISO 8601 timestamp + updatedAt: string // ISO 8601 timestamp; updated on any mutation +} +``` + +**Validation rules**: +- `code` generated server-side; MUST match `/^[A-Z]{4}$/`; MUST be unique within active rooms +- `status` starts as `"lobby"`; transitions to `"in-game"` via the start-game endpoint +- `participants` length MUST be ≥ 2 before `status` may be set to `"in-game"` + +**State machine**: + +``` +lobby ──[host clicks Start, ≥2 participants]──► in-game +``` + +--- + +### RoomSnapshot (API response shape) + +Read-only view of a room returned to clients. Differs from `Room` in that it +omits internal timestamps and adds vocabulary data. + +``` +RoomSnapshot { + code: string // same as Room.code + status: RoomStatus // same as Room.status + participants: Participant[] // full list including isHost flag + availableWords: string[] // word list for drawer (future use) + roles: string[] // role list (future use) +} +``` + +--- + +### RoomSessionResponse + +Returned by create-room and join-room endpoints. Gives the caller their own +participant identity alongside the room snapshot. + +``` +RoomSessionResponse { + participantId: string // caller's Participant.id + room: RoomSnapshot +} +``` + +--- + +## Key Invariants + +1. Exactly one participant in a room has `isHost: true` at all times. +2. The room code is immutable after creation. +3. `status` transitions are monotonic: `"lobby"` → `"in-game"` only. +4. Joining a room with `status: "in-game"` is **out of scope** for this feature. The `joinRoom` + service does not guard against room status; this is a known limitation deferred to a future + feature. The frontend does not expose a join-game path that would trigger this scenario. + +--- + +## TypeScript Diffs (backend `src/models/game.ts`) + +```diff +- export type RoomStatus = "lobby"; ++ export type RoomStatus = "lobby" | "in-game"; + + export interface Participant { + id: string; + name: string; + joinedAt: string; ++ isHost: boolean; + } +``` + +## TypeScript Diffs (frontend `src/services/api.ts`) + +```diff + export interface Participant { + id: string; + name: string; + joinedAt: string; ++ isHost: boolean; + } +``` diff --git a/specs/001-room-setup-lobby/plan.md b/specs/001-room-setup-lobby/plan.md new file mode 100644 index 000000000..668a4d01a --- /dev/null +++ b/specs/001-room-setup-lobby/plan.md @@ -0,0 +1,143 @@ +# Implementation Plan: Room Setup & Lobby + +**Branch**: `001-room-setup-lobby` | **Date**: 2026-06-14 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/001-room-setup-lobby/spec.md` + +## Summary + +Enable players to create and join drawing-game rooms via a unique 4-letter code, with the creator +automatically designated as host. The lobby auto-refreshes every 2 seconds via HTTP polling. +Only the host can start the game once ≥ 2 participants are present; a poll-failure banner appears +on any failed refresh and clears on recovery. + +This builds on the existing Express + React scaffold by adding host-tracking to the data model, +hardening validation, introducing the start-game endpoint, and replacing the manual refresh +button with automatic polling. + +## Technical Context + +**Language/Version**: TypeScript 5.6 / Node.js 24 (backend) + TypeScript 5.6 / React 18 (frontend) + +**Primary Dependencies**: Express 4, Zod 3, React Router 6, Vite 6 — all pre-existing + +**Storage**: In-memory `Map` in `backend/src/services/roomStore.ts` — no database + +**Testing**: Vitest (both packages); jsdom for frontend component tests + +**Target Platform**: Browser (frontend) + Node.js process (backend) + +**Project Type**: Web application — two separate npm packages + +**Performance Goals**: Create/join round-trip ≤ 5 s (SC-001/SC-002); new participant visible to +all lobby views within 4 s (SC-003; two poll cycles at 2 s each) + +**Constraints**: No WebSockets; no persistent storage; no authentication (constitution III) + +**Scale/Scope**: Small in-person sessions; no concurrent-user targets needed + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. TDD | ✅ PASS | Tests written before implementation in every task | +| II. TypeScript Strict Mode | ✅ PASS | All new code compiles under strict mode; no `any` | +| III. Architecture Integrity | ✅ PASS | HTTP polling only; in-memory state; no auth | +| IV. Lean Dependency Management | ✅ PASS | Zero new npm packages introduced | +| V. Production-Ready Code | ✅ PASS | No placeholders; polling cleanup on unmount; stable refs | + +**Post-Phase-1 re-check**: No violations found in design artifacts. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-room-setup-lobby/ +├── plan.md # This file +├── spec.md # Acceptance criteria +├── research.md # Phase 0 decisions +├── data-model.md # Updated TypeScript interfaces +├── quickstart.md # Manual validation guide +├── contracts/ +│ └── api.md # HTTP endpoint contracts +└── tasks.md # Generated by /speckit-tasks (not yet created) +``` + +### Source Code — file-level changes + +```text +backend/src/ +├── models/ +│ └── game.ts ← ADD isHost: boolean to Participant +│ ADD "in-game" to RoomStatus union +├── services/ +│ ├── roomStore.ts ← UPDATE createParticipant (isHost param) +│ │ UPDATE createRoom (host = true for creator) +│ │ UPDATE joinRoom (isHost = false) +│ │ UPDATE generateCode (letters only) +│ │ ADD startGame(code, participantId) function +│ └── roomStore.test.ts ← UPDATE regex to /^[A-Z]{4}$/ +│ UPDATE host assertion (isHost: true) +│ ADD tests for startGame +└── api/ + ├── schemas.ts ← UPDATE playerName to .string().min(1) + │ ADD startGameSchema + └── rooms.ts ← ADD POST /:code/start handler + +frontend/src/ +├── services/ +│ └── api.ts ← ADD isHost: boolean to Participant interface +│ ADD startGame(code, participantId) method +├── pages/ +│ ├── CreateRoomPage.tsx ← ADD client-side empty-name validation +│ ├── JoinRoomPage.tsx ← ADD client-side empty-name + empty-code validation +│ └── LobbyPage.tsx ← REPLACE manual refresh with setInterval polling +│ ADD pollError banner (FR-011) +│ ADD host detection (isHost comparison) +│ ADD host-only Start Game button +│ ADD disabled state when < 2 participants +│ ADD navigate to /game when status = "in-game" +└── services/ + └── api.test.ts ← ADD test for startGame API call (optional) +``` + +**Structure Decision**: Web application layout (Option 2 from template). Two independent packages +(`backend/`, `frontend/`) with no shared source. + +## Data Flow + +``` +[CreateRoomPage / JoinRoomPage] + │ POST /api/rooms (or /join) + ▼ +[backend rooms.ts handler] + │ calls createRoom() / joinRoom() + ▼ +[roomStore.ts service] ← assigns isHost, generates 4-letter code + │ returns { room, participantId } + ▼ +[frontend RoomStore.setRoomSession()] + │ stores room + participantId in shared state + ▼ +[LobbyPage] + setInterval(2000ms) ──► GET /api/rooms/:code?participantId=... + │ + success → setRoomSnapshot() → re-render list + failure → set pollError → show inline banner + next success → clear pollError + +[Host clicks Start Game] + │ POST /api/rooms/:code/start { participantId } + ▼ +[backend: verifies host, room.status ← "in-game"] + │ next poll returns status "in-game" + ▼ +[LobbyPage detects status change] → navigate("/game") +``` + +## Complexity Tracking + +> No constitution violations — this section is intentionally empty. diff --git a/specs/001-room-setup-lobby/quickstart.md b/specs/001-room-setup-lobby/quickstart.md new file mode 100644 index 000000000..27a025e3e --- /dev/null +++ b/specs/001-room-setup-lobby/quickstart.md @@ -0,0 +1,76 @@ +# Quickstart: Room Setup & Lobby + +Manual validation guide for the Room Setup & Lobby feature. + +## Prerequisites + +```bash +cd backend && npm run dev # terminal 1 — http://localhost:3001 +cd frontend && npm run dev # terminal 2 — http://localhost:5173 +``` + +--- + +## Scenario 1 — Host creates a room (SC-001, FR-001, FR-002, FR-005) + +1. Open `http://localhost:5173/create-room` +2. Submit with an **empty** player name → expect: validation error shown, no navigation +3. Enter name `Alice`, submit +4. Expect: redirected to `/lobby` within 5 seconds +5. Verify: lobby shows room code (4 uppercase letters), `Alice` in participant list with host indicator + +--- + +## Scenario 2 — Guest joins via code (SC-002, FR-003, FR-004, FR-005, FR-010) + +1. Note the room code from Scenario 1 (e.g., `KART`) +2. Open a **second** browser tab → `http://localhost:5173/join-room` +3. Submit with an **empty** room code → expect: validation error, no request sent +4. Submit with an invalid code `ZZZZ` → expect: "Room not found" error displayed +5. Enter name `Bob`, code `kart` (lowercase) → submit +6. Expect: redirected to `/lobby`, `Bob` in participant list as non-host + +--- + +## Scenario 3 — Live lobby polling (SC-003, FR-006) + +1. Alice's tab (from Scenario 1) is open in the lobby — do not touch it +2. In the Bob tab (Scenario 2), note the current time +3. Open a **third** tab → `http://localhost:5173/join-room`, join as `Carol` +4. Return to Alice's tab — within ~4 seconds `Carol` should appear in the list **without** any manual action +5. Verify Bob's tab also updates within ~4 seconds + +--- + +## Scenario 4 — Host-only start button (SC-005, FR-007, FR-008) + +1. Alice's lobby (2 participants: Alice + Bob): verify **"Start Game"** button is visible and enabled for Alice +2. Bob's lobby: verify **no "Start Game"** button is visible +3. Remove all but Alice from the lobby (restart and only create, don't join) → verify button is **disabled** with a "Need 2+ players" indication + +--- + +## Scenario 5 — Host starts the game (FR-007, FR-008, US4 AC1) + +1. With Alice (host) and Bob in the lobby +2. Alice clicks **Start Game** +3. Expect: within the next poll cycle (~2 s), both tabs transition away from the lobby + +--- + +## Scenario 6 — Poll failure banner (FR-011) + +1. Start the backend, create a room, enter the lobby +2. Stop the backend server (`Ctrl+C`) +3. Within ~2 s: expect an **inline error banner** appears in the lobby +4. Restart the backend +5. Within ~2 s: expect the **banner disappears** and the list refreshes normally + +--- + +## Scenario 7 — Room isolation (SC-006, FR-009) + +1. Create Room A as `Alice` in Tab 1 +2. Create Room B as `Charlie` in Tab 2 (different code) +3. In Tab 3, join Room A as `Dave` +4. Verify: Tab 2's lobby shows only `Charlie`; Tab 1's lobby shows only `Alice` and `Dave` diff --git a/specs/001-room-setup-lobby/research.md b/specs/001-room-setup-lobby/research.md new file mode 100644 index 000000000..3f2d74668 --- /dev/null +++ b/specs/001-room-setup-lobby/research.md @@ -0,0 +1,97 @@ +# Research: Room Setup & Lobby + +**Branch**: `001-room-setup-lobby` | **Date**: 2026-06-14 + +## Decision Log + +### 1. Host Identification + +**Decision**: Add `isHost: boolean` to the `Participant` type (backend model and frontend interface). + +**Rationale**: The spec defines Participant as having "a flag indicating whether they are the host." +Embedding the flag on the participant is the simplest approach — the snapshot returned by every +`GET /api/rooms/:code` poll already carries the full participant list, so the client can derive +host status without an extra field on `RoomSnapshot`. + +**Alternatives considered**: +- `hostId` field on `Room` / `RoomSnapshot` — requires the client to do a cross-reference lookup; + rejected as more complex with no benefit. +- Returning a separate `isHost` boolean at the top level of `RoomSessionResponse` — redundant once + `Participant.isHost` exists. + +--- + +### 2. Room Code Format + +**Decision**: Change `generateCode()` alphabet from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` to +`ABCDEFGHJKLMNPQRSTUVWXYZ` (letters only, already excluding ambiguous I/O). + +**Rationale**: Clarification session confirmed 4 uppercase letters (e.g., `KART`). Digits make +codes harder to read aloud. The existing 4-character length is correct. Existing test regex +`/^[A-Z0-9]{4}$/` must be updated to `/^[A-Z]{4}$/`. + +**Alternatives considered**: +- Keep mixed alphanumeric — rejected per spec clarification. +- 6-character code — rejected per spec clarification (4 chars chosen). + +--- + +### 3. Polling Implementation + +**Decision**: `setInterval` inside a `useEffect` in `LobbyPage`, calling `roomStore.fetchRoom()` +every 2000 ms. Cleanup via `clearInterval` on unmount or when room becomes null. + +**Rationale**: Matches the project's hard constraint (HTTP polling only, no WebSockets) and the +existing `useSyncExternalStore` pattern in `roomStore.ts`. The store already has a `fetchRoom` +method that updates shared state; all subscribed components re-render automatically. + +**Poll failure handling**: `fetchRoom` throws on HTTP error. The component catches the error and +sets `pollError` state. On the next successful poll, `pollError` is cleared (FR-011). A separate +`pollError` local state variable is used (not the store's `error` field) so that the banner +does not interfere with create/join error display. + +**Alternatives considered**: +- `setInterval` inside the store — rejected; the store should not own side-effect timers. +- `useSyncExternalStore` + external scheduler — over-engineered for this scope. + +--- + +### 4. Start Game Flow + +**Decision**: New endpoint `POST /api/rooms/:code/start` with body `{ participantId: string }`. +The handler verifies the caller is the host (`room.participants.find(p => p.id === participantId && p.isHost)`), +then transitions `room.status` from `"lobby"` to `"in-game"`. + +**Rationale**: Keeps all state mutations server-side. The frontend "Start Game" button POSTs to +this endpoint; success causes the room snapshot returned on the next poll cycle to have +`status: "in-game"`, which `LobbyPage` detects and redirects all participants to `/game`. + +**Alternatives considered**: +- Client-side status mutation — rejected; would cause inconsistency between participants. +- Combined `PATCH /api/rooms/:code` — less intention-revealing; rejected. + +--- + +### 5. Client-Side Validation + +**Decision**: Add trim + non-empty checks in `CreateRoomPage` and `JoinRoomPage` before any API +call is made (FR-005). Backend schemas also changed from `z.string().optional()` to +`z.string().min(1)` as a defence-in-depth layer. + +**Rationale**: FR-005 says "validation message shown *before* any request is sent." Client-side +is the right place. Backend validation is a secondary safety net. + +--- + +### 6. Existing Scaffolding Reuse + +The following scaffold items already work and need **no structural change**, only targeted edits: + +| Item | Current state | Change needed | +|------|---------------|---------------| +| `POST /api/rooms` route | Working | Harden `playerName` validation | +| `POST /api/rooms/:code/join` route | Working | Harden `playerName` validation | +| `GET /api/rooms/:code` route | Working | None (snapshot adds `isHost` automatically) | +| `JoinRoomPage` code uppercasing | Working | Add empty-check validation | +| `RoomStore.fetchRoom()` | Working | None | +| `RoomStore.createRoom/joinRoom` | Working | None | diff --git a/specs/001-room-setup-lobby/spec.md b/specs/001-room-setup-lobby/spec.md new file mode 100644 index 000000000..f50ea675a --- /dev/null +++ b/specs/001-room-setup-lobby/spec.md @@ -0,0 +1,130 @@ +# Feature Specification: Room Setup & Lobby + +**Feature Branch**: `001-room-setup-lobby` + +**Created**: 2026-06-12 + +**Status**: Draft + +**Input**: User description: "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." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Host Creates a Room (Priority: P1) + +A player opens the app and creates a new game room. The system generates a unique room code and the creator is automatically designated as the host. The creator is taken to the lobby, where they see the room code and their own name in the participant list. + +**Why this priority**: This is the entry point of every game session. Without room creation, no other scenario is possible. + +**Independent Test**: Create a room with a valid player name and confirm a unique code is returned, the creator appears in the participant list, and the host flag is set on the creator. + +**Acceptance Scenarios**: + +1. **Given** a player enters their name on the create-room screen, **When** they submit the form, **Then** a new room with a unique code is created, the player is recorded as the host, and they are redirected to the lobby. +2. **Given** a player submits the create-room form with an empty name, **When** the form is submitted, **Then** a clear validation error is shown and no room is created. + +--- + +### User Story 2 - Player Joins a Room via Code (Priority: P1) + +A player who was given a room code enters it in the join screen along with their name. If the code is valid and the room exists, they are added to the room as a non-host participant and taken to the lobby. + +**Why this priority**: Co-equal with room creation — a game requires at least two players. + +**Independent Test**: With an existing room code, join with a second player name and confirm they appear in the participant list as a non-host. + +**Acceptance Scenarios**: + +1. **Given** a valid room code and player name, **When** a player submits the join form, **Then** they are added to the room as a participant and redirected to the lobby. +2. **Given** an invalid or non-existent room code, **When** a player submits the join form, **Then** a clear error message is shown (e.g., "Room not found") and they remain on the join screen. +3. **Given** an empty room code field, **When** a player submits the join form, **Then** a validation error is shown before any network request is made. + +--- + +### User Story 3 - Lobby Shows Live Participant List (Priority: P2) + +Once in the lobby, all participants (host and guests) see an auto-refreshing list of everyone who has joined. The list updates automatically approximately every 2 seconds without any manual action required. + +**Why this priority**: Required for all players to know when the game is ready to start, but can be validated independently of the start-game action. + +**Independent Test**: With two browser tabs (one host, one guest), open both in the lobby and confirm the second player appears in the first player's participant list within 4 seconds of joining, without a page reload. + +**Acceptance Scenarios**: + +1. **Given** two players are in the lobby, **When** a third player joins via code, **Then** all existing players see the new participant appear within approximately 2 seconds. +2. **Given** a player is in the lobby, **When** no new players join for 30 seconds, **Then** the participant list remains accurate and no error is shown. + +--- + +### User Story 4 - Host Starts the Game (Priority: P2) + +Once at least 2 players are in the lobby, the host sees a "Start Game" button. Non-host participants do not see this button. Clicking it transitions the room to the game state, which all participants observe within the next polling cycle. + +**Why this priority**: Builds directly on the lobby polling and depends on at least 2 participants being present. + +**Independent Test**: With 2 players in the lobby, confirm only the host sees the start button; clicking it changes the room status and the guest's lobby view reflects the transition within ~2 seconds. + +**Acceptance Scenarios**: + +1. **Given** the host is in the lobby with 2 or more participants, **When** the host clicks "Start Game", **Then** the room transitions out of the lobby state and all participants are redirected accordingly. +2. **Given** only 1 participant is in the lobby, **When** the host views the lobby, **Then** the "Start Game" button is disabled or absent with an indication that more players are needed. +3. **Given** a non-host participant is in the lobby, **When** they view the lobby, **Then** no "Start Game" button or control is visible to them. + +--- + +### Edge Cases + +- What happens when a player tries to join a room that is already in-game (not in lobby status)? +- How does the system handle a player submitting the join form with a code in lowercase vs uppercase? +- **Polling failure**: If a lobby poll request fails, an inline error banner MUST appear immediately. It clears automatically on the next successful poll. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST assign the "host" role to the first participant who creates a room. +- **FR-002**: The system MUST generate a unique 4-letter uppercase room code (e.g., `KART`) for each newly created room. +- **FR-003**: Players MUST be able to join a room by entering its code and a player name. +- **FR-004**: The system MUST reject join attempts with an invalid, non-existent, or empty room code with a descriptive error message. +- **FR-005**: The system MUST reject create or join attempts where the player name is empty, with a descriptive validation message shown before any request is sent. +- **FR-006**: The lobby participant list MUST refresh automatically at approximately 2-second intervals for all participants. +- **FR-007**: Only the host MUST see the "Start Game" control in the lobby. +- **FR-008**: The "Start Game" control MUST be inactive (disabled or hidden) when fewer than 2 participants are present. +- **FR-009**: Each room MUST be fully isolated: participants in one room MUST NOT see or affect participants in another room. +- **FR-010**: Room codes MUST be treated case-insensitively so that a code entered in lowercase matches the same room as one entered in uppercase. +- **FR-011**: When a lobby poll request fails, the system MUST display an inline error banner to the affected participant immediately. The banner MUST clear automatically on the next successful poll. + +### Key Entities + +- **Room**: Represents a game session. Has a unique 4-letter uppercase code, a status (lobby / in-game), and a list of participants. +- **Participant**: A player in a room. Has a name, a unique identifier, a join timestamp, and a flag indicating whether they are the host. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A player can create a room and reach the lobby in under 5 seconds from form submission. +- **SC-002**: A player can join an existing room and reach the lobby in under 5 seconds from form submission. +- **SC-003**: A new participant appears in all existing lobby views within 4 seconds of joining, without any manual page refresh. +- **SC-004**: 100% of join attempts with an invalid or empty code are rejected with a visible error message; no room state is modified. +- **SC-005**: The "Start Game" button is visible only to the host and only when 2 or more participants are present. +- **SC-006**: Rooms are fully isolated: no participant or state data leaks between two simultaneously active rooms. + +## Clarifications + +### Session 2026-06-14 + +- Q: What is shown if a lobby poll request fails temporarily? → A: Show an inline error banner immediately on the first failed poll; clear it automatically on the next successful poll. +- Q: What format should the room code take? → A: 4 uppercase letters (e.g., `KART`); easy to read aloud. +- Q: What happens when a player closes their browser or navigates away while in the lobby? → A: The participant remains in the list until the game starts or the server restarts; there is no disconnect detection or timeout removal. + +## Assumptions + +- Room codes are case-insensitive; the system normalises them to uppercase internally. +- A player name is required but does not need to be unique within a room. +- There is no maximum room size defined for this scenario; rooms accept any number of participants. +- Rooms in "in-game" status do not appear joinable (joining a started game is out of scope for this scenario). +- The polling interval target is ~2 seconds; minor variance (e.g., up to ±500ms) is acceptable. +- Room data is not persisted across service restarts; all rooms are temporary for the duration of a session. +- No player authentication exists; the participant's identity is tied to the session they received when creating or joining. +- There is no disconnect detection or timeout-based removal. A participant who closes their browser remains in the lobby list until the game starts or the server restarts. No "Leave Room" action is required for this scenario. diff --git a/specs/001-room-setup-lobby/tasks.md b/specs/001-room-setup-lobby/tasks.md new file mode 100644 index 000000000..af8d5c618 --- /dev/null +++ b/specs/001-room-setup-lobby/tasks.md @@ -0,0 +1,219 @@ +--- +description: "Task list for Room Setup & Lobby feature" +--- + +# Tasks: Room Setup & Lobby + +**Input**: Design documents from `specs/001-room-setup-lobby/` + +**Prerequisites**: plan.md ✅ | spec.md ✅ | research.md ✅ | data-model.md ✅ | contracts/api.md ✅ + +**TDD**: Tests are **mandatory** per Constitution Principle I (NON-NEGOTIABLE). Every story phase begins with failing tests before any implementation task. + +**Organization**: Tasks grouped by user story to enable independent implementation and testing. + +## Format: `[ID] [P?] [Story?] Description` + +- **[P]**: Can run in parallel (different files, no shared dependencies) +- **[Story]**: Which user story this task belongs to (US1–US4) +- Exact file paths included in every task description + +## Path Conventions + +- Backend source: `backend/src/` +- Frontend source: `frontend/src/` + +--- + +## Phase 1: Setup + +**Purpose**: Establish a clean baseline before any changes. + +- [X] T001 Confirm baseline — run `npm run build` and `npm run test` in both `backend/` and `frontend/`; all existing tests MUST pass before story work begins + +**Checkpoint**: Both packages build cleanly and all existing tests pass. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Type-level changes that every user story depends on. No business logic — only model definitions. These MUST be complete before any story phase begins. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T002 [P] Add `isHost: boolean` to `Participant` interface and add `"in-game"` to `RoomStatus` union in `backend/src/models/game.ts` +- [X] T003 [P] Mirror `isHost: boolean` on `Participant` interface in `frontend/src/services/api.ts` + +**Checkpoint**: Both packages compile with zero TypeScript errors after model changes. + +--- + +## Phase 3: User Story 1 — Host Creates a Room (Priority: P1) 🎯 MVP + +**Goal**: A player creates a room, becomes the host automatically, and reaches the lobby. Room code is 4 uppercase letters. Empty player name is rejected before any request is sent. + +**Independent Test**: Create a room with name "Alice" → confirm a 4-letter code is returned, Alice appears in participants with `isHost: true`, redirected to lobby. + +### Tests for User Story 1 ⚠️ Write FIRST — confirm ALL fail before T005 + +- [X] T004 [US1] Write failing tests in `backend/src/services/roomStore.test.ts` and `backend/src/api/schemas.test.ts`: + - `createRoom("Alice")` → `participants[0].isHost === true` + - `createRoom("Alice")` → `room.code` matches `/^[A-Z]{4}$/` + - `createRoom("")` / schema parse with `{ playerName: "" }` → validation error + - `POST /api/rooms` with `{ playerName: "" }` → 400 + +### Implementation for User Story 1 + +- [X] T005 [US1] Change `generateCode()` alphabet from alphanumeric to `ABCDEFGHJKLMNPQRSTUVWXYZ` (letters only, I/O excluded) in `backend/src/services/roomStore.ts` +- [X] T006 [US1] Update `createParticipant(name, isHost)` to accept and stamp `isHost` param; update `createRoom()` to call `createParticipant(playerName, true)`; update `toRoomSnapshot()` to spread `isHost` on each mapped participant in `backend/src/services/roomStore.ts` +- [X] T007 [US1] Change `createRoomSchema.playerName` from `z.string().optional()` to `z.string().min(1)` in `backend/src/api/schemas.ts` +- [X] T008 [P] [US1] Add client-side empty-name guard: trim name, set validation error state and return early before calling `roomStore.createRoom` if blank in `frontend/src/pages/CreateRoomPage.tsx` + +**Checkpoint**: T004 tests all pass. Room creation works end-to-end with host flag and 4-letter code. + +--- + +## Phase 4: User Story 2 — Player Joins a Room via Code (Priority: P1) + +**Goal**: A second player joins via code, appears in lobby as non-host. Invalid/empty codes and names are rejected with clear feedback before any request is sent. + +**Independent Test**: With an existing room, join as "Bob" with correct code → Bob appears in participants with `isHost: false`. Join with invalid code → error shown, no navigation. + +### Tests for User Story 2 ⚠️ Write FIRST — confirm ALL fail before T010 + +- [X] T009 [US2] Write failing tests in `backend/src/services/roomStore.test.ts` and `backend/src/api/schemas.test.ts`: + - `joinRoom(code, "Bob")` → returned participant has `isHost === false` + - `POST /api/rooms/:code/join` with `{ playerName: "" }` → 400 + - `joinRoom("ZZZZ", "Bob")` → returns `null` + - `joinRoom("kart", "Bob")` (lowercase) → succeeds when room code is `"KART"` — verifies FR-010 case-insensitivity *(C2 fix)* + - Create Room A and Room B; join Bob to Room A; assert Room B's `participants` does not contain Bob — verifies FR-009 room isolation *(C1 fix)* + +### Implementation for User Story 2 + +- [X] T010 [US2] Update `joinRoom()` to call `createParticipant(playerName, false)` so the joining participant gets `isHost: false` in `backend/src/services/roomStore.ts` +- [X] T011 [US2] Change `joinRoomSchema.playerName` from `z.string().optional()` to `z.string().min(1)` in `backend/src/api/schemas.ts` +- [X] T012 [P] [US2] Add client-side guards: trim both `playerName` and `roomCode`, show distinct validation errors and return early before calling `roomStore.joinRoom` if either is blank in `frontend/src/pages/JoinRoomPage.tsx` + +**Checkpoint**: T009 tests all pass. Joining works end-to-end; invalid code and empty fields show clear errors without making a network request. + +--- + +## Phase 5: User Story 3 — Lobby Shows Live Participant List (Priority: P2) + +**Goal**: Once in the lobby, the participant list auto-refreshes every 2 seconds. If a poll request fails, an inline error banner appears immediately and clears on the next success. + +**Independent Test**: Open lobby in two tabs; join from one → other tab shows new participant within 4 s without manual action. Stop backend → banner appears within 2 s; restart → banner clears. + +### Tests for User Story 3 ⚠️ Write FIRST — confirm ALL fail before T014 + +- [X] T013 [US3] Write failing tests in `frontend/src/pages/LobbyPage.test.tsx` (new file): + - `setInterval` is called with 2000 ms on mount; `clearInterval` is called on unmount + - When `fetchRoom` rejects, an inline error banner is rendered + - When `fetchRoom` resolves after a previous rejection, the error banner is gone + +### Implementation for User Story 3 + +- [X] T014 [US3] Replace the `handleRefresh` function and manual "Refresh Room" button with a `useEffect` that calls `setInterval(() => { roomStore.fetchRoom().catch(...) }, 2000)` and returns `clearInterval` as cleanup in `frontend/src/pages/LobbyPage.tsx` +- [X] T015 [US3] Add `pollError` local state (`string | null`); set it when the poll promise rejects; clear it (`setPollError(null)`) at the start of each poll attempt that succeeds; render as an inline banner above the participant list in `frontend/src/pages/LobbyPage.tsx` + +**Checkpoint**: T013 tests all pass. Lobby polls automatically; error banner appears and disappears correctly. + +--- + +## Phase 6: User Story 4 — Host Starts the Game (Priority: P2) + +**Goal**: The host sees a "Start Game" button (disabled with "Need 2+ players" when fewer than 2 participants). Clicking it transitions the room to in-game; all participants navigate to `/game` on the next poll. + +**Independent Test**: With 2 players in lobby, only host sees the active Start Game button. Guest sees none. Host clicks → within ~2 s both tabs navigate to `/game`. + +### Tests for User Story 4 ⚠️ Write FIRST — confirm ALL fail before T018 + +- [X] T016 [P] [US4] Write failing backend tests in `backend/src/services/roomStore.test.ts`: + - `startGame(code, nonHostId)` → throws or returns error with 403-equivalent + - `startGame(code, hostId)` when only 1 participant → throws with 400-equivalent + - `startGame(code, hostId)` with ≥2 participants → returned room has `status === "in-game"` +- [X] T017 [P] [US4] Write failing frontend tests in `frontend/src/pages/LobbyPage.test.tsx`: + - Start Game button rendered when `participantId` matches host's `id` + - Start Game button NOT rendered when `participantId` does not match host + - Button is disabled when `room.participants.length < 2` + - Component navigates to `/game` when `room.status` becomes `"in-game"` + +### Implementation for User Story 4 + +- [X] T018 [US4] Add `startGameSchema` with `{ participantId: z.string().min(1) }` in `backend/src/api/schemas.ts` +- [X] T019 [US4] Add `startGame(code: string, participantId: string)` function: look up room, verify caller is host (403 if not), verify `participants.length >= 2` (400 if not), set `room.status = "in-game"`, save and return snapshot in `backend/src/services/roomStore.ts` +- [X] T020 [US4] Add `POST /:code/start` route handler: parse `startGameSchema`, call `startGame()`, return `{ room: snapshot }` on success in `backend/src/api/rooms.ts` +- [X] T021 [P] [US4] Add `startGame(code: string, participantId: string)` method to the `api` object in `frontend/src/services/api.ts` +- [X] T022 [US4] Derive `isHost` by finding participant where `p.id === participantId && p.isHost`; render Start Game button only when `isHost` is true; disable button with "Need 2+ players" label when `room.participants.length < 2`; call `api.startGame` on click in `frontend/src/pages/LobbyPage.tsx` +- [X] T023 [US4] Add a `useEffect` that watches `room?.status`; when value becomes `"in-game"`, call `navigate("/game", { replace: true })` in `frontend/src/pages/LobbyPage.tsx` + +**Checkpoint**: T016 and T017 tests all pass. Host can start game; non-host cannot; all participants transition to game route automatically. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +- [X] T024 [P] Run `npm run build` in `backend/` and `frontend/`; resolve any remaining TypeScript compilation errors +- [X] T025 Run `npm run test` in `backend/` and `frontend/`; verify coverage ≥ 80% per Constitution Principle I; fix any failing tests +- [ ] T026 Follow all 7 scenarios in `specs/001-room-setup-lobby/quickstart.md` to validate end-to-end behaviour with both dev servers running + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — run immediately +- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user stories +- **US1 (Phase 3)**: Depends on Foundational — no story dependencies +- **US2 (Phase 4)**: Depends on Foundational — no story dependencies; can start after Foundational alongside US1 +- **US3 (Phase 5)**: Depends on Foundational — no story dependencies +- **US4 (Phase 6)**: Depends on US1 + US2 (needs `isHost` plumbed through create + join to be meaningful end-to-end) +- **Polish (Phase 7)**: Depends on all story phases complete + +### Within Each User Story + +1. Write tests → confirm ALL fail (Red) +2. Implement → confirm tests pass (Green) +3. Refactor if needed (Refactor) +4. Commit + +### Parallel Opportunities + +- T002 and T003 can run in parallel (different packages) +- T008 (frontend) can run in parallel with T005/T006/T007 (backend) in Phase 3 +- T012 (frontend) can run in parallel with T010/T011 (backend) in Phase 4 +- T016 (backend tests) and T017 (frontend tests) can run in parallel in Phase 6 +- T021 (frontend API method) can run in parallel with T018/T019/T020 (backend) in Phase 6 +- T024 (build) can be run in parallel across both packages + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 + 2 only) + +1. Complete Phase 1 (Setup) + Phase 2 (Foundational) +2. Complete Phase 3 (US1 — Host Creates Room) +3. Complete Phase 4 (US2 — Player Joins) +4. **STOP and VALIDATE**: Two players can create and join; both appear in participant list; host flag is correct +5. Deploy/demo if ready + +### Full Delivery + +1. Setup + Foundational → baseline +2. US1 + US2 → players in lobby (MVP) +3. US3 → auto-refreshing lobby with error recovery +4. US4 → host starts game, all redirect +5. Polish → build, coverage, quickstart validation + +--- + +## Notes + +- `[P]` = different files, safe to parallelize +- `[USn]` = traceability back to spec.md user story +- Tests written per story MUST be confirmed failing before implementation starts (Constitution I) +- Tasks T005–T006 share `backend/src/services/roomStore.ts` — implement sequentially +- Tasks T022–T023 share `frontend/src/pages/LobbyPage.tsx` — implement sequentially; commit together +- `LobbyPage.test.tsx` is a new file created in T013; T017 adds more tests to the same file diff --git a/specs/002-game-start-drawer/checklists/game-mechanics.md b/specs/002-game-start-drawer/checklists/game-mechanics.md new file mode 100644 index 000000000..11c0dafbd --- /dev/null +++ b/specs/002-game-start-drawer/checklists/game-mechanics.md @@ -0,0 +1,52 @@ +# Game Mechanics Checklist: Game Start & Drawer Flow + +**Purpose**: Lightweight pre-plan sanity check — catch requirement gaps and ambiguities before moving to `/speckit-plan`. Weighted toward role assignment and word secrecy requirements (highest-risk correctness area). +**Created**: 2026-06-19 +**Audience**: Author self-review +**Feature**: [spec.md](../spec.md) + +## Input Validation Requirements + +- [ ] CHK001 - Are all name rejection triggers (empty after trim, whitespace-only, >20 chars after trim) explicitly enumerated as separate rules in the requirements? [Completeness, Spec §FR-001/FR-002/FR-002a] +- [ ] CHK002 - Is the error message text or pattern specified for each distinct validation failure (empty vs. whitespace-only vs. too long), or is a single generic message acceptable? [Clarity, Spec §FR-002/FR-002a] +- [ ] CHK003 - Are the boundary values for the 20-character limit unambiguous — is a name of exactly 20 characters allowed or rejected? [Clarity, Spec §FR-002a] +- [ ] CHK004 - Is Unicode whitespace handling (e.g., non-breaking space, tab) defined beyond ASCII space in the trimming rule? [Edge Case, Spec §Edge Cases] + +## Role Assignment Requirements + +- [ ] CHK005 - Is the "host = room creator = first participant" identity chain stated as an explicit requirement, not only as an assumption? [Clarity, Spec §FR-003/Assumptions] +- [ ] CHK006 - Is the exact moment of role assignment defined — does it occur when the host triggers game start, or when the first poll returns the new state? [Clarity, Gap, Spec §FR-003] +- [ ] CHK007 - Are requirements defined for the scenario where the host leaves or is no longer present when the game would start? [Edge Case, Gap] + +## Word Secrecy & Access Control Requirements + +- [ ] CHK008 - Is it specified at which layer word secrecy is enforced — server response, client rendering, or both? FR-010 implies server-side, but is this stated as a requirement? [Clarity, Spec §FR-009/FR-010] +- [ ] CHK009 - Does FR-010 explicitly require the server to return different response payloads per participant role (drawer vs. guesser), rather than relying on client-side filtering? [Completeness, Spec §FR-010] +- [ ] CHK010 - Are requirements defined for what fields appear in the guesser's polling response vs. the drawer's polling response? [Gap, Spec §FR-009/FR-010] +- [ ] CHK011 - Is "absent from their view entirely" (User Story 3, scenario 2) scoped — does it cover only visible UI, or also the underlying data returned to the guesser's client? [Clarity, Spec §US-3] +- [ ] CHK012 - Is the secret word's storage isolation defined in the data model — is it a field on the Round that is explicitly excluded from the public Room Snapshot? [Completeness, Spec §Key Entities] +- [ ] CHK013 - Is "deterministically selected" (FR-007) defined precisely enough to be reproduced in a test — e.g., does it name a specific index or selection rule rather than leaving it implicit? [Clarity, Spec §FR-007] + +## UX & Display Requirements + +- [ ] CHK014 - Is "clearly indicated" (FR-005) defined with a specific UI pattern, label, or location, or is it left to implementation discretion? [Clarity, Spec §FR-005] +- [ ] CHK015 - Is "visually distinct" (FR-006, drawer view vs. guesser view) measurable — are the required differentiating elements named? [Clarity, Spec §FR-006] +- [ ] CHK016 - Are display requirements defined for the guesser's view during the polling gap after game start (Clarifications §Session 2026-06-18 confirms lobby persists — is this captured in a functional requirement)? [Completeness, Spec §Clarifications] + +## State Model Requirements + +- [ ] CHK017 - Is the canonical room status value that represents "game in progress" named anywhere in the spec or key entities, or is it deferred entirely to planning? [Gap, Spec §Key Entities] +- [ ] CHK018 - Are round lifecycle states beyond "active" and "ended" explicitly excluded from this feature's scope, or are they unintentionally missing? [Completeness, Spec §Key Entities] + +## Acceptance Criteria Quality + +- [ ] CHK019 - Can SC-004 ("secret word appears in zero guesser views") be objectively verified by the author without implementation access — is there a requirement-level definition of what "guesser view" encompasses? [Measurability, Spec §SC-004] +- [ ] CHK020 - Are SC-001 through SC-005 traceable back to specific functional requirements (FR-xxx), or do any success criteria stand without a backing requirement? [Traceability, Spec §Success Criteria] +- [ ] CHK021 - Is SC-003 ("exactly one participant holds the drawer role") testable at the requirements level — is there a requirement that prevents multiple drawer assignments, not just a positive assignment rule? [Measurability, Spec §SC-003/FR-003] + +## Notes + +- Items marked `[Gap]` indicate requirements not currently present in the spec — author should decide whether to add them or explicitly defer to planning. +- Items marked `[Clarity]` indicate requirements that exist but may be too vague to drive unambiguous implementation decisions. +- Check items off as resolved: change `[ ]` to `[x]`. +- Unresolved gaps can be carried into `/speckit-plan` as open decisions. diff --git a/specs/002-game-start-drawer/checklists/requirements.md b/specs/002-game-start-drawer/checklists/requirements.md new file mode 100644 index 000000000..89f3ed8bc --- /dev/null +++ b/specs/002-game-start-drawer/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Game Start & Drawer Flow + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-18 +**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. Clarification session (2026-06-18) resolved: late-join (deferred/OOS), name max length (20 chars), guesser transition state (lobby persists until poll). Ready for `/speckit-plan`. diff --git a/specs/002-game-start-drawer/contracts/api-rooms.md b/specs/002-game-start-drawer/contracts/api-rooms.md new file mode 100644 index 000000000..d1c99397f --- /dev/null +++ b/specs/002-game-start-drawer/contracts/api-rooms.md @@ -0,0 +1,171 @@ +# API Contract: Rooms (updated for Game Start & Drawer Flow) + +**Branch**: `002-game-start-drawer` | **Date**: 2026-06-19 + +This document captures only the endpoints and response shapes that change in this feature. Unchanged endpoints (`GET /health`, `GET /api/`) are omitted. + +--- + +## POST /api/rooms — Create Room + +### Request body (updated validation) + +```json +{ "playerName": "Alice" } +``` + +| Field | Type | Rules | +|-------|------|-------| +| `playerName` | `string` | Required. Trimmed. Min 1 char. Max 20 chars (after trim). | + +**Validation errors** → `400 Bad Request` +```json +{ "message": "Invalid request payload" } +``` + +Triggers: empty string, whitespace-only, length > 20 after trim. + +### Response `200 Created` — unchanged shape, updated semantics + +```json +{ + "participantId": "uuid", + "room": { + "code": "ABCD", + "status": "lobby", + "participants": [{ "id": "uuid", "name": "Alice", "joinedAt": "iso", "isHost": true }], + "drawerId": null, + "secretWord": undefined + } +} +``` + +`drawerId` is `null` in lobby. `secretWord` is absent in lobby. + +--- + +## POST /api/rooms/:code/join — Join Room + +### Request body (updated validation) + +```json +{ "playerName": "Bob" } +``` + +Same rules as create: trimmed, min 1, max 20. + +### Response — same shape change as above + +`drawerId: null`, no `secretWord` while in lobby. + +--- + +## POST /api/rooms/:code/start — Start Game + +### Request body — unchanged + +```json +{ "participantId": "uuid" } +``` + +### Response `200 OK` — updated `room` shape + +**Caller is the host (drawer)**: +```json +{ + "room": { + "code": "ABCD", + "status": "in-game", + "participants": [...], + "drawerId": "host-uuid", + "secretWord": "rocket" + } +} +``` + +**Note**: The `start` endpoint response is only seen by the host (who is always the drawer). `secretWord` is always present in this response. + +**Error responses** (unchanged): +- `403` — caller is not the host +- `400` — fewer than 2 participants +- `404` — room not found + +--- + +## GET /api/rooms/:code?participantId= — Poll Room + +This is the primary endpoint affected by word secrecy. + +### Request — unchanged + +``` +GET /api/rooms/ABCD?participantId= +``` + +### Response shape — updated + +```typescript +{ + "room": { + "code": string, + "status": "lobby" | "in-game", + "participants": Participant[], + "drawerId": string | null, // null if status is "lobby" + "secretWord"?: string // present ONLY if participantId matches drawerId + } +} +``` + +**Drawer's response** (`participantId === drawerId`): +```json +{ + "room": { + "code": "ABCD", + "status": "in-game", + "participants": [...], + "drawerId": "host-uuid", + "secretWord": "rocket" + } +} +``` + +**Guesser's response** (`participantId !== drawerId`): +```json +{ + "room": { + "code": "ABCD", + "status": "in-game", + "participants": [...], + "drawerId": "host-uuid" + } +} +``` + +`secretWord` key is absent entirely from the guesser's response — not `null`, not `""`, but absent. + +**No `participantId` supplied** (unauthenticated poll): +```json +{ + "room": { + "code": "ABCD", + "status": "in-game", + "participants": [...], + "drawerId": "host-uuid" + } +} +``` + +Same as guesser — no secret word. + +--- + +## Removed fields (migration note) + +These fields were present in the previous `RoomSnapshot` shape but are removed: + +| Removed field | Replaced by | +|---------------|-------------| +| `availableWords: string[]` | `drawerId: string \| null` | +| `roles: ParticipantRole[]` | `secretWord?: string` | + +Neither field was read by any frontend component, so this is a non-breaking change in practice. diff --git a/specs/002-game-start-drawer/data-model.md b/specs/002-game-start-drawer/data-model.md new file mode 100644 index 000000000..a8c51ac44 --- /dev/null +++ b/specs/002-game-start-drawer/data-model.md @@ -0,0 +1,178 @@ +# Data Model: Game Start & Drawer Flow + +**Branch**: `002-game-start-drawer` | **Date**: 2026-06-19 + +--- + +## New Type: `Round` + +**File**: `backend/src/models/game.ts` + +```typescript +export interface Round { + drawerId: string; // participantId of the current drawer (always the host for round 1) + word: string; // secret word — never sent to non-drawer participants + status: "active"; // only "active" in scope; "ended" is a future feature +} +``` + +**Constraints**: +- `drawerId` MUST be the `id` of an existing `Participant` in the same `Room` +- `word` MUST be an element of `STARTER_WORDS` +- For round 1, `word` is always `STARTER_WORDS[0]` ("rocket") + +--- + +## Updated Type: `Room` + +**File**: `backend/src/models/game.ts` + +```typescript +export interface Room { + code: string; + status: RoomStatus; // "lobby" | "in-game" — unchanged + participants: Participant[]; // unchanged + createdAt: string; // unchanged + updatedAt: string; // unchanged + currentRound?: Round; // NEW — present only when status is "in-game" +} +``` + +**State invariants**: +- `currentRound` is `undefined` when `status === "lobby"` +- `currentRound` is defined when `status === "in-game"` + +--- + +## Updated Type: `RoomSnapshot` (backend + frontend) + +**Files**: `backend/src/models/game.ts`, `frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts` + +```typescript +export interface RoomSnapshot { + code: string; + status: RoomStatus; + participants: Participant[]; + drawerId: string | null; // REPLACES roles[] — participantId of drawer; null if not in-game + secretWord?: string; // REPLACES availableWords[] — present ONLY in drawer's response +} +``` + +**Secrecy contract**: +- `secretWord` is included in the snapshot if and only if `viewerParticipantId === currentRound.drawerId` +- Guessers receive `{ ..., drawerId: "", secretWord: undefined }` — no `secretWord` key +- Drawer receives `{ ..., drawerId: "", secretWord: "rocket" }` + +**Why `drawerId` instead of per-participant role field**: Role is derivable in O(1) by comparing `participant.id === drawerId`. Embedding role on every `Participant` would require server-side recomputation of a value the client can compute locally. + +--- + +## Updated Zod Schemas + +**File**: `backend/src/api/schemas.ts` + +```typescript +// Before: +export const createRoomSchema = z.object({ + playerName: z.string().min(1) +}); +export const joinRoomSchema = z.object({ + playerName: z.string().min(1) +}); + +// After: +const playerNameSchema = z.string().trim().min(1, "Name cannot be empty").max(20, "Name must be 20 characters or fewer"); + +export const createRoomSchema = z.object({ + playerName: playerNameSchema +}); +export const joinRoomSchema = z.object({ + playerName: playerNameSchema +}); +``` + +**Validation rules** (applied in order by Zod): +1. `.trim()` — strips leading/trailing whitespace before any other checks +2. `.min(1)` — rejects empty string (and whitespace-only after trim) +3. `.max(20)` — rejects names longer than 20 characters after trimming +4. Boundary: exactly 20 characters is accepted; 21 is rejected + +--- + +## `toRoomSnapshot` Signature (updated behaviour) + +**File**: `backend/src/services/roomStore.ts` + +```typescript +// Signature unchanged — viewerParticipantId was already present but ignored +export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot + +// New logic: +// - drawerId = room.currentRound?.drawerId ?? null +// - secretWord = (viewerParticipantId && viewerParticipantId === room.currentRound?.drawerId) +// ? room.currentRound.word +// : undefined +``` + +--- + +## `startGame` Updated Behaviour + +**File**: `backend/src/services/roomStore.ts` + +```typescript +// After existing host + player-count guards pass: +room.status = "in-game"; +room.currentRound = { + drawerId: caller.id, // caller is the host + word: STARTER_WORDS[0], // "rocket" — deterministic, index 0 + status: "active" +}; +room.updatedAt = now(); +``` + +--- + +## Frontend Form Validation Additions + +**Files**: `frontend/src/pages/CreateRoomPage.tsx`, `frontend/src/pages/JoinRoomPage.tsx` + +Client-side guard added before the API call (mirrors server Zod rule): +```typescript +const trimmed = playerName.trim(); +if (!trimmed) { + setError("Name cannot be empty"); + return; +} +if (trimmed.length > 20) { + setError("Name must be 20 characters or fewer"); + return; +} +``` + +HTML `` gets `maxLength={20}` attribute (prevents typing beyond 20 chars). + +--- + +## Type Relationship Diagram + +``` +Room (server-only) +├── code: string +├── status: "lobby" | "in-game" +├── participants: Participant[] +│ └── { id, name, joinedAt, isHost } +├── currentRound?: Round ← new, server-only +│ └── { drawerId, word, status } +└── createdAt / updatedAt + + │ toRoomSnapshot(room, viewerParticipantId) + ▼ + +RoomSnapshot (API response — viewer-scoped) +├── code: string +├── status: "lobby" | "in-game" +├── participants: Participant[] +├── drawerId: string | null ← new (was: roles[]) +└── secretWord?: string ← new (was: availableWords[]), drawer only +``` diff --git a/specs/002-game-start-drawer/plan.md b/specs/002-game-start-drawer/plan.md new file mode 100644 index 000000000..574be9783 --- /dev/null +++ b/specs/002-game-start-drawer/plan.md @@ -0,0 +1,163 @@ +# Implementation Plan: Game Start & Drawer Flow + +**Branch**: `002-game-start-drawer` | **Date**: 2026-06-19 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/002-game-start-drawer/spec.md` + +## Summary + +Extend the existing game-start flow with three capabilities: (1) enforce name trimming and a 20-character cap at both the Zod schema layer (server) and form validation (client); (2) when `startGame` is called, assign the host as drawer and deterministically select `STARTER_WORDS[0]` as the secret word, storing both in a new `currentRound` field on `Room`; (3) have `toRoomSnapshot` use the `viewerParticipantId` parameter — already present but unused — to conditionally include `secretWord` only in the drawer's polling response, enforcing secrecy at the server boundary. + +## Technical Context + +**Language/Version**: TypeScript 5.x, Node 24 (backend), Vite + React 18 (frontend) + +**Primary Dependencies**: Express, Zod (backend); React Router v6, Vitest (frontend) + +**Storage**: In-memory `Map` — `backend/src/services/roomStore.ts` + +**Testing**: Vitest (both packages) + +**Target Platform**: Local dev — backend port 3001, frontend port 5173 + +**Project Type**: Web service (backend REST API) + browser SPA (frontend) + +**Performance Goals**: No new targets; inherits 2-second polling cadence from feature 001 + +**Constraints**: No WebSockets, no databases, no auth + +**Scale/Scope**: Single-instance, in-memory; no persistence across restarts + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. TDD (red-green-refactor, ≥80% coverage) | ✅ Pass | All tasks follow write-test-first ordering | +| II. TypeScript strict mode, no `any` | ✅ Pass | All new types are explicit; `unknown` used for catch blocks | +| III. Architecture integrity — no WebSockets, no DB | ✅ Pass | HTTP polling only; in-memory `Map` unchanged | +| IV. Lean deps — no new packages | ✅ Pass | Zod `.trim()/.max()` are built-in; no new npm deps | +| V. Production-ready, self-documenting | ✅ Pass | Names carry semantics; no explanatory comments needed | + +**Post-Phase-1 re-check**: No violations introduced by design. `toRoomSnapshot` secrecy logic is a pure function of its inputs — no side effects, no hidden state. + +No Complexity Tracking table required (no violations). + +## Project Structure + +### Documentation (this feature) + +``` +specs/002-game-start-drawer/ +├── plan.md ← this file +├── research.md ← Phase 0 decisions +├── data-model.md ← Phase 1 type changes +├── contracts/ +│ └── api-rooms.md ← updated API contract +├── checklists/ +│ ├── requirements.md +│ └── game-mechanics.md +└── tasks.md ← generated by /speckit-tasks +``` + +### Source Code — files changed by this feature + +``` +backend/ +└── src/ + ├── models/ + │ └── game.ts ← add Round; update Room, RoomSnapshot + ├── services/ + │ ├── roomStore.ts ← startGame creates Round; toRoomSnapshot enforces secrecy + │ └── roomStore.test.ts ← new tests: round creation, word secrecy, name validation + └── api/ + └── schemas.ts ← playerName: .trim().min(1).max(20) + +frontend/ +└── src/ + ├── services/ + │ └── api.ts ← RoomSnapshot type: drawerId, secretWord + ├── state/ + │ └── roomStore.ts ← RoomSnapshot type: drawerId, secretWord + └── pages/ + ├── CreateRoomPage.tsx ← add max-20 guard + maxLength attr + ├── JoinRoomPage.tsx ← add max-20 guard + maxLength attr + └── GamePage.tsx ← drawer vs guesser views +``` + +**Test files** (TDD — written before implementation): +``` +backend/src/services/roomStore.test.ts ← extend existing test file +frontend/src/pages/CreateRoomPage.test.tsx ← new +frontend/src/pages/JoinRoomPage.test.tsx ← new +frontend/src/pages/GamePage.test.tsx ← new +``` + +**Structure Decision**: Web application (two-package layout). All changes are within the existing `backend/` and `frontend/` packages — no new packages or top-level directories. + +## Data Flow + +### Name Validation (create/join) + +``` +User types name (maxLength=20 on ) + → Client: trim + length guard → setError + return if invalid + → POST /api/rooms or POST /api/rooms/:code/join { playerName } + → Zod: .trim().min(1).max(20) → 400 if invalid + → roomStore.createRoom/joinRoom(trimmedName) → Participant.name stored trimmed +``` + +### Game Start → Drawer Assignment → Secrecy + +``` +Host clicks "Start Game" + → POST /api/rooms/:code/start { participantId } + → startGame(code, participantId) + → guards: isHost check, participants.length >= 2 + → room.status = "in-game" + → room.currentRound = { drawerId: caller.id, word: STARTER_WORDS[0], status: "active" } + → toRoomSnapshot(room, caller.id) → secretWord included (caller === drawer) + → Response to host: { room: { ..., drawerId: "", secretWord: "rocket" } } + +Guesser polls GET /api/rooms/:code?participantId= + → toRoomSnapshot(room, guesser-id) + → guesser-id !== drawerId → secretWord omitted + → Response: { room: { ..., drawerId: "" } } // no secretWord key + → LobbyPage: room.status === "in-game" → navigate("/game") + → GamePage: isDrawer = (participantId === room.drawerId) + → drawer: "You are drawing!" banner + "Your word: rocket" + → guesser: "Drawing: Alice" label + GuessForm +``` + +## File-Level Changes + +### `backend/src/models/game.ts` + +1. Add `Round` interface: `{ drawerId: string; word: string; status: "active" }` +2. Add `currentRound?: Round` to `Room` +3. Replace `availableWords: string[]` and `roles: ParticipantRole[]` in `RoomSnapshot` with `drawerId: string | null` and `secretWord?: string` + +### `backend/src/services/roomStore.ts` + +1. `startGame`: after existing host + player-count guards, set `room.currentRound = { drawerId: caller.id, word: STARTER_WORDS[0], status: "active" }` +2. `toRoomSnapshot`: derive `drawerId = room.currentRound?.drawerId ?? null`; derive `secretWord = (viewerParticipantId === drawerId && drawerId !== null) ? room.currentRound!.word : undefined`; replace `availableWords`/`roles` with `drawerId`/`secretWord` + +### `backend/src/api/schemas.ts` + +Extract `playerNameSchema = z.string().trim().min(1, "Name cannot be empty").max(20, "Name must be 20 characters or fewer")` and use in both `createRoomSchema` and `joinRoomSchema`. + +### `frontend/src/services/api.ts` + `frontend/src/state/roomStore.ts` + +Update `RoomSnapshot` type: remove `availableWords`, `roles`; add `drawerId: string | null` and `secretWord?: string`. + +### `frontend/src/pages/CreateRoomPage.tsx` + `JoinRoomPage.tsx` + +Add max-20 client guard (trim → empty check → length check). Add `maxLength={20}` to name ``. + +### `frontend/src/pages/GamePage.tsx` + +Derive `isDrawer = participantId === room.drawerId`. Render: +- Drawer: role banner + secret word card +- Guesser: current drawer label + `` +- Both: ``, canvas placeholder diff --git a/specs/002-game-start-drawer/research.md b/specs/002-game-start-drawer/research.md new file mode 100644 index 000000000..391cc3277 --- /dev/null +++ b/specs/002-game-start-drawer/research.md @@ -0,0 +1,71 @@ +# Research: Game Start & Drawer Flow + +**Branch**: `002-game-start-drawer` | **Date**: 2026-06-19 + +No NEEDS CLARIFICATION items were raised during planning — the existing codebase resolves all decisions directly. This file documents the key findings and the rationale behind design choices. + +--- + +## Decision 1: Name Validation Layer + +**Decision**: Enforce name constraints at both server (authoritative) and client (UX). + +**Rationale**: The Zod schemas in `backend/src/api/schemas.ts` are the single authoritative gate. Zod's `.trim()` + `.min(1)` + `.max(20)` covers all three rules (trim, empty rejection, length cap) in one chain. Client-side forms already do `playerName.trim()` checks — adding a `maxLength={20}` HTML attribute and a JS guard adds zero dependencies and prevents obviously-bad submissions before a network round trip. + +**Alternatives considered**: Server-only validation was considered but rejected — the existing pattern (`CreateRoomPage`, `JoinRoomPage`) already does client-side checks for the empty case, so adding max-length there is consistent. + +--- + +## Decision 2: Round State Storage + +**Decision**: Add a `currentRound` optional field to the in-memory `Room` object. `Round` holds `drawerId`, `word`, and `status`. + +**Rationale**: The existing `Room` type in `backend/src/models/game.ts` is the only server state object. A `currentRound?: Round` field is the minimal change that stores per-round data without introducing a new top-level map or restructuring the store. `currentRound` is `undefined` when status is `"lobby"` and populated when status is `"in-game"`. + +**Alternatives considered**: A separate `rounds` array was rejected — this feature only needs the current round, and a history array adds complexity without benefit for the scope defined. + +--- + +## Decision 3: Word Secrecy Enforcement + +**Decision**: Server-side filtering in `toRoomSnapshot`. The snapshot returned to a guesser never contains `secretWord`. The snapshot for the drawer includes `secretWord`. + +**Rationale**: `toRoomSnapshot(room, viewerParticipantId)` already accepts a `viewerParticipantId` parameter but ignores it (`void viewerParticipantId`). This was clearly a placeholder for this exact feature. All callers already pass `viewerParticipantId` or `participantId`. Enforcing secrecy here means it is impossible for a guesser's API response to contain the word, regardless of client behaviour. + +**Alternatives considered**: Client-side filtering was rejected outright — it would mean the word travels over the wire to every participant and relies on the client not rendering it. This violates FR-009 ("MUST NOT appear in any data accessible to guesser participants"). + +--- + +## Decision 4: Deterministic Word Selection + +**Decision**: `STARTER_WORDS[0]` for round 1 (the string `"rocket"`). + +**Rationale**: The spec requires deterministic selection from the starter list. Index 0 is the simplest deterministic rule and matches the spec's intent ("first word in the list for round 1"). The `STARTER_WORDS` constant is a fixed tuple at build time, so the selection is always predictable in tests. + +**Alternatives considered**: A hash-based or code-derived selection was considered for variety but rejected — it adds complexity and the spec explicitly defers multi-round word cycling. + +--- + +## Decision 5: RoomSnapshot Shape Update + +**Decision**: Replace the unused `availableWords: string[]` and `roles: ParticipantRole[]` fields in `RoomSnapshot` with `drawerId: string | null` and optional `secretWord?: string`. + +**Rationale**: `availableWords` and `roles` are not consumed anywhere in the frontend (no component reads `room.availableWords` or `room.roles`). They were placeholder fields. Replacing them with the semantically correct fields for this feature avoids carrying dead weight into the data model. Both frontend and backend `RoomSnapshot` types are updated together. + +**Alternatives considered**: Additive-only change (keep old fields, add new ones) was considered but rejected — the old fields would mislead future implementers into thinking they carry meaning. + +--- + +## Decision 6: Participant Role in Snapshot + +**Decision**: Roles are not added as a field on `Participant` in the snapshot. Role is derivable from `drawerId`: if `participant.id === drawerId` the participant is the drawer; otherwise they are a guesser. + +**Rationale**: Adding a `role` field to `Participant` would require the server to compute and embed it for every participant in every response. Since `drawerId` is already in the snapshot, the frontend can derive role with a single comparison. This keeps the data model lean. + +--- + +## Decision 7: `displayName` Fallback Removal Scope + +**Decision**: The existing `displayName` fallback (returns `"Player"` for falsy names) in `roomStore.ts` is out of scope — it will remain as-is. + +**Rationale**: After this feature's validation lands, no empty name can ever reach `createParticipant`. The fallback becomes dead code, but removing it is a separate cleanup task beyond this feature's scope. diff --git a/specs/002-game-start-drawer/spec.md b/specs/002-game-start-drawer/spec.md new file mode 100644 index 000000000..7a5d3596d --- /dev/null +++ b/specs/002-game-start-drawer/spec.md @@ -0,0 +1,118 @@ +# Feature Specification: Game Start & Drawer Flow + +**Feature Branch**: `002-game-start-drawer` + +**Created**: 2026-06-18 + +**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." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Player Name Validation on Join (Priority: P1) + +A player attempting to create or join a game must provide a non-empty, non-whitespace name. Names entered with only spaces or left blank are rejected immediately with a clear feedback message before any room interaction occurs. Valid names with surrounding whitespace are trimmed and accepted. + +**Why this priority**: Name validation is a gate that must work before any game flow can begin. Without it, empty names can appear in the participant list and the drawer identity becomes ambiguous. + +**Independent Test**: Attempt to create or join a room with an empty name, a whitespace-only name (e.g., `" "`), and a name with leading/trailing spaces. Confirm that empty/whitespace-only names are rejected with a message, and that a padded name (e.g., `" Alice "`) is accepted and stored as `"Alice"`. + +**Acceptance Scenarios**: + +1. **Given** a player enters only spaces in the name field, **When** they submit the form, **Then** the submission is blocked and a message such as "Name cannot be empty" is shown. +2. **Given** a player leaves the name field entirely blank, **When** they submit the form, **Then** the submission is blocked and a validation message is shown. +3. **Given** a player enters `" Alice "` (with surrounding whitespace), **When** they submit the form, **Then** the name is trimmed to `"Alice"` and accepted — the participant is recorded as `"Alice"`. +4. **Given** a player enters a name longer than 20 characters after trimming, **When** they submit the form, **Then** the submission is blocked and a message such as "Name must be 20 characters or fewer" is shown. + +--- + +### User Story 2 - Host Identified as Drawer at Round Start (Priority: P1) + +When the host starts the game and the first round begins, the host is automatically assigned the drawer role. All participants can clearly see who the current drawer is. The drawer themselves sees a prominent indicator of their own role that distinguishes their view from guessers. + +**Why this priority**: Correct role assignment is the foundation of every round. Without a clearly identified drawer, no drawing or guessing flow can proceed. + +**Independent Test**: With a host and at least one other player in the lobby, have the host start the game. Confirm that the host's participant record carries the drawer role, that all participants' views show the drawer's name, and that the host's view highlights their drawer status (e.g., a "You are drawing!" label). + +**Acceptance Scenarios**: + +1. **Given** a room has 2+ players and the host starts the game, **When** the first round begins, **Then** the host is assigned the drawer role and all other participants are assigned the guesser role. +2. **Given** the first round is active, **When** a non-host participant views the game screen, **Then** they see the drawer's name clearly indicated (e.g., "Drawing: Alice") but do not see the secret word. +3. **Given** the first round is active, **When** the drawer (host) views the game screen, **Then** they see a clear role indicator (e.g., "You are drawing!") distinguishing their view from guessers. + +--- + +### User Story 3 - Secret Word Visible Only to Drawer (Priority: P1) + +At round start, a secret word is selected deterministically from the starter word list and shown exclusively to the drawer. Guessers see no hint of the word anywhere in their view. + +**Why this priority**: The word-secrecy guarantee is the core game mechanic. If guessers can see the word, the game is broken at the most fundamental level. + +**Independent Test**: Start a game with two participants in separate browser tabs. Confirm that the drawer's tab displays the secret word, and the guesser's tab contains no trace of that word in any visible text or rendered content. + +**Acceptance Scenarios**: + +1. **Given** the first round has started, **When** the drawer views the game screen, **Then** the secret word is displayed prominently (e.g., "Your word: pizza"). +2. **Given** the first round has started, **When** a guesser views the game screen, **Then** the secret word is absent from their view entirely — no text, label, or hint reveals it. +3. **Given** a game starts, **When** the word is selected, **Then** it is chosen deterministically from the starter list (first word for round 1), so the outcome is predictable and testable. + +--- + +### Edge Cases + +- What happens when a player's name consists entirely of Unicode whitespace? — Treated as whitespace-only; rejected with the same message as a blank name. +- What happens if the starter word list is empty? — Round start is blocked with a clear error; a game cannot begin without available words. Implemented as a defensive guard in `startGame` (FR-003 scope). +- Can a player change their name after joining but before the game starts? — Out of scope; names are fixed on join. +- What if two participants join with the same trimmed name? — Allowed; names are display labels only, not unique identifiers (participants are identified by ID). +- What happens when a player tries to join a room that is already in-game? — Out of scope; late-join behavior is deferred to a future feature. +- What do non-drawer participants see in the polling gap after game start? — The lobby view persists until the next poll confirms the game has started; no special transition state is required. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST trim leading and trailing whitespace from player names before storing or processing them. +- **FR-002**: The system MUST reject any player name that is empty or whitespace-only after trimming, and return a clear error message to the user. +- **FR-002a**: The system MUST reject any player name that exceeds 20 characters after trimming, and return a clear error message to the user. +- **FR-003**: When the first round begins, the system MUST assign the drawer role to the host (the room creator). +- **FR-004**: All non-drawer participants MUST be assigned the guesser role at round start. +- **FR-005**: The game screen MUST display the current drawer's name to all participants. +- **FR-006**: The drawer's own view MUST include a clear indicator that they are the drawer (visually distinct from the guesser view). +- **FR-007**: The system MUST select the secret word deterministically from the starter word list (first available word for round 1). +- **FR-008**: The secret word MUST be transmitted to and displayed for the drawer only. +- **FR-009**: The secret word MUST NOT appear in any data or view accessible to guesser participants. +- **FR-010**: The room state returned to all participants via polling MUST include the current drawer's identity without exposing the secret word to non-drawers. + +### Key Entities + +- **Participant**: A player in a room, identified by a unique ID. Has a trimmed display name and a host flag. Role is derived — a participant is the drawer if their ID matches the room's `drawerId`; all others are guessers. No explicit role field is stored on the participant record. +- **Round**: A single drawing turn. Has a designated drawer, a secret word, and a status (active/ended). +- **Room Snapshot**: The view of room state exposed per-participant — the public snapshot omits the secret word; the drawer's snapshot includes it. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of submitted player names are stored in trimmed form — no leading or trailing whitespace persists in the participant record. +- **SC-002**: Empty, whitespace-only, or names exceeding 20 characters are rejected 100% of the time with a user-visible message, before any room state is modified. +- **SC-003**: In every game started, exactly one participant holds the drawer role at round start, and it is always the host. +- **SC-004**: The secret word appears in the drawer's view 100% of the time and in zero guesser views across all rounds. +- **SC-005**: All participants can identify the current drawer within one polling cycle (~2 seconds) of the round starting. + +## Clarifications + +### Session 2026-06-18 + +- Q: What should happen when a player tries to join a room that's already in-game? → A: Out of scope for this feature; late-join behavior is deferred to a future feature. +- Q: Is there a maximum character limit for player names (after trimming)? → A: 20 characters maximum. +- Q: What should non-drawer participants see in the polling gap after the host starts the game? → A: Continue showing the lobby view; no special transition state — guessers redirect on their next successful poll that confirms game-started status. + +## Assumptions + +- The host is always the room creator and always the first participant in the participant list; no tie-breaking logic is needed. +- Deterministic word selection means the first word in the starter list is used for round 1; subsequent-round word cycling is a later feature. +- Only the first round's drawer assignment is in scope; drawer rotation across rounds is out of scope. +- The polling interval is approximately 2 seconds, consistent with the lobby polling established in the Room Setup feature. +- The starter word list is fixed at the time of round start and is not configurable by players. +- Player name uniqueness is not enforced; two players may share the same display name without conflict. diff --git a/specs/002-game-start-drawer/tasks.md b/specs/002-game-start-drawer/tasks.md new file mode 100644 index 000000000..62d2707b0 --- /dev/null +++ b/specs/002-game-start-drawer/tasks.md @@ -0,0 +1,195 @@ +--- +description: "Task list for Game Start & Drawer Flow feature" +--- + +# Tasks: Game Start & Drawer Flow + +**Input**: Design documents from `specs/002-game-start-drawer/` + +**Prerequisites**: plan.md ✅ | spec.md ✅ | research.md ✅ | data-model.md ✅ | contracts/api-rooms.md ✅ + +**TDD**: Tests are **mandatory** per Constitution Principle I (NON-NEGOTIABLE). Every story phase begins with failing tests before any implementation task. + +**Organization**: Tasks grouped by user story to enable independent implementation and testing. + +## Format: `[ID] [P?] [Story?] Description` + +- **[P]**: Can run in parallel (different files, no shared state dependencies) +- **[Story]**: Which user story this task belongs to (US1–US3) +- Exact file paths included in every task description + +## Path Conventions + +- Backend source: `backend/src/` +- Frontend source: `frontend/src/` + +--- + +## Phase 1: Setup + +**Purpose**: Establish a clean baseline before any changes. + +- [X] T001 Confirm baseline — run `npm run build` and `npm run test` in both `backend/` and `frontend/`; all existing tests MUST pass before story work begins + + +**Checkpoint**: Both packages build cleanly and all existing tests pass. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Type-level changes that US2 and US3 depend on. No business logic — only model and type definitions. Must be complete before Phase 4+ can begin. + +**⚠️ CRITICAL**: US2 and US3 work cannot begin until this phase is complete. US1 (name validation) may proceed in parallel since it does not use the new types. + +- [X] T002 Update `backend/src/models/game.ts`: add `Round` interface (`{ drawerId: string; word: string; status: "active" }`); add `currentRound?: Round` to `Room`; replace `availableWords: string[]` and `roles: ParticipantRole[]` in `RoomSnapshot` with `drawerId: string | null` and `secretWord?: string` — ⚠️ TypeScript build will fail until T014 updates `toRoomSnapshot` to supply `drawerId`; proceed directly to T014 if working sequentially +- [X] T003 [P] Update `RoomSnapshot` in `frontend/src/services/api.ts`: replace `availableWords` and `roles` with `drawerId: string | null` and `secretWord?: string` +- [X] T004 [P] Update `RoomSnapshot` in `frontend/src/state/roomStore.ts`: same replacement as T003 (`drawerId: string | null`, `secretWord?: string`) + +**Checkpoint**: Both packages compile with zero TypeScript errors after type changes. + +--- + +## Phase 3: User Story 1 — Player Name Validation (Priority: P1) + +**Goal**: Player names are trimmed before storage. Empty/whitespace-only names and names longer than 20 characters are rejected with clear error messages at both server and client layers. + +**Independent Test**: Submit `" Alice "` → stored as `"Alice"`. Submit `" "` → rejected with message. Submit a 21-character name → rejected with message. Submit a 20-character name → accepted. + +### Tests for User Story 1 ⚠️ Write FIRST — confirm ALL fail before T008 + +- [X] T005 [US1] Write failing schema tests in `backend/src/api/schemas.test.ts` (new file); test via `createRoomSchema.parse({ playerName: "..." })` — do NOT import `playerNameSchema` directly since it may not be exported yet: + - `createRoomSchema.parse({ playerName: " Alice " }).playerName` === `"Alice"` (trim applied) + - `createRoomSchema.parse({ playerName: " " })` → throws ZodError (whitespace-only rejected) + - `createRoomSchema.parse({ playerName: "" })` → throws ZodError (empty rejected) + - `createRoomSchema.parse({ playerName: "A".repeat(21) })` → throws ZodError (>20 chars rejected) + - `createRoomSchema.parse({ playerName: "A".repeat(20) }).playerName` has length 20 (exactly 20 accepted) +- [X] T006 [P] [US1] Write failing tests in `frontend/src/pages/CreateRoomPage.test.tsx` (new file): + - Submitting a name of 21 characters shows error "Name must be 20 characters or fewer" and does not call `roomStore.createRoom` +- [X] T007 [P] [US1] Write failing tests in `frontend/src/pages/JoinRoomPage.test.tsx` (new file): + - Submitting a name of 21 characters shows error "Name must be 20 characters or fewer" and does not call `roomStore.joinRoom` + +### Implementation for User Story 1 + +- [X] T008 [US1] Update `backend/src/api/schemas.ts`: extract `export const playerNameSchema = z.string().trim().min(1, "Name cannot be empty").max(20, "Name must be 20 characters or fewer")` (named export required); use it in both `createRoomSchema` and `joinRoomSchema` (makes T005 pass) +- [X] T009 [P] [US1] Update `frontend/src/pages/CreateRoomPage.tsx`: add length guard before API call (`const trimmed = playerName.trim(); if (trimmed.length > 20) { setError("Name must be 20 characters or fewer"); return; }`); add `maxLength={20}` to name `` (makes T006 pass) +- [X] T010 [P] [US1] Update `frontend/src/pages/JoinRoomPage.tsx`: add same length guard as T009 before API call; add `maxLength={20}` to name `` (makes T007 pass) + +**Checkpoint**: T005–T007 tests all pass. Name trimming and max-20 enforcement works end-to-end on both server and client. + +--- + +## Phase 4: User Story 2 — Host as Drawer at Round Start (Priority: P1) + +**Goal**: When `startGame` is called, the host is assigned as drawer and a round is created. All participants see the drawer's name after the next poll. The drawer sees a distinct role indicator in the game screen. + +**Independent Test**: Start a game with host Alice and guesser Bob. Poll as Bob → response has `drawerId === Alice's participantId`. Bob's GamePage shows "Drawing: Alice". Alice's GamePage shows "You are drawing!". + +### Tests for User Story 2 ⚠️ Write FIRST — confirm ALL fail before T012 + +- [X] T011 [US2] Write failing tests in `backend/src/services/roomStore.test.ts` (extend existing file): + - After `startGame(code, hostId)`: the room's internal `currentRound.drawerId === hostId` + - After `startGame(code, hostId)`: `currentRound.word === "rocket"` (STARTER_WORDS[0]) + - After `startGame(code, hostId)`: `currentRound.status === "active"` + - `toRoomSnapshot(inGameRoom, hostId).drawerId === hostId` + - `toRoomSnapshot(lobbyRoom, hostId).drawerId === null` + - `toRoomSnapshot(inGameRoom, guesserId).drawerId !== guesserId` — confirms a non-host participant is identifiably not the drawer (covers FR-004 guesser derivation) +- [X] T012 [P] [US2] Write failing tests in `frontend/src/pages/GamePage.test.tsx` (new file): + - When `participantId === room.drawerId`: component renders an element containing "You are drawing!" (or equivalent drawer indicator text) + - When `participantId !== room.drawerId`: component renders drawer's display name (e.g., text matching the drawer participant's name) + - When `participantId !== room.drawerId`: "You are drawing!" text is NOT present anywhere in the rendered output + +### Implementation for User Story 2 + +- [X] T013 [US2] Update `startGame` in `backend/src/services/roomStore.ts`: after existing host and player-count guards pass, add a defensive guard `if (STARTER_WORDS.length === 0) throw new Error("400: No words available to start the game")`; then set `room.currentRound = { drawerId: caller.id, word: STARTER_WORDS[0], status: "active" }` before updating `room.updatedAt` (makes T011 first three assertions pass; defensive guard covers spec edge case) +- [X] T014 [US2] Update `toRoomSnapshot` in `backend/src/services/roomStore.ts`: derive `const drawerId = room.currentRound?.drawerId ?? null`; replace `availableWords: listWords()` and `roles: [...STARTER_ROLES]` with `drawerId` in the returned object (makes T011 last two assertions pass) +- [X] T015 [US2] Update `frontend/src/pages/GamePage.tsx`: derive `const isDrawer = participantId === room.drawerId`; render a drawer role banner ("You are drawing!") when `isDrawer` is true; render drawer's display name (find participant where `p.id === room.drawerId`, show e.g. `"Drawing: ${drawerName}"`) when `isDrawer` is false (makes T012 pass) + +**Checkpoint**: T011–T012 tests all pass. After game start, every participant can identify the drawer; drawer sees their own role indicator. + +--- + +## Phase 5: User Story 3 — Secret Word Visible Only to Drawer (Priority: P1) + +**Goal**: The secret word travels only to the drawer's polling response. Guessers receive no `secretWord` field. The drawer's GamePage displays the word prominently; the guesser's GamePage contains no trace of it. + +**Independent Test**: Start game, poll as host (drawer) → response contains `secretWord: "rocket"`. Poll as second player (guesser) → response has no `secretWord` key at all. Drawer's GamePage shows "rocket"; guesser's GamePage does not contain "rocket" anywhere. + +### Tests for User Story 3 ⚠️ Write FIRST — confirm ALL fail before T017 + +- [X] T016 [US3] Write failing tests in `backend/src/services/roomStore.test.ts` (extend existing file, depends on T013/T014 being implemented): + - `toRoomSnapshot(inGameRoom, drawerId).secretWord === "rocket"` + - `toRoomSnapshot(inGameRoom, guesserId).secretWord` is `undefined` (key absent) + - `toRoomSnapshot(inGameRoom, undefined).secretWord` is `undefined` (no viewer ID) + - `toRoomSnapshot(lobbyRoom, drawerId).secretWord` is `undefined` (no round in lobby) +- [X] T017 [P] [US3] Write failing tests in `frontend/src/pages/GamePage.test.tsx` (extend existing file from T012): + - When `room.secretWord === "rocket"` and `isDrawer` is true: component renders "rocket" (e.g., in "Your word: rocket" label) + - When `room.secretWord` is `undefined` and `isDrawer` is false: text "rocket" (or any word from STARTER_WORDS) is NOT present anywhere in the rendered output + +### Implementation for User Story 3 + +- [X] T018 [US3] Update `toRoomSnapshot` in `backend/src/services/roomStore.ts`: add `const secretWord = (viewerParticipantId && viewerParticipantId === drawerId) ? room.currentRound!.word : undefined`; include `secretWord` (when defined) or omit the key from the returned snapshot (makes T016 pass) +- [X] T019 [US3] Update `frontend/src/pages/GamePage.tsx`: in the drawer branch (`isDrawer === true`), render the secret word from `room.secretWord` (e.g., `

    Your word: {room.secretWord}

    `); ensure the guesser branch renders no text derived from `room.secretWord` (makes T017 pass) + +**Checkpoint**: T016–T017 tests all pass. Secret word is server-enforced: absent from guesser API responses and absent from guesser UI. + +--- + +## Phase 6: Polish & Verification + +**Purpose**: Confirm the full feature compiles and all tests pass across both packages before the branch is ready for review. + +- [X] T020 [P] Run `npx vitest run --coverage` in `backend/` — all tests green (including T005, T011, T016 and all pre-existing tests); confirm the coverage summary shows ≥80% for statements, functions, and lines (Constitution Principle I MUST) +- [X] T021 [P] Run `npx vitest run --coverage` in `frontend/` — all tests green (including T006, T007, T012, T017 and all pre-existing tests); confirm the coverage summary shows ≥80% for statements, functions, and lines (Constitution Principle I MUST) +- [X] T022 Run `npm run build` in `backend/` — zero TypeScript errors +- [X] T023 [P] Run `npm run build` in `frontend/` — zero TypeScript errors (can run after T022 starts, different package) + +**Checkpoint**: All tasks complete. Branch is ready for `/speckit-git-commit` and PR. + +--- + +## Dependencies + +``` +T001 (baseline) + └─ T002, T003 [P], T004 [P] (foundational types — parallel) + └─ T011, T012 [P] (US2 tests — can start once types are set) + +T001 (baseline) + └─ T005, T006 [P], T007 [P] (US1 tests — parallel, independent of types) + ├─ T008 (US1 backend impl) + ├─ T009 [P] (US1 frontend CreateRoom impl) + └─ T010 [P] (US1 frontend JoinRoom impl) + +T011 + └─ T013 → T014 (US2 backend impl: startGame then toRoomSnapshot drawerId) + └─ T016 (US3 tests, needs drawerId working) + └─ T018 (US3 backend impl: secretWord) + +T012 + └─ T015 (US2 frontend: drawer banner, needs drawerId type from T002-T004) + └─ T017 (US3 frontend tests: secretWord display) + └─ T019 (US3 frontend impl: secretWord display) + +T018, T019 → T020, T021, T022, T023 [P] (verification) +``` + +## Parallel Execution Opportunities + +**US1 is fully independent** — T005/T006/T007 can start as soon as T001 passes, with no dependency on Phase 2 type changes. + +**Within Phase 2**: T003 and T004 are parallel (different files, same shape change). + +**Within US1 implementation**: T009 and T010 are parallel (different page files). + +**Within US2 tests**: T011 and T012 are parallel (backend test file vs. frontend test file). + +**Verification**: T020/T021/T022/T023 — T020 and T021 are parallel (different packages); T023 can start alongside T022. + +## Implementation Strategy + +**MVP**: Phases 1–3 (T001–T010) deliver US1 name validation as a fully shippable increment with no dependency on Phase 2 types. + +**Core game mechanics**: Phases 4–5 (T011–T019) deliver US2 and US3 together — drawer assignment and word secrecy are implemented sequentially since US3 extends the `toRoomSnapshot` function touched by US2. + +**Recommended delivery order**: US1 → US2 backend → US3 backend → US2+US3 frontend (GamePage) → verification. This ensures the server-side contract is stable before the UI is wired up. diff --git a/specs/003-gameplay-interaction/checklists/requirements.md b/specs/003-gameplay-interaction/checklists/requirements.md new file mode 100644 index 000000000..d7341d97a --- /dev/null +++ b/specs/003-gameplay-interaction/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Gameplay Interaction + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-19 +**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 `/speckit-plan`. +Clarification session (2026-06-19): 3 questions answered — scoring model (all guessers score independently), repeated-correct-guess behavior (unlimited +100), and GuessForm post-correct-guess UX (clear + "Correct!" + re-enable). All clarifications integrated into spec without introducing implementation details. diff --git a/specs/003-gameplay-interaction/contracts/api-rooms.md b/specs/003-gameplay-interaction/contracts/api-rooms.md new file mode 100644 index 000000000..3c7250334 --- /dev/null +++ b/specs/003-gameplay-interaction/contracts/api-rooms.md @@ -0,0 +1,125 @@ +# API Contract: Rooms (updated for Gameplay Interaction) + +**Branch**: `003-gameplay-interaction` | **Date**: 2026-06-19 + +Documents only the endpoints and response shapes that change in this feature. Unchanged endpoints from features 001 and 002 are omitted. + +--- + +## POST /api/rooms/:code/guess — Submit a Guess + +### Request body + +```json +{ "participantId": "uuid", "guess": "rocket" } +``` + +| Field | Type | Rules | +|-------|------|-------| +| `participantId` | `string` | Required, non-empty | +| `guess` | `string` | Required; Zod `.trim().min(1)` applied server-side | + +### Response `200 OK` + +```json +{ + "isCorrect": true, + "room": { + "code": "ABCD", + "status": "in-game", + "participants": [ + { "id": "uuid-bob", "name": "Bob", "joinedAt": "iso", "isHost": false, "score": 100 } + ], + "drawerId": "uuid-alice", + "guesses": [ + { + "participantId": "uuid-bob", + "participantName": "Bob", + "text": "rocket", + "isCorrect": true, + "timestamp": "iso" + } + ], + "canvasData": "data:image/png;base64,..." + } +} +``` + +The `room` snapshot is scoped to the guesser's `participantId` — no `secretWord` key. + +`isCorrect: true` on both a scoring guess (first correct) and a non-scoring correct guess (subsequent from the same player). The score on `participants[]` reflects whether points were actually awarded. + +### Error responses + +| Code | Condition | +|------|-----------| +| `400` | Guess is empty after trim | +| `400` | No active round (`currentRound` not set) | +| `403` | `participantId === drawerId` — drawer cannot guess | +| `404` | Room not found | + +--- + +## POST /api/rooms/:code/drawing — Update Canvas + +### Request body + +```json +{ "participantId": "uuid", "canvasData": "data:image/png;base64,..." } +``` + +| Field | Type | Rules | +|-------|------|-------| +| `participantId` | `string` | Required, non-empty | +| `canvasData` | `string` | Required; `""` means clear; no size validation | + +### Response `200 OK` + +```json +{ "ok": true } +``` + +### Error responses + +| Code | Condition | +|------|-----------| +| `400` | No active round | +| `403` | `participantId !== drawerId` — only the drawer can update the canvas | +| `404` | Room not found | + +--- + +## Updated `RoomSnapshot` (all existing endpoints) + +All endpoints that return a `room` object now include `guesses`, `canvasData`, and `score` on participants: + +```typescript +{ + code: string, + status: "lobby" | "in-game", + participants: Array<{ + id: string, + name: string, + joinedAt: string, + isHost: boolean, + score: number // NEW — 0 in lobby; running total in-game + }>, + drawerId: string | null, + secretWord?: string, // drawer only; absent for guessers (unchanged) + guesses: Guess[], // NEW — [] in lobby; full history in-game; public + canvasData: string // NEW — "" in lobby or blank canvas; base64 PNG in-game +} +``` + +**`Guess` shape**: +```typescript +{ + participantId: string, + participantName: string, + text: string, + isCorrect: boolean, + timestamp: string // ISO 8601 +} +``` + +**`isCorrect` note**: A guess with `isCorrect: true` may or may not have awarded points — check `participant.score` changes across polls to determine scoring. The first correct guess per player per round increments `score`; subsequent correct guesses from the same player do not. diff --git a/specs/003-gameplay-interaction/data-model.md b/specs/003-gameplay-interaction/data-model.md new file mode 100644 index 000000000..4fd7b1a2e --- /dev/null +++ b/specs/003-gameplay-interaction/data-model.md @@ -0,0 +1,196 @@ +# Data Model: Gameplay Interaction + +**Branch**: `003-gameplay-interaction` | **Date**: 2026-06-19 + +Builds on the types established in feature 002 (`Round`, `Room`, `RoomSnapshot`, `Participant`). + +--- + +## New Type: `Guess` + +**File**: `backend/src/models/game.ts` + +```typescript +export interface Guess { + participantId: string; // who submitted + participantName: string; // denormalised at write time — avoids join on render + text: string; // trimmed guess as submitted + isCorrect: boolean; // true if text.toLowerCase() === word.toLowerCase() + timestamp: string; // ISO 8601, set at server receive time +} +``` + +**Constraints**: +- `text` is always trimmed (Zod `.trim()` applied before store call) +- `participantName` is copied from `room.participants` at write time; not updated if the name later changes (names are immutable after join) +- `isCorrect` reflects semantic correctness only — it does NOT imply points were awarded (a guesser's second correct guess has `isCorrect: true` but scores 0 per FR-009) + +--- + +## Updated Type: `Round` (extended) + +**File**: `backend/src/models/game.ts` + +```typescript +export interface Round { + drawerId: string; + word: string; + status: "active"; + guesses: Guess[]; // NEW — all guesses submitted this round, in arrival order + canvasData: string; // NEW — base64 PNG data URL; "" when blank/cleared +} +``` + +**Initialisation** (in `startGame`): +```typescript +room.currentRound = { + drawerId: caller.id, + word: STARTER_WORDS[0], + status: "active", + guesses: [], // empty at round start + canvasData: "" // blank canvas at round start +}; +``` + +**Invariants**: +- `guesses` is append-only — no mutation of existing entries +- `canvasData` is `""` when the canvas has been cleared or not yet drawn; never `null` or `undefined` + +--- + +## Updated Type: `Participant` (score added) + +**File**: `backend/src/models/game.ts` + +```typescript +export interface Participant { + id: string; + name: string; + joinedAt: string; + isHost: boolean; + score: number; // NEW — starts at 0; incremented by 100 on first correct guess per round +} +``` + +`createParticipant` sets `score: 0`. Score is a running total; it is NOT reset between rounds (round-reset is a future feature). + +--- + +## Updated Type: `RoomSnapshot` (guesses + canvasData added) + +**Files**: `backend/src/models/game.ts`, `frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts` + +```typescript +export interface RoomSnapshot { + code: string; + status: RoomStatus; + participants: Participant[]; // now includes score + drawerId: string | null; + secretWord?: string; // drawer only (unchanged from feature 002) + guesses: Guess[]; // NEW — public; all participants see full history + canvasData: string; // NEW — base64 PNG or ""; same for all participants +} +``` + +`guesses` and `canvasData` are always present — never conditionally omitted, unlike `secretWord`. + +--- + +## New Zod Schemas + +**File**: `backend/src/api/schemas.ts` + +```typescript +export const submitGuessSchema = z.object({ + participantId: z.string().min(1), + guess: z.string().trim().min(1, "Guess cannot be empty") +}); + +export const updateDrawingSchema = z.object({ + participantId: z.string().min(1), + canvasData: z.string() // base64 data URL or ""; no server-side size cap +}); +``` + +--- + +## Service Function Signatures + +**File**: `backend/src/services/roomStore.ts` + +```typescript +// Submit a guess for the active round +export function submitGuess( + code: string, + participantId: string, + guessText: string // already trimmed by Zod schema +): { snapshot: RoomSnapshot; isCorrect: boolean } +``` + +**`submitGuess` logic**: +1. Room not found → throw `"404: Room not found"` +2. No active round → throw `"400: No active round"` +3. `participantId === drawerId` → throw `"403: Drawer cannot guess"` +4. `isCorrect = guessText.toLowerCase() === room.currentRound.word.toLowerCase()` +5. `hasAlreadyScored = room.currentRound.guesses.some(g => g.participantId === participantId && g.isCorrect)` — derived from history (no separate field) +6. If `isCorrect && !hasAlreadyScored`: find participant in `room.participants`, increment `score += 100` +7. Push `Guess` record: `{ participantId, participantName, text: guessText, isCorrect, timestamp: now() }` +8. Return `{ snapshot: toRoomSnapshot(room, participantId), isCorrect }` + +```typescript +// Update the stored canvas image (drawer only) +export function updateCanvas( + code: string, + participantId: string, + canvasData: string +): void +``` + +**`updateCanvas` logic**: +1. Room not found → throw `"404: Room not found"` +2. No active round → throw `"400: No active round"` +3. `participantId !== drawerId` → throw `"403: Only the drawer can update the canvas"` +4. `room.currentRound.canvasData = canvasData`; update `room.updatedAt` + +--- + +## `toRoomSnapshot` Updates + +**File**: `backend/src/services/roomStore.ts` + +```typescript +// Additions to existing toRoomSnapshot: +guesses: room.currentRound?.guesses ?? [], +canvasData: room.currentRound?.canvasData ?? "" +``` + +Secrecy: `guesses` and `canvasData` are NOT filtered per viewer — both are public. `secretWord` filtering is unchanged from feature 002. + +--- + +## Type Relationship Diagram + +``` +Room (server-only) +├── code: string +├── status: "lobby" | "in-game" +├── participants: Participant[] +│ └── { id, name, joinedAt, isHost, score } ← score added +├── currentRound?: Round +│ ├── drawerId, word, status: "active" +│ ├── guesses: Guess[] ← added; append-only +│ └── canvasData: string ← added; last drawer POST +└── createdAt / updatedAt + + │ toRoomSnapshot(room, viewerParticipantId) + ▼ + +RoomSnapshot (API response — viewer-scoped) +├── code: string +├── status: "lobby" | "in-game" +├── participants: Participant[] ← now includes score +├── drawerId: string | null +├── secretWord?: string ← drawer only (unchanged) +├── guesses: Guess[] ← public; all participants +└── canvasData: string ← public; same for all viewers +``` diff --git a/specs/003-gameplay-interaction/plan.md b/specs/003-gameplay-interaction/plan.md new file mode 100644 index 000000000..8a124b935 --- /dev/null +++ b/specs/003-gameplay-interaction/plan.md @@ -0,0 +1,248 @@ +# Implementation Plan: Gameplay Interaction + +**Branch**: `003-gameplay-interaction` | **Date**: 2026-06-19 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/003-gameplay-interaction/spec.md` + +## Summary + +Add three interconnected capabilities to the active game screen: (1) an interactive `` for the drawer with pointer-based drawing and a clear button — canvas state serialised to a base64 PNG data URL on each completed stroke and pushed to the server via `POST /api/rooms/:code/drawing`, then delivered to all participants through the existing polling loop; (2) guess submission via `POST /api/rooms/:code/guess` with Zod trim + empty rejection, case-insensitive word comparison, and GuessForm feedback (clear input + "Correct!" on success); (3) scoring — first correct guess per player per round awards +100 (subsequent correct guesses by the same player score 0, derived from the guess history without a separate tracking field), all scores and guess history included in every `RoomSnapshot`. + +## Technical Context + +**Language/Version**: TypeScript 5.x, Node 24 (backend), Vite + React 18 (frontend) + +**Primary Dependencies**: Express, Zod (backend); React Router v6, Vitest (frontend) + +**Storage**: In-memory `Map` — `backend/src/services/roomStore.ts` + +**Testing**: Vitest (both packages) + +**Target Platform**: Local dev — backend port 3001, frontend port 5173 + +**Project Type**: Web service (backend REST API) + browser SPA (frontend) + +**Performance Goals**: Canvas updates visible to guessers within one polling cycle (~2 s); no other new targets + +**Constraints**: No WebSockets, no databases, no auth + +**Scale/Scope**: Single-instance, in-memory; no persistence across restarts + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. TDD (red-green-refactor, ≥80% coverage) | ✅ Pass | All tasks write failing tests first | +| II. TypeScript strict mode, no `any` | ✅ Pass | `canvasData: string`; canvas API typed via `HTMLCanvasElement`; no `any` | +| III. Architecture integrity — no WebSockets, no DB | ✅ Pass | Canvas sync via HTTP POST; state stays in-memory `Map` | +| IV. Lean deps — no new packages | ✅ Pass | `` is a native browser API; no third-party drawing library | +| V. Production-ready, self-documenting | ✅ Pass | `submitGuess`, `updateCanvas`, `isCorrect`, `canvasData` are self-descriptive | + +**Post-Phase-1 re-check**: No violations. `submitGuess` has no side effects beyond the single `Room` it mutates. `updateCanvas` is a guarded setter. `toRoomSnapshot` extensions are purely additive field reads. + +No Complexity Tracking table required. + +## Project Structure + +### Documentation (this feature) + +``` +specs/003-gameplay-interaction/ +├── plan.md ← this file +├── research.md ← Phase 0 decisions (8 decisions) +├── data-model.md ← Phase 1 type changes +├── contracts/ +│ └── api-rooms.md ← 2 new endpoints + updated RoomSnapshot +├── checklists/ +│ └── requirements.md ← spec quality checklist (16/16 pass) +└── tasks.md ← generated by /speckit-tasks +``` + +### Source Code — files changed by this feature + +``` +backend/ +└── src/ + ├── models/ + │ └── game.ts ← Guess (new); Round+ (guesses, canvasData); + │ Participant+ (score); RoomSnapshot+ (guesses, canvasData) + ├── services/ + │ ├── roomStore.ts ← createParticipant (score:0); startGame (guesses:[], canvasData:""); + │ submitGuess (new); updateCanvas (new); toRoomSnapshot+ + │ └── roomStore.test.ts ← extend: submitGuess, updateCanvas, scoring, first-correct-only + └── api/ + ├── schemas.ts ← submitGuessSchema, updateDrawingSchema (new) + └── rooms.ts ← POST /:code/guess, POST /:code/drawing (new handlers) + +frontend/ +└── src/ + ├── services/ + │ └── api.ts ← Guess (new type); Participant+ (score); RoomSnapshot+ (guesses, canvasData); + │ submitGuess, updateCanvas (new API calls) + ├── state/ + │ └── roomStore.ts ← same type updates; submitGuess, updateCanvas store methods (new) + ├── components/ + │ ├── GuessForm.tsx ← wire to API; trim+empty validation; "Correct!" feedback; clear on submit + │ ├── Scoreboard.tsx ← read from participants[].score; sorted desc + │ └── ResultPanel.tsx ← read from room.guesses; most-recent-first; correct styled green + └── pages/ + └── GamePage.tsx ← canvas impl for drawer; for guessers; props to components +``` + +**Test files** (TDD — written before implementation): +``` +backend/src/services/roomStore.test.ts ← extend existing +backend/src/api/schemas.test.ts ← extend existing (submitGuessSchema, updateDrawingSchema) +frontend/src/components/GuessForm.test.tsx ← new +frontend/src/components/Scoreboard.test.tsx ← new +frontend/src/components/ResultPanel.test.tsx ← new +frontend/src/pages/GamePage.test.tsx ← extend existing +``` + +## Data Flow + +### Canvas — Drawer to Server to Guessers + +``` +Drawer: pointerdown → pointermove (draw strokes locally) → pointerup + → canvas.toDataURL("image/png") + → store.updateCanvas(code, participantId, dataURL) + → POST /api/rooms/:code/drawing { participantId, canvasData: "data:image/png;base64,..." } + → updateCanvas(): guard (drawer only) → room.currentRound.canvasData = dataURL + → 200 { ok: true } + +Drawer clicks "Clear" + → ctx.clearRect(0, 0, width, height) — local canvas blanked immediately + → store.updateCanvas(code, participantId, "") + → POST /api/rooms/:code/drawing { participantId, canvasData: "" } + → room.currentRound.canvasData = "" + +Guesser polls GET /api/rooms/:code?participantId= + → toRoomSnapshot() includes canvasData + → GamePage: + blank when canvasData === "" (no broken-image icon; CSS min-height + white bg) +``` + +### Guess Submission — Guesser to Server to All + +``` +Guesser types guess → GuessForm submit + → client: trim → if empty: setError("Guess cannot be empty"); return + → store.submitGuess(code, participantId, guess) + → POST /api/rooms/:code/guess { participantId, guess } + → Zod: .trim().min(1) → 400 if empty after trim + → submitGuess(): + 404 if room not found + 400 if no currentRound + 403 if participantId === drawerId + isCorrect = guess.toLowerCase() === word.toLowerCase() + hasAlreadyScored = guesses.some(g => g.participantId === pid && g.isCorrect) + if isCorrect && !hasAlreadyScored: participant.score += 100 + guesses.push({ participantId, participantName, text: guess, isCorrect, timestamp }) + return { isCorrect, snapshot: toRoomSnapshot(room, participantId) } + → 200 { isCorrect, room } + +GuessForm receives response: + → isCorrect: clear input + show "Correct!" message + re-enable + → incorrect: clear input (guess appears in history) + → store.setRoomSnapshot(room) → Scoreboard + ResultPanel re-render via useSyncExternalStore + +All participants poll → receive updated guesses[], participants[].score, canvasData +``` + +## File-Level Changes + +### `backend/src/models/game.ts` + +1. Add `Guess` interface: `{ participantId, participantName, text, isCorrect, timestamp }` +2. Add `score: number` to `Participant` +3. Extend `Round`: add `guesses: Guess[]` and `canvasData: string` +4. Extend `RoomSnapshot`: add `guesses: Guess[]` and `canvasData: string` + +### `backend/src/api/schemas.ts` + +Add: +```typescript +export const submitGuessSchema = z.object({ + participantId: z.string().min(1), + guess: z.string().trim().min(1, "Guess cannot be empty") +}); + +export const updateDrawingSchema = z.object({ + participantId: z.string().min(1), + canvasData: z.string() +}); +``` + +### `backend/src/services/roomStore.ts` + +1. `createParticipant`: add `score: 0` +2. `startGame`: initialise `currentRound` with `guesses: [], canvasData: ""` +3. New `submitGuess(code, participantId, guessText)`: guards → compare → first-correct-only check → score update → push Guess → return `{ snapshot, isCorrect }` +4. New `updateCanvas(code, participantId, canvasData)`: guards (drawer only) → set `currentRound.canvasData` +5. `toRoomSnapshot`: add `guesses: room.currentRound?.guesses ?? []` and `canvasData: room.currentRound?.canvasData ?? ""` + +### `backend/src/api/rooms.ts` + +Add two route handlers after the existing `/start` route: +- `POST /:code/guess` — parse `submitGuessSchema`, call `submitGuess`, return `{ isCorrect, room: snapshot }` +- `POST /:code/drawing` — parse `updateDrawingSchema`, call `updateCanvas`, return `{ ok: true }` + +Error forwarding mirrors the existing `/start` handler pattern (prefix-coded errors → `HttpError`). + +### `frontend/src/services/api.ts` + +1. Add `Guess` interface +2. Add `score: number` to `Participant` +3. Add `guesses: Guess[]` and `canvasData: string` to `RoomSnapshot` +4. Add to `api` object: + - `submitGuess(code, participantId, guess)` → `POST /rooms/:code/guess` → `{ isCorrect, room }` + - `updateCanvas(code, participantId, canvasData)` → `POST /rooms/:code/drawing` → `{ ok: true }` + +### `frontend/src/state/roomStore.ts` + +Same type additions as `api.ts`. Add to `RoomStore` class: +- `async submitGuess(code, participantId, guess)` → calls `api.submitGuess`, calls `setRoomSnapshot(room)`, returns `isCorrect` +- `async updateCanvas(code, participantId, canvasData)` → calls `api.updateCanvas` (fire-and-update: no loading state needed) + +### `frontend/src/components/GuessForm.tsx` + +Replace stub with functional implementation: +- Props: `roomCode: string`, `participantId: string` +- State: `guessText`, `error`, `feedback` (`"correct" | "incorrect" | null`) +- On submit: trim → empty check (setError) → `store.submitGuess(...)` → on `isCorrect`: set feedback "correct", clear input → on incorrect: clear input → re-enable +- Renders: input + submit button + error message + "Correct!" feedback (shown when `feedback === "correct"`) +- Hidden/disabled when `isDrawer` (controlled by parent via prop or by not rendering) + +### `frontend/src/components/Scoreboard.tsx` + +Replace stub with functional implementation: +- Props: `participants: Participant[]` +- Render: `[...participants].sort((a, b) => b.score - a.score).map(p => )` + +### `frontend/src/components/ResultPanel.tsx` + +Replace stub with functional implementation: +- Props: `guesses: Guess[]` +- Render: `[...guesses].reverse().map(g => )` (most-recent-first) +- Correct guesses styled with a distinct colour (green text or checkmark) + +### `frontend/src/pages/GamePage.tsx` + +**Drawer additions**: +- `canvasRef = useRef(null)` +- `useEffect` mounts pointer event handlers: `pointerdown` → begin path; `pointermove` → `lineTo` + `stroke`; `pointerup` → `store.updateCanvas(code, participantId, canvas.toDataURL("image/png"))` +- Replace canvas placeholder `
    ` with `` +- Add "Clear" ` + )} + {!isHost && ( +

    Waiting for host to restart…

    + )} +
    +
    +``` + +`handleRestart`: calls `store.restartGame(room.code, participantId ?? "")` — the status-redirect `useEffect` navigates to `/lobby` automatically once the snapshot updates. + +### `frontend/src/routes/index.tsx` + +```typescript +import { ResultsPage } from "../pages/ResultsPage"; +// Add inside : +} /> +``` + +### `frontend/src/styles/app.css` + +New classes: + +```css +.results-page /* panel grid, gap 32px, padding 40px */ +.results-page__header /* flex, justify-content space-between, flex-wrap wrap, gap 24px */ +.results-page__title /* same as .game-page__title */ +.results-page__word /* font-size 2rem, font-weight 700, color var(--ink), margin 0 */ +.results-page__columns /* display grid, grid-template-columns repeat(auto-fit, minmax(280px,1fr)), gap 32px */ +.results-page__waiting /* color var(--ink-soft), font-style italic, font-size 0.875rem, margin 0 */ +``` + +## Test Coverage Plan + +### Backend — `roomStore.test.ts` additions (≥12 new tests) + +**`endRound`** (6 tests): +- throws 404 when room not found +- throws 400 when room is not "in-game" +- throws 403 when caller is not the host +- transitions status to "results" when called by host +- preserves currentRound data after end (guesses, canvasData still present) +- exposes secretWord in snapshot for all viewers after end (not just drawer) + +**`restartGame`** (6 tests): +- throws 404 when room not found +- throws 400 when room is not "results" +- throws 403 when caller is not the host +- transitions status to "lobby" when called by host +- clears currentRound after restart +- preserves all participants (and their scores) after restart + +### Backend — `schemas.test.ts` additions (≥4 new tests) + +- `endRoundSchema` rejects empty `participantId` +- `endRoundSchema` accepts valid `participantId` +- `restartGameSchema` rejects empty `participantId` +- `restartGameSchema` accepts valid `participantId` + +### Frontend — `ResultsPage.test.tsx` (new, ≥12 tests) + +- navigates to `/` when no room is loaded +- navigates to `/lobby` when `room.status === "lobby"` (auto-redirect after restart) +- navigates to `/game` when `room.status === "in-game"` +- stays on results page when `room.status === "results"` +- displays `room.secretWord` +- shows `Scoreboard` with participants +- shows `ResultPanel` with guesses +- shows "Back to Lobby" button for host +- hides "Back to Lobby" button for non-host +- shows waiting message for non-host +- calls `store.restartGame` when host clicks "Back to Lobby" +- polls fetchRoom every 2000ms (mirrors GamePage/LobbyPage polling test pattern) + +### Frontend — `GamePage.test.tsx` additions (≥3 new tests) + +- navigates to `/results` when `room.status === "results"` +- shows "End Round" button only for host (`isHost === true`) +- hides "End Round" button for non-host (`isHost === false`) + +## Navigation State Machine + +``` +lobby ──[host starts]──▶ in-game ──[host ends]──▶ results + ▲ │ + └──────────────[host restarts]──────────────────────────┘ +``` + +All transitions polled by all clients via `GET /rooms/:code?participantId=`. +Each page owns its redirect logic (`useEffect` on `room.status`). + +## Spec-to-Plan Coverage + +| Spec Requirement | Plan Coverage | +|-----------------|---------------| +| FR-001: host ends round → results state | `endRound` sets `room.status = "results"` | +| FR-002: only host can end | 403 guard in `endRound` | +| FR-003: secret word visible to all in results | `toRoomSnapshot` returns `secretWord` when `status === "results"` | +| FR-004: results screen shows word, scores, history | `ResultsPage` renders `Scoreboard`, `ResultPanel`, `room.secretWord` | +| FR-005: restart control for host only | `isHost && ` | +| FR-006: restart → lobby + clear round data | `restartGame` sets `"lobby"`, `currentRound = undefined` | +| FR-007: only host can restart | 403 guard in `restartGame` | +| FR-008: participants preserved | `restartGame` touches only `currentRound`, not `participants` | +| FR-009: auto-transition to lobby within 2s | `ResultsPage` polling + status redirect to `/lobby` | +| FR-010: auto-transition to results within 2s | `GamePage` status redirect to `/results` | +| FR-011: scores preserved on restart | `restartGame` does NOT reset `participant.score` | + +## File-Level Change Summary + +| File | Change Type | Description | +|------|-------------|-------------| +| `backend/src/models/game.ts` | Edit | Add `"results"` to `RoomStatus` union | +| `backend/src/services/roomStore.ts` | Edit | Add `endRound`, `restartGame`; update `toRoomSnapshot` | +| `backend/src/services/roomStore.test.ts` | Edit | Add ≥12 tests | +| `backend/src/api/schemas.ts` | Edit | Add `endRoundSchema`, `restartGameSchema` | +| `backend/src/api/schemas.test.ts` | Edit | Add ≥4 tests | +| `backend/src/api/rooms.ts` | Edit | Add `POST /:code/end`, `POST /:code/restart` handlers | +| `frontend/src/services/api.ts` | Edit | Update `RoomStatus`; add `endRound`, `restartGame` | +| `frontend/src/state/roomStore.ts` | Edit | Add `endRound`, `restartGame` store methods | +| `frontend/src/pages/GamePage.tsx` | Edit | Add `/results` redirect + host "End Round" button | +| `frontend/src/pages/GamePage.test.tsx` | Edit | Add ≥3 tests | +| `frontend/src/pages/ResultsPage.tsx` | **New** | Full results page component | +| `frontend/src/pages/ResultsPage.test.tsx` | **New** | ≥12 tests | +| `frontend/src/routes/index.tsx` | Edit | Add `/results` route | +| `frontend/src/styles/app.css` | Edit | Add results page CSS classes | diff --git a/specs/004-result-restart/quickstart.md b/specs/004-result-restart/quickstart.md new file mode 100644 index 000000000..4f4194566 --- /dev/null +++ b/specs/004-result-restart/quickstart.md @@ -0,0 +1,86 @@ +# Quickstart: Result, Restart & Final Validation + +**Feature**: `004-result-restart` | **Date**: 2026-06-19 + +Manual integration scenarios covering the full end-round and restart flow. Each scenario can be run with two browser tabs (one host, one guesser). + +--- + +## Scenario A — End Round and View Results + +**Setup**: +1. Create a room (tab 1 = host). Join with a second participant (tab 2 = guesser). +2. Host starts the game. Both tabs should be on the `/game` screen. +3. Guesser submits at least one guess (correct or incorrect). + +**Steps**: +1. On the host tab, click **End Round**. +2. Within ≈2 seconds (one polling cycle), both tabs should automatically navigate to `/results`. + +**Expected results on the results screen**: +- Secret word is visible to both participants (not just the drawer). +- Scoreboard shows final scores sorted highest-to-lowest. +- Guess history shows all submitted guesses, most-recent-first. +- Host sees a **Back to Lobby** button. +- Guesser sees "Waiting for host to restart…" (no button). + +--- + +## Scenario B — Host Restarts + +**Setup**: Continue from Scenario A (both tabs on `/results`). + +**Steps**: +1. On the host tab, click **Back to Lobby**. +2. Within ≈2 seconds, both tabs should automatically navigate to `/lobby`. + +**Expected results in the lobby**: +- Both participants are still listed in the player list. +- Scores from the previous round are visible (if lobby shows scores; otherwise verify via starting a new game). +- Host can start a new game again. + +--- + +## Scenario C — New Game After Restart Has No Stale Data + +**Setup**: Continue from Scenario B (both tabs on `/lobby`). + +**Steps**: +1. Host clicks **Start Game**. Both tabs navigate to `/game`. +2. Check: no prior guesses appear in the guess history panel. +3. Check: canvas area is empty (no drawing from the previous round). + +**Expected results**: +- Guess history: empty. +- Canvas: blank. +- Scores: accumulated from previous round (not reset). + +--- + +## Scenario D — Non-Host Cannot End or Restart + +**Setup**: Two browser tabs. Tab 1 = host (in-game). Tab 2 = guesser (in-game). + +**Steps** (manual API test — use browser console or curl): +1. From the guesser's participantId, call `POST /rooms/:code/end`. +2. Confirm response is `403 Forbidden`. +3. Manually transition to results (host ends round). +4. From the guesser's participantId, call `POST /rooms/:code/restart`. +5. Confirm response is `403 Forbidden`. + +**Expected results**: All non-host requests are rejected with 403. Room state is unchanged in both cases. + +--- + +## Scenario E — Direct Navigation to `/results` in Wrong State + +**Setup**: Room is in "lobby" or "in-game" status. + +**Steps**: +1. Manually type `/results` in the browser URL bar. +2. Observe automatic redirect. + +**Expected results**: +- If room status is `"lobby"` → redirected to `/lobby`. +- If room status is `"in-game"` → redirected to `/game`. +- If no room is loaded → redirected to `/` (home). diff --git a/specs/004-result-restart/research.md b/specs/004-result-restart/research.md new file mode 100644 index 000000000..99cda7f21 --- /dev/null +++ b/specs/004-result-restart/research.md @@ -0,0 +1,70 @@ +# Research: Result, Restart & Final Validation + +**Feature**: `004-result-restart` | **Date**: 2026-06-19 + +All decisions were resolved from existing codebase patterns and the spec. No unknowns required external research. + +## Decisions + +### 1. Status Machine Extension + +**Decision**: Add `"results"` as a third value to the `RoomStatus` union (`"lobby" | "in-game" | "results"`). + +**Rationale**: The existing pattern uses a discriminated string union for room status. Adding one value is the minimal change that gives the application a concrete state to branch on. No new type or interface is needed. + +**Alternatives considered**: +- Separate `isResultsActive: boolean` flag — rejected; the status field already encodes all room phases and a flag would introduce a second source of truth. +- Reuse `"lobby"` status post-round — rejected; all participants need to distinguish "pre-game lobby" from "just-finished results" to render the correct screen. + +--- + +### 2. Secret Word Visibility in Results + +**Decision**: When `room.status === "results"`, `toRoomSnapshot` returns `secretWord` unconditionally (no `viewerParticipantId` check). + +**Rationale**: The existing `toRoomSnapshot` already performs a conditional inclusion via spread; adding a status check is a one-line extension. Revealing the word to everyone is the intended UX of the results screen (spec FR-003). + +**Alternatives considered**: +- Add a separate `revealedWord` field to `RoomSnapshot` — rejected; the existing `secretWord` field already carries the semantics; a second field would be redundant. + +--- + +### 3. Round End Trigger + +**Decision**: Manual host action via `POST /rooms/:code/end`. + +**Rationale**: Spec Assumption: "The round end is a manual host action; automatic round-end triggers (all guessers correct, timer expiry) are out of scope." Manual host control is consistent with how `startGame` works. + +**Alternatives considered**: +- Auto-end when all guessers answer correctly — deferred per spec assumptions. +- Timer-based auto-end — deferred per spec assumptions. + +--- + +### 4. Restart Behaviour — Score Reset + +**Decision**: Scores are NOT reset on restart. Only `currentRound` is cleared. + +**Rationale**: Spec FR-011: "Scores accumulated in previous rounds MUST be preserved after a restart; only round data is cleared." Scores live on `Participant`, not on `Round`; clearing `currentRound` leaves `participants[].score` untouched. + +**Alternatives considered**: +- Reset all scores on restart — rejected by spec FR-011. + +--- + +### 5. ResultsPage Routing + +**Decision**: Dedicated `/results` route with a new `ResultsPage` component. Each page (`GamePage`, `ResultsPage`) owns its own status-redirect `useEffect`. + +**Rationale**: Mirrors the existing routing pattern (one route per game phase: `/lobby` → `LobbyPage`, `/game` → `GamePage`). Avoids conditional rendering sprawl inside `GamePage`. + +**Alternatives considered**: +- Inline results panel inside `GamePage` — rejected; would couple two distinct phases into one component and complicate redirect logic. + +--- + +### 6. Non-Host Access Guard + +**Decision**: Defense in depth — server-side 403 rejection + client-side button hidden. + +**Rationale**: Matches the existing `startGame` pattern. The server is the authority; the client hide is UX convenience only. diff --git a/specs/004-result-restart/spec.md b/specs/004-result-restart/spec.md new file mode 100644 index 000000000..0684b6188 --- /dev/null +++ b/specs/004-result-restart/spec.md @@ -0,0 +1,97 @@ +# Feature Specification: Result, Restart & Final Validation + +**Feature Branch**: `004-result-restart` + +**Created**: 2026-06-19 + +**Status**: Draft + +**Input**: User description: "Scenario 4 — Result, Restart & Final Validation: 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." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Result Display (Priority: P1) + +When the host ends the round, all participants transition automatically to a results screen within approximately 2 seconds. The results screen reveals the secret word to every participant (not just the drawer), displays final scores for all participants sorted highest-to-lowest, and shows the complete guess history most-recent-first with correct/incorrect indicators. Non-host participants wait on the results screen until the host restarts. + +**Why this priority**: The results screen is the natural conclusion of a round and the primary information payoff for all players — revealing who knew the answer and how well everyone scored. + +**Independent Test**: With a round active (secret word "rocket"), have the host click "End Round". Confirm that within 2 seconds all participant tabs show the results screen with: (a) "rocket" revealed as the secret word, (b) final scores for all players, (c) the complete guess history. + +**Acceptance Scenarios**: + +1. **Given** a round is active, **When** the host ends the round, **Then** all participants see the results screen within 2 seconds. +2. **Given** the results screen is displayed, **When** any participant views it, **Then** the secret word is visible to all participants regardless of their role (drawer or guesser). +3. **Given** the results screen is displayed, **When** any participant views it, **Then** all participant scores are shown sorted highest-to-lowest. +4. **Given** the results screen is displayed, **When** any participant views it, **Then** the complete guess history (correct and incorrect) is visible most-recent-first with the submitter's name and correctness indicated. +5. **Given** a non-host participant is on the results screen, **When** no host action has occurred, **Then** they see a waiting indicator rather than a restart control. + +--- + +### User Story 2 — Host Restart (Priority: P1) + +From the results screen, the host can restart the game. On restart, the room returns to the waiting lobby, all participants from the previous round are preserved in the room, and all round data (secret word, guesses, canvas) is cleared. All other participants are automatically redirected to the lobby within 2 seconds when they detect the status change. Scores accumulated across rounds are preserved; only round data is cleared. + +**Why this priority**: Restart closes the game loop — without it, every session is a dead end requiring manual room recreation. + +**Independent Test**: With a results screen displayed (host + one guesser), have the host click "Back to Lobby". Confirm: (a) both tabs redirect to the lobby within 2 seconds, (b) both participants still appear in the lobby player list, (c) the next game start begins with no prior guesses or canvas data. + +**Acceptance Scenarios**: + +1. **Given** the results screen is displayed and the participant is the host, **When** they click "Back to Lobby", **Then** the room returns to lobby status and all round data is cleared. +2. **Given** the host has restarted, **When** any participant's results screen checks for updates, **Then** their view automatically navigates to the lobby within 2 seconds. +3. **Given** the host has restarted, **When** the lobby is displayed, **Then** all participants from the previous round are present in the participant list. +4. **Given** the host has restarted and a new game is started, **When** any participant views the game screen, **Then** there are no guesses and no canvas data from the previous round. +5. **Given** the results screen is displayed and the participant is NOT the host, **Then** no restart control is visible or accessible to them. + +--- + +### Edge Cases + +- What if the host ends a round that has no guesses? — Allowed; the results screen shows an empty guess history and all scores at 0. +- What if a non-host participant tries to end the round? — The system rejects the action; only the host may end a round. +- What if a non-host participant tries to restart the game? — The system rejects the action; only the host may restart. +- What if a participant accesses the results screen directly without a round having ended? — The system redirects them to the appropriate screen based on the current game state. +- What if the host leaves before restarting? — The room remains in results status; other participants continue waiting on the results screen. Host reconnection and room cleanup are future features. +- What if the host restarts and immediately starts a new game? — The new round begins with the word selection mechanism from the previous start; scores from the prior round are preserved and continue to accumulate. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: When the host ends the round, the system MUST transition the game to a results state. +- **FR-002**: Only the host MUST be able to end the round; any non-host attempt MUST be rejected. +- **FR-003**: When a round ends, the system MUST make the secret word visible to ALL participants in the results view. +- **FR-004**: The results screen MUST display the secret word, final scores (sorted highest-to-lowest), and the complete guess history to all participants. The guess history MUST be ordered most-recent-first; each entry MUST show the submitter's name and a correct/incorrect indicator. +- **FR-005**: The system MUST include a restart control (e.g., "Back to Lobby" button) visible only to the host on the results screen. +- **FR-006**: When the host restarts, the system MUST return the room to lobby status and clear all round data. +- **FR-007**: Only the host MUST be able to restart; any non-host attempt MUST be rejected. +- **FR-008**: All participants MUST be preserved in the room after a restart; no participant is removed. +- **FR-009**: All participants MUST automatically transition to the lobby within 2 seconds of the host restarting. +- **FR-010**: All participants MUST automatically transition to the results screen within 2 seconds of the host ending the round. +- **FR-011**: Scores accumulated in previous rounds MUST be preserved after a restart; only round data is cleared. + +### Key Entities + +- **Game State**: Extended with a results phase that follows an active round. Three phases total: waiting for players (lobby), active round (in-game), round complete (results). +- **Secret Word Visibility**: Hidden from guessers during an active round (known only to the drawer); revealed to all participants when the round ends. +- **Round Data**: The completed round's data (guesses, canvas drawing, secret word) remains accessible during results display and is cleared when the host restarts. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Within 2 seconds of the host ending the round, all participants' screens transition from the game view to the results view. +- **SC-002**: The results screen shows the correct secret word, accurate final scores, and the complete guess history for 100% of participants. +- **SC-003**: Within 2 seconds of the host restarting, all participants' screens transition from the results view to the lobby view. +- **SC-004**: After restart, the lobby participant count matches the pre-restart count (no participants lost). +- **SC-005**: After restart and a new game start, the new round has zero guesses and empty canvas data. + +## Assumptions + +- Score reset on restart is out of scope for this scenario; scores accumulate across rounds within a session. Only round data (guesses, canvas, secret word) is cleared on restart. +- The host is always present during the end-round and restart flow; host departure handling is a future feature. +- The next game after restart uses the same word selection mechanism as the initial start; word rotation is a future feature. +- A single results phase covers the post-round state; multi-round tournament tracking is out of scope. +- The round end is a manual host action; automatic round-end triggers (all guessers correct, timer expiry) are out of scope. +- The game supports one active round at a time; concurrent rounds are out of scope. diff --git a/specs/004-result-restart/tasks.md b/specs/004-result-restart/tasks.md new file mode 100644 index 000000000..3eb032717 --- /dev/null +++ b/specs/004-result-restart/tasks.md @@ -0,0 +1,213 @@ +# Tasks: Result, Restart & Final Validation + +**Input**: Design documents from `specs/004-result-restart/` + +**Prerequisites**: plan.md ✅ | spec.md ✅ | research.md ✅ | data-model.md ✅ | contracts/ ✅ | quickstart.md ✅ + +**TDD**: Tests MUST be written and confirmed failing before any implementation begins (Constitution Principle I). + +**Organization**: Tasks grouped by user story for independent implementation and testing. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no shared dependencies) +- **[Story]**: Which user story this task belongs to +- Include exact file paths in descriptions + +--- + +## Phase 2: Foundational — Type System (Blocking Prerequisites) + +**Purpose**: Add the `"results"` status to the shared type system. All US1 and US2 work depends on this change. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T001 Add `"results"` to `RoomStatus` union type in `backend/src/models/game.ts` +- [x] T002 [P] Update `RoomStatus` type alias to include `"results"` in `frontend/src/services/api.ts` + +**Checkpoint**: Both packages compile with the updated type; TypeScript reports no errors. + +--- + +## Phase 3: User Story 1 — Result Display (Priority: P1) 🎯 MVP + +**Goal**: When the host ends the round, all participants automatically see a results screen showing the revealed secret word, final scores, and full guess history within ~2 seconds. + +**Independent Test**: With two browser tabs (host + guesser) in an active round, host clicks "End Round". Within 2 seconds both tabs navigate to `/results` showing the secret word, sorted scores, and guess history. The guesser tab shows "Waiting for host to restart…" instead of a button. + +### Tests for User Story 1 (RED — write first, verify failing) + +- [x] T003 [US1] Write failing tests for `endRound` service (404/400/403/transitions-to-results/preserves-round-data/exposes-secretWord-for-all) in `backend/src/services/roomStore.test.ts` +- [x] T004 [P] [US1] Write failing tests for `endRoundSchema` (rejects empty participantId, accepts valid participantId) in `backend/src/api/schemas.test.ts` +- [x] T005 [P] [US1] Write failing tests for `ResultsPage` display (no-room guard, stays-on-results, in-game redirect, secretWord rendered, Scoreboard rendered, ResultPanel rendered, polls every 2000ms) in `frontend/src/pages/ResultsPage.test.tsx` — `ResultPanel` is the existing shared component from `frontend/src/components/ResultPanel.tsx` +- [x] T006 [P] [US1] Write failing tests for `GamePage`: navigates to `/results` when `room.status === "results"`, "End Round" button visible for host, hidden for non-host in `frontend/src/pages/GamePage.test.tsx` + +### Implementation for User Story 1 (GREEN) + +- [x] T007 [US1] Implement `endRound` function and update `toRoomSnapshot` to expose `secretWord` for all viewers when `room.status === "results"` in `backend/src/services/roomStore.ts` +- [x] T008 [P] [US1] Add `endRoundSchema` (`z.object({ participantId: z.string().min(1) })`) to `backend/src/api/schemas.ts` +- [x] T009 [US1] Add `POST /:code/end` route handler (parse `endRoundSchema`, call `endRound`, return `{ room: snapshot }`) in `backend/src/api/rooms.ts` +- [x] T010 [US1] Add `endRound(code, participantId)` API call (`POST /rooms/:code/end`) in `frontend/src/services/api.ts` +- [x] T011 [P] [US1] Add `endRound(code, participantId)` store method (calls `api.endRound`, then `this.setRoomSnapshot`) in `frontend/src/state/roomStore.ts` +- [x] T012 [US1] Add `"results"` status redirect (`navigate("/results", { replace: true })`) and "End Round" host button (calls `store.endRound`) to `frontend/src/pages/GamePage.tsx` +- [x] T013 [US1] Implement `ResultsPage` component: polling `useEffect` (2000ms, clearInterval on unmount), guard redirect (`!room → /`), status redirect (`"in-game" → /game`), display of `room.secretWord` in a ``, ``, `` in `frontend/src/pages/ResultsPage.tsx` — import `ResultPanel` from the existing `../components/ResultPanel` (shared with GamePage; already renders most-recent-first with submitter name and correct/incorrect indicator) +- [x] T014 [US1] Add `/results` route (`} />`) in `frontend/src/routes/index.tsx` +- [x] T015 [P] [US1] Add ResultsPage CSS classes (`.results-page`, `.results-page__header`, `.results-page__title`, `.results-page__word`, `.results-page__columns`, `.results-page__waiting`) in `frontend/src/styles/app.css` + +**Checkpoint**: After T015, the end-round flow is fully functional. Host sees results screen with secret word. Non-host tabs auto-redirect and see the same data plus "Waiting for host to restart…" text (not yet interactive). + +--- + +## Phase 4: User Story 2 — Host Restart (Priority: P1) + +**Goal**: From the results screen, the host can restart the game. All participants automatically return to the lobby within ~2 seconds with their participant data preserved and all round data cleared. + +**Independent Test**: On the results screen (host + guesser), host clicks "Back to Lobby". Within 2 seconds both tabs navigate to `/lobby`. Both participants appear in the player list. Starting a new game shows no prior guesses and no canvas data. + +### Tests for User Story 2 (RED — write first, verify failing) + +- [x] T016 [US2] Write failing tests for `restartGame` service (404/400/403/transitions-to-lobby/clears-currentRound/preserves-participants-and-scores) in `backend/src/services/roomStore.test.ts` +- [x] T017 [P] [US2] Write failing tests for `restartGameSchema` (rejects empty participantId, accepts valid participantId) in `backend/src/api/schemas.test.ts` +- [x] T018 [US2] Write failing tests for `ResultsPage` restart flow ("Back to Lobby" button shown for host, hidden for non-host, waiting message for non-host, calls `store.restartGame` on click, navigates to `/lobby` when `room.status === "lobby"`) in `frontend/src/pages/ResultsPage.test.tsx` + +### Implementation for User Story 2 (GREEN) + +- [x] T019 [US2] Implement `restartGame` function (guards: 404/400/403, sets `room.status = "lobby"`, clears `room.currentRound = undefined`) in `backend/src/services/roomStore.ts` +- [x] T020 [P] [US2] Add `restartGameSchema` (`z.object({ participantId: z.string().min(1) })`) to `backend/src/api/schemas.ts` +- [x] T021 [US2] Add `POST /:code/restart` route handler (parse `restartGameSchema`, call `restartGame`, return `{ room: snapshot }`) in `backend/src/api/rooms.ts` +- [x] T022 [US2] Add `restartGame(code, participantId)` API call (`POST /rooms/:code/restart`) in `frontend/src/services/api.ts` +- [x] T023 [P] [US2] Add `restartGame(code, participantId)` store method (calls `api.restartGame`, then `this.setRoomSnapshot`) in `frontend/src/state/roomStore.ts` +- [x] T024 [US2] Extend `ResultsPage`: add "Back to Lobby" `