From 991d07de05441e79c266e8f748d02a222f5c258b Mon Sep 17 00:00:00 2001 From: Vishal S Date: Fri, 29 May 2026 16:44:24 +0530 Subject: [PATCH 1/9] Add discovery notes and Scenario 1 spec artifacts --- .specify/extensions.yml | 149 ++++ .specify/extensions/.registry | 30 + .specify/extensions/git/README.md | 100 +++ .../git/commands/speckit.git.commit.md | 48 ++ .../git/commands/speckit.git.feature.md | 67 ++ .../git/commands/speckit.git.initialize.md | 49 ++ .../git/commands/speckit.git.remote.md | 45 ++ .../git/commands/speckit.git.validate.md | 49 ++ .specify/extensions/git/config-template.yml | 62 ++ .specify/extensions/git/extension.yml | 140 ++++ .specify/extensions/git/git-config.yml | 62 ++ .../git/scripts/bash/auto-commit.sh | 140 ++++ .../git/scripts/bash/create-new-feature.sh | 453 ++++++++++++ .../extensions/git/scripts/bash/git-common.sh | 54 ++ .../git/scripts/bash/initialize-repo.sh | 54 ++ .../git/scripts/powershell/auto-commit.ps1 | 169 +++++ .../scripts/powershell/create-new-feature.ps1 | 403 +++++++++++ .../git/scripts/powershell/git-common.ps1 | 51 ++ .../scripts/powershell/initialize-repo.ps1 | 69 ++ .specify/feature.json | 3 + .specify/init-options.json | 10 + .specify/integration.json | 15 + .specify/integrations/codex.manifest.json | 16 + .specify/integrations/speckit.manifest.json | 17 + .specify/memory/constitution.md | 143 ++++ .specify/scripts/bash/check-prerequisites.sh | 192 ++++++ .specify/scripts/bash/common.sh | 645 ++++++++++++++++++ .specify/scripts/bash/create-new-feature.sh | 413 +++++++++++ .specify/scripts/bash/setup-plan.sh | 91 +++ .specify/scripts/bash/setup-tasks.sh | 96 +++ .specify/templates/checklist-template.md | 40 ++ .specify/templates/constitution-template.md | 50 ++ .specify/templates/plan-template.md | 112 +++ .specify/templates/spec-template.md | 155 +++++ .specify/templates/tasks-template.md | 266 ++++++++ .specify/workflows/speckit/workflow.yml | 77 +++ .specify/workflows/workflow-registry.json | 13 + discovery-notes.md | 15 + .../checklists/requirements.md | 36 + .../contracts/rooms-scenario1.openapi.yaml | 206 ++++++ specs/001-room-lobby-setup/data-model.md | 99 +++ specs/001-room-lobby-setup/plan.md | 267 ++++++++ specs/001-room-lobby-setup/quickstart.md | 57 ++ specs/001-room-lobby-setup/research.md | 85 +++ specs/001-room-lobby-setup/spec.md | 191 ++++++ specs/001-room-lobby-setup/tasks.md | 241 +++++++ 46 files changed, 5745 insertions(+) create mode 100644 .specify/extensions.yml create mode 100644 .specify/extensions/.registry create mode 100644 .specify/extensions/git/README.md create mode 100644 .specify/extensions/git/commands/speckit.git.commit.md create mode 100644 .specify/extensions/git/commands/speckit.git.feature.md create mode 100644 .specify/extensions/git/commands/speckit.git.initialize.md create mode 100644 .specify/extensions/git/commands/speckit.git.remote.md create mode 100644 .specify/extensions/git/commands/speckit.git.validate.md create mode 100644 .specify/extensions/git/config-template.yml create mode 100644 .specify/extensions/git/extension.yml create mode 100644 .specify/extensions/git/git-config.yml create mode 100755 .specify/extensions/git/scripts/bash/auto-commit.sh create mode 100755 .specify/extensions/git/scripts/bash/create-new-feature.sh create mode 100755 .specify/extensions/git/scripts/bash/git-common.sh create mode 100755 .specify/extensions/git/scripts/bash/initialize-repo.sh create mode 100644 .specify/extensions/git/scripts/powershell/auto-commit.ps1 create mode 100644 .specify/extensions/git/scripts/powershell/create-new-feature.ps1 create mode 100644 .specify/extensions/git/scripts/powershell/git-common.ps1 create mode 100644 .specify/extensions/git/scripts/powershell/initialize-repo.ps1 create mode 100644 .specify/feature.json create mode 100644 .specify/init-options.json create mode 100644 .specify/integration.json create mode 100644 .specify/integrations/codex.manifest.json create mode 100644 .specify/integrations/speckit.manifest.json create mode 100644 .specify/memory/constitution.md create mode 100755 .specify/scripts/bash/check-prerequisites.sh create mode 100755 .specify/scripts/bash/common.sh create mode 100755 .specify/scripts/bash/create-new-feature.sh create mode 100755 .specify/scripts/bash/setup-plan.sh create mode 100755 .specify/scripts/bash/setup-tasks.sh create mode 100644 .specify/templates/checklist-template.md create mode 100644 .specify/templates/constitution-template.md create mode 100644 .specify/templates/plan-template.md create mode 100644 .specify/templates/spec-template.md create mode 100644 .specify/templates/tasks-template.md create mode 100644 .specify/workflows/speckit/workflow.yml create mode 100644 .specify/workflows/workflow-registry.json create mode 100644 discovery-notes.md create mode 100644 specs/001-room-lobby-setup/checklists/requirements.md create mode 100644 specs/001-room-lobby-setup/contracts/rooms-scenario1.openapi.yaml create mode 100644 specs/001-room-lobby-setup/data-model.md create mode 100644 specs/001-room-lobby-setup/plan.md create mode 100644 specs/001-room-lobby-setup/quickstart.md create mode 100644 specs/001-room-lobby-setup/research.md create mode 100644 specs/001-room-lobby-setup/spec.md create mode 100644 specs/001-room-lobby-setup/tasks.md diff --git a/.specify/extensions.yml b/.specify/extensions.yml new file mode 100644 index 0000000..42dd0a9 --- /dev/null +++ b/.specify/extensions.yml @@ -0,0 +1,149 @@ +installed: +- git +settings: + auto_execute_hooks: true +hooks: + before_constitution: + - extension: git + command: speckit.git.initialize + enabled: true + optional: false + prompt: Execute speckit.git.initialize? + description: Initialize Git repository before constitution setup + condition: null + before_specify: + - extension: git + command: speckit.git.feature + enabled: true + optional: false + prompt: Execute speckit.git.feature? + description: Create feature branch before specification + condition: null + before_clarify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before clarification? + description: Auto-commit before spec clarification + condition: null + before_plan: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before planning? + description: Auto-commit before implementation planning + condition: null + before_tasks: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before task generation? + description: Auto-commit before task generation + condition: null + before_implement: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before implementation? + description: Auto-commit before implementation + condition: null + before_checklist: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before checklist? + description: Auto-commit before checklist generation + condition: null + before_analyze: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before analysis? + description: Auto-commit before analysis + condition: null + before_taskstoissues: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before issue sync? + description: Auto-commit before tasks-to-issues conversion + condition: null + after_constitution: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit constitution changes? + description: Auto-commit after constitution update + condition: null + after_specify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit specification changes? + description: Auto-commit after specification + condition: null + after_clarify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit clarification changes? + description: Auto-commit after spec clarification + condition: null + after_plan: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit plan changes? + description: Auto-commit after implementation planning + condition: null + after_tasks: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit task changes? + description: Auto-commit after task generation + condition: null + after_implement: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit implementation changes? + description: Auto-commit after implementation + condition: null + after_checklist: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit checklist changes? + description: Auto-commit after checklist generation + condition: null + after_analyze: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit analysis results? + description: Auto-commit after analysis + condition: null + after_taskstoissues: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit after syncing issues? + description: Auto-commit after tasks-to-issues conversion + condition: null diff --git a/.specify/extensions/.registry b/.specify/extensions/.registry new file mode 100644 index 0000000..bff5989 --- /dev/null +++ b/.specify/extensions/.registry @@ -0,0 +1,30 @@ +{ + "schema_version": "1.0", + "extensions": { + "git": { + "version": "1.0.0", + "source": "local", + "manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79", + "enabled": true, + "priority": 10, + "registered_commands": { + "agy": [ + "speckit.git.feature", + "speckit.git.validate", + "speckit.git.remote", + "speckit.git.initialize", + "speckit.git.commit" + ], + "codex": [ + "speckit.git.feature", + "speckit.git.validate", + "speckit.git.remote", + "speckit.git.initialize", + "speckit.git.commit" + ] + }, + "registered_skills": [], + "installed_at": "2026-05-29T10:01:56.080266+00:00" + } + } +} \ No newline at end of file diff --git a/.specify/extensions/git/README.md b/.specify/extensions/git/README.md new file mode 100644 index 0000000..31ba75c --- /dev/null +++ b/.specify/extensions/git/README.md @@ -0,0 +1,100 @@ +# Git Branching Workflow Extension + +Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit. + +## Overview + +This extension provides Git operations as an optional, self-contained module. It manages: + +- **Repository initialization** with configurable commit messages +- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering +- **Branch validation** to ensure branches follow naming conventions +- **Git remote detection** for GitHub integration (e.g., issue creation) +- **Auto-commit** after core commands (configurable per-command with custom messages) + +## Commands + +| Command | Description | +|---------|-------------| +| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message | +| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering | +| `speckit.git.validate` | Validate current branch follows feature branch naming conventions | +| `speckit.git.remote` | Detect Git remote URL for GitHub integration | +| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) | + +## Hooks + +| Event | Command | Optional | Description | +|-------|---------|----------|-------------| +| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution | +| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification | +| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification | +| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning | +| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation | +| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation | +| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist | +| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis | +| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync | +| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update | +| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification | +| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification | +| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning | +| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation | +| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation | +| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist | +| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis | +| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync | + +## Configuration + +Configuration is stored in `.specify/extensions/git/git-config.yml`: + +```yaml +# Branch numbering strategy: "sequential" or "timestamp" +branch_numbering: sequential + +# Custom commit message for git init +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit per command (all disabled by default) +# Example: enable auto-commit after specify +auto_commit: + default: false + after_specify: + enabled: true + message: "[Spec Kit] Add specification" +``` + +## Installation + +```bash +# Install the bundled git extension (no network required) +specify extension add git +``` + +## Disabling + +```bash +# Disable the git extension (spec creation continues without branching) +specify extension disable git + +# Re-enable it +specify extension enable git +``` + +## Graceful Degradation + +When Git is not installed or the directory is not a Git repository: +- Spec directories are still created under `specs/` +- Branch creation is skipped with a warning +- Branch validation is skipped with a warning +- Remote detection returns empty results + +## Scripts + +The extension bundles cross-platform scripts: + +- `scripts/bash/create-new-feature.sh` — Bash implementation +- `scripts/bash/git-common.sh` — Shared Git utilities (Bash) +- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation +- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell) diff --git a/.specify/extensions/git/commands/speckit.git.commit.md b/.specify/extensions/git/commands/speckit.git.commit.md new file mode 100644 index 0000000..e606f91 --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.commit.md @@ -0,0 +1,48 @@ +--- +description: "Auto-commit changes after a Spec Kit command completes" +--- + +# Auto-Commit Changes + +Automatically stage and commit all changes after a Spec Kit command completes. + +## Behavior + +This command is invoked as a hook after (or before) core commands. It: + +1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`) +2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section +3. Looks up the specific event key to see if auto-commit is enabled +4. Falls back to `auto_commit.default` if no event-specific key exists +5. Uses the per-command `message` if configured, otherwise a default message +6. If enabled and there are uncommitted changes, runs `git add .` + `git commit` + +## Execution + +Determine the event name from the hook that triggered this command, then run the script: + +- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh ` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 ` + +Replace `` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). + +## Configuration + +In `.specify/extensions/git/git-config.yml`: + +```yaml +auto_commit: + default: false # Global toggle — set true to enable for all commands + after_specify: + enabled: true # Override per-command + message: "[Spec Kit] Add specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" +``` + +## Graceful Degradation + +- If Git is not available or the current directory is not a repository: skips with a warning +- If no config file exists: skips (disabled by default) +- If no changes to commit: skips with a message diff --git a/.specify/extensions/git/commands/speckit.git.feature.md b/.specify/extensions/git/commands/speckit.git.feature.md new file mode 100644 index 0000000..5bed9e5 --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.feature.md @@ -0,0 +1,67 @@ +--- +description: "Create a feature branch with sequential or timestamp numbering" +--- + +# Create Feature Branch + +Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `__SPECKIT_COMMAND_SPECIFY__` workflow. + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Environment Variable Override + +If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: +- The script uses the exact value as the branch name, bypassing all prefix/suffix generation +- `--short-name`, `--number`, and `--timestamp` flags are ignored +- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name + +## Prerequisites + +- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, warn the user and skip branch creation + +## Branch Numbering Mode + +Determine the branch numbering strategy by checking configuration in this order: + +1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value +2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) +3. Default to `sequential` if neither exists + +## Execution + +Generate a concise short name (2-4 words) for the branch: +- Analyze the feature description and extract the most meaningful keywords +- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") +- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.) + +Run the appropriate script based on your platform: + +- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "" ""` +- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "" ""` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "" ""` +- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "" ""` + +**IMPORTANT**: +- Do NOT pass `--number` — the script determines the correct next number automatically +- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably +- You must only ever run this script once per feature +- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM` + +## Graceful Degradation + +If Git is not installed or the current directory is not a Git repository: +- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation` +- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them + +## Output + +The script outputs JSON with: +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) +- `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/.specify/extensions/git/commands/speckit.git.initialize.md b/.specify/extensions/git/commands/speckit.git.initialize.md new file mode 100644 index 0000000..93962c2 --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.initialize.md @@ -0,0 +1,49 @@ +--- +description: "Initialize a Git repository with an initial commit" +--- + +# Initialize Git Repository + +Initialize a Git repository in the current project directory if one does not already exist. + +## Execution + +Run the appropriate script from the project root: + +- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1` + +If the extension scripts are not found, fall back to: +- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"` +- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"` + +The script handles all checks internally: +- Skips if Git is not available +- Skips if already inside a Git repository +- Runs `git init`, `git add .`, and `git commit` with an initial commit message + +## Customization + +Replace the script to add project-specific Git initialization steps: +- Custom `.gitignore` templates +- Default branch naming (`git config init.defaultBranch`) +- Git LFS setup +- Git hooks installation +- Commit signing configuration +- Git Flow initialization + +## Output + +On success: +- `[OK] Git repository initialized` + +## Graceful Degradation + +If Git is not installed: +- Warn the user +- Skip repository initialization +- The project continues to function without Git (specs can still be created under `specs/`) + +If Git is installed but `git init`, `git add .`, or `git commit` fails: +- Surface the error to the user +- Stop this command rather than continuing with a partially initialized repository diff --git a/.specify/extensions/git/commands/speckit.git.remote.md b/.specify/extensions/git/commands/speckit.git.remote.md new file mode 100644 index 0000000..712a3e8 --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.remote.md @@ -0,0 +1,45 @@ +--- +description: "Detect Git remote URL for GitHub integration" +--- + +# Detect Git Remote URL + +Detect the Git remote URL for integration with GitHub services (e.g., issue creation). + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and return empty: + ``` + [specify] Warning: Git repository not detected; cannot determine remote URL + ``` + +## Execution + +Run the following command to get the remote URL: + +```bash +git config --get remote.origin.url +``` + +## Output + +Parse the remote URL and determine: + +1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`) +2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`) +3. **Is GitHub**: Whether the remote points to a GitHub repository + +Supported URL formats: +- HTTPS: `https://github.com//.git` +- SSH: `git@github.com:/.git` + +> [!CAUTION] +> ONLY report a GitHub repository if the remote URL actually points to github.com. +> Do NOT assume the remote is GitHub if the URL format doesn't match. + +## Graceful Degradation + +If Git is not installed, the directory is not a Git repository, or no remote is configured: +- Return an empty result +- Do NOT error — other workflows should continue without Git remote information diff --git a/.specify/extensions/git/commands/speckit.git.validate.md b/.specify/extensions/git/commands/speckit.git.validate.md new file mode 100644 index 0000000..dd84618 --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.validate.md @@ -0,0 +1,49 @@ +--- +description: "Validate current branch follows feature branch naming conventions" +--- + +# Validate Feature Branch + +Validate that the current Git branch follows the expected feature branch naming conventions. + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and skip validation: + ``` + [specify] Warning: Git repository not detected; skipped branch validation + ``` + +## Validation Rules + +Get the current branch name: + +```bash +git rev-parse --abbrev-ref HEAD +``` + +The branch name must match one of these patterns: + +1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`) +2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`) + +## Execution + +If on a feature branch (matches either pattern): +- Output: `✓ On feature branch: ` +- Check if the corresponding spec directory exists under `specs/`: + - For sequential branches, look for `specs/-*` where prefix matches the numeric portion + - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion +- If spec directory exists: `✓ Spec directory found: ` +- If spec directory missing: `⚠ No spec directory found for prefix ` + +If NOT on a feature branch: +- Output: `✗ Not on a feature branch. Current branch: ` +- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name` + +## Graceful Degradation + +If Git is not installed or the directory is not a Git repository: +- Check the `SPECIFY_FEATURE` environment variable as a fallback +- If set, validate that value against the naming patterns +- If not set, skip validation with a warning diff --git a/.specify/extensions/git/config-template.yml b/.specify/extensions/git/config-template.yml new file mode 100644 index 0000000..8c414ba --- /dev/null +++ b/.specify/extensions/git/config-template.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/.specify/extensions/git/extension.yml b/.specify/extensions/git/extension.yml new file mode 100644 index 0000000..13c1977 --- /dev/null +++ b/.specify/extensions/git/extension.yml @@ -0,0 +1,140 @@ +schema_version: "1.0" + +extension: + id: git + name: "Git Branching Workflow" + version: "1.0.0" + description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection" + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT + +requires: + speckit_version: ">=0.2.0" + tools: + - name: git + required: false + +provides: + commands: + - name: speckit.git.feature + file: commands/speckit.git.feature.md + description: "Create a feature branch with sequential or timestamp numbering" + - name: speckit.git.validate + file: commands/speckit.git.validate.md + description: "Validate current branch follows feature branch naming conventions" + - name: speckit.git.remote + file: commands/speckit.git.remote.md + description: "Detect Git remote URL for GitHub integration" + - name: speckit.git.initialize + file: commands/speckit.git.initialize.md + description: "Initialize a Git repository with an initial commit" + - name: speckit.git.commit + file: commands/speckit.git.commit.md + description: "Auto-commit changes after a Spec Kit command completes" + + config: + - name: "git-config.yml" + template: "config-template.yml" + description: "Git branching configuration" + required: false + +hooks: + before_constitution: + command: speckit.git.initialize + optional: false + description: "Initialize Git repository before constitution setup" + before_specify: + command: speckit.git.feature + optional: false + description: "Create feature branch before specification" + before_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before clarification?" + description: "Auto-commit before spec clarification" + before_plan: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before planning?" + description: "Auto-commit before implementation planning" + before_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before task generation?" + description: "Auto-commit before task generation" + before_implement: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before implementation?" + description: "Auto-commit before implementation" + before_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before checklist?" + description: "Auto-commit before checklist generation" + before_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before analysis?" + description: "Auto-commit before analysis" + before_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before issue sync?" + description: "Auto-commit before tasks-to-issues conversion" + after_constitution: + command: speckit.git.commit + optional: true + prompt: "Commit constitution changes?" + description: "Auto-commit after constitution update" + after_specify: + command: speckit.git.commit + optional: true + prompt: "Commit specification changes?" + description: "Auto-commit after specification" + after_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit clarification changes?" + description: "Auto-commit after spec clarification" + after_plan: + command: speckit.git.commit + optional: true + prompt: "Commit plan changes?" + description: "Auto-commit after implementation planning" + after_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit task changes?" + description: "Auto-commit after task generation" + after_implement: + command: speckit.git.commit + optional: true + prompt: "Commit implementation changes?" + description: "Auto-commit after implementation" + after_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit checklist changes?" + description: "Auto-commit after checklist generation" + after_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit analysis results?" + description: "Auto-commit after analysis" + after_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit after syncing issues?" + description: "Auto-commit after tasks-to-issues conversion" + +tags: + - "git" + - "branching" + - "workflow" + +config: + defaults: + branch_numbering: sequential + init_commit_message: "[Spec Kit] Initial commit" diff --git a/.specify/extensions/git/git-config.yml b/.specify/extensions/git/git-config.yml new file mode 100644 index 0000000..8c414ba --- /dev/null +++ b/.specify/extensions/git/git-config.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/.specify/extensions/git/scripts/bash/auto-commit.sh b/.specify/extensions/git/scripts/bash/auto-commit.sh new file mode 100755 index 0000000..f0b4231 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/auto-commit.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Git extension: auto-commit.sh +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.sh +# e.g.: auto-commit.sh after_specify + +set -e + +EVENT_NAME="${1:-}" +if [ -z "$EVENT_NAME" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped auto-commit" >&2 + exit 0 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2 + exit 0 +fi + +# Read per-command config from git-config.yml +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +_enabled=false +_commit_msg="" + +if [ -f "$_config_file" ]; then + # Parse the auto_commit section for this event. + # Look for auto_commit..enabled and .message + # Also check auto_commit.default as fallback. + _in_auto_commit=false + _in_event=false + _default_enabled=false + + while IFS= read -r _line; do + # Detect auto_commit: section + if echo "$_line" | grep -q '^auto_commit:'; then + _in_auto_commit=true + _in_event=false + continue + fi + + # Exit auto_commit section on next top-level key + if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then + break + fi + + if $_in_auto_commit; then + # Check default key + if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _default_enabled=true + fi + + # Detect our event subsection + if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then + _in_event=true + continue + fi + + # Inside our event subsection + if $_in_event; then + # Exit on next sibling key (same indent level as event name) + if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then + _in_event=false + continue + fi + if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _enabled=true + [ "$_val" = "false" ] && _enabled=false + fi + if echo "$_line" | grep -Eq '[[:space:]]+message:'; then + _commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + fi + fi + fi + done < "$_config_file" + + # If event-specific key not found, use default + if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then + # Only use default if the event wasn't explicitly set to false + # Check if event section existed at all + if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then + _enabled=true + fi + fi +else + # No config file — auto-commit disabled by default + exit 0 +fi + +if [ "$_enabled" != "true" ]; then + exit 0 +fi + +# Check if there are changes to commit +if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then + echo "[specify] No changes to commit after $EVENT_NAME" >&2 + exit 0 +fi + +# Derive a human-readable command name from the event +# e.g., after_specify -> specify, before_plan -> plan +_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//') +_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after') + +# Use custom message if configured, otherwise default +if [ -z "$_commit_msg" ]; then + _commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}" +fi + +# Stage and commit +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "[OK] Changes committed ${_phase} ${_command_name}" >&2 diff --git a/.specify/extensions/git/scripts/bash/create-new-feature.sh b/.specify/extensions/git/scripts/bash/create-new-feature.sh new file mode 100755 index 0000000..f7aa316 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/create-new-feature.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash +# Git extension: create-new-feature.sh +# Adapted from core scripts/bash/create-new-feature.sh for extension layout. +# Sources common.sh from the project's installed scripts, falling back to +# git-common.sh for minimal git helpers. + +set -e + +JSON_MODE=false +DRY_RUN=false +ALLOW_EXISTING=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --dry-run) + DRY_RUN=true + ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then + echo 'Error: --number must be a non-negative integer' >&2 + exit 1 + fi + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --dry-run Compute branch name without creating the branch" + echo " --allow-existing-branch Switch to branch if it already exists instead of failing" + echo " --short-name Provide a custom short name (2-4 words) for the branch" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --help, -h Show this help message" + echo "" + echo "Environment variables:" + echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + echo " GIT_BRANCH_NAME=my-branch $0 'feature description'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +_extract_highest_number() { + local highest=0 + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + echo "$highest" +} + +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches and return next available number. +check_existing_branches() { + local specs_dir="$1" + local skip_fetch="${2:-false}" + + if [ "$skip_fetch" = true ]; then + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi + + local highest_spec=$(get_highest_from_specs "$specs_dir") + + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# --------------------------------------------------------------------------- +# Source common.sh for resolve_template, json_escape, get_repo_root, has_git. +# +# Search locations in priority order: +# 1. .specify/scripts/bash/common.sh under the project root (installed project) +# 2. scripts/bash/common.sh under the project root (source checkout fallback) +# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template) +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root by walking up from the script location +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +_common_loaded=false +_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true + +if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" + _common_loaded=true +elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/scripts/bash/common.sh" + _common_loaded=true +elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then + source "$SCRIPT_DIR/git-common.sh" + _common_loaded=true +fi + +if [ "$_common_loaded" != "true" ]; then + echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2 + exit 1 +fi + +# Resolve repository root +if type get_repo_root >/dev/null 2>&1; then + REPO_ROOT=$(get_repo_root) +elif git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT=$(git rev-parse --show-toplevel) +elif [ -n "$_PROJECT_ROOT" ]; then + REPO_ROOT="$_PROJECT_ROOT" +else + echo "Error: Could not determine repository root." >&2 + exit 1 +fi + +# Check if git is available at this repo root +if type has_git >/dev/null 2>&1; then + if has_git "$REPO_ROOT"; then + HAS_GIT=true + else + HAS_GIT=false + fi +elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + HAS_GIT=true +else + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" + +# Function to generate branch name with stop word filtering +generate_branch_name() { + local description="$1" + + local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + local meaningful_words=() + for word in $clean_name; do + [ -z "$word" ] && continue + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -qw -- "${word^^}"; then + meaningful_words+=("$word") + fi + fi + done + + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if [ -n "${GIT_BRANCH_NAME:-}" ]; then + BRANCH_NAME="$GIT_BRANCH_NAME" + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern + if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + else + FEATURE_NUM="$BRANCH_NAME" + BRANCH_SUFFIX="$BRANCH_NAME" + fi +else + # Generate branch name + if [ -n "$SHORT_NAME" ]; then + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") + else + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") + fi + + # Warn if --number and --timestamp are both specified + if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" + fi + + # Determine branch prefix + if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + else + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + fi + + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + fi +fi + +# GitHub enforces a 244-byte limit on branch names +MAX_BRANCH_LENGTH=244 +_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; } +BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME") +if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes." + exit 1 +elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +if [ "$DRY_RUN" != true ]; then + if [ "$HAS_GIT" = true ]; then + branch_create_error="" + if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + if [ "$current_branch" = "$BRANCH_NAME" ]; then + : + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + exit 1 + fi + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'." + if [ -n "$branch_create_error" ]; then + >&2 printf '%s\n' "$branch_create_error" + else + >&2 echo "Please check your git configuration and try again." + fi + exit 1 + fi + fi + else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + fi + + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 +fi + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}' + fi + else + if type json_escape >/dev/null 2>&1; then + _je_branch=$(json_escape "$BRANCH_NAME") + _je_num=$(json_escape "$FEATURE_NUM") + else + _je_branch="$BRANCH_NAME" + _je_num="$FEATURE_NUM" + fi + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num" + else + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num" + fi + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "FEATURE_NUM: $FEATURE_NUM" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi +fi diff --git a/.specify/extensions/git/scripts/bash/git-common.sh b/.specify/extensions/git/scripts/bash/git-common.sh new file mode 100755 index 0000000..b78356d --- /dev/null +++ b/.specify/extensions/git/scripts/bash/git-common.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git-specific common functions for the git extension. +# Extracted from scripts/bash/common.sh — contains only git-specific +# branch validation and detection logic. + +# Check if we have git available at the repo root +has_git() { + local repo_root="${1:-$(pwd)}" + { [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \ + command -v git >/dev/null 2>&1 && \ + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + +# Validate that a branch name matches the expected feature branch pattern. +# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. +# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization. +check_feature_branch() { + local raw="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + local branch + branch=$(spec_kit_effective_branch_name "$raw") + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + return 1 + fi + + return 0 +} diff --git a/.specify/extensions/git/scripts/bash/initialize-repo.sh b/.specify/extensions/git/scripts/bash/initialize-repo.sh new file mode 100755 index 0000000..296e363 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/initialize-repo.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git extension: initialize-repo.sh +# Initialize a Git repository with an initial commit. +# Customizable — replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. + +set -e + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Read commit message from extension config, fall back to default +COMMIT_MSG="[Spec Kit] Initial commit" +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +if [ -f "$_config_file" ]; then + _msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + if [ -n "$_msg" ]; then + COMMIT_MSG="$_msg" + fi +fi + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped repository initialization" >&2 + exit 0 +fi + +# Check if already a git repo +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Git repository already initialized; skipping" >&2 + exit 0 +fi + +# Initialize +_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; } +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "✓ Git repository initialized" >&2 diff --git a/.specify/extensions/git/scripts/powershell/auto-commit.ps1 b/.specify/extensions/git/scripts/powershell/auto-commit.ps1 new file mode 100644 index 0000000..34767f8 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/auto-commit.ps1 @@ -0,0 +1,169 @@ +#!/usr/bin/env pwsh +# Git extension: auto-commit.ps1 +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.ps1 +# e.g.: auto-commit.ps1 after_specify +param( + [Parameter(Position = 0, Mandatory = $true)] + [string]$EventName +) +$ErrorActionPreference = 'Stop' + +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped auto-commit" + exit 0 +} + +# Temporarily relax ErrorActionPreference so git stderr warnings +# (e.g. CRLF notices on Windows) do not become terminating errors. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + $isRepo = $LASTEXITCODE -eq 0 +} finally { + $ErrorActionPreference = $savedEAP +} +if (-not $isRepo) { + Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit" + exit 0 +} + +# Read per-command config from git-config.yml +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +$enabled = $false +$commitMsg = "" + +if (Test-Path $configFile) { + # Parse YAML to find auto_commit section + $inAutoCommit = $false + $inEvent = $false + $defaultEnabled = $false + + foreach ($line in Get-Content $configFile) { + # Detect auto_commit: section + if ($line -match '^auto_commit:') { + $inAutoCommit = $true + $inEvent = $false + continue + } + + # Exit auto_commit section on next top-level key + if ($inAutoCommit -and $line -match '^[a-z]') { + break + } + + if ($inAutoCommit) { + # Check default key + if ($line -match '^\s+default:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $defaultEnabled = $true } + } + + # Detect our event subsection + if ($line -match "^\s+${EventName}:") { + $inEvent = $true + continue + } + + # Inside our event subsection + if ($inEvent) { + # Exit on next sibling key (2-space indent, not 4+) + if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') { + $inEvent = $false + continue + } + if ($line -match '\s+enabled:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $enabled = $true } + if ($val -eq 'false') { $enabled = $false } + } + if ($line -match '\s+message:\s*(.+)$') { + $commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + } + } + } + } + + # If event-specific key not found, use default + if (-not $enabled -and $defaultEnabled) { + $hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet + if (-not $hasEventKey) { + $enabled = $true + } + } +} else { + # No config file -- auto-commit disabled by default + exit 0 +} + +if (-not $enabled) { + exit 0 +} + +# Check if there are changes to commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE + git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE + $untracked = git ls-files --others --exclude-standard 2>$null +} finally { + $ErrorActionPreference = $savedEAP +} + +if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) { + Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray + exit 0 +} + +# Derive a human-readable command name from the event +$commandName = $EventName -replace '^after_', '' -replace '^before_', '' +$phase = if ($EventName -match '^before_') { 'before' } else { 'after' } + +# Use custom message if configured, otherwise default +if (-not $commitMsg) { + $commitMsg = "[Spec Kit] Auto-commit $phase $commandName" +} + +# Stage and commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate, +# while still allowing redirected error output to be captured for diagnostics. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} finally { + $ErrorActionPreference = $savedEAP +} + +Write-Host "[OK] Changes committed $phase $commandName" diff --git a/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 b/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 new file mode 100644 index 0000000..b579f05 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -0,0 +1,403 @@ +#!/usr/bin/env pwsh +# Git extension: create-new-feature.ps1 +# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout. +# Sources common.ps1 from the project's installed scripts, falling back to +# git-common.ps1 for minimal git helpers. +[CmdletBinding()] +param( + [switch]$Json, + [switch]$AllowExistingBranch, + [switch]$DryRun, + [string]$ShortName, + [Parameter()] + [long]$Number = 0, + [switch]$Timestamp, + [switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$FeatureDescription +) +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "" + Write-Host "Options:" + Write-Host " -Json Output in JSON format" + Write-Host " -DryRun Compute branch name without creating the branch" + Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" + Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" + Write-Host " -Number N Specify branch number manually (overrides auto-detection)" + Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + Write-Host " -Help Show this help message" + Write-Host "" + Write-Host "Environment variables:" + Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + Write-Host "" + exit 0 +} + +if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + exit 1 +} + +$featureDesc = ($FeatureDescription -join ' ').Trim() + +if ([string]::IsNullOrWhiteSpace($featureDesc)) { + Write-Error "Error: Feature description cannot be empty or contain only whitespace" + exit 1 +} + +function Get-HighestNumberFromSpecs { + param([string]$SpecsDir) + + [long]$highest = 0 + if (Test-Path $SpecsDir) { + Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { + if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + } + return $highest +} + +function Get-HighestNumberFromNames { + param([string[]]$Names) + + [long]$highest = 0 + foreach ($name in $Names) { + if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + return $highest +} + +function Get-HighestNumberFromBranches { + param() + + try { + $branches = git branch -a 2>$null + if ($LASTEXITCODE -eq 0 -and $branches) { + $cleanNames = $branches | ForEach-Object { + $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + } + return Get-HighestNumberFromNames -Names $cleanNames + } + } catch { + Write-Verbose "Could not check Git branches: $_" + } + return 0 +} + +function Get-HighestNumberFromRemoteRefs { + [long]$highest = 0 + try { + $remotes = git remote 2>$null + if ($remotes) { + foreach ($remote in $remotes) { + $env:GIT_TERMINAL_PROMPT = '0' + $refs = git ls-remote --heads $remote 2>$null + $env:GIT_TERMINAL_PROMPT = $null + if ($LASTEXITCODE -eq 0 -and $refs) { + $refNames = $refs | ForEach-Object { + if ($_ -match 'refs/heads/(.+)$') { $matches[1] } + } | Where-Object { $_ } + $remoteHighest = Get-HighestNumberFromNames -Names $refNames + if ($remoteHighest -gt $highest) { $highest = $remoteHighest } + } + } + } + } catch { + Write-Verbose "Could not query remote refs: $_" + } + return $highest +} + +function Get-NextBranchNumber { + param( + [string]$SpecsDir, + [switch]$SkipFetch + ) + + if ($SkipFetch) { + $highestBranch = Get-HighestNumberFromBranches + $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = [Math]::Max($highestBranch, $highestRemote) + } else { + try { + git fetch --all --prune 2>$null | Out-Null + } catch { } + $highestBranch = Get-HighestNumberFromBranches + } + + $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir + $maxNum = [Math]::Max($highestBranch, $highestSpec) + return $maxNum + 1 +} + +function ConvertTo-CleanBranchName { + param([string]$Name) + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' +} + +# --------------------------------------------------------------------------- +# Source common.ps1 from the project's installed scripts. +# Search locations in priority order: +# 1. .specify/scripts/powershell/common.ps1 under the project root +# 2. scripts/powershell/common.ps1 under the project root (source checkout) +# 3. git-common.ps1 next to this script (minimal fallback) +# --------------------------------------------------------------------------- +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot +$commonLoaded = $false + +if ($projectRoot) { + $candidates = @( + (Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"), + (Join-Path $projectRoot "scripts/powershell/common.ps1") + ) + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + . $candidate + $commonLoaded = $true + break + } + } +} + +if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) { + . "$PSScriptRoot/git-common.ps1" + $commonLoaded = $true +} + +if (-not $commonLoaded) { + throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." +} + +# Resolve repository root +if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { + $repoRoot = Get-RepoRoot +} elseif ($projectRoot) { + $repoRoot = $projectRoot +} else { + throw "Could not determine repository root." +} + +# Check if git is available +if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { + # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param) + # and git-common.ps1 (has -RepoRoot param with default). + $hasGit = Test-HasGit +} else { + try { + git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + $hasGit = ($LASTEXITCODE -eq 0) + } catch { + $hasGit = $false + } +} + +Set-Location $repoRoot + +$specsDir = Join-Path $repoRoot 'specs' + +function Get-BranchName { + param([string]$Description) + + $stopWords = @( + 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', + 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', + 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall', + 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', + 'want', 'need', 'add', 'get', 'set' + ) + + $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' + $words = $cleanName -split '\s+' | Where-Object { $_ } + + $meaningfulWords = @() + foreach ($word in $words) { + if ($stopWords -contains $word) { continue } + if ($word.Length -ge 3) { + $meaningfulWords += $word + } elseif ($Description -match "\b$($word.ToUpper())\b") { + $meaningfulWords += $word + } + } + + if ($meaningfulWords.Count -gt 0) { + $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } + $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' + return $result + } else { + $result = ConvertTo-CleanBranchName -Name $Description + $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 + return [string]::Join('-', $fallbackWords) + } +} + +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if ($env:GIT_BRANCH_NAME) { + $branchName = $env:GIT_BRANCH_NAME + # Check 244-byte limit (UTF-8) for override names + $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName) + if ($branchNameUtf8ByteCount -gt 244) { + throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." + } + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern + if ($branchName -match '^(\d{8}-\d{6})-') { + $featureNum = $matches[1] + } elseif ($branchName -match '^(\d+)-') { + $featureNum = $matches[1] + } else { + $featureNum = $branchName + } +} else { + if ($ShortName) { + $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName + } else { + $branchSuffix = Get-BranchName -Description $featureDesc + } + + if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 + } + + if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" + } else { + if ($Number -eq 0) { + if ($DryRun -and $hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } + } + + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" + } +} + +$maxBranchLength = 244 +if ($branchName.Length -gt $maxBranchLength) { + $prefixLength = $featureNum.Length + 1 + $maxSuffixLength = $maxBranchLength - $prefixLength + + $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) + $truncatedSuffix = $truncatedSuffix -replace '-$', '' + + $originalBranchName = $branchName + $branchName = "$featureNum-$truncatedSuffix" + + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" + Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" + Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" +} + +if (-not $DryRun) { + if ($hasGit) { + $branchCreated = $false + $branchCreateError = '' + try { + $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } + } catch { + $branchCreateError = $_.Exception.Message + } + + if (-not $branchCreated) { + $currentBranch = '' + try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + if ($AllowExistingBranch) { + if ($currentBranch -eq $branchName) { + # Already on the target branch + } else { + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + if ($switchBranchError) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())" + } else { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + } + exit 1 + } + } + } elseif ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + exit 1 + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + exit 1 + } + } else { + if ($branchCreateError) { + Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())" + } else { + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." + } + exit 1 + } + } + } else { + if ($Json) { + [Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName") + } else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" + } + } + + $env:SPECIFY_FEATURE = $branchName +} + +if ($Json) { + $obj = [PSCustomObject]@{ + BRANCH_NAME = $branchName + FEATURE_NUM = $featureNum + HAS_GIT = $hasGit + } + if ($DryRun) { + $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true + } + $obj | ConvertTo-Json -Compress +} else { + Write-Output "BRANCH_NAME: $branchName" + Write-Output "FEATURE_NUM: $featureNum" + Write-Output "HAS_GIT: $hasGit" + if (-not $DryRun) { + Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + } +} diff --git a/.specify/extensions/git/scripts/powershell/git-common.ps1 b/.specify/extensions/git/scripts/powershell/git-common.ps1 new file mode 100644 index 0000000..13ea754 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/git-common.ps1 @@ -0,0 +1,51 @@ +#!/usr/bin/env pwsh +# Git-specific common functions for the git extension. +# Extracted from scripts/powershell/common.ps1 -- contains only git-specific +# branch validation and detection logic. + +function Test-HasGit { + param([string]$RepoRoot = (Get-Location)) + try { + if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false } + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false } + git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } +} + +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + +function Test-FeatureBranch { + param( + [string]$Branch, + [bool]$HasGit = $true + ) + + # For non-git repos, we can't enforce branch naming but still provide output + if (-not $HasGit) { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" + return $true + } + + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') + $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") + return $false + } + return $true +} diff --git a/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 b/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 new file mode 100644 index 0000000..fd835f8 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 @@ -0,0 +1,69 @@ +#!/usr/bin/env pwsh +# Git extension: initialize-repo.ps1 +# Initialize a Git repository with an initial commit. +# Customizable -- replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. +$ErrorActionPreference = 'Stop' + +# Find project root +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Read commit message from extension config, fall back to default +$commitMsg = "[Spec Kit] Initial commit" +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +if (Test-Path $configFile) { + foreach ($line in Get-Content $configFile) { + if ($line -match '^init_commit_message:\s*(.+)$') { + $val = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + if ($val) { $commitMsg = $val } + break + } + } +} + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped repository initialization" + exit 0 +} + +# Check if already a git repo +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Warning "[specify] Git repository already initialized; skipping" + exit 0 + } +} catch { } + +# Initialize +try { + $out = git init -q 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" } + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} + +Write-Host "[OK] Git repository initialized" diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 0000000..131af49 --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1,3 @@ +{ + "feature_directory": "specs/001-room-lobby-setup" +} diff --git a/.specify/init-options.json b/.specify/init-options.json new file mode 100644 index 0000000..adcb825 --- /dev/null +++ b/.specify/init-options.json @@ -0,0 +1,10 @@ +{ + "ai": "codex", + "ai_skills": true, + "branch_numbering": "sequential", + "context_file": "AGENTS.md", + "here": true, + "integration": "codex", + "script": "sh", + "speckit_version": "0.8.18.dev0" +} \ No newline at end of file diff --git a/.specify/integration.json b/.specify/integration.json new file mode 100644 index 0000000..48fb8e0 --- /dev/null +++ b/.specify/integration.json @@ -0,0 +1,15 @@ +{ + "version": "0.8.18.dev0", + "integration_state_schema": 1, + "installed_integrations": [ + "codex" + ], + "integration_settings": { + "codex": { + "script": "sh", + "invoke_separator": "-" + } + }, + "integration": "codex", + "default_integration": "codex" +} diff --git a/.specify/integrations/codex.manifest.json b/.specify/integrations/codex.manifest.json new file mode 100644 index 0000000..eef2e9f --- /dev/null +++ b/.specify/integrations/codex.manifest.json @@ -0,0 +1,16 @@ +{ + "integration": "codex", + "version": "0.8.18.dev0", + "installed_at": "2026-05-29T10:05:59.866278+00:00", + "files": { + ".agents/skills/speckit-analyze/SKILL.md": "753f1d49d830abc130132ad2864c780ea61fd57bbc71aa9888be24fdf0774800", + ".agents/skills/speckit-checklist/SKILL.md": "3776d66fa5d1642bbcdc60dcb90c0088e53d5ca98502d1377681e26329cc008d", + ".agents/skills/speckit-clarify/SKILL.md": "34def376ea227bd0d26c940deae58e3b1057278eb8ee087b156c1853ae2bdaa3", + ".agents/skills/speckit-constitution/SKILL.md": "e2cbe859958c5a05be52a44d63821e6a84d39f3d37acc05b550cc7ad85da0dab", + ".agents/skills/speckit-implement/SKILL.md": "796ab9a7f04281fee7d390087e89438f4215cbe2396a8a0118dafd12c0268894", + ".agents/skills/speckit-plan/SKILL.md": "0698a2b289379aedbccddbf37a407d38387cb1253dba51c31aa97ebd016e253c", + ".agents/skills/speckit-specify/SKILL.md": "b0c6ed74fb79db440ec002c271eee38b37ab1f2216ac13f8a77743a4bde98b59", + ".agents/skills/speckit-tasks/SKILL.md": "2276befa1114e7f63b2bfb3a847636cbaec2d990113345d4ff292de7ce09928f", + ".agents/skills/speckit-taskstoissues/SKILL.md": "7bfbf6ab4d061e6b3c9e35d68aba29dc83db57c9aa95d3c832a59a783ef99d47" + } +} diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json new file mode 100644 index 0000000..7a14873 --- /dev/null +++ b/.specify/integrations/speckit.manifest.json @@ -0,0 +1,17 @@ +{ + "integration": "speckit", + "version": "0.8.18.dev0", + "installed_at": "2026-05-29T10:01:56.023672+00:00", + "files": { + ".specify/scripts/bash/common.sh": "dd638316259e699fd466542c77ef16af5eb198efe0447c081f86b890db414ba8", + ".specify/scripts/bash/setup-plan.sh": "b23cca3d769a217ab812a6059adb549622471f6893af234cf98ca2019ac4e1a1", + ".specify/scripts/bash/setup-tasks.sh": "e8d050c63c5afb664a8b671b0b0155513fb9cab0567b335e16b9eb035482aad2", + ".specify/scripts/bash/check-prerequisites.sh": "3fed31d68ceb4a8ccd02f650581446f20acb28f0da9cac29f258657c91300833", + ".specify/scripts/bash/create-new-feature.sh": "bcf4964ca0c6c78717bb42d9e66b8c7e5ee82779cd96afc5aa7b08b75abe5790", + ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", + ".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c", + ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842", + ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", + ".specify/templates/plan-template.md": "cc7f7979cf8d8836ec26492785affd80791d3422a2b745062ec695be8c985ef7" + } +} diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 0000000..2d8f325 --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,143 @@ + +# Scribble Constitution + +## Core Principles + +### I. Scenario-Scoped Incremental Delivery +Every change MUST map to a concrete business scenario or user story and remain +independently testable. Specs, plans, tasks, and implementation MUST preserve +the README checkpoint order: room setup and lobby, game start and drawer flow, +gameplay interaction, then result and restart. Work that skips ahead, bundles +multiple scenario groups without justification, or rewrites existing starter +behavior instead of extending it is non-compliant. +Rationale: the assignment is graded on traceability, incremental reasoning, and +brownfield judgment rather than volume of code. + +### II. Type-Safe API and State Contracts +All backend and frontend changes MUST be fully typed in TypeScript. `any` is +prohibited unless an explicit reviewer-approved exception is documented; use +`unknown` at dynamic boundaries instead. Backend request and response shapes +MUST be validated with Zod at the API boundary, and shared gameplay/state +transitions MUST have explicit types in `backend/src/models`, `backend/src/services`, +or `frontend/src/state` as appropriate. +Rationale: multiplayer state bugs compound quickly when request payloads, +derived room state, or UI assumptions drift. + +### III. Polling-Only, In-Memory Multiplayer Rules +The game MUST synchronize exclusively through HTTP request/response polling. +WebSockets, Socket.io, Server-Sent Events, background brokers, databases, +sessions, authentication, or any other persistence layer are forbidden. Room +state MUST remain in memory only, deterministic, isolated by room code, and +small enough to be discarded cleanly when inactive. Gameplay rules such as +host permissions, drawer assignment, word selection, guess evaluation, scoring, +and restart reset MUST be deterministic and explicitly documented in the spec. +Rationale: the lab intentionally constrains architecture so implementation +quality can be judged without infrastructure noise. + +### IV. Verification Before Progression +Behavior changes MUST ship with verification that matches the touched surface. +At minimum, contributors MUST run the affected app builds and affected test +suites before handoff. Changes that affect multiplayer behavior, room state, +or user-visible flows MUST also include a manual validation path using two +browser tabs or equivalent evidence. Plans MUST state the verification +strategy, tasks MUST include verification work, and reviews MUST block +progression when failing tests or unverified acceptance criteria remain. +Rationale: polling-based multiplayer defects often survive static review unless +they are exercised end to end. + +### V. Brownfield Discipline and Minimal Surface Area +Contributors MUST preserve the established monorepo structure and extend the +starter in place: backend routes in `backend/src/api`, gameplay logic in +`backend/src/services`, core types in `backend/src/models`, client pages in +`frontend/src/pages`, reusable UI in `frontend/src/components`, API calls in +`frontend/src/services`, and complex client state in `frontend/src/state`. +New top-level dependencies, new architectural layers, or broad refactors +require explicit justification in the plan. Error handling MUST fail fast and +gracefully, and UI changes MUST not crash on API failures. +Rationale: the assignment expects disciplined enhancement of an existing codebase, +not a wholesale redesign. + +## Operational Constraints + +- The repository is a TypeScript-first monorepo with an Express backend and a + React 18 + Vite frontend using ES Modules throughout. +- Backend payload validation MUST use Zod. +- Multiplayer synchronization MUST use HTTP polling at the cadence defined by + the current spec; near-2-second lobby/game refresh is the default constraint + unless a narrower requirement is specified. +- All data storage MUST remain in memory only. Restarting the backend is + allowed to clear rooms. +- Authentication, authorization, sessions, and account systems MUST NOT be + introduced. +- New state-management or routing libraries on the frontend MUST NOT be added + unless the plan documents why the existing stack cannot satisfy the need. +- Out-of-scope features listed in `README.md` MUST remain out of scope unless + the constitution is amended first. + +## Delivery Workflow and Review Gates + +1. Discovery MUST happen before design or coding. Contributors MUST inspect the + relevant starter files, document gaps and assumptions, and avoid rewriting + code they have not understood. +2. Specifications MUST define acceptance criteria, edge cases, deterministic + game rules, and explicit non-goals consistent with this constitution. +3. Plans MUST reference real repository paths, identify changed backend and + frontend surfaces, and pass the Constitution Check before implementation + begins. +4. Tasks MUST be organized by user story or scenario slice, include exact file + paths, and include verification work for each story plus final cross-cutting + validation. +5. Code review and self-review MUST reject changes that add forbidden + technologies, weaken typing, skip Zod validation at new backend boundaries, + or omit required verification. +6. Before handoff, contributors MUST run the affected commands from this set + unless a command is genuinely unaffected: `cd backend && npm run build`, + `cd backend && npm test`, `cd frontend && npm run build`, + `cd frontend && npm test`. + +## Governance + +This constitution overrides conflicting local habits, draft artifacts, and AI +suggestions. Amendments require: (1) the proposed rule change to be written in +the constitution, (2) the impact on templates and active artifacts to be +documented, and (3) dependent templates or guidance files to be updated in the +same change set when applicable. + +Versioning policy: +- MAJOR: remove a principle, redefine a non-negotiable constraint, or allow a + previously forbidden architectural class. +- MINOR: add a new principle, section, or materially stronger delivery or + review requirement. +- PATCH: clarify wording, examples, or non-semantic guidance without changing + obligations. + +Compliance review expectations: +- Every spec, plan, tasks file, and implementation review MUST perform an + explicit constitution compliance check. +- Any justified exception MUST be documented in the relevant plan under + complexity or tradeoff tracking and approved before implementation. +- Unverified or non-compliant work MUST not be treated as complete. + +**Version**: 1.0.0 | **Ratified**: 2026-05-29 | **Last Amended**: 2026-05-29 diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh new file mode 100755 index 0000000..b244ea7 --- /dev/null +++ b/.specify/scripts/bash/check-prerequisites.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash + +# Consolidated prerequisite checking script +# +# This script provides unified prerequisite checking for Spec-Driven Development workflow. +# It replaces the functionality previously spread across multiple scripts. +# +# Usage: ./check-prerequisites.sh [OPTIONS] +# +# OPTIONS: +# --json Output in JSON format +# --require-tasks Require tasks.md to exist (for implementation phase) +# --include-tasks Include tasks.md in AVAILABLE_DOCS list +# --paths-only Only output path variables (no validation) +# --help, -h Show help message +# +# OUTPUTS: +# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]} +# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md +# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc. + +set -e + +# Parse command line arguments +JSON_MODE=false +REQUIRE_TASKS=false +INCLUDE_TASKS=false +PATHS_ONLY=false + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --require-tasks) + REQUIRE_TASKS=true + ;; + --include-tasks) + INCLUDE_TASKS=true + ;; + --paths-only) + PATHS_ONLY=true + ;; + --help|-h) + cat << 'EOF' +Usage: check-prerequisites.sh [OPTIONS] + +Consolidated prerequisite checking for Spec-Driven Development workflow. + +OPTIONS: + --json Output in JSON format + --require-tasks Require tasks.md to exist (for implementation phase) + --include-tasks Include tasks.md in AVAILABLE_DOCS list + --paths-only Only output path variables (no prerequisite validation) + --help, -h Show this help message + +EXAMPLES: + # Check task prerequisites (plan.md required) + ./check-prerequisites.sh --json + + # Check implementation prerequisites (plan.md + tasks.md required) + ./check-prerequisites.sh --json --require-tasks --include-tasks + + # Get feature paths only (no validation) + ./check-prerequisites.sh --paths-only + +EOF + exit 0 + ;; + *) + echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2 + exit 1 + ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# If paths-only mode, output paths and exit (no validation) +if $PATHS_ONLY; then + if $JSON_MODE; then + # Minimal JSON paths payload (no validation performed) + if has_jq; then + jq -cn \ + --arg repo_root "$REPO_ROOT" \ + --arg branch "$CURRENT_BRANCH" \ + --arg feature_dir "$FEATURE_DIR" \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg tasks "$TASKS" \ + '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' + else + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ + "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" + fi + else + echo "REPO_ROOT: $REPO_ROOT" + echo "BRANCH: $CURRENT_BRANCH" + echo "FEATURE_DIR: $FEATURE_DIR" + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "TASKS: $TASKS" + fi + exit 0 +fi + +# Validate branch name +check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 + +# Validate required directories and files +if [[ ! -d "$FEATURE_DIR" ]]; then + echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 + exit 1 +fi + +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 + exit 1 +fi + +# Check for tasks.md if required +if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then + echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.tasks first to create the task list." >&2 + exit 1 +fi + +# Build list of available documents +docs=() + +# Always check these optional docs +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") + +# Check contracts directory (only if it exists and has files) +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi + +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Include tasks.md if requested and it exists +if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then + docs+=("tasks.md") +fi + +# Output results +if $JSON_MODE; then + # Build JSON array of documents + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" + fi +else + # Text output + echo "FEATURE_DIR:$FEATURE_DIR" + echo "AVAILABLE_DOCS:" + + # Show status of each potential document + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" + + if $INCLUDE_TASKS; then + check_file "$TASKS" "tasks.md" + fi +fi diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh new file mode 100755 index 0000000..03141e4 --- /dev/null +++ b/.specify/scripts/bash/common.sh @@ -0,0 +1,645 @@ +#!/usr/bin/env bash +# Common functions and variables for all scripts + +# Find repository root by searching upward for .specify directory +# This is the primary marker for spec-kit projects +find_specify_root() { + local dir="${1:-$(pwd)}" + # Normalize to absolute path to prevent infinite loop with relative paths + # Use -- to handle paths starting with - (e.g., -P, -L) + dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 + local prev_dir="" + while true; do + if [ -d "$dir/.specify" ]; then + echo "$dir" + return 0 + fi + # Stop if we've reached filesystem root or dirname stops changing + if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then + break + fi + prev_dir="$dir" + dir="$(dirname "$dir")" + done + return 1 +} + +# Get repository root, prioritizing .specify directory over git +# This prevents using a parent git repo when spec-kit is initialized in a subdirectory +get_repo_root() { + # First, look for .specify directory (spec-kit's own marker) + local specify_root + if specify_root=$(find_specify_root); then + echo "$specify_root" + return + fi + + # Fallback to git if no .specify found + if git rev-parse --show-toplevel >/dev/null 2>&1; then + git rev-parse --show-toplevel + return + fi + + # Final fallback to script location for non-git repos + local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "$script_dir/../../.." && pwd) +} + +# Get current branch, with fallback for non-git repositories +get_current_branch() { + # First check if SPECIFY_FEATURE environment variable is set + if [[ -n "${SPECIFY_FEATURE:-}" ]]; then + echo "$SPECIFY_FEATURE" + return + fi + + # Then check git if available at the spec-kit root (not parent) + local repo_root=$(get_repo_root) + if has_git; then + git -C "$repo_root" rev-parse --abbrev-ref HEAD + return + fi + + # For non-git repos, try to find the latest feature directory + local specs_dir="$repo_root/specs" + + if [[ -d "$specs_dir" ]]; then + local latest_feature="" + local highest=0 + local latest_timestamp="" + + for dir in "$specs_dir"/*; do + if [[ -d "$dir" ]]; then + local dirname=$(basename "$dir") + if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + # Timestamp-based branch: compare lexicographically + local ts="${BASH_REMATCH[1]}" + if [[ "$ts" > "$latest_timestamp" ]]; then + latest_timestamp="$ts" + latest_feature=$dirname + fi + elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then + local number=${BASH_REMATCH[1]} + number=$((10#$number)) + if [[ "$number" -gt "$highest" ]]; then + highest=$number + # Only update if no timestamp branch found yet + if [[ -z "$latest_timestamp" ]]; then + latest_feature=$dirname + fi + fi + fi + fi + done + + if [[ -n "$latest_feature" ]]; then + echo "$latest_feature" + return + fi + fi + + echo "main" # Final fallback +} + +# Check if we have git available at the spec-kit root level +# Returns true only if git is installed and the repo root is inside a git work tree +# Handles both regular repos (.git directory) and worktrees/submodules (.git file) +has_git() { + # First check if git command is available (before calling get_repo_root which may use git) + command -v git >/dev/null 2>&1 || return 1 + local repo_root=$(get_repo_root) + # Check if .git exists (directory or file for worktrees/submodules) + [ -e "$repo_root/.git" ] || return 1 + # Verify it's actually a valid git work tree + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + +check_feature_branch() { + local raw="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + local branch + branch=$(spec_kit_effective_branch_name "$raw") + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + return 1 + fi + + return 0 +} + +# Safely read .specify/feature.json's "feature_directory" value. +# Prints the raw value (possibly relative) to stdout, or empty string if the file +# is missing, unparseable, or does not contain the key. Always returns 0 so callers +# under `set -e` cannot be aborted by parser failure. +# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed. +read_feature_json_feature_directory() { + local repo_root="$1" + local fj="$repo_root/.specify/feature.json" + [[ -f "$fj" ]] || { printf '%s' ''; return 0; } + + local _fd='' + if command -v jq >/dev/null 2>&1; then + if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then + _fd='' + fi + elif command -v python3 >/dev/null 2>&1; then + # Use Python so pretty-printed/multi-line JSON still parses correctly. + if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then + _fd='' + fi + else + # Last-resort single-line grep/sed fallback. The `|| true` guards against + # grep returning 1 (no match) aborting under `set -e` / `pipefail`. + _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ + | head -n 1 \ + | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' ) + fi + + printf '%s' "$_fd" + return 0 +} + +# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory +# and matches the resolved active FEATURE_DIR (so /speckit.plan can skip git branch pattern checks). +# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`. +feature_json_matches_feature_dir() { + local repo_root="$1" + local active_feature_dir="$2" + + local _fd + _fd=$(read_feature_json_feature_directory "$repo_root") + + [[ -n "$_fd" ]] || return 1 + [[ "$_fd" != /* ]] && _fd="$repo_root/$_fd" + [[ -d "$_fd" ]] || return 1 + + local norm_json norm_active + norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1 + norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1 + + [[ "$norm_json" == "$norm_active" ]] +} + +# Find feature directory by numeric prefix instead of exact branch match +# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) +find_feature_dir_by_prefix() { + local repo_root="$1" + local branch_name + branch_name=$(spec_kit_effective_branch_name "$2") + local specs_dir="$repo_root/specs" + + # Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches) + local prefix="" + if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + prefix="${BASH_REMATCH[1]}" + elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then + prefix="${BASH_REMATCH[1]}" + else + # If branch doesn't have a recognized prefix, fall back to exact match + echo "$specs_dir/$branch_name" + return + fi + + # Search for directories in specs/ that start with this prefix + local matches=() + if [[ -d "$specs_dir" ]]; then + for dir in "$specs_dir"/"$prefix"-*; do + if [[ -d "$dir" ]]; then + matches+=("$(basename "$dir")") + fi + done + fi + + # Handle results + if [[ ${#matches[@]} -eq 0 ]]; then + # No match found - return the branch name path (will fail later with clear error) + echo "$specs_dir/$branch_name" + elif [[ ${#matches[@]} -eq 1 ]]; then + # Exactly one match - perfect! + echo "$specs_dir/${matches[0]}" + else + # Multiple matches - this shouldn't happen with proper naming convention + echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 + echo "Please ensure only one spec directory exists per prefix." >&2 + return 1 + fi +} + +get_feature_paths() { + local repo_root=$(get_repo_root) + local current_branch=$(get_current_branch) + local has_git_repo="false" + + if has_git; then + has_git_repo="true" + fi + + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # 3. Branch-name-based prefix lookup (legacy fallback) + local feature_dir + if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then + feature_dir="$SPECIFY_FEATURE_DIRECTORY" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + elif [[ -f "$repo_root/.specify/feature.json" ]]; then + # Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on + # missing/unparseable/unset so we fall through to the branch-prefix lookup. + local _fd + _fd=$(read_feature_json_feature_directory "$repo_root") + if [[ -n "$_fd" ]]; then + feature_dir="$_fd" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi + elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi + + # Use printf '%q' to safely quote values, preventing shell injection + # via crafted branch names or paths containing special characters + printf 'REPO_ROOT=%q\n' "$repo_root" + printf 'CURRENT_BRANCH=%q\n' "$current_branch" + printf 'HAS_GIT=%q\n' "$has_git_repo" + printf 'FEATURE_DIR=%q\n' "$feature_dir" + printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" + printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" + printf 'TASKS=%q\n' "$feature_dir/tasks.md" + printf 'RESEARCH=%q\n' "$feature_dir/research.md" + printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" + printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" + printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" +} + +# Check if jq is available for safe JSON construction +has_jq() { + command -v jq >/dev/null 2>&1 +} + +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). +# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + s="${s//$'\b'/\\b}" + s="${s//$'\f'/\\f}" + # Escape any remaining U+0001-U+001F control characters as \uXXXX. + # (U+0000/NUL cannot appear in bash strings and is excluded.) + # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, + # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. + local LC_ALL=C + local i char code + for (( i=0; i<${#s}; i++ )); do + char="${s:$i:1}" + printf -v code '%d' "'$char" 2>/dev/null || code=256 + if (( code >= 1 && code <= 31 )); then + printf '\\u%04x' "$code" + else + printf '%s' "$char" + fi + done +} + +check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } +check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } + +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +resolve_template() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Priority 1: Project overrides + local override="$base/overrides/${template_name}.md" + [ -f "$override" ] && echo "$override" && return 0 + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + # Read preset IDs sorted by priority (lower number = higher precedence). + # The python3 call is wrapped in an if-condition so that set -e does not + # abort the function when python3 exits non-zero (e.g. invalid JSON). + local sorted_presets="" + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + # python3 succeeded and returned preset IDs — search in priority order + while IFS= read -r preset_id; do + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done <<< "$sorted_presets" + fi + # python3 succeeded but registry has no presets — nothing to search + else + # python3 failed (missing, or registry parse error) — fall back to unordered directory scan + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + else + # Fallback: alphabetical directory order (no python3 available) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + fi + + # Priority 3: Extension-provided templates + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + # Skip hidden directories (e.g. .backup, .cache) + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + + # Priority 4: Core templates + local core="$base/${template_name}.md" + [ -f "$core" ] && echo "$core" && return 0 + + # Template not found in any location. + # Return 1 so callers can distinguish "not found" from "found". + # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true + return 1 +} + +# Resolve a template name to composed content using composition strategies. +# Reads strategy metadata from preset manifests and composes content +# from multiple layers using prepend, append, or wrap strategies. +# +# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT") +# Returns composed content string on stdout; exit code 1 if not found. +resolve_template_content() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Collect all layers (highest priority first) + local -a layer_paths=() + local -a layer_strategies=() + + # Priority 1: Project overrides (always "replace") + local override="$base/overrides/${template_name}.md" + if [ -f "$override" ]; then + layer_paths+=("$override") + layer_strategies+=("replace") + fi + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + local sorted_presets="" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + local yaml_warned=false + while IFS= read -r preset_id; do + # Read strategy and file path from preset manifest + local strategy="replace" + local manifest_file="" + local manifest="$presets_dir/$preset_id/preset.yml" + if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then + # Requires PyYAML; falls back to replace/convention if unavailable + local result + local py_stderr + py_stderr=$(mktemp) + result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c " +import sys, os +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + print('replace\t') + sys.exit(0) +try: + with open(os.environ['SPECKIT_MANIFEST']) as f: + data = yaml.safe_load(f) + for t in data.get('provides', {}).get('templates', []): + if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': + print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + sys.exit(0) + print('replace\t') +except Exception: + print('replace\t') +" 2>"$py_stderr") + local parse_status=$? + if [ $parse_status -eq 0 ] && [ -n "$result" ]; then + IFS=$'\t' read -r strategy manifest_file <<< "$result" + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') + fi + if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then + echo "Warning: PyYAML not available; composition strategies may be ignored" >&2 + yaml_warned=true + fi + rm -f "$py_stderr" + fi + # Try manifest file path first, then convention path + local candidate="" + if [ -n "$manifest_file" ]; then + # Reject absolute paths and parent traversal + case "$manifest_file" in + /*|*../*|../*) manifest_file="" ;; + esac + fi + if [ -n "$manifest_file" ]; then + local mf="$presets_dir/$preset_id/$manifest_file" + [ -f "$mf" ] && candidate="$mf" + fi + if [ -z "$candidate" ]; then + local cf="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$cf" ] && candidate="$cf" + fi + if [ -n "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("$strategy") + fi + done <<< "$sorted_presets" + fi + else + # python3 failed — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + else + # No python3 or registry — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + fi + + # Priority 3: Extension-provided templates (always "replace") + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + + # Priority 4: Core templates (always "replace") + local core="$base/${template_name}.md" + if [ -f "$core" ]; then + layer_paths+=("$core") + layer_strategies+=("replace") + fi + + local count=${#layer_paths[@]} + [ "$count" -eq 0 ] && return 1 + + # Check if any layer uses a non-replace strategy + local has_composition=false + for s in "${layer_strategies[@]}"; do + [ "$s" != "replace" ] && has_composition=true && break + done + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if [ "${layer_strategies[0]}" = "replace" ]; then + cat "${layer_paths[0]}" + return 0 + fi + + if [ "$has_composition" = false ]; then + cat "${layer_paths[0]}" + return 0 + fi + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + local base_idx=-1 + local i + for (( i=0; i=0; i-- )); do + local path="${layer_paths[$i]}" + local strat="${layer_strategies[$i]}" + local layer_content + # Preserve trailing newlines + layer_content=$(cat "$path"; printf x) + layer_content="${layer_content%x}" + + case "$strat" in + replace) content="$layer_content" ;; + prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;; + append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;; + wrap) + case "$layer_content" in + *'{CORE_TEMPLATE}'*) ;; + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; + esac + while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do + local before="${layer_content%%\{CORE_TEMPLATE\}*}" + local after="${layer_content#*\{CORE_TEMPLATE\}}" + layer_content="${before}${content}${after}" + done + content="$layer_content" + ;; + *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;; + esac + done + + printf '%s' "$content" + return 0 +} + diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh new file mode 100755 index 0000000..c353770 --- /dev/null +++ b/.specify/scripts/bash/create-new-feature.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash + +set -e + +JSON_MODE=false +DRY_RUN=false +ALLOW_EXISTING=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --dry-run) + DRY_RUN=true + ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + # Check if the next argument is another option (starts with --) + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --dry-run Compute branch name and paths without creating branches, directories, or files" + echo " --allow-existing-branch Switch to branch if it already exists instead of failing" + echo " --short-name Provide a custom short name (2-4 words) for the branch" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +# Shared by get_highest_from_branches and get_highest_from_remote_refs. +_extract_highest_number() { + local highest=0 + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + echo "$highest" +} + +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches (local and remote) and return next available number. +# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching. +check_existing_branches() { + local specs_dir="$1" + local skip_fetch="${2:-false}" + + if [ "$skip_fetch" = true ]; then + # Side-effect-free: query remotes via ls-remote + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi + + # Get highest number from ALL specs (not just matching short name) + local highest_spec=$(get_highest_from_specs "$specs_dir") + + # Take the maximum of both + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + # Return next number + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# Resolve repository root using common.sh functions which prioritize .specify over git +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +REPO_ROOT=$(get_repo_root) + +# Check if git is available at this repo root (not a parent) +if has_git; then + HAS_GIT=true +else + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" +if [ "$DRY_RUN" != true ]; then + mkdir -p "$SPECS_DIR" +fi + +# Function to generate branch name with stop word filtering and length filtering +generate_branch_name() { + local description="$1" + + # Common stop words to filter out + local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + + # Convert to lowercase and split into words + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) + local meaningful_words=() + for word in $clean_name; do + # Skip empty words + [ -z "$word" ] && continue + + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -q "\b${word^^}\b"; then + # Keep short words if they appear as uppercase in original (likely acronyms) + meaningful_words+=("$word") + fi + fi + done + + # If we have meaningful words, use first 3-4 of them + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + # Fallback to original logic if no meaningful words found + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Generate branch name +if [ -n "$SHORT_NAME" ]; then + # Use provided short name, just clean it up + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +else + # Generate from description with smart filtering + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") +fi + +# Warn if --number and --timestamp are both specified +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" +fi + +# Determine branch prefix +if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + # Determine branch number + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + # Dry-run: query remotes via ls-remote (side-effect-free, no fetch) + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + # Dry-run without git: local spec dirs only + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + # Check existing branches on remotes + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + # Fall back to local directory check + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + fi + + # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +fi + +# GitHub enforces a 244-byte limit on branch names +# Validate and truncate if necessary +MAX_BRANCH_LENGTH=244 +if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then + # Calculate how much we need to trim from suffix + # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + # Truncate suffix at word boundary if possible + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + # Remove trailing hyphen if truncation created one + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +SPEC_FILE="$FEATURE_DIR/spec.md" + +if [ "$DRY_RUN" != true ]; then + if [ "$HAS_GIT" = true ]; then + branch_create_error="" + if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + # Check if branch already exists + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + # If we're already on the branch, continue without another checkout. + if [ "$current_branch" = "$BRANCH_NAME" ]; then + : + # Otherwise switch to the existing branch instead of failing. + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + exit 1 + fi + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'." + if [ -n "$branch_create_error" ]; then + >&2 printf '%s\n' "$branch_create_error" + else + >&2 echo "Please check your git configuration and try again." + fi + exit 1 + fi + fi + else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + fi + + mkdir -p "$FEATURE_DIR" + + if [ ! -f "$SPEC_FILE" ]; then + TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true + if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" + else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" + fi + fi + + # Inform the user how to persist the feature variable in their own shell + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 +fi + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + fi + else + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi +fi diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh new file mode 100755 index 0000000..945385c --- /dev/null +++ b/.specify/scripts/bash/setup-plan.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false +ARGS=() + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac +done + +# Get script directory and load common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths and variables from common functions +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# If feature.json pins an existing feature directory, branch naming is not required. +if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then + check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 +fi + +# Ensure the feature directory exists +mkdir -p "$FEATURE_DIR" + +# Copy plan template if plan doesn't already exist +if [[ -f "$IMPL_PLAN" ]]; then + if $JSON_MODE; then + echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2 + else + echo "Plan already exists at $IMPL_PLAN, skipping template copy" + fi +else + TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true + if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then + cp "$TEMPLATE" "$IMPL_PLAN" + if $JSON_MODE; then + echo "Copied plan template to $IMPL_PLAN" >&2 + else + echo "Copied plan template to $IMPL_PLAN" + fi + else + if $JSON_MODE; then + echo "Warning: Plan template not found" >&2 + else + echo "Warning: Plan template not found" + fi + # Create a basic plan file if template doesn't exist + touch "$IMPL_PLAN" + fi +fi + +# Output results +if $JSON_MODE; then + if has_jq; then + jq -cn \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg specs_dir "$FEATURE_DIR" \ + --arg branch "$CURRENT_BRANCH" \ + --arg has_git "$HAS_GIT" \ + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}' + else + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")" + fi +else + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "SPECS_DIR: $FEATURE_DIR" + echo "BRANCH: $CURRENT_BRANCH" + echo "HAS_GIT: $HAS_GIT" +fi + diff --git a/.specify/scripts/bash/setup-tasks.sh b/.specify/scripts/bash/setup-tasks.sh new file mode 100755 index 0000000..3f6a40b --- /dev/null +++ b/.specify/scripts/bash/setup-tasks.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false + +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# Validate branch +# If feature.json pins an existing feature directory, branch naming is not required. +if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then + check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 +fi + +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 + exit 1 +fi + +if [[ ! -f "$FEATURE_SPEC" ]]; then + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 + exit 1 +fi + +# Build available docs list +docs=() +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Resolve tasks template through override stack +TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true +if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then + echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2 + echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2 + exit 1 +fi + +# Output results +if $JSON_MODE; then + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + --arg tasks_template "${TASKS_TEMPLATE:-}" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \ + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" + fi +else + echo "FEATURE_DIR: $FEATURE_DIR" + echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}" + echo "AVAILABLE_DOCS:" + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" +fi diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md new file mode 100644 index 0000000..c4aa166 --- /dev/null +++ b/.specify/templates/checklist-template.md @@ -0,0 +1,40 @@ +# [CHECKLIST TYPE] Checklist: [FEATURE NAME] + +**Purpose**: [Brief description of what this checklist covers] +**Created**: [DATE] +**Feature**: [Link to spec.md or relevant documentation] + +**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements. + + + +## [Category 1] + +- [ ] CHK001 First checklist item with clear action +- [ ] CHK002 Second checklist item +- [ ] CHK003 Third checklist item + +## [Category 2] + +- [ ] CHK004 Another category item +- [ ] CHK005 Item with specific criteria +- [ ] CHK006 Final item in this category + +## Notes + +- Check items off as completed: `[x]` +- Add comments or findings inline +- Link to relevant resources or documentation +- Items are numbered sequentially for easy reference diff --git a/.specify/templates/constitution-template.md b/.specify/templates/constitution-template.md new file mode 100644 index 0000000..a4670ff --- /dev/null +++ b/.specify/templates/constitution-template.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md new file mode 100644 index 0000000..2f1481a --- /dev/null +++ b/.specify/templates/plan-template.md @@ -0,0 +1,112 @@ +# Implementation Plan: [FEATURE] + +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] + +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` + +**Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +[Extract from feature spec: primary requirement + technical approach from research] + +## Technical Context + + + +**Language/Version**: TypeScript 5.x on Node.js 18+ (backend) and React 18 +with Vite (frontend) + +**Primary Dependencies**: Express, Zod, React, React Router, Vite, Vitest + +**Storage**: In-memory room and game state only + +**Testing**: `cd backend && npm test`, `cd frontend && npm test`, plus manual +two-tab browser validation for multiplayer flows + +**Target Platform**: Node.js backend and modern desktop browser clients + +**Project Type**: Monorepo web application (`backend/` + `frontend/`) + +**Performance Goals**: Keep polling-based room refresh responsive enough for +the assignment flow; default target is user-visible state refresh in about 2s + +**Constraints**: HTTP polling only; no WebSockets; no database/persistence; no +authentication/session layer; keep room memory footprint minimal; preserve the +starter architecture + +**Scale/Scope**: Small multiplayer rooms for one-round game flows validated in +local multi-tab testing + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [ ] The change is scoped to a concrete scenario/user story and preserves the + README checkpoint order unless a deviation is justified. +- [ ] All changed backend boundaries have explicit TypeScript types and Zod + validation for request/response payloads. +- [ ] Multiplayer synchronization remains HTTP polling against in-memory state + only; no forbidden persistence or realtime transport is introduced. +- [ ] The plan preserves the existing monorepo structure and documents any new + dependency or abstraction that materially expands the surface area. +- [ ] Verification covers every touched surface, including affected builds, + affected tests, and manual two-tab validation for multiplayer/UI flows. + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit-plan command output) +├── research.md # Phase 0 output (/speckit-plan command) +├── data-model.md # Phase 1 output (/speckit-plan command) +├── quickstart.md # Phase 1 output (/speckit-plan command) +├── contracts/ # Phase 1 output (/speckit-plan command) +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) +``` + +### Source Code (repository root) + + +```text +backend/ +├── src/ +│ ├── models/ +│ ├── services/ +│ ├── api/ +│ ├── app.ts +│ └── server.ts +└── src/**/*.test.ts + +frontend/ +├── src/ +│ ├── components/ +│ ├── pages/ +│ ├── routes/ +│ ├── services/ +│ ├── state/ +│ └── styles/ +└── src/**/*.test.ts +``` + +**Structure Decision**: Use the existing monorepo structure above. Keep +backend/game rules in `backend/src` and client UI/state work in `frontend/src`; +do not introduce new top-level app/package directories without justification. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., new dependency] | [current need] | [why existing stack is insufficient] | +| [e.g., new shared abstraction] | [specific problem] | [why a smaller local change is insufficient] | diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md new file mode 100644 index 0000000..3d8a34d --- /dev/null +++ b/.specify/templates/spec-template.md @@ -0,0 +1,155 @@ +# Feature Specification: [FEATURE NAME] + +**Feature Branch**: `[###-feature-name]` + +**Created**: [DATE] + +**Status**: Draft + +**Input**: User description: "$ARGUMENTS" + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - [Brief Title] (Priority: P1) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] +2. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 2 - [Brief Title] (Priority: P2) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 3 - [Brief Title] (Priority: P3) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + + + +- What happens when [boundary condition]? +- How does system handle [error scenario]? + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +*Example of marking unclear requirements:* + +- **FR-006**: Lobby state MUST refresh every [NEEDS CLARIFICATION: polling + interval not specified] +- **FR-007**: System MUST assign the initial drawer via + [NEEDS CLARIFICATION: host-only, first player, or another deterministic rule?] + +### Key Entities *(include if feature involves data)* + +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +## Constraints & Non-Goals *(mandatory)* + +- **CN-001**: The feature MUST preserve HTTP polling as the only synchronization + mechanism; WebSockets, Socket.io, SSE, or equivalent push transport are out + of scope. +- **CN-002**: The feature MUST keep all runtime data in memory only; databases, + local persistence, and external stores are out of scope. +- **CN-003**: The feature MUST NOT introduce authentication, authorization, + sessions, or account management. +- **CN-004**: The feature MUST extend the starter architecture in place and MUST + justify any new top-level dependency or architectural layer in the plan. +- **CN-005**: The feature MUST document deterministic gameplay rules and room + isolation behavior whenever it changes multiplayer state. + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] + +## Assumptions + + + +- [Assumption about target users, e.g., "Users have stable internet connectivity"] +- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] +- [Assumption about data/environment, e.g., "Players can keep two browser tabs + open during multiplayer validation"] +- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] + +## Verification Plan *(mandatory)* + +- [List affected automated checks, e.g., "`cd backend && npm test`"] +- [List affected build checks, e.g., "`cd frontend && npm run build`"] +- [Describe manual validation for the primary user journey, including multi-tab + verification when multiplayer behavior changes] diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md new file mode 100644 index 0000000..c549e0a --- /dev/null +++ b/.specify/templates/tasks-template.md @@ -0,0 +1,266 @@ +--- + +description: "Task list template for feature implementation" +--- + +# Tasks: [FEATURE NAME] + +**Input**: Design documents from `/specs/[###-feature-name]/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Include automated test tasks whenever backend logic or frontend +behavior changes, and always include manual validation tasks for user-visible +flows. Each user story must define how its acceptance criteria will be verified. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Web app**: `backend/src/`, `frontend/src/` +- Paths below should default to this repository's monorepo layout unless the + plan documents a justified exception + + + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [ ] T001 Confirm affected files and validation commands from plan.md +- [ ] T002 Create or update supporting types/schemas/interfaces needed by the + selected story slice +- [ ] T003 [P] Capture any required test fixtures or mock data updates + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +Examples of foundational tasks (adjust based on your project): + +- [ ] T004 Add or update shared backend models in `backend/src/models/` +- [ ] T005 [P] Add or update room/game services in `backend/src/services/` +- [ ] T006 [P] Add or update API schemas/routes in `backend/src/api/` +- [ ] T007 Add or update shared frontend state in `frontend/src/state/` +- [ ] T008 Confirm error-handling and fallback states for affected flows +- [ ] T009 Document polling cadence or state transition assumptions if changed + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Verification for User Story 1 ⚠️ + +> **NOTE: Write or update automated checks before or alongside implementation, +> then confirm they fail when appropriate and pass after the change** + +- [ ] T010 [P] [US1] Backend test coverage in `backend/src/**/*.test.ts` +- [ ] T011 [P] [US1] Frontend test coverage in `frontend/src/**/*.test.ts` +- [ ] T012 [US1] Manual multi-tab validation for [user journey] + +### Implementation for User Story 1 + +- [ ] T013 [P] [US1] Update backend model/service files in `backend/src/...` +- [ ] T014 [P] [US1] Update backend API/schema files in `backend/src/api/...` +- [ ] T015 [P] [US1] Update frontend API/state files in `frontend/src/services/` + or `frontend/src/state/` +- [ ] T016 [US1] Update UI flow in `frontend/src/pages/` and/or + `frontend/src/components/` +- [ ] T017 [US1] Add validation and error handling for the story flow + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - [Title] (Priority: P2) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Verification for User Story 2 ⚠️ + +- [ ] T018 [P] [US2] Backend test coverage in `backend/src/**/*.test.ts` +- [ ] T019 [P] [US2] Frontend test coverage in `frontend/src/**/*.test.ts` +- [ ] T020 [US2] Manual multi-tab validation for [user journey] + +### Implementation for User Story 2 + +- [ ] T021 [P] [US2] Update backend model/service files in `backend/src/...` +- [ ] T022 [P] [US2] Update backend API/schema files in `backend/src/api/...` +- [ ] T023 [P] [US2] Update frontend API/state files in `frontend/src/services/` + or `frontend/src/state/` +- [ ] T024 [US2] Update UI flow in `frontend/src/pages/` and/or + `frontend/src/components/` + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: User Story 3 - [Title] (Priority: P3) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Verification for User Story 3 ⚠️ + +- [ ] T025 [P] [US3] Backend test coverage in `backend/src/**/*.test.ts` +- [ ] T026 [P] [US3] Frontend test coverage in `frontend/src/**/*.test.ts` +- [ ] T027 [US3] Manual multi-tab validation for [user journey] + +### Implementation for User Story 3 + +- [ ] T028 [P] [US3] Update backend model/service files in `backend/src/...` +- [ ] T029 [P] [US3] Update backend API/schema files in `backend/src/api/...` +- [ ] T030 [P] [US3] Update frontend API/state files in `frontend/src/services/` + or `frontend/src/state/` +- [ ] T031 [US3] Update UI flow in `frontend/src/pages/` and/or + `frontend/src/components/` + +**Checkpoint**: All user stories should now be independently functional + +--- + +[Add more user story phases as needed, following the same pattern] + +--- + +## Phase N: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] TXXX [P] Documentation updates in `README.md` or feature artifacts +- [ ] TXXX Code cleanup and refactoring +- [ ] TXXX Performance or polling-efficiency adjustments across all stories +- [ ] TXXX [P] Additional automated coverage in `backend/src/**/*.test.ts` or + `frontend/src/**/*.test.ts` +- [ ] TXXX Run final backend/frontend build and test commands +- [ ] TXXX Run final end-to-end multi-tab validation against acceptance criteria + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P2 → P3) +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable + +### Within Each User Story + +- Verification tasks MUST be completed before the story is treated as done +- Models/types before services +- Services/state before routes or UI integration +- Core implementation before cross-story integration +- Story complete before moving to the next priority + +### Parallel Opportunities + +- All Setup tasks marked [P] can run in parallel +- All Foundational tasks marked [P] can run in parallel (within Phase 2) +- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) +- All automated verification tasks for a user story marked [P] can run in parallel +- Models within a story marked [P] can run in parallel +- Different user stories can be worked on in parallel by different team members + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch automated verification updates for User Story 1 together: +Task: "Backend test coverage in backend/src/**/*.test.ts" +Task: "Frontend test coverage in frontend/src/**/*.test.ts" + +# Launch independent implementation work for User Story 1 together: +Task: "Update backend model/service files in backend/src/..." +Task: "Update frontend API/state files in frontend/src/services/ or frontend/src/state/" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Test User Story 1 independently +5. Deploy/demo if ready + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) +3. Add User Story 2 → Test independently → Deploy/Demo +4. Add User Story 3 → Test independently → Deploy/Demo +5. Each story adds value without breaking previous stories + +### Parallel Team Strategy + +With multiple developers: + +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 3 +3. Stories complete and integrate independently + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story should be independently completable and verifiable +- Verify affected automated checks and manual acceptance flows before closing a story +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- Avoid: vague tasks, forbidden architecture changes, same-file conflicts, or + cross-story dependencies that break independence diff --git a/.specify/workflows/speckit/workflow.yml b/.specify/workflows/speckit/workflow.yml new file mode 100644 index 0000000..f69efea --- /dev/null +++ b/.specify/workflows/speckit/workflow.yml @@ -0,0 +1,77 @@ +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + # 0.8.5 is the first release with engine-side resolution of the + # ``integration: "auto"`` default. Older versions would treat "auto" + # as a literal integration key and fail at dispatch. + speckit_version: ">=0.8.5" + integrations: + # The four commands below (specify, plan, tasks, implement) are core + # spec-kit commands provided by every integration. The list here is an + # advisory, non-exhaustive compatibility hint following the documented + # ``any: [...]`` schema -- it is NOT a closed set. The workflow runs + # against any integration the project was initialized with, including + # ones not listed below, as long as that integration provides the four + # core commands referenced in ``steps``. + any: + - "claude" + - "copilot" + - "gemini" + - "opencode" + +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + integration: + type: string + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" diff --git a/.specify/workflows/workflow-registry.json b/.specify/workflows/workflow-registry.json new file mode 100644 index 0000000..4497da4 --- /dev/null +++ b/.specify/workflows/workflow-registry.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "workflows": { + "speckit": { + "name": "Full SDD Cycle", + "version": "1.0.0", + "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", + "source": "bundled", + "installed_at": "2026-05-29T10:01:56.088165+00:00", + "updated_at": "2026-05-29T10:01:56.088170+00:00" + } + } +} \ No newline at end of file diff --git a/discovery-notes.md b/discovery-notes.md new file mode 100644 index 0000000..7829d7f --- /dev/null +++ b/discovery-notes.md @@ -0,0 +1,15 @@ +# Discovery Notes + +## Incomplete Behaviors + +1. The lobby only supports manual refresh, there is no automatic polling to keep participants in sync across tabs. +2. There is no host concept in the current room model, so the app cannot restrict game start to the room creator. +3. The game screen is scaffolded only, drawing, guess submission, scoring, and result handling are not implemented. +4. Room snapshots are not viewer-specific, so there is currently no way to show the secret word only to the drawer. +5. Restart flow is missing, once the user enters the game screen, there is no real round lifecycle to reset back to the lobby. + +## Assumptions + +1. The backend in-memory room store should remain the source of truth for room and game state, with the frontend using polling to stay updated. +2. A single-round implementation is sufficient because multiple rounds, drawer rotation, and timers are explicitly out of scope. +3. Validation and permission checks such as host-only start should be enforced on the backend, not only hidden in the UI. diff --git a/specs/001-room-lobby-setup/checklists/requirements.md b/specs/001-room-lobby-setup/checklists/requirements.md new file mode 100644 index 0000000..3e479be --- /dev/null +++ b/specs/001-room-lobby-setup/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Scenario 1 Room Setup & Lobby + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-29 +**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 + +- [x] Specification is limited to Scenario 1 room setup and lobby behavior. +- [x] Exclusions for drawer flow, gameplay interaction, results, and restart are + explicitly documented. diff --git a/specs/001-room-lobby-setup/contracts/rooms-scenario1.openapi.yaml b/specs/001-room-lobby-setup/contracts/rooms-scenario1.openapi.yaml new file mode 100644 index 0000000..9dda89d --- /dev/null +++ b/specs/001-room-lobby-setup/contracts/rooms-scenario1.openapi.yaml @@ -0,0 +1,206 @@ +openapi: 3.1.0 +info: + title: Scribble Scenario 1 Rooms API + version: 1.0.0 + description: > + Contract for room setup and lobby behavior only. Drawer assignment, word + visibility, drawing, guesses, scoring, results, and restart are excluded. +paths: + /rooms: + post: + summary: Create a room and return the creator's room session + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + playerName: + type: string + responses: + "201": + description: Room created + content: + application/json: + schema: + $ref: "#/components/schemas/RoomSessionResponse" + /rooms/{code}/join: + post: + summary: Join an existing room by room code + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + playerName: + type: string + responses: + "200": + description: Room joined + content: + application/json: + schema: + $ref: "#/components/schemas/RoomSessionResponse" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + /rooms/{code}: + get: + summary: Fetch the latest room snapshot for a room + parameters: + - $ref: "#/components/parameters/RoomCode" + - name: participantId + in: query + required: false + schema: + type: string + responses: + "200": + description: Room snapshot loaded + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + /rooms/{code}/start: + post: + summary: Start the game for a room as the host + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [participantId] + properties: + participantId: + type: string + responses: + "200": + description: Room left the lobby and entered playing state + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "403": + description: Only the host may start the room + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: Room cannot start yet + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" +components: + parameters: + RoomCode: + name: code + in: path + required: true + schema: + type: string + pattern: "^[A-Z0-9]{4}$" + responses: + BadRequest: + description: Invalid request payload or malformed room code + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: Room not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + schemas: + Participant: + type: object + additionalProperties: false + required: [id, name, joinedAt] + properties: + id: + type: string + name: + type: string + joinedAt: + type: string + format: date-time + RoomSnapshot: + type: object + additionalProperties: false + required: + - code + - status + - participants + - hostParticipantId + - viewerIsHost + - canStartGame + - minimumPlayersToStart + properties: + code: + type: string + pattern: "^[A-Z0-9]{4}$" + status: + type: string + enum: [lobby, playing] + participants: + type: array + items: + $ref: "#/components/schemas/Participant" + hostParticipantId: + type: string + viewerIsHost: + type: boolean + canStartGame: + type: boolean + minimumPlayersToStart: + type: integer + enum: [2] + RoomSessionResponse: + type: object + additionalProperties: false + required: [participantId, room] + properties: + participantId: + type: string + room: + $ref: "#/components/schemas/RoomSnapshot" + ErrorResponse: + type: object + additionalProperties: false + required: [message] + properties: + message: + type: string diff --git a/specs/001-room-lobby-setup/data-model.md b/specs/001-room-lobby-setup/data-model.md new file mode 100644 index 0000000..8419913 --- /dev/null +++ b/specs/001-room-lobby-setup/data-model.md @@ -0,0 +1,99 @@ +# Data Model: Scenario 1 Room Setup & Lobby + +## Room + +**Purpose**: Represents one isolated multiplayer game room during the room setup +and lobby phase. + +**Fields**: + +- `code`: unique 4-character room identifier +- `status`: `"lobby" | "playing"` +- `hostParticipantId`: participant ID of the room host +- `participants`: ordered list of room members +- `createdAt`: room creation timestamp +- `updatedAt`: last room mutation timestamp + +**Validation Rules**: + +- `code` must be unique among active in-memory rooms +- `status` starts as `"lobby"` +- `hostParticipantId` must reference an existing participant in `participants` +- room start is allowed only when `participants.length >= 2` + +**State Transitions**: + +- `createRoom` creates a room in `lobby` +- `joinRoom` keeps the room in `lobby` +- `startRoom` transitions `lobby -> playing` only for the host and only when the + minimum player count is satisfied + +## Participant + +**Purpose**: Represents a player currently associated with a room. + +**Fields**: + +- `id`: unique participant identifier +- `name`: display name supplied by the player or default fallback +- `joinedAt`: timestamp when the participant entered the room + +**Validation Rules**: + +- `id` must be unique within a room +- name validation beyond the current default/fallback behavior is deferred to + Scenario 2 + +## Lobby Snapshot + +**Purpose**: Represents the room state exposed to the current viewer during +room setup and lobby polling. + +**Fields**: + +- `code`: room identifier +- `status`: `"lobby" | "playing"` +- `participants`: current participants in the room +- `hostParticipantId`: participant ID for the room host +- `viewerIsHost`: whether the requesting participant is the host +- `canStartGame`: whether the requesting participant may start the game now +- `minimumPlayersToStart`: fixed value `2` + +**Derived Rules**: + +- `viewerIsHost = participantId === hostParticipantId` +- `canStartGame = viewerIsHost && status === "lobby" && participants.length >= 2` + +## Start Game Command + +**Purpose**: Represents the host's request to move the room out of the lobby. + +**Fields**: + +- `code`: target room identifier from the route +- `participantId`: caller identity from the current room session + +**Validation Rules**: + +- `code` must be a well-formed 4-character room code +- `participantId` must be present +- the room must exist +- the participant must match `hostParticipantId` +- the room must still be in `lobby` +- the room must contain at least 2 participants + +## Room Code Input + +**Purpose**: Represents user-provided room code entry during join and room fetch +operations. + +**Normalization Rules**: + +- trim leading and trailing whitespace +- convert letters to uppercase + +**Validation Rules**: + +- empty or whitespace-only input is rejected +- the normalized value must be exactly 4 characters +- the value must match the starter room-code character set expectations diff --git a/specs/001-room-lobby-setup/plan.md b/specs/001-room-lobby-setup/plan.md new file mode 100644 index 0000000..7f7bb73 --- /dev/null +++ b/specs/001-room-lobby-setup/plan.md @@ -0,0 +1,267 @@ +# Implementation Plan: Scenario 1 Room Setup & Lobby + +**Branch**: `assignment` | **Date**: 2026-05-29 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/001-room-lobby-setup/spec.md` + +**Note**: This plan is limited to Scenario 1 room setup and lobby behavior. + +## Summary + +Extend the starter room flow so room creation records a host, joining enforces +clear empty/invalid room-code feedback, lobby snapshots stay isolated per room, +and the lobby refreshes automatically every 2 seconds. The backend remains the +source of truth for host-only start rules through a dedicated start endpoint, +while the frontend room store and lobby page consume the richer room snapshot +and redirect to the existing game placeholder only after the backend marks the +room as started. + +## Technical Context + +**Language/Version**: TypeScript 5.x on Node.js 18+ (backend) and React 18 +with Vite (frontend) + +**Primary Dependencies**: Express, Zod, React, React Router, Vite, Vitest + +**Storage**: In-memory room and game state only + +**Testing**: `cd backend && npm test`, `cd frontend && npm test`, plus manual +two-tab browser validation for multiplayer flows + +**Target Platform**: Node.js backend and modern desktop browser clients + +**Project Type**: Monorepo web application (`backend/` + `frontend/`) + +**Performance Goals**: Lobby membership updates should appear for connected +players within one polling interval, with a target of about 2 seconds + +**Constraints**: HTTP polling only; no WebSockets; no database/persistence; no +authentication/session layer; keep room memory footprint minimal; preserve the +starter architecture; keep scope strictly to Scenario 1 + +**Scale/Scope**: Small multiplayer rooms for one-round local validation using +at least two browser tabs and optionally a second room for isolation checks + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] The change is scoped to a concrete scenario/user story and preserves the + README checkpoint order unless a deviation is justified. +- [x] All changed backend boundaries have explicit TypeScript types and Zod + validation for request/response payloads. +- [x] Multiplayer synchronization remains HTTP polling against in-memory state + only; no forbidden persistence or realtime transport is introduced. +- [x] The plan preserves the existing monorepo structure and documents any new + dependency or abstraction that materially expands the surface area. +- [x] Verification covers every touched surface, including affected builds, + affected tests, and manual two-tab validation for multiplayer/UI flows. + +**Post-Design Re-Check**: Pass. The design keeps all state in memory, adds no +new dependencies, uses Zod for new request bodies and stricter room-code +validation, and limits the room-state transition to `lobby -> playing` without +introducing Scenario 2+ gameplay logic. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-room-lobby-setup/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── rooms-scenario1.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +backend/ +└── src/ + ├── api/ + │ ├── rooms.ts + │ ├── schemas.ts + │ └── schemas.test.ts + ├── models/ + │ └── game.ts + └── services/ + ├── roomStore.ts + └── roomStore.test.ts + +frontend/ +└── src/ + ├── pages/ + │ ├── JoinRoomPage.tsx + │ ├── LobbyPage.tsx + │ └── GamePage.tsx + ├── services/ + │ ├── api.ts + │ └── api.test.ts + ├── state/ + │ └── roomStore.ts + └── styles/ + └── app.css +``` + +**Structure Decision**: Keep backend room rules in `backend/src/services`, +request validation in `backend/src/api`, and client session/lobby flow in +`frontend/src/state` plus `frontend/src/pages`. No new app layers or packages +are required. + +## Phase 0: Research Outcomes + +- Use an explicit `hostParticipantId` on each room so host authority is stable + across polling and does not depend on list position. +- Add a backend-owned `POST /rooms/:code/start` action that receives the + caller's `participantId` and enforces host-only start plus the 2-player + minimum before changing room status. +- Represent the Scenario 1 start transition by expanding room status from + `lobby` to `playing`, allowing the existing game placeholder route to be used + without introducing drawer or scoring logic. +- Poll from `LobbyPage` on a 2-second interval using the existing room store's + fetch path so the store remains the single source of frontend room state. +- Tighten room-code validation at both boundaries: client-side trim/empty guard + for fast feedback and backend schema validation for exact 4-character codes + before join/start/fetch logic runs. +- Correct the frontend API base URL to point to the backend host instead of the + broken starter default so Scenario 1 can run end to end. + +See [research.md](./research.md) for decisions, rationale, and alternatives. + +## Phase 1: Design + +### Backend Model Changes + +- Update [`backend/src/models/game.ts`](../../../backend/src/models/game.ts) so + `RoomStatus` becomes `"lobby" | "playing"`. +- Add `hostParticipantId` to `Room` and `RoomSnapshot`. +- Add `viewerIsHost`, `canStartGame`, and `minimumPlayersToStart` to + `RoomSnapshot` so the client can render host state without duplicating the + backend's start rules. +- Keep participant shape unchanged for Scenario 1; player-name trimming remains + a Scenario 2 concern. + +### Backend Service Changes + +- Update [`backend/src/services/roomStore.ts`](../../../backend/src/services/roomStore.ts) + so `createRoom()` sets the initial host to the creator. +- Keep `joinRoom()` scoped to membership changes in an existing room and ensure + it never mutates unrelated rooms. +- Add a `startRoom(code, participantId)` service that: + - returns a not-found result when the room does not exist + - returns a forbidden result when the caller is not the host + - returns a conflict result when the room has fewer than 2 players or is no + longer in lobby state + - updates only the addressed room to `status: "playing"` on success +- Update `toRoomSnapshot()` to calculate viewer-specific flags from + `hostParticipantId`, `participantId`, current status, and participant count. + +### API Changes + +- Keep `POST /rooms` and `POST /rooms/:code/join`, but return the enriched room + snapshot with host and start-state metadata. +- Tighten [`backend/src/api/schemas.ts`](../../../backend/src/api/schemas.ts): + - room-code params trim and uppercase input before validating an exact + 4-character code + - add a start-room request schema requiring `participantId` +- Update [`backend/src/api/rooms.ts`](../../../backend/src/api/rooms.ts) to add + `POST /rooms/:code/start`. +- Error mapping: + - `400` for invalid room-code format or malformed start request + - `403` for non-host start attempts + - `404` for unknown room codes + - `409` for valid requests that violate the minimum-player or room-state rule + +### Frontend Room Store Changes + +- Fix [`frontend/src/services/api.ts`](../../../frontend/src/services/api.ts) so + the default base URL points to the actual backend host. +- Extend `RoomSnapshot` and `RoomSessionResponse` types in + [`frontend/src/services/api.ts`](../../../frontend/src/services/api.ts) to + match the backend contract. +- Add `startGame()` to [`frontend/src/state/roomStore.ts`](../../../frontend/src/state/roomStore.ts) + so the current session can call `POST /rooms/:code/start` using the stored + `participantId`. +- Keep `fetchRoom()` lightweight for polling and continue storing the latest + room snapshot centrally for all room pages. + +### Frontend Page Changes + +- Update [`frontend/src/pages/JoinRoomPage.tsx`](../../../frontend/src/pages/JoinRoomPage.tsx) + to trim the room code before submit and stop empty/whitespace-only attempts + before the request is sent. +- Update [`frontend/src/pages/LobbyPage.tsx`](../../../frontend/src/pages/LobbyPage.tsx) + to: + - start a 2-second polling interval while the user remains in a lobby room + - display which participant is host + - show host-only start availability and the 2-player minimum clearly + - disable or hide start interactions for non-hosts + - call `roomStore.startGame()` for hosts + - navigate to `/game` automatically when polled room status changes to + `"playing"` +- Keep [`frontend/src/pages/GamePage.tsx`](../../../frontend/src/pages/GamePage.tsx) + as a placeholder screen, but ensure it can accept the post-start room shape + without depending on Scenario 2+ fields. +- Update [`frontend/src/styles/app.css`](../../../frontend/src/styles/app.css) + only as needed for host labeling and disabled/start-state messaging. + +### File-Level Change Plan + +- `backend/src/models/game.ts`: add host and start-state fields to room types +- `backend/src/services/roomStore.ts`: add host tracking, start rule + enforcement, and enriched snapshots +- `backend/src/services/roomStore.test.ts`: cover host assignment, room + isolation, minimum-player enforcement, and non-host rejection +- `backend/src/api/schemas.ts`: add start schema and stricter room-code rules +- `backend/src/api/schemas.test.ts`: cover trimmed/invalid room-code validation +- `backend/src/api/rooms.ts`: add start endpoint and map service outcomes to + HTTP responses +- `frontend/src/services/api.ts`: fix base URL, add `startGame`, update room + snapshot types +- `frontend/src/services/api.test.ts`: cover start endpoint request shape and + fetch/create/join URL expectations +- `frontend/src/state/roomStore.ts`: add `startGame`, keep polling snapshot + updates centralized +- `frontend/src/pages/JoinRoomPage.tsx`: client-side empty-code validation +- `frontend/src/pages/LobbyPage.tsx`: polling, host display, guarded start flow, + and automatic transition to the game route +- `frontend/src/pages/GamePage.tsx`: accept enriched room snapshot after start +- `frontend/src/styles/app.css`: host marker and lobby status messaging + +### Backend Enforcement Rules + +- A room creator is always the initial host of the room they create. +- Only the participant whose ID matches `hostParticipantId` may start that room. +- A room may only be started while its status is `lobby`. +- A room may only be started when `participants.length >= 2`. +- Join, fetch, and start operations must operate only on the addressed room + code and must never leak participants or state across rooms. +- Invalid room-code format fails before room lookup; unknown well-formed room + codes fail at lookup time. + +### Validation Strategy + +- Automated backend validation: + - schema tests for room-code parsing and start request validation + - room-store tests for host assignment, isolation, and start guards +- Automated frontend validation: + - API service tests for fetch/create/join/start request formatting +- Manual two-tab validation: + - Tab A creates a room and confirms host labeling + - Tab B joins with the valid room code and appears in Tab A within one + polling interval + - empty and invalid room-code attempts stay on the join screen with clear + errors + - Tab B cannot start the game + - Tab A cannot start while alone but can start after Tab B joins + - both tabs leave the lobby once the host starts + - a second room can be created in parallel to confirm isolation + +## Complexity Tracking + +No constitution exceptions or additional architectural complexity are required +for this feature. diff --git a/specs/001-room-lobby-setup/quickstart.md b/specs/001-room-lobby-setup/quickstart.md new file mode 100644 index 0000000..b6ded41 --- /dev/null +++ b/specs/001-room-lobby-setup/quickstart.md @@ -0,0 +1,57 @@ +# Quickstart: Scenario 1 Room Setup & Lobby + +## Prerequisites + +- Node.js 18+ and npm 9+ +- Two browser tabs for multiplayer validation + +## Run the apps + +```bash +cd backend +npm install +npm run dev +``` + +```bash +cd frontend +npm install +npm run dev +``` + +## Validate Scenario 1 + +1. Open the frontend in Tab A and create a room. +2. Confirm Tab A lands in the lobby and the creator is identified as the host. +3. In Tab A, confirm the start control is blocked while only one player is in + the room. +4. Open Tab B, navigate to Join Room, and submit an empty room code. +5. Confirm Tab B stays on the join flow and shows a clear validation message. +6. In Tab B, submit an unknown room code. +7. Confirm Tab B stays on the join flow and shows a clear invalid-room message. +8. In Tab B, join the valid room created in Tab A. +9. Confirm Tab A shows the new participant within about 2 seconds without using + a manual refresh action. +10. In Tab B, confirm the player cannot start the game. +11. In Tab A, start the game as host. +12. Confirm both tabs leave the lobby and land on the existing game placeholder. + +## Validate room isolation + +1. Create a second room in another tab or browser window. +2. Join only that second room with an additional tab. +3. Confirm each lobby shows only its own participant list and state. + +## Automated checks + +```bash +cd backend +npm test +npm run build +``` + +```bash +cd frontend +npm test +npm run build +``` diff --git a/specs/001-room-lobby-setup/research.md b/specs/001-room-lobby-setup/research.md new file mode 100644 index 0000000..1497538 --- /dev/null +++ b/specs/001-room-lobby-setup/research.md @@ -0,0 +1,85 @@ +# Research: Scenario 1 Room Setup & Lobby + +## Decision: Track the host explicitly on the room model + +**Rationale**: The current room model stores only a participant list. Relying on +list position for host authority would be implicit and brittle once polling and +future room mutations are added. An explicit `hostParticipantId` keeps host-only +rules stable and easy to test. + +**Alternatives considered**: + +- Infer the host as `participants[0]` + Rejected because host authority becomes positional rather than declarative. +- Store a boolean `isHost` on each participant + Rejected because it duplicates room-level authority and increases mutation + surface. + +## Decision: Add a dedicated start endpoint instead of a client-only transition + +**Rationale**: Scenario 1 requires host-only start and a minimum of 2 players. +Those rules must be enforced by the backend so non-host clients cannot bypass +them by navigating directly to `/game`. A dedicated `POST /rooms/:code/start` +endpoint makes the rule explicit and keeps the backend authoritative. + +**Alternatives considered**: + +- Let the lobby button navigate directly to `/game` + Rejected because it enforces nothing and does not update other players. +- Auto-start once a second player joins + Rejected because the spec requires host-only control over game start. + +## Decision: Use polling on the lobby page every 2 seconds + +**Rationale**: The constitution and README require HTTP polling for +synchronization. The existing room store already supports room fetching, so the +lowest-risk design is a `LobbyPage` interval that refreshes the current room +every 2 seconds and reacts to changed room state. + +**Alternatives considered**: + +- Manual refresh only + Rejected because the scenario explicitly requires automatic lobby updates. +- Push-based updates + Rejected because realtime transports are constitutionally forbidden. + +## Decision: Validate room codes at both the client and server boundary + +**Rationale**: Empty or whitespace-only codes should fail immediately in the join +flow, while malformed or unknown codes must still be rejected safely on the +backend. Dual-layer validation improves feedback without weakening backend +correctness. + +**Alternatives considered**: + +- Frontend-only validation + Rejected because backend endpoints must remain safe against malformed input. +- Backend-only validation + Rejected because it delays obvious user feedback for empty input. + +## Decision: Represent the Scenario 1 start transition with `status: "playing"` + +**Rationale**: Scenario 1 only needs the lobby to end and the existing game +placeholder route to become active. A simple `lobby -> playing` transition +supports that requirement without introducing drawer assignment, words, guesses, +or scoring. + +**Alternatives considered**: + +- Keep status fixed as `lobby` and navigate only on the initiating client + Rejected because other players would never observe the started state. +- Add multiple new round/gameplay states now + Rejected because they belong to later scenarios and would expand scope. + +## Decision: Fix the starter frontend API base URL as part of Scenario 1 + +**Rationale**: The current default points to `http://localhost:3001/bug`, which +prevents room creation, joining, and polling from working end to end. Scenario 1 +cannot be validated without correcting that base path. + +**Alternatives considered**: + +- Leave the default broken and rely on manual environment overrides + Rejected because it makes the starter flow fail by default. +- Introduce an additional proxy layer + Rejected because it adds unnecessary surface area for a local starter app. diff --git a/specs/001-room-lobby-setup/spec.md b/specs/001-room-lobby-setup/spec.md new file mode 100644 index 0000000..d921fde --- /dev/null +++ b/specs/001-room-lobby-setup/spec.md @@ -0,0 +1,191 @@ +# Feature Specification: Scenario 1 Room Setup & Lobby + +**Feature Branch**: `assignment` + +**Created**: 2026-05-29 + +**Status**: Draft + +**Input**: User description: "Scenario 1 room setup and lobby with host tracking, +empty and invalid room code validation, room isolation, lobby polling every 2 +seconds, and host-only game start requiring at least 2 players. Keep this +limited to Scenario 1 only. Exclude drawer assignment, secret word visibility, +drawing, guesses, scoring, results, and restart." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Host Creates a Room (Priority: P1) + +A player creates a new game room and immediately becomes the host in the lobby. +The host can see who has joined and can only start the game when the room has +at least two players. + +**Why this priority**: Creating a room and recognizing the host is the entry +point for the rest of the multiplayer flow. + +**Independent Test**: Create a room in one browser tab, confirm the creator is +identified as the host, then verify the start action is unavailable until a +second player joins. + +**Acceptance Scenarios**: + +1. **Given** a player creates a new room, **When** the lobby opens, **Then** the + creator is shown as the host for that room. +2. **Given** a host is alone in the lobby, **When** the host views the start + control, **Then** the game cannot be started yet and the minimum player rule + is communicated clearly. +3. **Given** a host has at least two players in the room, **When** the host + starts the game, **Then** the room leaves the lobby state for all players in + that room. + +--- + +### User Story 2 - Player Joins by Room Code (Priority: P2) + +A player joins an existing room using its code and receives clear feedback when +the code is empty or invalid. + +**Why this priority**: Joining is required for multiplayer testing and must be +reliable before lobby behavior can be validated. + +**Independent Test**: Try joining with an empty code, an unknown code, and a +valid code from a second browser tab. + +**Acceptance Scenarios**: + +1. **Given** a player submits an empty room code, **When** the join action is + attempted, **Then** the player stays on the join flow and sees a clear + validation message. +2. **Given** a player submits a room code that does not exist, **When** the join + action is attempted, **Then** the player stays on the join flow and sees a + clear invalid-room message. +3. **Given** a player submits a valid room code, **When** the join succeeds, + **Then** the player enters that room's lobby and appears in that room's + participant list. + +--- + +### User Story 3 - Lobbies Stay Synced and Isolated (Priority: P3) + +Players in a room see lobby updates automatically, while players in other rooms +see only their own room state. + +**Why this priority**: Polling and room isolation are the core quality gates for +Scenario 1 and prevent cross-room confusion. + +**Independent Test**: Run two rooms in parallel across multiple tabs, join +players to each room, and verify each lobby refreshes only its own participant +list within the expected refresh window. + +**Acceptance Scenarios**: + +1. **Given** a room lobby is open, **When** another player joins that same room, + **Then** the participant list refreshes automatically within about 2 seconds + without requiring a manual refresh action. +2. **Given** two different rooms exist at the same time, **When** players join + or view either room, **Then** each room shows only its own participants and + state. +3. **Given** a non-host player is in the lobby, **When** the player attempts to + start the game, **Then** the action is blocked and the room remains in the + lobby state. + +### Edge Cases + +- A player enters only whitespace into the room code field. +- A player tries to join with a room code from a different active room. +- The host tries to start the game before a second player has joined. +- A non-host player reaches the lobby after polling has refreshed and still must + not gain host-only actions. +- Two rooms receive new joins at nearly the same time and each lobby must remain + isolated. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST create a unique room that opens in the lobby view + when a player chooses to create a room. +- **FR-002**: The system MUST automatically mark the room creator as the host for + that room. +- **FR-003**: The system MUST allow another player to join an existing room by + entering a valid room code. +- **FR-004**: The system MUST reject empty or whitespace-only room codes with a + clear validation message and without entering a room. +- **FR-005**: The system MUST reject unknown room codes with a clear invalid-room + message and without entering a room. +- **FR-006**: The system MUST keep room membership and lobby state isolated by + room so that activity in one room never appears in another room. +- **FR-007**: The lobby MUST refresh automatically about every 2 seconds while a + player remains in that room's lobby. +- **FR-008**: The lobby MUST show the latest participant list for that room after + each refresh. +- **FR-009**: Only the host MUST be allowed to start the game from the lobby. +- **FR-010**: The system MUST prevent game start until at least two players are + present in the room and MUST communicate that rule clearly. +- **FR-011**: When the host starts the game with at least two players, the room + MUST leave the lobby state for all players currently in that room. +- **FR-012**: This feature MUST exclude drawer assignment, secret word + visibility, drawing, guesses, scoring, results, and restart behavior. + +### Key Entities *(include if feature involves data)* + +- **Room**: A multiplayer game session identified by a unique code, containing + its current lobby state, its host, and its participants. +- **Player**: A participant who can create or join a room and who may be either + the room host or a non-host member. +- **Lobby Snapshot**: The current room-specific view that players see before the + game starts, including participant membership, host designation, and whether + game start is currently allowed. + +## Constraints & Non-Goals *(mandatory)* + +- **CN-001**: Room updates in this scenario MUST arrive through scheduled + refreshes rather than instant push-based updates. +- **CN-002**: Room data for this scenario MUST remain temporary for the current + runtime only and is not expected to survive a service restart. +- **CN-003**: Players MUST be able to access rooms without sign-in, account + creation, or identity verification features. +- **CN-004**: This feature MUST build on the existing starter experience without + expanding into unrelated product areas. +- **CN-005**: The feature MUST keep room behavior deterministic and room state + isolated whenever multiplayer state changes. +- **CN-006**: The scope is limited to Scenario 1 room setup and lobby behavior + only. +- **CN-007**: Drawer assignment, word selection, word visibility, drawing, + guesses, scoring, results, and restart flows are explicit non-goals for this + specification. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In a two-player validation session, a player who joins a valid room + appears in the host's lobby view within 5 seconds without requiring a manual + refresh. +- **SC-002**: In manual validation, 100% of empty or unknown room-code attempts + keep the player out of a room and show actionable feedback. +- **SC-003**: In manual validation, hosts can start a room with two or more + players on the first attempt, while non-hosts and single-player rooms are + blocked every time they try. +- **SC-004**: In a validation session with at least two active rooms, no player + ever sees participants or lobby state from another room. + +## Assumptions + +- Player-name trimming and empty-name validation are addressed in Scenario 2 and + are out of scope for this specification. +- Room codes continue to use the starter application's existing format and + uniqueness behavior. +- Starting the game from the lobby only needs to move players out of the lobby + state for this scenario; the next-stage gameplay details are defined later. +- Players can keep at least two browser tabs open during multiplayer validation. + +## Verification Plan *(mandatory)* + +- Validate room creation in one browser tab and joining from a second tab. +- Validate empty and unknown room-code failures from the join flow. +- Validate that lobby membership refreshes automatically without manual action. +- Validate host-only start behavior with one player, two players, and a non-host + participant. +- Validate room isolation by operating at least two rooms in parallel and + confirming no cross-room leakage. diff --git a/specs/001-room-lobby-setup/tasks.md b/specs/001-room-lobby-setup/tasks.md new file mode 100644 index 0000000..c0e2965 --- /dev/null +++ b/specs/001-room-lobby-setup/tasks.md @@ -0,0 +1,241 @@ +--- + +description: "Task list for Scenario 1 room setup and lobby implementation" + +--- + +# Tasks: Scenario 1 Room Setup & Lobby + +**Input**: Design documents from `/specs/001-room-lobby-setup/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Include automated backend and frontend API coverage plus manual two-tab validation for each user story. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Web app**: `backend/src/`, `frontend/src/` +- Paths below follow this repository's monorepo layout + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm the active Scenario 1 artifacts and validation targets before editing code + +- [ ] T001 Review implementation inputs in `specs/001-room-lobby-setup/spec.md`, `specs/001-room-lobby-setup/plan.md`, and `specs/001-room-lobby-setup/contracts/rooms-scenario1.openapi.yaml` +- [ ] T002 Confirm manual and automated validation steps in `specs/001-room-lobby-setup/quickstart.md`, `backend/src/api/rooms.ts`, and `frontend/src/pages/LobbyPage.tsx` +- [ ] T003 [P] Capture shared room-state expectations from `specs/001-room-lobby-setup/data-model.md` and `specs/001-room-lobby-setup/research.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared room, snapshot, and request-contract changes that all Scenario 1 stories depend on + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [ ] T004 Update shared room and snapshot types in `backend/src/models/game.ts` +- [ ] T005 [P] Extend shared room API types and fix the default backend base URL in `frontend/src/services/api.ts` +- [ ] T006 [P] Add reusable room-code normalization and start-request validation in `backend/src/api/schemas.ts` +- [ ] T007 [P] Add shared room session and snapshot update support in `frontend/src/state/roomStore.ts` +- [ ] T008 Implement shared room snapshot derivation helpers and status transitions in `backend/src/services/roomStore.ts` + +**Checkpoint**: Foundation ready - user story implementation can now begin in priority order + +--- + +## Phase 3: User Story 1 - Host Creates a Room (Priority: P1) 🎯 MVP + +**Goal**: The room creator is identified as host and can start the room only when at least two players are present + +**Independent Test**: Create a room in one tab, confirm the creator is marked as host, verify start is blocked while alone, then join with a second tab and confirm only the host can start the room + +### Verification for User Story 1 ⚠️ + +- [ ] T009 [P] [US1] Add host assignment and minimum-player start tests in `backend/src/services/roomStore.test.ts` +- [ ] T010 [P] [US1] Add create-room and start-room request coverage in `frontend/src/services/api.test.ts` +- [ ] T011 [US1] Validate host creation and two-player start gating with `specs/001-room-lobby-setup/quickstart.md` + +### Implementation for User Story 1 + +- [ ] T012 [P] [US1] Add `hostParticipantId`, `viewerIsHost`, `canStartGame`, and `minimumPlayersToStart` fields in `backend/src/models/game.ts` +- [ ] T013 [US1] Implement creator host assignment and `startRoom()` enforcement in `backend/src/services/roomStore.ts` +- [ ] T014 [US1] Add `POST /rooms/:code/start` handling and status/error mapping in `backend/src/api/rooms.ts` +- [ ] T015 [P] [US1] Add `startGame()` request handling in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` +- [ ] T016 [US1] Update host labeling, start gating, and started-room navigation in `frontend/src/pages/LobbyPage.tsx` and `frontend/src/pages/GamePage.tsx` + +**Checkpoint**: At this point, room creation, host designation, and host-only start should be functional and testable independently + +--- + +## Phase 4: User Story 2 - Player Joins by Room Code (Priority: P2) + +**Goal**: Players can join valid rooms and receive clear feedback for empty or invalid room codes + +**Independent Test**: Attempt to join with empty, whitespace-only, unknown, and valid room codes from a second tab and confirm only the valid code enters the lobby + +### Verification for User Story 2 ⚠️ + +- [ ] T017 [P] [US2] Add room-code validation and join failure coverage in `backend/src/api/schemas.test.ts` and `backend/src/services/roomStore.test.ts` +- [ ] T018 [P] [US2] Add join-room and fetch-room request coverage in `frontend/src/services/api.test.ts` +- [ ] T019 [US2] Validate empty, invalid, and valid join flows with `specs/001-room-lobby-setup/quickstart.md` + +### Implementation for User Story 2 + +- [ ] T020 [US2] Tighten room-code parsing and malformed-request rejection in `backend/src/api/schemas.ts` +- [ ] T021 [US2] Return clear join and room-load error messages in `backend/src/api/rooms.ts` +- [ ] T022 [US2] Trim, uppercase, and block empty room-code submission in `frontend/src/pages/JoinRoomPage.tsx` +- [ ] T023 [US2] Preserve join failure feedback and successful room-session updates in `frontend/src/state/roomStore.ts` + +**Checkpoint**: At this point, users can join valid rooms and receive clear feedback for invalid room-code attempts without breaking User Story 1 + +--- + +## Phase 5: User Story 3 - Lobbies Stay Synced and Isolated (Priority: P3) + +**Goal**: Lobby state refreshes automatically every 2 seconds and remains isolated per room + +**Independent Test**: Run at least two rooms in parallel across multiple tabs and confirm each lobby refreshes automatically while showing only its own participants and state + +### Verification for User Story 3 ⚠️ + +- [ ] T024 [P] [US3] Add room-isolation and lobby-snapshot transition coverage in `backend/src/services/roomStore.test.ts` +- [ ] T025 [P] [US3] Add polling fetch and started-room transition coverage in `frontend/src/services/api.test.ts` +- [ ] T026 [US3] Validate two-room isolation and 2-second lobby refresh with `specs/001-room-lobby-setup/quickstart.md` + +### Implementation for User Story 3 + +- [ ] T027 [US3] Ensure room-specific fetch and snapshot isolation in `backend/src/services/roomStore.ts` and `backend/src/api/rooms.ts` +- [ ] T028 [US3] Implement 2-second lobby polling and refresh-state handling in `frontend/src/pages/LobbyPage.tsx` and `frontend/src/state/roomStore.ts` +- [ ] T029 [US3] Add host markers and synced lobby status messaging in `frontend/src/styles/app.css` and `frontend/src/pages/LobbyPage.tsx` + +**Checkpoint**: All Scenario 1 lobby synchronization and room isolation behavior should now be independently functional + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation and artifact alignment across the completed Scenario 1 slice + +- [ ] T030 [P] Refresh Scenario 1 behavior notes in `specs/001-room-lobby-setup/quickstart.md` and `specs/001-room-lobby-setup/contracts/rooms-scenario1.openapi.yaml` if implementation wording changed +- [ ] T031 Run backend validation for `backend/src/models/game.ts`, `backend/src/services/roomStore.ts`, and `backend/src/api/rooms.ts` with `cd backend && npm test && npm run build` +- [ ] T032 Run frontend validation for `frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts`, `frontend/src/pages/JoinRoomPage.tsx`, and `frontend/src/pages/LobbyPage.tsx` with `cd frontend && npm test && npm run build` +- [ ] T033 Run the final end-to-end multi-tab Scenario 1 checks in `specs/001-room-lobby-setup/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Foundational completion and defines the MVP slice +- **User Story 2 (Phase 4)**: Depends on Foundational completion and should be completed after User Story 1 because it extends the shared room flow already used by the host path +- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from User Stories 1 and 2 being in place so polling and isolation can be validated end to end +- **Polish (Phase 6)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: No dependency on other user stories; establishes host metadata and start enforcement +- **User Story 2 (P2)**: Uses the shared room/session contract and should preserve the host flow from User Story 1 +- **User Story 3 (P3)**: Uses the room/session contract plus the join/start flow from User Stories 1 and 2 for full validation + +### Within Each User Story + +- Verification tasks MUST be completed before the story is treated as done +- Shared types before services +- Services before routes or client state integration +- Client state before page-level UI behavior +- Manual two-tab validation before moving to the next priority + +### Parallel Opportunities + +- `T003` can run in parallel with `T001-T002` +- `T005-T007` can run in parallel once `T004` is defined +- `T009-T010`, `T017-T018`, and `T024-T025` can run in parallel within their user stories +- `T015` can run in parallel with `T013-T014` once the backend contract is stable +- `T030` can run in parallel with final validation once implementation is complete + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch User Story 1 automated verification together: +Task: "Add host assignment and minimum-player start tests in backend/src/services/roomStore.test.ts" +Task: "Add create-room and start-room request coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 1 implementation work together: +Task: "Add hostParticipantId, viewerIsHost, canStartGame, and minimumPlayersToStart fields in backend/src/models/game.ts" +Task: "Add startGame() request handling in frontend/src/services/api.ts and frontend/src/state/roomStore.ts" +``` + +## Parallel Example: User Story 2 + +```bash +# Launch User Story 2 automated verification together: +Task: "Add room-code validation and join failure coverage in backend/src/api/schemas.test.ts and backend/src/services/roomStore.test.ts" +Task: "Add join-room and fetch-room request coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 2 implementation work together: +Task: "Return clear join and room-load error messages in backend/src/api/rooms.ts" +Task: "Trim, uppercase, and block empty room-code submission in frontend/src/pages/JoinRoomPage.tsx" +``` + +## Parallel Example: User Story 3 + +```bash +# Launch User Story 3 automated verification together: +Task: "Add room-isolation and lobby-snapshot transition coverage in backend/src/services/roomStore.test.ts" +Task: "Add polling fetch and started-room transition coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 3 implementation work together: +Task: "Ensure room-specific fetch and snapshot isolation in backend/src/services/roomStore.ts and backend/src/api/rooms.ts" +Task: "Add host markers and synced lobby status messaging in frontend/src/styles/app.css and frontend/src/pages/LobbyPage.tsx" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Confirm host designation and host-only start with two tabs +5. Demo the host-controlled room start on the existing game placeholder + +### Incremental Delivery + +1. Complete Setup + Foundational → shared room contract ready +2. Add User Story 1 → validate host creation/start → MVP complete +3. Add User Story 2 → validate join feedback and valid-room entry +4. Add User Story 3 → validate polling and room isolation +5. Finish with Phase 6 validation and artifact cleanup + +### Parallel Team Strategy + +1. Team completes Setup + Foundational together +2. After foundation is stable: + - Developer A: backend service and route changes for the active story + - Developer B: frontend store and page changes for the active story + - Developer C: automated verification updates in `backend/src/**/*.test.ts` and `frontend/src/services/api.test.ts` +3. Rejoin for manual two-tab validation at the end of each story + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] labels map tasks to specific user stories for traceability +- Every task includes an exact file path and can be executed without additional artifact discovery +- Suggested MVP scope: Phase 3 / User Story 1 only From 9eeef48aef403e61858b76826a9bb2255f6c6595 Mon Sep 17 00:00:00 2001 From: Vishal S Date: Fri, 29 May 2026 17:09:24 +0530 Subject: [PATCH 2/9] Implement Scenario 1 room setup and lobby --- backend/src/api/rooms.ts | 37 +++++++-- backend/src/api/router.ts | 5 +- backend/src/api/schemas.test.ts | 20 ++++- backend/src/api/schemas.ts | 14 +++- backend/src/models/game.ts | 9 ++- backend/src/services/roomStore.test.ts | 106 ++++++++++++++++++++++++- backend/src/services/roomStore.ts | 68 ++++++++++++++-- frontend/src/pages/GamePage.tsx | 7 +- frontend/src/pages/JoinRoomPage.tsx | 15 +++- frontend/src/pages/LobbyPage.tsx | 76 ++++++++++++++++-- frontend/src/services/api.test.ts | 102 +++++++++++++++++++++++- frontend/src/services/api.ts | 18 +++-- frontend/src/state/roomStore.ts | 15 +++- frontend/src/styles/app.css | 10 +++ specs/001-room-lobby-setup/tasks.md | 56 ++++++------- 15 files changed, 491 insertions(+), 67 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 8a6c6c9..a433d7e 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -4,9 +4,10 @@ import { HttpError, joinRoomSchema, roomCodeParamsSchema, - roomViewerQuerySchema + roomViewerQuerySchema, + startRoomSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { createRoom, getRoom, joinRoom, startRoom, toRoomSnapshot } from "../services/roomStore.js"; export function createRoomsRouter() { const router = Router(); @@ -29,10 +30,10 @@ export function createRoomsRouter() { try { const { code } = roomCodeParamsSchema.parse(request.params); const { playerName } = joinRoomSchema.parse(request.body); - const result = joinRoom(code.toUpperCase(), playerName); + const result = joinRoom(code, playerName); if (!result) { - throw new HttpError(404, "Unable to join room"); + throw new HttpError(404, "Room code was not found"); } response.json({ @@ -44,14 +45,38 @@ export function createRoomsRouter() { } }); + router.post("/:code/start", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = startRoomSchema.parse(request.body); + const result = startRoom(code, participantId); + + if (!result.ok) { + const statusCodeByReason = { + "not-found": 404, + forbidden: 403, + conflict: 409 + } as const; + + throw new HttpError(statusCodeByReason[result.reason], result.message); + } + + response.json({ + room: toRoomSnapshot(result.room, participantId) + }); + } catch (error) { + next(error); + } + }); + router.get("/:code", (request, response, next) => { try { const { code } = roomCodeParamsSchema.parse(request.params); const { participantId } = roomViewerQuerySchema.parse(request.query); - const room = getRoom(code.toUpperCase()); + const room = getRoom(code); if (!room) { - throw new HttpError(404, "Unable to load room"); + throw new HttpError(404, "Room code was not found"); } response.json({ diff --git a/backend/src/api/router.ts b/backend/src/api/router.ts index 1270595..673d623 100644 --- a/backend/src/api/router.ts +++ b/backend/src/api/router.ts @@ -1,5 +1,6 @@ import type { NextFunction, Request, Response } from "express"; import { Router } from "express"; +import { ZodError } from "zod"; import { createRoomsRouter } from "./rooms.js"; export function createApiRouter() { @@ -29,8 +30,8 @@ export function errorHandler( response: Response, _next: NextFunction ) { - if (error.name === "ZodError") { - response.status(400).json({ message: "Invalid request payload" }); + if (error instanceof ZodError) { + response.status(400).json({ message: error.issues[0]?.message ?? "Invalid request payload" }); return; } diff --git a/backend/src/api/schemas.test.ts b/backend/src/api/schemas.test.ts index 641efea..740efb0 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, roomCodeParamsSchema, startRoomSchema } from "./schemas.js"; describe("schemas", () => { it("createRoomSchema accepts a valid body with playerName", () => { @@ -11,4 +11,22 @@ describe("schemas", () => { it("roomCodeParamsSchema rejects missing code", () => { expect(() => roomCodeParamsSchema.parse({})).toThrow(); }); + + it("roomCodeParamsSchema trims and uppercases a valid code", () => { + const result = roomCodeParamsSchema.parse({ code: " ab12 " }); + + expect(result.code).toBe("AB12"); + }); + + it("roomCodeParamsSchema rejects whitespace-only codes", () => { + expect(() => roomCodeParamsSchema.parse({ code: " " })).toThrow(); + }); + + it("roomCodeParamsSchema rejects malformed room codes", () => { + expect(() => roomCodeParamsSchema.parse({ code: "A!2" })).toThrow(); + }); + + it("startRoomSchema requires a participantId", () => { + expect(() => startRoomSchema.parse({ participantId: " " })).toThrow(); + }); }); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index bfebba0..d413894 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -1,5 +1,13 @@ import { z } from "zod"; +const roomCodeSchema = z + .string() + .trim() + .transform((value) => value.toUpperCase()) + .refine((value) => /^[A-Z0-9]{4}$/.test(value), { + message: "Room code must be 4 uppercase letters or numbers" + }); + export const createRoomSchema = z.object({ playerName: z.string().optional() }); @@ -9,13 +17,17 @@ export const joinRoomSchema = z.object({ }); export const roomCodeParamsSchema = z.object({ - code: z.string() + code: roomCodeSchema }); export const roomViewerQuerySchema = z.object({ participantId: z.string().optional() }); +export const startRoomSchema = z.object({ + participantId: z.string().trim().min(1) +}); + export class HttpError extends Error { statusCode: number; diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 88ce946..9fe18aa 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,5 +1,5 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby"; +export type RoomStatus = "lobby" | "playing"; export interface Participant { id: string; @@ -10,6 +10,7 @@ export interface Participant { export interface Room { code: string; status: RoomStatus; + hostParticipantId: string; participants: Participant[]; createdAt: string; updatedAt: string; @@ -18,9 +19,11 @@ export interface Room { export interface RoomSnapshot { code: string; status: RoomStatus; + hostParticipantId: string; participants: Participant[]; - availableWords: string[]; - roles: ParticipantRole[]; + viewerIsHost: boolean; + canStartGame: boolean; + minimumPlayersToStart: number; } export interface RoomSessionResponse { diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index b70ef77..dc65eea 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoom, joinRoom } from "./roomStore.js"; +import { createRoom, getRoom, joinRoom, startRoom, toRoomSnapshot } from "./roomStore.js"; describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { @@ -8,6 +8,7 @@ describe("roomStore", () => { expect(result.room.code).toMatch(/^[A-Z0-9]{4}$/); expect(result.room.participants).toHaveLength(1); expect(result.room.participants[0].name).toBe("Alice"); + expect(result.room.hostParticipantId).toBe(result.participantId); expect(result.participantId).toBeDefined(); }); @@ -16,4 +17,107 @@ describe("roomStore", () => { expect(result).toBeNull(); }); + + it("joinRoom updates only the targeted room and returns a new room session", () => { + const firstRoom = createRoom("Alice"); + const secondRoom = createRoom("Cara"); + + const joined = joinRoom(firstRoom.room.code, "Bob"); + + expect(joined).not.toBeNull(); + expect(joined?.room.code).toBe(firstRoom.room.code); + expect(joined?.room.participants).toHaveLength(2); + + const untouchedRoom = getRoom(secondRoom.room.code); + expect(untouchedRoom?.participants).toHaveLength(1); + expect(untouchedRoom?.participants[0].name).toBe("Cara"); + }); + + it("toRoomSnapshot exposes host-aware start metadata", () => { + const result = createRoom("Alice"); + + const snapshot = toRoomSnapshot(result.room, result.participantId); + + expect(snapshot.hostParticipantId).toBe(result.participantId); + expect(snapshot.viewerIsHost).toBe(true); + expect(snapshot.canStartGame).toBe(false); + expect(snapshot.minimumPlayersToStart).toBe(2); + }); + + it("toRoomSnapshot enables start for the host once a second player joins but not for the guest", () => { + const host = createRoom("Alice"); + const joined = joinRoom(host.room.code, "Bob"); + + expect(joined).not.toBeNull(); + + const updatedRoom = getRoom(host.room.code); + expect(updatedRoom).not.toBeNull(); + + const hostSnapshot = toRoomSnapshot(updatedRoom!, host.participantId); + const guestSnapshot = toRoomSnapshot(updatedRoom!, joined!.participantId); + + expect(hostSnapshot.canStartGame).toBe(true); + expect(hostSnapshot.viewerIsHost).toBe(true); + expect(guestSnapshot.canStartGame).toBe(false); + expect(guestSnapshot.viewerIsHost).toBe(false); + }); + + it("startRoom rejects the host when fewer than two players are present", () => { + const result = createRoom("Alice"); + + const started = startRoom(result.room.code, result.participantId); + + expect(started).toEqual({ + ok: false, + reason: "conflict", + message: "At least 2 players are required to start the game" + }); + }); + + it("startRoom rejects non-host participants", () => { + const result = createRoom("Alice"); + const joined = joinRoom(result.room.code, "Bob"); + + expect(joined).not.toBeNull(); + + const started = startRoom(result.room.code, joined!.participantId); + + expect(started).toEqual({ + ok: false, + reason: "forbidden", + message: "Only the host can start the game" + }); + }); + + it("startRoom allows the host to start once two players are present", () => { + const result = createRoom("Alice"); + joinRoom(result.room.code, "Bob"); + + const started = startRoom(result.room.code, result.participantId); + + expect(started.ok).toBe(true); + if (!started.ok) { + return; + } + + expect(started.room.status).toBe("playing"); + + const snapshot = toRoomSnapshot(started.room, result.participantId); + expect(snapshot.viewerIsHost).toBe(true); + expect(snapshot.canStartGame).toBe(false); + }); + + it("starting one room does not affect other active rooms", () => { + const firstRoom = createRoom("Alice"); + const secondRoom = createRoom("Cara"); + joinRoom(firstRoom.room.code, "Bob"); + + const started = startRoom(firstRoom.room.code, firstRoom.participantId); + + expect(started.ok).toBe(true); + + const untouchedRoom = getRoom(secondRoom.room.code); + expect(untouchedRoom?.status).toBe("lobby"); + expect(untouchedRoom?.participants).toHaveLength(1); + }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index e53987a..90c650a 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; import type { Participant, Room, RoomSnapshot } from "../models/game.js"; -import { STARTER_ROLES, STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); +const MINIMUM_PLAYERS_TO_START = 2; function now() { return new Date().toISOString(); @@ -45,8 +45,16 @@ function cloneRoom(room: Room) { return structuredClone(room); } -export function listWords() { - return [...STARTER_WORDS]; +type StartRoomResult = + | { ok: true; room: Room } + | { ok: false; reason: "not-found" | "forbidden" | "conflict"; message: string }; + +function canStartRoom(room: Room, viewerParticipantId?: string) { + return ( + room.status === "lobby" && + room.participants.length >= MINIMUM_PLAYERS_TO_START && + room.hostParticipantId === viewerParticipantId + ); } export function createRoom(playerName?: string) { @@ -54,6 +62,7 @@ export function createRoom(playerName?: string) { const room: Room = { code: generateUniqueCode(), status: "lobby", + hostParticipantId: participant.id, participants: [participant], createdAt: now(), updatedAt: now() @@ -90,6 +99,51 @@ export function getRoom(code: string) { return room ? cloneRoom(room) : null; } +export function startRoom(code: string, participantId: string): StartRoomResult { + const room = rooms.get(code); + + if (!room) { + return { + ok: false, + reason: "not-found", + message: "Room code was not found" + }; + } + + if (room.hostParticipantId !== participantId) { + return { + ok: false, + reason: "forbidden", + message: "Only the host can start the game" + }; + } + + if (room.status !== "lobby") { + return { + ok: false, + reason: "conflict", + message: "Game has already started" + }; + } + + if (room.participants.length < MINIMUM_PLAYERS_TO_START) { + return { + ok: false, + reason: "conflict", + message: `At least ${MINIMUM_PLAYERS_TO_START} players are required to start the game` + }; + } + + room.status = "playing"; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { + ok: true, + room: cloneRoom(room) + }; +} + export function saveRoom(room: Room) { room.updatedAt = now(); rooms.set(room.code, cloneRoom(room)); @@ -97,13 +151,15 @@ export function saveRoom(room: Room) { } export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { - void viewerParticipantId; + const viewerIsHost = room.hostParticipantId === viewerParticipantId; return { code: room.code, status: room.status, + hostParticipantId: room.hostParticipantId, participants: room.participants.map((participant) => ({ ...participant })), - availableWords: listWords(), - roles: [...STARTER_ROLES] + viewerIsHost, + canStartGame: canStartRoom(room, viewerParticipantId), + minimumPlayersToStart: MINIMUM_PLAYERS_TO_START }; } diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a768183..5ddddb8 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -14,6 +14,11 @@ export function GamePage() { useEffect(() => { if (!room) { navigate("/", { replace: true }); + return; + } + + if (room.status === "lobby") { + navigate("/lobby", { replace: true }); } }, [navigate, room]); @@ -56,7 +61,7 @@ export function GamePage() {
Status
-
Playing
+
{room.viewerIsHost ? "Host" : "Player"}
diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index db4f530..4b2132a 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -12,10 +12,21 @@ export function JoinRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const normalizedRoomCode = roomCode.trim().toUpperCase(); try { + if (!normalizedRoomCode) { + setError("Enter a room code to join a lobby."); + return; + } + + if (!/^[A-Z0-9]{4}$/.test(normalizedRoomCode)) { + setError("Room codes must be 4 letters or numbers."); + return; + } + setError(null); - await roomStore.joinRoom(roomCode.toUpperCase(), playerName); + await roomStore.joinRoom(normalizedRoomCode, playerName); navigate("/lobby"); } catch (caughtError) { setError(caughtError instanceof Error ? caughtError.message : "Unable to join room"); @@ -45,7 +56,7 @@ export function JoinRoomPage() { setRoomCode(event.target.value.toUpperCase())} + onChange={(event) => setRoomCode(event.target.value)} placeholder="ABCD" /> diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1c99bd2..9830f26 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -8,8 +8,9 @@ import { useRoomState, useRoomStore } from "../state/roomStore"; export function LobbyPage() { const navigate = useNavigate(); const roomStore = useRoomStore(); - const { room, error, isLoading } = useRoomState(); + const { room, participantId, error, isLoading } = useRoomState(); const [refreshError, setRefreshError] = useState(null); + const [startError, setStartError] = useState(null); useEffect(() => { if (!room) { @@ -17,6 +18,33 @@ export function LobbyPage() { } }, [navigate, room]); + useEffect(() => { + if (room?.status === "playing") { + navigate("/game", { replace: true }); + } + }, [navigate, room?.status]); + + useEffect(() => { + if (!room || room.status !== "lobby") { + return undefined; + } + + const intervalId = window.setInterval(() => { + void roomStore + .fetchRoom() + .then(() => { + setRefreshError(null); + }) + .catch((caughtError) => { + setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); + }); + }, 2000); + + return () => { + window.clearInterval(intervalId); + }; + }, [room?.code, room?.status, roomStore]); + async function handleRefresh() { try { setRefreshError(null); @@ -26,10 +54,27 @@ export function LobbyPage() { } } + async function handleStartGame() { + try { + setStartError(null); + await roomStore.startGame(); + } catch (caughtError) { + setStartError(caughtError instanceof Error ? caughtError.message : "Unable to start game"); + } + } + if (!room) { return null; } + const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const startMessage = room.viewerIsHost + ? room.canStartGame + ? "You can start the game as soon as everyone is ready." + : `You need at least ${room.minimumPlayersToStart} players to start the game.` + : "Only the host can start the game."; + const statusMessage = startError ?? error ?? refreshError ?? startMessage; + return (
@@ -50,7 +95,15 @@ export function LobbyPage() { {room.participants.map((participant) => (
  • {participant.name} - joined + + {participant.id === room.hostParticipantId + ? participant.id === participantId + ? "you · host" + : "host" + : participant.id === participantId + ? "you" + : "joined"} +
  • ))} @@ -58,10 +111,15 @@ export function LobbyPage() { -

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

    + {isLoading ? "Updating room..." : room.viewerIsHost ? "Host controls enabled" : "Waiting for host"}

    -

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

    +

    {statusMessage}

    + {viewer ? ( +

    + Signed in as {viewer.name}{room.viewerIsHost ? " (host)" : ""} +

    + ) : null}
    @@ -69,8 +127,12 @@ export function LobbyPage() { -
    diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 67601f5..1ab74de 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -12,7 +12,15 @@ describe("api service", () => { json: () => Promise.resolve({ participantId: "p1", - room: { code: "ABCD", status: "lobby", participants: [] }, + room: { + code: "ABCD", + status: "lobby", + hostParticipantId: "p1", + participants: [], + viewerIsHost: true, + canStartGame: false, + minimumPlayersToStart: 2 + }, }), }; vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); @@ -33,7 +41,15 @@ describe("api service", () => { ok: true, json: () => Promise.resolve({ - room: { code: "XYZW", status: "lobby", participants: [] }, + room: { + code: "XYZW", + status: "lobby", + hostParticipantId: "p1", + participants: [], + viewerIsHost: true, + canStartGame: false, + minimumPlayersToStart: 2 + }, }), }; vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); @@ -45,4 +61,86 @@ describe("api service", () => { expect.anything() ); }); + + it("joinRoom sends POST to /rooms/:code/join with playerName in body", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + participantId: "p2", + room: { + code: "ABCD", + status: "lobby", + hostParticipantId: "p1", + participants: [], + viewerIsHost: false, + canStartGame: false, + minimumPlayersToStart: 2 + } + }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.joinRoom("ABCD", "Bob"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/join"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ playerName: "Bob" }), + }) + ); + }); + + it("startGame sends POST to /rooms/:code/start with participantId in body", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { + code: "ABCD", + status: "playing", + hostParticipantId: "p1", + participants: [], + viewerIsHost: true, + canStartGame: false, + minimumPlayersToStart: 2 + } + }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.startGame("ABCD", "p1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/start"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p1" }), + }) + ); + }); + + it("fetchRoom supports polling a playing room snapshot", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: { + code: "ABCD", + status: "playing", + hostParticipantId: "p1", + participants: [], + viewerIsHost: false, + canStartGame: false, + minimumPlayersToStart: 2 + } + }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + const response = await api.fetchRoom("ABCD", "p2"); + + expect(response.room.status).toBe("playing"); + }); }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6899a6d..dcd9a6e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,5 +1,3 @@ -export type ParticipantRole = "drawer" | "guesser"; - export interface Participant { id: string; name: string; @@ -8,10 +6,12 @@ export interface Participant { export interface RoomSnapshot { code: string; - status: "lobby"; + status: "lobby" | "playing"; + hostParticipantId: string; participants: Participant[]; - availableWords: string[]; - roles: ParticipantRole[]; + viewerIsHost: boolean; + canStartGame: boolean; + minimumPlayersToStart: number; } export interface RoomSessionResponse { @@ -19,7 +19,7 @@ export interface RoomSessionResponse { room: RoomSnapshot; } -const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001/bug"; +const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001"; async function request(path: string, init?: RequestInit) { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -54,6 +54,12 @@ export const api = { body: JSON.stringify({ playerName }) }); }, + startGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/start`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index aefd373..be7e2df 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -84,11 +84,24 @@ class RoomStore { } async joinRoom(code: string, playerName: string) { - const response = await this.withLoading(() => api.joinRoom(code, playerName)); + const normalizedCode = code.trim().toUpperCase(); + const response = await this.withLoading(() => api.joinRoom(normalizedCode, playerName)); this.setRoomSession(response); return response; } + async startGame() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await this.withLoading(() => + api.startGame(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } + async fetchRoom() { if (!this.state.room) { return null; diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index c929a6d..6baae33 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -416,6 +416,16 @@ input { font-weight: 600; } +.status-line--info { + background: #e0e7ff; + color: #3730a3; +} + +.status-line--loading { + background: #fef3c7; + color: #b45309; +} + .placeholder-block { display: grid; gap: 16px; diff --git a/specs/001-room-lobby-setup/tasks.md b/specs/001-room-lobby-setup/tasks.md index c0e2965..93b30cf 100644 --- a/specs/001-room-lobby-setup/tasks.md +++ b/specs/001-room-lobby-setup/tasks.md @@ -29,9 +29,9 @@ description: "Task list for Scenario 1 room setup and lobby implementation" **Purpose**: Confirm the active Scenario 1 artifacts and validation targets before editing code -- [ ] T001 Review implementation inputs in `specs/001-room-lobby-setup/spec.md`, `specs/001-room-lobby-setup/plan.md`, and `specs/001-room-lobby-setup/contracts/rooms-scenario1.openapi.yaml` -- [ ] T002 Confirm manual and automated validation steps in `specs/001-room-lobby-setup/quickstart.md`, `backend/src/api/rooms.ts`, and `frontend/src/pages/LobbyPage.tsx` -- [ ] T003 [P] Capture shared room-state expectations from `specs/001-room-lobby-setup/data-model.md` and `specs/001-room-lobby-setup/research.md` +- [X] T001 Review implementation inputs in `specs/001-room-lobby-setup/spec.md`, `specs/001-room-lobby-setup/plan.md`, and `specs/001-room-lobby-setup/contracts/rooms-scenario1.openapi.yaml` +- [X] T002 Confirm manual and automated validation steps in `specs/001-room-lobby-setup/quickstart.md`, `backend/src/api/rooms.ts`, and `frontend/src/pages/LobbyPage.tsx` +- [X] T003 [P] Capture shared room-state expectations from `specs/001-room-lobby-setup/data-model.md` and `specs/001-room-lobby-setup/research.md` --- @@ -41,11 +41,11 @@ description: "Task list for Scenario 1 room setup and lobby implementation" **⚠️ CRITICAL**: No user story work can begin until this phase is complete -- [ ] T004 Update shared room and snapshot types in `backend/src/models/game.ts` -- [ ] T005 [P] Extend shared room API types and fix the default backend base URL in `frontend/src/services/api.ts` -- [ ] T006 [P] Add reusable room-code normalization and start-request validation in `backend/src/api/schemas.ts` -- [ ] T007 [P] Add shared room session and snapshot update support in `frontend/src/state/roomStore.ts` -- [ ] T008 Implement shared room snapshot derivation helpers and status transitions in `backend/src/services/roomStore.ts` +- [X] T004 Update shared room and snapshot types in `backend/src/models/game.ts` +- [X] T005 [P] Extend shared room API types and fix the default backend base URL in `frontend/src/services/api.ts` +- [X] T006 [P] Add reusable room-code normalization and start-request validation in `backend/src/api/schemas.ts` +- [X] T007 [P] Add shared room session and snapshot update support in `frontend/src/state/roomStore.ts` +- [X] T008 Implement shared room snapshot derivation helpers and status transitions in `backend/src/services/roomStore.ts` **Checkpoint**: Foundation ready - user story implementation can now begin in priority order @@ -59,17 +59,17 @@ description: "Task list for Scenario 1 room setup and lobby implementation" ### Verification for User Story 1 ⚠️ -- [ ] T009 [P] [US1] Add host assignment and minimum-player start tests in `backend/src/services/roomStore.test.ts` -- [ ] T010 [P] [US1] Add create-room and start-room request coverage in `frontend/src/services/api.test.ts` +- [X] T009 [P] [US1] Add host assignment and minimum-player start tests in `backend/src/services/roomStore.test.ts` +- [X] T010 [P] [US1] Add create-room and start-room request coverage in `frontend/src/services/api.test.ts` - [ ] T011 [US1] Validate host creation and two-player start gating with `specs/001-room-lobby-setup/quickstart.md` ### Implementation for User Story 1 -- [ ] T012 [P] [US1] Add `hostParticipantId`, `viewerIsHost`, `canStartGame`, and `minimumPlayersToStart` fields in `backend/src/models/game.ts` -- [ ] T013 [US1] Implement creator host assignment and `startRoom()` enforcement in `backend/src/services/roomStore.ts` -- [ ] T014 [US1] Add `POST /rooms/:code/start` handling and status/error mapping in `backend/src/api/rooms.ts` -- [ ] T015 [P] [US1] Add `startGame()` request handling in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` -- [ ] T016 [US1] Update host labeling, start gating, and started-room navigation in `frontend/src/pages/LobbyPage.tsx` and `frontend/src/pages/GamePage.tsx` +- [X] T012 [P] [US1] Add `hostParticipantId`, `viewerIsHost`, `canStartGame`, and `minimumPlayersToStart` fields in `backend/src/models/game.ts` +- [X] T013 [US1] Implement creator host assignment and `startRoom()` enforcement in `backend/src/services/roomStore.ts` +- [X] T014 [US1] Add `POST /rooms/:code/start` handling and status/error mapping in `backend/src/api/rooms.ts` +- [X] T015 [P] [US1] Add `startGame()` request handling in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` +- [X] T016 [US1] Update host labeling, start gating, and started-room navigation in `frontend/src/pages/LobbyPage.tsx` and `frontend/src/pages/GamePage.tsx` **Checkpoint**: At this point, room creation, host designation, and host-only start should be functional and testable independently @@ -83,16 +83,16 @@ description: "Task list for Scenario 1 room setup and lobby implementation" ### Verification for User Story 2 ⚠️ -- [ ] T017 [P] [US2] Add room-code validation and join failure coverage in `backend/src/api/schemas.test.ts` and `backend/src/services/roomStore.test.ts` -- [ ] T018 [P] [US2] Add join-room and fetch-room request coverage in `frontend/src/services/api.test.ts` +- [X] T017 [P] [US2] Add room-code validation and join failure coverage in `backend/src/api/schemas.test.ts` and `backend/src/services/roomStore.test.ts` +- [X] T018 [P] [US2] Add join-room and fetch-room request coverage in `frontend/src/services/api.test.ts` - [ ] T019 [US2] Validate empty, invalid, and valid join flows with `specs/001-room-lobby-setup/quickstart.md` ### Implementation for User Story 2 -- [ ] T020 [US2] Tighten room-code parsing and malformed-request rejection in `backend/src/api/schemas.ts` -- [ ] T021 [US2] Return clear join and room-load error messages in `backend/src/api/rooms.ts` -- [ ] T022 [US2] Trim, uppercase, and block empty room-code submission in `frontend/src/pages/JoinRoomPage.tsx` -- [ ] T023 [US2] Preserve join failure feedback and successful room-session updates in `frontend/src/state/roomStore.ts` +- [X] T020 [US2] Tighten room-code parsing and malformed-request rejection in `backend/src/api/schemas.ts` +- [X] T021 [US2] Return clear join and room-load error messages in `backend/src/api/rooms.ts` +- [X] T022 [US2] Trim, uppercase, and block empty room-code submission in `frontend/src/pages/JoinRoomPage.tsx` +- [X] T023 [US2] Preserve join failure feedback and successful room-session updates in `frontend/src/state/roomStore.ts` **Checkpoint**: At this point, users can join valid rooms and receive clear feedback for invalid room-code attempts without breaking User Story 1 @@ -106,15 +106,15 @@ description: "Task list for Scenario 1 room setup and lobby implementation" ### Verification for User Story 3 ⚠️ -- [ ] T024 [P] [US3] Add room-isolation and lobby-snapshot transition coverage in `backend/src/services/roomStore.test.ts` -- [ ] T025 [P] [US3] Add polling fetch and started-room transition coverage in `frontend/src/services/api.test.ts` +- [X] T024 [P] [US3] Add room-isolation and lobby-snapshot transition coverage in `backend/src/services/roomStore.test.ts` +- [X] T025 [P] [US3] Add polling fetch and started-room transition coverage in `frontend/src/services/api.test.ts` - [ ] T026 [US3] Validate two-room isolation and 2-second lobby refresh with `specs/001-room-lobby-setup/quickstart.md` ### Implementation for User Story 3 -- [ ] T027 [US3] Ensure room-specific fetch and snapshot isolation in `backend/src/services/roomStore.ts` and `backend/src/api/rooms.ts` -- [ ] T028 [US3] Implement 2-second lobby polling and refresh-state handling in `frontend/src/pages/LobbyPage.tsx` and `frontend/src/state/roomStore.ts` -- [ ] T029 [US3] Add host markers and synced lobby status messaging in `frontend/src/styles/app.css` and `frontend/src/pages/LobbyPage.tsx` +- [X] T027 [US3] Ensure room-specific fetch and snapshot isolation in `backend/src/services/roomStore.ts` and `backend/src/api/rooms.ts` +- [X] T028 [US3] Implement 2-second lobby polling and refresh-state handling in `frontend/src/pages/LobbyPage.tsx` and `frontend/src/state/roomStore.ts` +- [X] T029 [US3] Add host markers and synced lobby status messaging in `frontend/src/styles/app.css` and `frontend/src/pages/LobbyPage.tsx` **Checkpoint**: All Scenario 1 lobby synchronization and room isolation behavior should now be independently functional @@ -125,8 +125,8 @@ description: "Task list for Scenario 1 room setup and lobby implementation" **Purpose**: Final validation and artifact alignment across the completed Scenario 1 slice - [ ] T030 [P] Refresh Scenario 1 behavior notes in `specs/001-room-lobby-setup/quickstart.md` and `specs/001-room-lobby-setup/contracts/rooms-scenario1.openapi.yaml` if implementation wording changed -- [ ] T031 Run backend validation for `backend/src/models/game.ts`, `backend/src/services/roomStore.ts`, and `backend/src/api/rooms.ts` with `cd backend && npm test && npm run build` -- [ ] T032 Run frontend validation for `frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts`, `frontend/src/pages/JoinRoomPage.tsx`, and `frontend/src/pages/LobbyPage.tsx` with `cd frontend && npm test && npm run build` +- [X] T031 Run backend validation for `backend/src/models/game.ts`, `backend/src/services/roomStore.ts`, and `backend/src/api/rooms.ts` with `cd backend && npm test && npm run build` +- [X] T032 Run frontend validation for `frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts`, `frontend/src/pages/JoinRoomPage.tsx`, and `frontend/src/pages/LobbyPage.tsx` with `cd frontend && npm test && npm run build` - [ ] T033 Run the final end-to-end multi-tab Scenario 1 checks in `specs/001-room-lobby-setup/quickstart.md` --- From 128672be4d08ee5dd0f2ac507dd5abcfce43a8b6 Mon Sep 17 00:00:00 2001 From: Vishal S Date: Fri, 29 May 2026 18:02:06 +0530 Subject: [PATCH 3/9] Add Scenario 2 spec artifacts --- .specify/feature.json | 2 +- .../checklists/requirements.md | 36 +++ .../contracts/rooms-scenario2.openapi.yaml | 218 ++++++++++++++ specs/002-game-start-drawer/data-model.md | 87 ++++++ specs/002-game-start-drawer/plan.md | 280 ++++++++++++++++++ specs/002-game-start-drawer/quickstart.md | 54 ++++ specs/002-game-start-drawer/research.md | 85 ++++++ specs/002-game-start-drawer/spec.md | 186 ++++++++++++ specs/002-game-start-drawer/tasks.md | 241 +++++++++++++++ 9 files changed, 1188 insertions(+), 1 deletion(-) create mode 100644 specs/002-game-start-drawer/checklists/requirements.md create mode 100644 specs/002-game-start-drawer/contracts/rooms-scenario2.openapi.yaml create mode 100644 specs/002-game-start-drawer/data-model.md create mode 100644 specs/002-game-start-drawer/plan.md create mode 100644 specs/002-game-start-drawer/quickstart.md create mode 100644 specs/002-game-start-drawer/research.md create mode 100644 specs/002-game-start-drawer/spec.md create mode 100644 specs/002-game-start-drawer/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json index 131af49..58544f6 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/001-room-lobby-setup" + "feature_directory": "specs/002-game-start-drawer" } 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 0000000..c0dcbac --- /dev/null +++ b/specs/002-game-start-drawer/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Scenario 2 Game Start & Drawer Flow + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-29 +**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 + +- [x] Specification is limited to Scenario 2 game start and drawer flow behavior. +- [x] Exclusions for drawing, guessing, scoring, results, and restart are + explicitly documented. diff --git a/specs/002-game-start-drawer/contracts/rooms-scenario2.openapi.yaml b/specs/002-game-start-drawer/contracts/rooms-scenario2.openapi.yaml new file mode 100644 index 0000000..e73457d --- /dev/null +++ b/specs/002-game-start-drawer/contracts/rooms-scenario2.openapi.yaml @@ -0,0 +1,218 @@ +openapi: 3.1.0 +info: + title: Scribble Scenario 2 Rooms API + version: 1.0.0 + description: > + Contract for deterministic game start, drawer assignment, trimmed player + names, and drawer-only secret word visibility. Drawing, guesses, scoring, + results, and restart are excluded. +paths: + /rooms: + post: + summary: Create a room with a validated player name + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + playerName: + type: string + responses: + "201": + description: Room created + content: + application/json: + schema: + $ref: "#/components/schemas/RoomSessionResponse" + "400": + $ref: "#/components/responses/BadRequest" + /rooms/{code}/join: + post: + summary: Join an existing room with a validated player name + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + playerName: + type: string + responses: + "200": + description: Room joined + content: + application/json: + schema: + $ref: "#/components/schemas/RoomSessionResponse" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + /rooms/{code}: + get: + summary: Fetch the latest viewer-specific room snapshot + parameters: + - $ref: "#/components/parameters/RoomCode" + - name: participantId + in: query + required: false + schema: + type: string + responses: + "200": + description: Room snapshot loaded + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + /rooms/{code}/start: + post: + summary: Start the room and initialize the first deterministic round + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [participantId] + properties: + participantId: + type: string + responses: + "200": + description: Room entered playing state with initialized round data + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "403": + description: Only the host may start the room + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: Room cannot start yet + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" +components: + parameters: + RoomCode: + name: code + in: path + required: true + schema: + type: string + pattern: "^[A-Z0-9]{4}$" + responses: + BadRequest: + description: Invalid request payload or malformed room data + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: Room not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + schemas: + Participant: + type: object + additionalProperties: false + required: [id, name, joinedAt] + properties: + id: + type: string + name: + type: string + joinedAt: + type: string + format: date-time + RoomSnapshot: + type: object + additionalProperties: false + required: + - code + - status + - hostParticipantId + - participants + - viewerIsHost + - canStartGame + - minimumPlayersToStart + properties: + code: + type: string + pattern: "^[A-Z0-9]{4}$" + status: + type: string + enum: [lobby, playing] + hostParticipantId: + type: string + participants: + type: array + items: + $ref: "#/components/schemas/Participant" + viewerIsHost: + type: boolean + canStartGame: + type: boolean + minimumPlayersToStart: + type: integer + enum: [2] + drawerParticipantId: + type: string + viewerIsDrawer: + type: boolean + wordVisibility: + type: string + enum: [visible, hidden] + secretWord: + type: string + RoomSessionResponse: + type: object + additionalProperties: false + required: [participantId, room] + properties: + participantId: + type: string + room: + $ref: "#/components/schemas/RoomSnapshot" + ErrorResponse: + type: object + additionalProperties: false + required: [message] + properties: + message: + type: string 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 0000000..f69a753 --- /dev/null +++ b/specs/002-game-start-drawer/data-model.md @@ -0,0 +1,87 @@ +# Data Model: Scenario 2 Game Start & Drawer Flow + +## Room + +**Purpose**: Represents one isolated multiplayer room that can move from lobby +state into an active round. + +**Fields**: + +- `code`: unique 4-character room identifier +- `status`: `"lobby" | "playing"` +- `hostParticipantId`: participant ID for the room host +- `participants`: ordered room members with trimmed accepted names +- `round`: current round state, present when the room is actively playing +- `createdAt`: room creation timestamp +- `updatedAt`: last room mutation timestamp + +**Validation Rules**: + +- `hostParticipantId` must reference an existing participant while host state is + healthy +- `participants` may not contain whitespace-only accepted names +- `round` is initialized only when the room transitions out of lobby + +## Participant + +**Purpose**: Represents a room member visible in lobby and game views. + +**Fields**: + +- `id`: unique participant identifier +- `name`: accepted trimmed display name +- `joinedAt`: timestamp when the participant entered the room + +**Validation Rules**: + +- incoming names are trimmed before storage +- whitespace-only names are rejected before participant creation + +## Round State + +**Purpose**: Represents the deterministic game-start state created for Scenario 2. + +**Fields**: + +- `drawerParticipantId`: participant assigned as drawer +- `secretWord`: selected deterministic word from the starter list +- `startedAt`: timestamp for when the round began + +**Validation Rules**: + +- `drawerParticipantId` must reference a participant in the room +- `secretWord` must come from the starter word list +- the same room state must produce the same drawer/word selection outcome + +## Viewer Game Snapshot + +**Purpose**: Represents the room snapshot returned to a specific participant +after the round begins. + +**Fields**: + +- shared room fields from Scenario 1 +- `drawerParticipantId`: current round drawer +- `viewerIsDrawer`: whether the current participant is the drawer +- `secretWord`: actual word only when the viewer is the drawer +- `wordVisibility`: viewer-facing state such as `visible` or `hidden` + +**Derived Rules**: + +- `viewerIsDrawer = participantId === drawerParticipantId` +- `secretWord` is omitted or hidden for non-drawers +- all viewers share the same drawer and round identity, but not the same word + visibility payload + +## Player Name Input + +**Purpose**: Represents player name submission for room creation and joining. + +**Normalization Rules**: + +- trim leading and trailing whitespace + +**Validation Rules**: + +- empty result after trim is rejected +- accepted names are stored in trimmed form and reused in lobby/game displays diff --git a/specs/002-game-start-drawer/plan.md b/specs/002-game-start-drawer/plan.md new file mode 100644 index 0000000..e43b95e --- /dev/null +++ b/specs/002-game-start-drawer/plan.md @@ -0,0 +1,280 @@ +# Implementation Plan: Scenario 2 Game Start & Drawer Flow + +**Branch**: `assignment` | **Date**: 2026-05-29 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/002-game-start-drawer/spec.md` + +**Note**: This plan is limited to Scenario 2 game start and drawer flow behavior. + +## Summary + +Extend the existing Scenario 1 room start flow so starting a game also creates a +deterministic round state with a drawer and secret word, trims accepted player +names, rejects whitespace-only names before room entry, and exposes viewer- +specific game snapshots so only the drawer sees the actual word. The backend +remains authoritative for name validation, round initialization, drawer +assignment, and word selection, while the frontend lobby and game views react to +the richer room snapshot and render different game-state details for drawer and +non-drawer players. + +## Technical Context + +**Language/Version**: TypeScript 5.x on Node.js 18+ (backend) and React 18 +with Vite (frontend) + +**Primary Dependencies**: Express, Zod, React, React Router, Vite, Vitest + +**Storage**: In-memory room and game state only + +**Testing**: `cd backend && npm test`, `cd frontend && npm test`, plus manual +two-tab browser validation for multiplayer flows + +**Target Platform**: Node.js backend and modern desktop browser clients + +**Project Type**: Monorepo web application (`backend/` + `frontend/`) + +**Performance Goals**: Viewer-specific game state should appear to both tabs +within one polling interval after the room leaves the lobby, with a default +target of about 2 seconds for state convergence + +**Constraints**: HTTP polling only; no WebSockets; no database/persistence; no +authentication/session layer; keep room memory footprint minimal; preserve the +starter architecture; keep scope strictly to Scenario 2 + +**Scale/Scope**: Small multiplayer rooms running a single deterministic round +validated in local multi-tab testing + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] The change is scoped to a concrete scenario/user story and preserves the + README checkpoint order unless a deviation is justified. +- [x] All changed backend boundaries have explicit TypeScript types and Zod + validation for request/response payloads. +- [x] Multiplayer synchronization remains HTTP polling against in-memory state + only; no forbidden persistence or realtime transport is introduced. +- [x] The plan preserves the existing monorepo structure and documents any new + dependency or abstraction that materially expands the surface area. +- [x] Verification covers every touched surface, including affected builds, + affected tests, and manual two-tab validation for multiplayer/UI flows. + +**Post-Design Re-Check**: Pass. The design keeps Scenario 1 room/lobby behavior +intact, adds deterministic round state inside the existing room model, continues +using in-memory polling-driven sync only, and limits the new gameplay surface to +drawer assignment, name validation, and viewer-specific secret word visibility. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-game-start-drawer/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── rooms-scenario2.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +backend/ +└── src/ + ├── api/ + │ ├── rooms.ts + │ ├── schemas.ts + │ └── schemas.test.ts + ├── models/ + │ └── game.ts + ├── seed/ + │ └── starterData.ts + └── services/ + ├── roomStore.ts + └── roomStore.test.ts + +frontend/ +└── src/ + ├── pages/ + │ ├── CreateRoomPage.tsx + │ ├── JoinRoomPage.tsx + │ ├── LobbyPage.tsx + │ └── GamePage.tsx + ├── services/ + │ ├── api.ts + │ └── api.test.ts + ├── state/ + │ └── roomStore.ts + └── styles/ + └── app.css +``` + +**Structure Decision**: Keep backend round rules and viewer-specific snapshot +logic in `backend/src/services`, request validation in `backend/src/api`, and +client room/game rendering in `frontend/src/state` and `frontend/src/pages`. +No new top-level packages or app layers are required. + +## Phase 0: Research Outcomes + +- Trim and validate player names at the backend boundary for both room creation + and room join so the room store cannot persist whitespace-only names. +- Store round state directly on the room model instead of inventing a separate + top-level store, because Scenario 2 still operates within a single in-memory + room lifecycle. +- Extend the existing start-room action so starting the room also initializes the + round's deterministic drawer and secret word in one atomic transition. +- Use the host as the primary drawer rule and fall back to the first participant + in room order only if the room's host reference is unusable at round-start + time. +- Select the secret word deterministically from the starter list using a stable + room-derived calculation so repeated starts from the same room state yield the + same word. +- Design room snapshots as viewer-specific: the drawer receives the actual + secret word while all other players receive only a hidden-word view. + +See [research.md](./research.md) for decisions, rationale, and alternatives. + +## Phase 1: Design + +### Backend Model Changes + +- Update [`backend/src/models/game.ts`](../../../backend/src/models/game.ts) to + introduce a round-state model on the room: + - drawer participant ID + - selected secret word + - round status or active-round presence indicator +- Extend `RoomSnapshot` with viewer-specific game-state fields such as: + - current drawer participant ID + - viewer role for the round + - word visibility state + - actual secret word only when the viewer is the drawer +- Preserve Scenario 1 host and start metadata so the lobby behavior remains + intact. + +### Backend Validation and Request Changes + +- Update [`backend/src/api/schemas.ts`](../../../backend/src/api/schemas.ts) so + `createRoomSchema` and `joinRoomSchema` trim player names and reject + whitespace-only values. +- Continue using Zod to normalize and validate room-code inputs and start-room + requests. +- Keep `POST /rooms/:code/start` as the public start action, but return the + viewer-specific room snapshot that now contains round-state fields. + +### Backend Service Changes + +- Update [`backend/src/services/roomStore.ts`](../../../backend/src/services/roomStore.ts) + to trim accepted player names before participant creation. +- Add deterministic helpers for: + - selecting the drawer + - selecting the secret word from `STARTER_WORDS` + - constructing viewer-specific room snapshots +- Extend `startRoom()` so it: + - preserves Scenario 1 host-only and minimum-player rules + - initializes the round state only once when transitioning out of the lobby + - stores the drawer and selected secret word on the room +- Extend `toRoomSnapshot()` so it reveals the actual word only when the viewer + matches the drawer. + +### Viewer-Specific Response Design + +- `RoomSnapshot` remains the only room/game payload returned to the frontend. +- Shared fields visible to all viewers: + - room code + - room status + - host participant ID + - participants + - current drawer participant ID + - viewer host/drawer flags +- Viewer-specific word fields: + - drawer sees the real selected word + - non-drawers receive a hidden-word state with no actual word value +- This same viewer-specific snapshot shape should be used for create, join, + fetch, and start responses so the frontend consumes one consistent contract. + +### Start-of-Round State Flow + +1. Host starts a valid room from the lobby. +2. Backend validates the caller and minimum-player rule. +3. Backend trims/persists accepted player names already present in the room. +4. Backend selects the drawer deterministically: + - host first + - first participant fallback if host reference is unusable +5. Backend selects the secret word deterministically from the starter list. +6. Backend updates the room from lobby-only state to active round state. +7. Backend returns a viewer-specific room snapshot to the caller. +8. Other players load the same room through polling/fetch and receive the same + drawer and round identity, but only the drawer sees the actual word. + +### Frontend Room Store and Page Changes + +- Extend [`frontend/src/services/api.ts`](../../../frontend/src/services/api.ts) + room snapshot types to include round-state and viewer-specific fields. +- Keep [`frontend/src/state/roomStore.ts`](../../../frontend/src/state/roomStore.ts) + as the single source of room/game state, with no new client-side state library. +- Update [`frontend/src/pages/CreateRoomPage.tsx`](../../../frontend/src/pages/CreateRoomPage.tsx) + and [`frontend/src/pages/JoinRoomPage.tsx`](../../../frontend/src/pages/JoinRoomPage.tsx) + to show clear errors for whitespace-only names while preserving current room + code behavior. +- Update [`frontend/src/pages/LobbyPage.tsx`](../../../frontend/src/pages/LobbyPage.tsx) + only as needed to reflect trimmed names and the deterministic transition into + the game route. +- Update [`frontend/src/pages/GamePage.tsx`](../../../frontend/src/pages/GamePage.tsx) + to: + - show the drawer identity clearly + - show the actual secret word only to the drawer + - show a hidden-word state to non-drawers + - avoid introducing drawing, guessing, scoring, or result logic +- Update [`frontend/src/styles/app.css`](../../../frontend/src/styles/app.css) + only as needed for drawer/word visibility states. + +### File-Level Change Plan + +- `backend/src/models/game.ts`: add round-state and viewer-specific snapshot + fields +- `backend/src/services/roomStore.ts`: trim names, initialize round state, + choose drawer/word deterministically, and build viewer-specific snapshots +- `backend/src/services/roomStore.test.ts`: cover trimmed names, whitespace-only + rejections, deterministic drawer assignment, deterministic word selection, and + drawer-only word visibility +- `backend/src/api/schemas.ts`: trim/reject invalid player names +- `backend/src/api/schemas.test.ts`: cover trimmed and whitespace-only player + name validation +- `backend/src/api/rooms.ts`: return viewer-specific snapshots after start and + preserve clear validation failures +- `frontend/src/services/api.ts`: extend room snapshot types for round state and + secret-word visibility +- `frontend/src/services/api.test.ts`: cover updated response shapes for create, + join, fetch, and start +- `frontend/src/state/roomStore.ts`: continue storing the enriched room snapshot +- `frontend/src/pages/CreateRoomPage.tsx`: surface whitespace-only name errors +- `frontend/src/pages/JoinRoomPage.tsx`: surface whitespace-only name errors for + join +- `frontend/src/pages/LobbyPage.tsx`: preserve correct transition into the game + state with trimmed names +- `frontend/src/pages/GamePage.tsx`: display drawer identity and viewer-specific + word visibility +- `frontend/src/styles/app.css`: add drawer/hidden-word presentation states + +### Validation Strategy + +- Automated backend validation: + - schema tests for trimmed and whitespace-only name handling + - room-store tests for deterministic drawer assignment and word selection + - room-store tests for viewer-specific word visibility and name trimming +- Automated frontend validation: + - API service tests for updated snapshot shapes across create/join/fetch/start +- Manual two-tab validation: + - validate trimmed names on create and join + - validate whitespace-only names are rejected before room entry + - start the same room and confirm drawer identity is deterministic + - verify the drawer sees the word and the non-drawer does not + - repeat the same room-state start path to confirm deterministic word choice + +## Complexity Tracking + +No constitution exceptions or additional architectural complexity are required +for this feature. diff --git a/specs/002-game-start-drawer/quickstart.md b/specs/002-game-start-drawer/quickstart.md new file mode 100644 index 0000000..ce96157 --- /dev/null +++ b/specs/002-game-start-drawer/quickstart.md @@ -0,0 +1,54 @@ +# Quickstart: Scenario 2 Game Start & Drawer Flow + +## Prerequisites + +- Node.js 18+ and npm 9+ +- At least two browser tabs +- Scenario 1 room setup and lobby behavior working locally + +## Run the apps + +```bash +cd backend +npm install +npm run dev +``` + +```bash +cd frontend +npm install +npm run dev +``` + +## Validate Scenario 2 + +1. Open the frontend in Tab A and try creating a room with a whitespace-only + name. +2. Confirm the create flow rejects the request with a clear validation message. +3. Create a room in Tab A with a name that has leading/trailing spaces. +4. Confirm the accepted player name appears trimmed in the lobby. +5. Open Tab B and try joining the same room with a whitespace-only name. +6. Confirm the join flow rejects the request with a clear validation message. +7. Join the room in Tab B with a valid name containing extra spaces. +8. Confirm the joined player appears in the lobby with the trimmed name. +9. Start the game from Tab A. +10. Confirm the host is identified as the drawer unless host state is + intentionally unavailable. +11. Confirm Tab A sees the actual secret word. +12. Confirm Tab B does not see the actual secret word value. +13. Repeat the same room-state start path and confirm the same drawer and word + are selected for that state. + +## Automated checks + +```bash +cd backend +npm test +npm run build +``` + +```bash +cd frontend +npm test +npm run build +``` diff --git a/specs/002-game-start-drawer/research.md b/specs/002-game-start-drawer/research.md new file mode 100644 index 0000000..d44d380 --- /dev/null +++ b/specs/002-game-start-drawer/research.md @@ -0,0 +1,85 @@ +# Research: Scenario 2 Game Start & Drawer Flow + +## Decision: Trim and validate player names at the API boundary + +**Rationale**: Scenario 2 requires whitespace-only name rejection for both room +creation and joining. Enforcing this at the API boundary keeps invalid names out +of the room store entirely and guarantees the rest of the system only receives +trimmed, accepted values. + +**Alternatives considered**: + +- Validate only in frontend forms + Rejected because backend correctness cannot depend on client behavior. +- Accept whitespace-only names and replace them with a default + Rejected because the spec requires rejection, not silent substitution. + +## Decision: Store round state on the room model + +**Rationale**: The current game flow already centers on one in-memory room +object. Adding round state directly to that room keeps the state transition +atomic and avoids introducing a new top-level store for a single-round feature. + +**Alternatives considered**: + +- Separate round map keyed by room code + Rejected because it adds coordination overhead without new value. +- Derive drawer and word on every fetch without persisting them + Rejected because round identity must remain stable once the game starts. + +## Decision: Use host-first drawer assignment with first-player fallback + +**Rationale**: The user requested deterministic assignment to the host or first +player. Using the room host as the primary rule keeps Scenario 2 aligned with +Scenario 1 authority, while first participant fallback covers degraded host +state without ambiguity. + +**Alternatives considered**: + +- Always choose the first participant + Rejected because it ignores the Scenario 1 host model. +- Random assignment + Rejected because Scenario 2 requires deterministic behavior. + +## Decision: Select the secret word deterministically from the starter list + +**Rationale**: Scenario 2 needs a stable, repeatable word source without adding +new packs or randomization complexity. A deterministic room-derived selection +from the starter list satisfies repeatability and keeps the feature in scope. + +**Alternatives considered**: + +- Random word selection + Rejected because the scenario requires deterministic behavior. +- User-provided word source + Rejected because custom packs are out of scope. + +## Decision: Build viewer-specific room snapshots + +**Rationale**: Drawer-only word visibility is easiest to preserve when the +backend emits different room snapshots for different participants. This keeps +secret word enforcement in one place and avoids relying on the frontend to hide +sensitive state it should never receive. + +**Alternatives considered**: + +- Send the real word to every client and hide it in the UI + Rejected because non-drawers would still receive the secret value. +- Create a separate `/rooms/:code/secret-word` endpoint for the drawer + Rejected because it complicates polling and splits one round state across two + contracts. + +## Decision: Keep Scenario 2 on the existing start-room endpoint + +**Rationale**: Starting the room already marks the transition from lobby to +game. Extending `POST /rooms/:code/start` to initialize the round state keeps +the transition atomic and avoids a second command that would duplicate state +checks. + +**Alternatives considered**: + +- Add a second endpoint to initialize the round after start + Rejected because it adds unnecessary sequencing risk. +- Initialize round state lazily on first game fetch + Rejected because different clients could observe inconsistent first-load + behavior. diff --git a/specs/002-game-start-drawer/spec.md b/specs/002-game-start-drawer/spec.md new file mode 100644 index 0000000..a776178 --- /dev/null +++ b/specs/002-game-start-drawer/spec.md @@ -0,0 +1,186 @@ +# Feature Specification: Scenario 2 Game Start & Drawer Flow + +**Feature Branch**: `assignment` + +**Created**: 2026-05-29 + +**Status**: Draft + +**Input**: User description: "Scenario 2 game start and drawer flow with trimmed +player names, whitespace-only name rejection, deterministic drawer assignment to +the host or first player, deterministic secret word selection from the starter +list, and drawer-only secret word visibility. Keep this limited to Scenario 2 +only. Exclude drawing interactions, clear canvas, guess submission, scoring, +result state, and restart." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Start a Round with a Deterministic Drawer (Priority: P1) + +When a lobby is ready to begin, the game starts with a clearly assigned drawer +chosen by a deterministic rule so all players enter the same round state. + +**Why this priority**: Scenario 2 exists to move the room out of the lobby and +establish a stable first-round state before later gameplay interactions are +added. + +**Independent Test**: Create a room, add a second player, start the game, and +confirm the same player is identified as the drawer every time the same room +state is used. + +**Acceptance Scenarios**: + +1. **Given** a room host starts a game with enough players, **When** the round + begins, **Then** the host becomes the drawer for that round. +2. **Given** a room enters the round without a usable host designation, + **When** the drawer must still be chosen deterministically, **Then** the + first player in room order becomes the drawer. +3. **Given** the same room membership and word source are used repeatedly, + **When** the round starts, **Then** the same drawer assignment rule produces + the same drawer outcome every time. + +--- + +### User Story 2 - Validate and Preserve Player Names (Priority: P2) + +Players enter the game with names that are trimmed for display, while +whitespace-only names are rejected with clear feedback before they can +participate. + +**Why this priority**: Drawer identification and player-facing round state are +not trustworthy if player names are blank or inconsistently formatted. + +**Independent Test**: Try creating and joining a room with leading/trailing +spaces and with whitespace-only names, then confirm only trimmed valid names +enter the game. + +**Acceptance Scenarios**: + +1. **Given** a player enters a name with leading or trailing spaces, **When** + the room session is created or joined, **Then** the stored player name is + trimmed before it appears in the lobby or game. +2. **Given** a player submits a whitespace-only name, **When** the request is + made to create or join a room, **Then** the request is rejected and a clear + validation message is shown. +3. **Given** a room starts a round, **When** player information is shown, + **Then** every displayed name uses the trimmed value that was accepted + earlier. + +--- + +### User Story 3 - Reveal the Secret Word Only to the Drawer (Priority: P3) + +The game selects a deterministic secret word from the starter list and reveals +it only to the assigned drawer, while all other players stay unaware of the +actual word. + +**Why this priority**: Scenario 2 must establish the asymmetric information +needed for later guessing without yet implementing drawing or scoring. + +**Independent Test**: Start the same room in two tabs, confirm the drawer sees +the chosen word, and confirm the non-drawer does not see the actual word value. + +**Acceptance Scenarios**: + +1. **Given** a round begins, **When** the secret word is chosen, **Then** it is + selected deterministically from the existing starter word list. +2. **Given** the drawer opens the game screen, **When** the round is active, + **Then** the drawer sees the actual secret word. +3. **Given** any non-drawer player opens the same round, **When** the round is + active, **Then** the actual secret word is hidden from that player. + +### Edge Cases + +- A player enters only spaces as a name while creating a room. +- A player enters only spaces as a name while joining a room. +- A player enters a name with extra spaces around otherwise valid text. +- A room starts after the host record is missing or unusable and the drawer must + still be assigned deterministically. +- The same room is loaded by both drawer and non-drawer at the same time and + they must receive different word visibility outcomes. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST trim leading and trailing whitespace from player + names before accepting them into a room. +- **FR-002**: The system MUST reject whitespace-only player names with clear + feedback and MUST keep the player out of the room when that happens. +- **FR-003**: When a host starts a valid room, the system MUST assign the round's + drawer deterministically. +- **FR-004**: The deterministic drawer rule MUST assign the room host as the + drawer when a valid host is present. +- **FR-005**: If a valid host is not available when the round starts, the system + MUST assign the first player in room order as the drawer. +- **FR-006**: The system MUST choose the secret word deterministically from the + existing starter word list when the round begins. +- **FR-007**: The system MUST reveal the actual secret word to the drawer while + the round is active. +- **FR-008**: The system MUST hide the actual secret word from every non-drawer + player while the round is active. +- **FR-009**: All players in the same room MUST receive the same round identity, + drawer assignment, and selected word for that round. +- **FR-010**: This feature MUST exclude drawing interactions, clear canvas, + guess submission, scoring, result state, and restart behavior. + +### Key Entities *(include if feature involves data)* + +- **Round State**: The shared in-room game state created when a lobby begins a + round, including the selected drawer and the selected secret word. +- **Drawer Assignment**: The deterministic association between the current round + and the player who is allowed to see the actual secret word. +- **Player Display Name**: The trimmed player identity shown across the lobby and + round views after validation succeeds. + +## Constraints & Non-Goals *(mandatory)* + +- **CN-001**: Room updates in this scenario MUST continue using scheduled + refreshes rather than instant push-based updates. +- **CN-002**: Round and room data for this scenario MUST remain temporary for + the current runtime only and are not expected to survive a service restart. +- **CN-003**: Players MUST continue to access rooms without sign-in, account + creation, or identity verification features. +- **CN-004**: This feature MUST build on the existing starter and Scenario 1 + flow without expanding into unrelated product areas. +- **CN-005**: Drawer assignment and secret word selection MUST be deterministic + for the same underlying room state. +- **CN-006**: The scope is limited to Scenario 2 game start and drawer flow + only. +- **CN-007**: Drawing interactions, clear canvas, guess submission, scoring, + result handling, and restart flows are explicit non-goals for this + specification. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In repeated manual validation with the same room membership, the + same player is chosen as drawer every time for that room state. +- **SC-002**: In manual validation, 100% of whitespace-only name submissions are + rejected before the player enters the room. +- **SC-003**: In manual validation, accepted player names appear trimmed in both + lobby and game views every time. +- **SC-004**: In a two-tab validation session, the drawer sees the actual secret + word while the non-drawer does not see the actual word value. + +## Assumptions + +- Scenario 1 host tracking and host-only start behavior already exist and remain + the source of truth when a valid host is available. +- The starter word list remains the only word source for this scenario. +- One round is sufficient for Scenario 2; multiple rounds and rotation rules are + deferred to later scenarios. +- Non-drawer players may see generic round status messaging, but not the actual + selected word value. + +## Verification Plan *(mandatory)* + +- Validate trimmed and whitespace-only player-name behavior for both room + creation and room joining. +- Validate deterministic drawer assignment when a valid host is present. +- Validate the fallback drawer rule when host designation is unavailable. +- Validate deterministic word selection from the starter list across repeated + starts with the same room state. +- Validate drawer-only word visibility across at least two tabs in the same + room. diff --git a/specs/002-game-start-drawer/tasks.md b/specs/002-game-start-drawer/tasks.md new file mode 100644 index 0000000..c1781f6 --- /dev/null +++ b/specs/002-game-start-drawer/tasks.md @@ -0,0 +1,241 @@ +--- + +description: "Task list for Scenario 2 game start and drawer flow implementation" + +--- + +# Tasks: Scenario 2 Game Start & Drawer Flow + +**Input**: Design documents from `/specs/002-game-start-drawer/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Include automated backend and frontend API coverage plus manual two-tab validation for each user story. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Web app**: `backend/src/`, `frontend/src/` +- Paths below follow this repository's monorepo layout + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm the active Scenario 2 artifacts and validation targets before editing code + +- [X] T001 Review implementation inputs in `specs/002-game-start-drawer/spec.md`, `specs/002-game-start-drawer/plan.md`, and `specs/002-game-start-drawer/contracts/rooms-scenario2.openapi.yaml` +- [X] T002 Confirm manual and automated validation steps in `specs/002-game-start-drawer/quickstart.md`, `backend/src/api/rooms.ts`, and `frontend/src/pages/GamePage.tsx` +- [X] T003 [P] Capture shared round-state and viewer-snapshot expectations from `specs/002-game-start-drawer/data-model.md` and `specs/002-game-start-drawer/research.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared round-state, validation, and snapshot contract changes that all Scenario 2 stories depend on + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 Update shared room, round, and viewer snapshot types in `backend/src/models/game.ts` +- [X] T005 [P] Extend shared room snapshot and session response types in `frontend/src/services/api.ts` +- [X] T006 [P] Add trimmed player-name normalization and whitespace-only rejection in `backend/src/api/schemas.ts` +- [X] T007 [P] Add round-aware room session and snapshot storage in `frontend/src/state/roomStore.ts` +- [X] T008 Implement shared deterministic drawer/word helpers and viewer-specific snapshot derivation in `backend/src/services/roomStore.ts` +- [X] T009 Implement shared room response mapping for create, join, fetch, and start snapshots in `backend/src/api/rooms.ts` + +**Checkpoint**: Foundation ready - user story implementation can now begin in priority order + +--- + +## Phase 3: User Story 1 - Start a Round with a Deterministic Drawer (Priority: P1) 🎯 MVP + +**Goal**: Starting a valid room moves it into a stable playing state with a deterministic drawer assignment + +**Independent Test**: Create a room, add a second player, start the game, and confirm the same drawer is chosen every time for the same room state + +### Verification for User Story 1 ⚠️ + +- [X] T010 [P] [US1] Add deterministic drawer assignment and host-fallback coverage in `backend/src/services/roomStore.test.ts` +- [X] T011 [P] [US1] Add start-room playing snapshot coverage in `frontend/src/services/api.test.ts` +- [ ] T012 [US1] Validate deterministic host-first drawer assignment with `specs/002-game-start-drawer/quickstart.md` + +### Implementation for User Story 1 + +- [X] T013 [US1] Initialize round state with host-first drawer selection and first-player fallback in `backend/src/services/roomStore.ts` +- [X] T014 [US1] Return playing-room start snapshots and preserve start failure mapping in `backend/src/api/rooms.ts` +- [X] T015 [P] [US1] Preserve drawer-aware start responses in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` +- [X] T016 [US1] Update lobby-to-game transition and drawer announcement states in `frontend/src/pages/LobbyPage.tsx` and `frontend/src/pages/GamePage.tsx` + +**Checkpoint**: At this point, the room can leave the lobby with a deterministic drawer and a stable round identity + +--- + +## Phase 4: User Story 2 - Validate and Preserve Player Names (Priority: P2) + +**Goal**: Accepted player names are trimmed consistently, while whitespace-only names are rejected with clear feedback before room entry + +**Independent Test**: Try creating and joining with leading/trailing spaces and with whitespace-only names, then confirm only trimmed valid names enter the room + +### Verification for User Story 2 ⚠️ + +- [X] T017 [P] [US2] Add trimmed-name and whitespace-only rejection coverage in `backend/src/api/schemas.test.ts` and `backend/src/services/roomStore.test.ts` +- [X] T018 [P] [US2] Add create-room and join-room player-name request coverage in `frontend/src/services/api.test.ts` +- [ ] T019 [US2] Validate trimmed and rejected player-name flows with `specs/002-game-start-drawer/quickstart.md` + +### Implementation for User Story 2 + +- [X] T020 [US2] Enforce trimmed player-name parsing and whitespace-only rejection in `backend/src/api/schemas.ts` +- [X] T021 [US2] Persist trimmed accepted names and return clear validation failures in `backend/src/services/roomStore.ts` and `backend/src/api/rooms.ts` +- [X] T022 [P] [US2] Preserve trimmed player names in room-session responses in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` +- [X] T023 [US2] Surface create/join player-name validation feedback in `frontend/src/pages/CreateRoomPage.tsx` and `frontend/src/pages/JoinRoomPage.tsx` + +**Checkpoint**: At this point, invalid names are blocked and accepted names remain trimmed across lobby and game flows + +--- + +## Phase 5: User Story 3 - Reveal the Secret Word Only to the Drawer (Priority: P3) + +**Goal**: The round selects a deterministic secret word and reveals it only to the assigned drawer + +**Independent Test**: Start the same room in two tabs, confirm the drawer sees the chosen word, and confirm the non-drawer does not receive the actual word value + +### Verification for User Story 3 ⚠️ + +- [X] T024 [P] [US3] Add deterministic secret-word selection and drawer-only visibility coverage in `backend/src/services/roomStore.test.ts` +- [X] T025 [P] [US3] Add viewer-specific playing snapshot coverage in `frontend/src/services/api.test.ts` +- [ ] T026 [US3] Validate drawer-only secret-word visibility with `specs/002-game-start-drawer/quickstart.md` + +### Implementation for User Story 3 + +- [X] T027 [US3] Select and persist the deterministic secret word from `backend/src/seed/starterData.ts` inside `backend/src/services/roomStore.ts` +- [X] T028 [US3] Return shared round identity plus hidden-word snapshots from `backend/src/services/roomStore.ts` and `backend/src/api/rooms.ts` +- [X] T029 [P] [US3] Store viewer-specific word visibility fields in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` +- [X] T030 [US3] Render drawer identity, visible-word, and hidden-word game states in `frontend/src/pages/GamePage.tsx` and `frontend/src/styles/app.css` + +**Checkpoint**: All Scenario 2 drawer and secret-word visibility behavior should now be independently functional + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation and artifact alignment across the completed Scenario 2 slice + +- [ ] T031 [P] Refresh Scenario 2 behavior notes in `specs/002-game-start-drawer/quickstart.md` and `specs/002-game-start-drawer/contracts/rooms-scenario2.openapi.yaml` if implementation wording changed +- [X] T032 Run backend validation for `backend/src/models/game.ts`, `backend/src/services/roomStore.ts`, `backend/src/api/schemas.ts`, and `backend/src/api/rooms.ts` with `cd backend && npm test && npm run build` +- [X] T033 Run frontend validation for `frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts`, `frontend/src/pages/CreateRoomPage.tsx`, `frontend/src/pages/JoinRoomPage.tsx`, `frontend/src/pages/LobbyPage.tsx`, and `frontend/src/pages/GamePage.tsx` with `cd frontend && npm test && npm run build` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Foundational completion and defines the MVP slice +- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow User Story 1 so trimmed names are validated through the existing room-start flow +- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from User Stories 1 and 2 being in place so drawer identity and viewer-specific word visibility can be validated end to end +- **Polish (Phase 6)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: No dependency on other user stories; establishes the deterministic round-start flow +- **User Story 2 (P2)**: Uses the shared room/session contract and should preserve the start flow from User Story 1 +- **User Story 3 (P3)**: Uses the round-start contract plus the validated name flow from User Story 2 for full two-tab verification + +### Within Each User Story + +- Verification tasks MUST be completed before the story is treated as done +- Shared types before services +- Services before routes or client state integration +- Client state before page-level UI behavior +- Manual two-tab validation before moving to the next priority + +### Parallel Opportunities + +- `T003` can run in parallel with `T001-T002` +- `T005-T007` can run in parallel once `T004` is defined +- `T010-T011`, `T017-T018`, and `T024-T025` can run in parallel within their user stories +- `T015`, `T022`, and `T029` can run in parallel with their paired backend work once the backend contract is stable +- `T031` can run in parallel with final validation once implementation is complete + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch User Story 1 automated verification together: +Task: "Add deterministic drawer assignment and host-fallback coverage in backend/src/services/roomStore.test.ts" +Task: "Add start-room playing snapshot coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 1 implementation work together: +Task: "Initialize round state with host-first drawer selection and first-player fallback in backend/src/services/roomStore.ts" +Task: "Preserve drawer-aware start responses in frontend/src/services/api.ts and frontend/src/state/roomStore.ts" +``` + +## Parallel Example: User Story 2 + +```bash +# Launch User Story 2 automated verification together: +Task: "Add trimmed-name and whitespace-only rejection coverage in backend/src/api/schemas.test.ts and backend/src/services/roomStore.test.ts" +Task: "Add create-room and join-room player-name request coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 2 implementation work together: +Task: "Persist trimmed accepted names and return clear validation failures in backend/src/services/roomStore.ts and backend/src/api/rooms.ts" +Task: "Surface create/join player-name validation feedback in frontend/src/pages/CreateRoomPage.tsx and frontend/src/pages/JoinRoomPage.tsx" +``` + +## Parallel Example: User Story 3 + +```bash +# Launch User Story 3 automated verification together: +Task: "Add deterministic secret-word selection and drawer-only visibility coverage in backend/src/services/roomStore.test.ts" +Task: "Add viewer-specific playing snapshot coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 3 implementation work together: +Task: "Select and persist the deterministic secret word from backend/src/seed/starterData.ts inside backend/src/services/roomStore.ts" +Task: "Store viewer-specific word visibility fields in frontend/src/services/api.ts and frontend/src/state/roomStore.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Confirm deterministic host-first drawer assignment with two tabs +5. Demo the room start transition into the Scenario 2 game state + +### Incremental Delivery + +1. Complete Setup + Foundational -> shared round contract ready +2. Add User Story 1 -> validate deterministic drawer assignment -> MVP complete +3. Add User Story 2 -> validate trimmed names and whitespace-only rejection +4. Add User Story 3 -> validate deterministic word selection and drawer-only visibility +5. Finish with Phase 6 validation and artifact cleanup + +### Parallel Team Strategy + +1. Team completes Setup + Foundational together +2. After foundation is stable: + - Developer A: backend service and route changes for the active story + - Developer B: frontend store and page changes for the active story + - Developer C: automated verification updates in `backend/src/**/*.test.ts` and `frontend/src/services/api.test.ts` +3. Rejoin for manual two-tab validation at the end of each story + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] labels map tasks to specific user stories for traceability +- Every task includes an exact file path and can be executed without additional artifact discovery +- Suggested MVP scope: Phase 3 / User Story 1 only From 223af0416a873b265c74c62cb228d4d585b38036 Mon Sep 17 00:00:00 2001 From: Vishal S Date: Fri, 29 May 2026 18:02:19 +0530 Subject: [PATCH 4/9] Implement Scenario 2 game start and drawer flow --- backend/src/api/rooms.ts | 32 +++--- backend/src/api/schemas.test.ts | 24 ++++- backend/src/api/schemas.ts | 9 +- backend/src/models/game.ts | 12 +++ backend/src/services/roomStore.test.ts | 93 +++++++++++++++-- backend/src/services/roomStore.ts | 38 ++++++- frontend/src/pages/CreateRoomPage.tsx | 6 ++ frontend/src/pages/GamePage.tsx | 134 +++++++++++++++++++------ frontend/src/pages/JoinRoomPage.tsx | 6 ++ frontend/src/pages/LobbyPage.tsx | 8 +- frontend/src/services/api.test.ts | 24 +++-- frontend/src/services/api.ts | 6 ++ frontend/src/state/roomStore.ts | 3 +- frontend/src/styles/app.css | 41 ++++++++ 14 files changed, 368 insertions(+), 68 deletions(-) diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index a433d7e..4a4903b 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import type { Room } from "../models/game.js"; import { createRoomSchema, HttpError, @@ -9,6 +10,19 @@ import { } from "./schemas.js"; import { createRoom, getRoom, joinRoom, startRoom, toRoomSnapshot } from "../services/roomStore.js"; +function createRoomSessionResponse(room: Room, participantId: string) { + return { + participantId, + room: toRoomSnapshot(room, participantId) + }; +} + +function createRoomSnapshotResponse(room: Room, participantId?: string) { + return { + room: toRoomSnapshot(room, participantId) + }; +} + export function createRoomsRouter() { const router = Router(); @@ -17,10 +31,7 @@ export function createRoomsRouter() { const { playerName } = createRoomSchema.parse(request.body); const result = createRoom(playerName); - response.status(201).json({ - participantId: result.participantId, - room: toRoomSnapshot(result.room, result.participantId) - }); + response.status(201).json(createRoomSessionResponse(result.room, result.participantId)); } catch (error) { next(error); } @@ -36,10 +47,7 @@ export function createRoomsRouter() { throw new HttpError(404, "Room code was not found"); } - response.json({ - participantId: result.participantId, - room: toRoomSnapshot(result.room, result.participantId) - }); + response.json(createRoomSessionResponse(result.room, result.participantId)); } catch (error) { next(error); } @@ -61,9 +69,7 @@ export function createRoomsRouter() { throw new HttpError(statusCodeByReason[result.reason], result.message); } - response.json({ - room: toRoomSnapshot(result.room, participantId) - }); + response.json(createRoomSnapshotResponse(result.room, participantId)); } catch (error) { next(error); } @@ -79,9 +85,7 @@ export function createRoomsRouter() { throw new HttpError(404, "Room code was not found"); } - response.json({ - room: toRoomSnapshot(room, participantId) - }); + response.json(createRoomSnapshotResponse(room, participantId)); } catch (error) { next(error); } diff --git a/backend/src/api/schemas.test.ts b/backend/src/api/schemas.test.ts index 740efb0..b1a1df1 100644 --- a/backend/src/api/schemas.test.ts +++ b/backend/src/api/schemas.test.ts @@ -1,13 +1,31 @@ import { describe, expect, it } from "vitest"; -import { createRoomSchema, roomCodeParamsSchema, startRoomSchema } from "./schemas.js"; +import { createRoomSchema, joinRoomSchema, roomCodeParamsSchema, startRoomSchema } from "./schemas.js"; describe("schemas", () => { - it("createRoomSchema accepts a valid body with playerName", () => { - const result = createRoomSchema.parse({ playerName: "Alice" }); + it("createRoomSchema trims a valid playerName", () => { + const result = createRoomSchema.parse({ playerName: " Alice " }); expect(result.playerName).toBe("Alice"); }); + it("createRoomSchema rejects a whitespace-only playerName", () => { + expect(() => createRoomSchema.parse({ playerName: " " })).toThrow( + "Player name must include at least one non-space character" + ); + }); + + it("joinRoomSchema trims a valid playerName", () => { + const result = joinRoomSchema.parse({ playerName: " Bob " }); + + expect(result.playerName).toBe("Bob"); + }); + + it("joinRoomSchema rejects a whitespace-only playerName", () => { + expect(() => joinRoomSchema.parse({ playerName: " " })).toThrow( + "Player name must include at least one non-space character" + ); + }); + it("roomCodeParamsSchema rejects missing code", () => { expect(() => roomCodeParamsSchema.parse({})).toThrow(); }); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index d413894..2091d6c 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -8,12 +8,17 @@ const roomCodeSchema = z message: "Room code must be 4 uppercase letters or numbers" }); +const playerNameSchema = z + .string() + .trim() + .min(1, { message: "Player name must include at least one non-space character" }); + export const createRoomSchema = z.object({ - playerName: z.string().optional() + playerName: playerNameSchema.optional() }); export const joinRoomSchema = z.object({ - playerName: z.string().optional() + playerName: playerNameSchema.optional() }); export const roomCodeParamsSchema = z.object({ diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 9fe18aa..6406c9b 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,5 +1,6 @@ export type ParticipantRole = "drawer" | "guesser"; export type RoomStatus = "lobby" | "playing"; +export type WordVisibility = "visible" | "hidden"; export interface Participant { id: string; @@ -7,11 +8,18 @@ export interface Participant { joinedAt: string; } +export interface RoundState { + drawerParticipantId: string; + secretWord: string; + startedAt: string; +} + export interface Room { code: string; status: RoomStatus; hostParticipantId: string; participants: Participant[]; + round?: RoundState; createdAt: string; updatedAt: string; } @@ -24,6 +32,10 @@ export interface RoomSnapshot { viewerIsHost: boolean; canStartGame: boolean; minimumPlayersToStart: number; + drawerParticipantId?: string; + viewerIsDrawer: boolean; + wordVisibility?: WordVisibility; + secretWord?: string; } export interface RoomSessionResponse { diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index dc65eea..bcde8dd 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRoom, getRoom, joinRoom, startRoom, toRoomSnapshot } from "./roomStore.js"; +import { createRoom, getRoom, joinRoom, saveRoom, startRoom, toRoomSnapshot } from "./roomStore.js"; describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { @@ -12,12 +12,26 @@ describe("roomStore", () => { expect(result.participantId).toBeDefined(); }); + it("createRoom stores a trimmed player name", () => { + const result = createRoom(" Alice "); + + expect(result.room.participants[0].name).toBe("Alice"); + }); + it("joinRoom returns null for an unknown room code", () => { const result = joinRoom("ZZZZ", "Bob"); expect(result).toBeNull(); }); + it("joinRoom stores a trimmed player name", () => { + const host = createRoom("Alice"); + const joined = joinRoom(host.room.code, " Bob "); + + expect(joined).not.toBeNull(); + expect(joined?.room.participants.at(-1)?.name).toBe("Bob"); + }); + it("joinRoom updates only the targeted room and returns a new room session", () => { const firstRoom = createRoom("Alice"); const secondRoom = createRoom("Cara"); @@ -40,6 +54,7 @@ describe("roomStore", () => { expect(snapshot.hostParticipantId).toBe(result.participantId); expect(snapshot.viewerIsHost).toBe(true); + expect(snapshot.viewerIsDrawer).toBe(false); expect(snapshot.canStartGame).toBe(false); expect(snapshot.minimumPlayersToStart).toBe(2); }); @@ -89,11 +104,13 @@ describe("roomStore", () => { }); }); - it("startRoom allows the host to start once two players are present", () => { - const result = createRoom("Alice"); - joinRoom(result.room.code, "Bob"); + it("startRoom assigns the host as drawer and reveals the word only to the drawer", () => { + const host = createRoom("Alice"); + const joined = joinRoom(host.room.code, "Bob"); - const started = startRoom(result.room.code, result.participantId); + expect(joined).not.toBeNull(); + + const started = startRoom(host.room.code, host.participantId); expect(started.ok).toBe(true); if (!started.ok) { @@ -101,10 +118,69 @@ describe("roomStore", () => { } expect(started.room.status).toBe("playing"); + expect(started.room.round?.drawerParticipantId).toBe(host.participantId); + expect(started.room.round?.secretWord).toBeDefined(); - const snapshot = toRoomSnapshot(started.room, result.participantId); - expect(snapshot.viewerIsHost).toBe(true); - expect(snapshot.canStartGame).toBe(false); + const hostSnapshot = toRoomSnapshot(started.room, host.participantId); + const guestSnapshot = toRoomSnapshot(started.room, joined!.participantId); + + expect(hostSnapshot.viewerIsHost).toBe(true); + expect(hostSnapshot.viewerIsDrawer).toBe(true); + expect(hostSnapshot.wordVisibility).toBe("visible"); + expect(hostSnapshot.secretWord).toBe(started.room.round?.secretWord); + expect(hostSnapshot.canStartGame).toBe(false); + + expect(guestSnapshot.viewerIsHost).toBe(false); + expect(guestSnapshot.viewerIsDrawer).toBe(false); + expect(guestSnapshot.drawerParticipantId).toBe(host.participantId); + expect(guestSnapshot.wordVisibility).toBe("hidden"); + expect(guestSnapshot.secretWord).toBeUndefined(); + }); + + it("startRoom falls back to the first participant when the host record is unusable", () => { + const host = createRoom("Alice"); + const joined = joinRoom(host.room.code, "Bob"); + const third = joinRoom(host.room.code, "Cara"); + + expect(joined).not.toBeNull(); + expect(third).not.toBeNull(); + + const degradedRoom = getRoom(host.room.code); + expect(degradedRoom).not.toBeNull(); + + degradedRoom!.participants = degradedRoom!.participants.filter( + (participant) => participant.id !== host.participantId + ); + saveRoom(degradedRoom!); + + const started = startRoom(host.room.code, host.participantId); + + expect(started.ok).toBe(true); + if (!started.ok) { + return; + } + + expect(started.room.round?.drawerParticipantId).toBe(joined!.participantId); + }); + + it("startRoom selects the same secret word for the same ordered participant names", () => { + const firstRun = createRoom("Alice"); + joinRoom(firstRun.room.code, "Bob"); + + const secondRun = createRoom("Alice"); + joinRoom(secondRun.room.code, "Bob"); + + const firstStarted = startRoom(firstRun.room.code, firstRun.participantId); + const secondStarted = startRoom(secondRun.room.code, secondRun.participantId); + + expect(firstStarted.ok).toBe(true); + expect(secondStarted.ok).toBe(true); + + if (!firstStarted.ok || !secondStarted.ok) { + return; + } + + expect(firstStarted.room.round?.secretWord).toBe(secondStarted.room.round?.secretWord); }); it("starting one room does not affect other active rooms", () => { @@ -119,5 +195,6 @@ describe("roomStore", () => { const untouchedRoom = getRoom(secondRoom.room.code); expect(untouchedRoom?.status).toBe("lobby"); expect(untouchedRoom?.participants).toHaveLength(1); + expect(untouchedRoom?.round).toBeUndefined(); }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 90c650a..782dde9 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { Participant, Room, RoomSnapshot } from "../models/game.js"; +import { STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); const MINIMUM_PLAYERS_TO_START = 2; @@ -30,7 +31,8 @@ function generateUniqueCode() { } function displayName(name?: string) { - return name || "Player"; + const trimmedName = name?.trim(); + return trimmedName && trimmedName.length > 0 ? trimmedName : "Player"; } function createParticipant(name?: string): Participant { @@ -45,6 +47,26 @@ function cloneRoom(room: Room) { return structuredClone(room); } +function getDrawer(room: Room) { + return room.participants.find((participant) => participant.id === room.hostParticipantId) ?? room.participants[0]; +} + +function hashSeed(value: string) { + let hash = 0; + + for (let index = 0; index < value.length; index += 1) { + hash = (hash + value.charCodeAt(index) * (index + 1)) % 2_147_483_647; + } + + return hash; +} + +function getSecretWord(room: Room, drawer: Participant) { + const participantSeed = room.participants.map((participant) => participant.name).join("|"); + const selectionSeed = `${participantSeed}:${drawer.name}:${room.participants.length}`; + return STARTER_WORDS[hashSeed(selectionSeed) % STARTER_WORDS.length]; +} + type StartRoomResult = | { ok: true; room: Room } | { ok: false; reason: "not-found" | "forbidden" | "conflict"; message: string }; @@ -134,7 +156,14 @@ export function startRoom(code: string, participantId: string): StartRoomResult }; } + const drawer = getDrawer(room); + room.status = "playing"; + room.round = { + drawerParticipantId: drawer.id, + secretWord: getSecretWord(room, drawer), + startedAt: now() + }; room.updatedAt = now(); rooms.set(room.code, room); @@ -152,6 +181,7 @@ export function saveRoom(room: Room) { export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { const viewerIsHost = room.hostParticipantId === viewerParticipantId; + const viewerIsDrawer = room.round?.drawerParticipantId === viewerParticipantId; return { code: room.code, @@ -160,6 +190,10 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn participants: room.participants.map((participant) => ({ ...participant })), viewerIsHost, canStartGame: canStartRoom(room, viewerParticipantId), - minimumPlayersToStart: MINIMUM_PLAYERS_TO_START + minimumPlayersToStart: MINIMUM_PLAYERS_TO_START, + drawerParticipantId: room.round?.drawerParticipantId, + viewerIsDrawer, + wordVisibility: room.round ? (viewerIsDrawer ? "visible" : "hidden") : undefined, + secretWord: viewerIsDrawer ? room.round?.secretWord : undefined }; } diff --git a/frontend/src/pages/CreateRoomPage.tsx b/frontend/src/pages/CreateRoomPage.tsx index fa31fee..715960d 100644 --- a/frontend/src/pages/CreateRoomPage.tsx +++ b/frontend/src/pages/CreateRoomPage.tsx @@ -11,8 +11,14 @@ export function CreateRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const normalizedPlayerName = playerName.trim(); try { + if (!normalizedPlayerName) { + setError("Enter a player name to continue."); + return; + } + setError(null); await roomStore.createRoom(playerName); navigate("/lobby"); diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 5ddddb8..dc98251 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -1,15 +1,14 @@ -import { useEffect } from "react"; +import { useEffect, useState } 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 roomStore = useRoomStore(); const { room, participantId } = useRoomState(); + const [refreshError, setRefreshError] = useState(null); useEffect(() => { if (!room) { @@ -22,37 +21,67 @@ export function GamePage() { } }, [navigate, room]); + useEffect(() => { + if (!room || room.status !== "playing") { + return undefined; + } + + const intervalId = window.setInterval(() => { + void roomStore + .fetchRoom() + .then(() => { + setRefreshError(null); + }) + .catch((caughtError) => { + setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); + }); + }, 2000); + + return () => { + window.clearInterval(intervalId); + }; + }, [room?.code, room?.status, roomStore]); + + async function handleRefresh() { + try { + setRefreshError(null); + await roomStore.fetchRoom(); + } catch (caughtError) { + setRefreshError(caughtError instanceof Error ? caughtError.message : "Unable to refresh room"); + } + } + if (!room) { return null; } const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; + const drawer = + room.participants.find((participant) => participant.id === room.drawerParticipantId) ?? null; + const drawerStatus = room.viewerIsDrawer ? "You are the drawer for this round." : `${drawer?.name ?? "Another player"} is drawing this round.`; + const wordStatus = room.wordVisibility === "visible" ? "Secret word visible" : "Secret word hidden"; + const wordValue = room.wordVisibility === "visible" ? room.secretWord ?? "Unavailable" : "Only the drawer can see the word right now."; return (
    Round 1 -

    Guess the Word!

    +

    Round Started

    - - -
    - -
    - Waiting for drawer... -
    +
    - - - +
    + +

    + {wordStatus} +

    +
    + + {room.wordVisibility === "visible" ? "Your word" : "Word visibility"} + + {wordValue} +
    - -
    -
    - + +
    + Drawing interactions start in the next scenario. +
    +
    +
    + +
    ); diff --git a/frontend/src/pages/JoinRoomPage.tsx b/frontend/src/pages/JoinRoomPage.tsx index 4b2132a..f1f347e 100644 --- a/frontend/src/pages/JoinRoomPage.tsx +++ b/frontend/src/pages/JoinRoomPage.tsx @@ -12,9 +12,15 @@ export function JoinRoomPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + const normalizedPlayerName = playerName.trim(); const normalizedRoomCode = roomCode.trim().toUpperCase(); try { + if (!normalizedPlayerName) { + setError("Enter a player name to continue."); + return; + } + if (!normalizedRoomCode) { setError("Enter a room code to join a lobby."); return; diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 9830f26..c563e82 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -70,9 +70,9 @@ export function LobbyPage() { const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; const startMessage = room.viewerIsHost ? room.canStartGame - ? "You can start the game as soon as everyone is ready." - : `You need at least ${room.minimumPlayersToStart} players to start the game.` - : "Only the host can start the game."; + ? "You can start the round as soon as everyone is ready." + : `You need at least ${room.minimumPlayersToStart} players to start the round.` + : "Only the host can start the round."; const statusMessage = startError ?? error ?? refreshError ?? startMessage; return ( @@ -132,7 +132,7 @@ export function LobbyPage() { disabled={!room.canStartGame || isLoading} onClick={handleStartGame} > - {room.viewerIsHost ? "Start Game" : "Host Can Start"} + {room.viewerIsHost ? "Start Round" : "Host Can Start"} diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 1ab74de..b4bb0be 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -19,7 +19,8 @@ describe("api service", () => { participants: [], viewerIsHost: true, canStartGame: false, - minimumPlayersToStart: 2 + minimumPlayersToStart: 2, + viewerIsDrawer: false }, }), }; @@ -48,7 +49,8 @@ describe("api service", () => { participants: [], viewerIsHost: true, canStartGame: false, - minimumPlayersToStart: 2 + minimumPlayersToStart: 2, + viewerIsDrawer: false }, }), }; @@ -75,7 +77,8 @@ describe("api service", () => { participants: [], viewerIsHost: false, canStartGame: false, - minimumPlayersToStart: 2 + minimumPlayersToStart: 2, + viewerIsDrawer: false } }), }; @@ -104,7 +107,11 @@ describe("api service", () => { participants: [], viewerIsHost: true, canStartGame: false, - minimumPlayersToStart: 2 + minimumPlayersToStart: 2, + drawerParticipantId: "p1", + viewerIsDrawer: true, + wordVisibility: "visible", + secretWord: "rocket" } }), }; @@ -121,7 +128,7 @@ describe("api service", () => { ); }); - it("fetchRoom supports polling a playing room snapshot", async () => { + it("fetchRoom supports polling a viewer-specific playing room snapshot", async () => { const mockResponse = { ok: true, json: () => @@ -133,7 +140,10 @@ describe("api service", () => { participants: [], viewerIsHost: false, canStartGame: false, - minimumPlayersToStart: 2 + minimumPlayersToStart: 2, + drawerParticipantId: "p1", + viewerIsDrawer: false, + wordVisibility: "hidden" } }), }; @@ -142,5 +152,7 @@ describe("api service", () => { const response = await api.fetchRoom("ABCD", "p2"); expect(response.room.status).toBe("playing"); + expect(response.room.viewerIsDrawer).toBe(false); + expect(response.room.secretWord).toBeUndefined(); }); }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index dcd9a6e..52579d0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -4,6 +4,8 @@ export interface Participant { joinedAt: string; } +export type WordVisibility = "visible" | "hidden"; + export interface RoomSnapshot { code: string; status: "lobby" | "playing"; @@ -12,6 +14,10 @@ export interface RoomSnapshot { viewerIsHost: boolean; canStartGame: boolean; minimumPlayersToStart: number; + drawerParticipantId?: string; + viewerIsDrawer: boolean; + wordVisibility?: WordVisibility; + secretWord?: string; } export interface RoomSessionResponse { diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index be7e2df..16bef61 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -107,7 +107,8 @@ class RoomStore { return null; } - const response = await api.fetchRoom(this.state.room.code, this.state.participantId ?? undefined); + const activeParticipantId = this.state.participantId ?? undefined; + const response = await api.fetchRoom(this.state.room.code, activeParticipantId); this.setRoomSnapshot(response.room); return response.room; } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 6baae33..40a21aa 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -426,6 +426,16 @@ input { color: #b45309; } +.status-line--success { + background: #dcfce7; + color: #166534; +} + +.status-line--muted { + background: #e5e7eb; + color: #374151; +} + .placeholder-block { display: grid; gap: 16px; @@ -553,6 +563,37 @@ input { font-weight: 500; } +.word-panel { + display: grid; + gap: 10px; + padding: 20px; + border: 1px solid var(--line); + border-radius: 12px; +} + +.word-panel--visible { + background: #eff6ff; + border-color: #bfdbfe; +} + +.word-panel--hidden { + background: #f9fafb; +} + +.word-panel__label { + color: var(--ink-soft); + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.word-panel__value { + color: var(--ink); + font-size: clamp(1.25rem, 3vw, 2rem); + line-height: 1.2; +} + @media (max-width: 720px) { .app-shell { padding: 24px 16px; From 8f0489cd08e9b346657daa2a3bdc9f688a0dd2ba Mon Sep 17 00:00:00 2001 From: Vishal S Date: Sat, 30 May 2026 13:59:14 +0530 Subject: [PATCH 5/9] Add Scenario 3 spec artifacts --- .specify/feature.json | 2 +- .../checklists/requirements.md | 36 ++ .../contracts/rooms-scenario3.openapi.yaml | 417 ++++++++++++++++++ specs/003-drawing-guess-scoring/data-model.md | 138 ++++++ specs/003-drawing-guess-scoring/plan.md | 318 +++++++++++++ specs/003-drawing-guess-scoring/quickstart.md | 57 +++ specs/003-drawing-guess-scoring/research.md | 100 +++++ specs/003-drawing-guess-scoring/spec.md | 206 +++++++++ 8 files changed, 1273 insertions(+), 1 deletion(-) create mode 100644 specs/003-drawing-guess-scoring/checklists/requirements.md create mode 100644 specs/003-drawing-guess-scoring/contracts/rooms-scenario3.openapi.yaml create mode 100644 specs/003-drawing-guess-scoring/data-model.md create mode 100644 specs/003-drawing-guess-scoring/plan.md create mode 100644 specs/003-drawing-guess-scoring/quickstart.md create mode 100644 specs/003-drawing-guess-scoring/research.md create mode 100644 specs/003-drawing-guess-scoring/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index 58544f6..dcb05c9 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/002-game-start-drawer" + "feature_directory": "specs/003-drawing-guess-scoring" } diff --git a/specs/003-drawing-guess-scoring/checklists/requirements.md b/specs/003-drawing-guess-scoring/checklists/requirements.md new file mode 100644 index 0000000..7d7c021 --- /dev/null +++ b/specs/003-drawing-guess-scoring/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Scenario 3 Gameplay Interaction + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-30 +**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 + +- [x] Specification is limited to Scenario 3 gameplay interaction behavior. +- [x] Exclusions for result state, restart, multiple rounds, timers, bonuses, + and Scenario 4 behavior are explicitly documented. diff --git a/specs/003-drawing-guess-scoring/contracts/rooms-scenario3.openapi.yaml b/specs/003-drawing-guess-scoring/contracts/rooms-scenario3.openapi.yaml new file mode 100644 index 0000000..567b2b1 --- /dev/null +++ b/specs/003-drawing-guess-scoring/contracts/rooms-scenario3.openapi.yaml @@ -0,0 +1,417 @@ +openapi: 3.1.0 +info: + title: Scribble Scenario 3 Rooms API + version: 1.0.0 + description: > + Contract for Scenario 3 shared drawing, clear-canvas actions, trimmed guess + submission, synced guess history, and deterministic 100-or-0 scoring. + Result state, restart, multiple rounds, timers, bonuses, and Scenario 4 + behavior are excluded. +paths: + /rooms: + post: + summary: Create a room with a validated player name + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + playerName: + type: string + responses: + "201": + description: Room created + content: + application/json: + schema: + $ref: "#/components/schemas/RoomSessionResponse" + "400": + $ref: "#/components/responses/BadRequest" + /rooms/{code}/join: + post: + summary: Join an existing room with a validated player name + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + playerName: + type: string + responses: + "200": + description: Room joined + content: + application/json: + schema: + $ref: "#/components/schemas/RoomSessionResponse" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + /rooms/{code}: + get: + summary: Fetch the latest viewer-specific gameplay snapshot + parameters: + - $ref: "#/components/parameters/RoomCode" + - name: participantId + in: query + required: false + schema: + type: string + responses: + "200": + description: Room snapshot loaded + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + /rooms/{code}/start: + post: + summary: Start the room and initialize the first deterministic round + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [participantId] + properties: + participantId: + type: string + responses: + "200": + description: Room entered playing state with initialized round data + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /rooms/{code}/drawing: + post: + summary: Append one drawer-authored stroke to the shared canvas + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [participantId, points] + properties: + participantId: + type: string + points: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/DrawingPoint" + responses: + "200": + description: Stroke accepted and snapshot updated + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /rooms/{code}/drawing/clear: + post: + summary: Clear the shared canvas for the active room + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [participantId] + properties: + participantId: + type: string + responses: + "200": + description: Canvas cleared and snapshot updated + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /rooms/{code}/guesses: + post: + summary: Submit a trimmed guess for the active round + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [participantId, guess] + properties: + participantId: + type: string + guess: + type: string + responses: + "200": + description: Guess accepted and scored + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" +components: + parameters: + RoomCode: + name: code + in: path + required: true + schema: + type: string + pattern: "^[A-Z0-9]{4}$" + responses: + BadRequest: + description: Invalid request payload or malformed room data + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Forbidden: + description: Viewer is not allowed to perform this room action + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: Room not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Conflict: + description: Room state does not allow the requested action + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + schemas: + Participant: + type: object + additionalProperties: false + required: [id, name, joinedAt, score] + properties: + id: + type: string + name: + type: string + joinedAt: + type: string + format: date-time + score: + type: integer + minimum: 0 + DrawingPoint: + type: object + additionalProperties: false + required: [x, y] + properties: + x: + type: number + y: + type: number + DrawingStroke: + type: object + additionalProperties: false + required: [id, points, drawnByParticipantId, createdAt] + properties: + id: + type: string + points: + type: array + items: + $ref: "#/components/schemas/DrawingPoint" + drawnByParticipantId: + type: string + createdAt: + type: string + format: date-time + CanvasState: + type: object + additionalProperties: false + required: [strokes] + properties: + strokes: + type: array + items: + $ref: "#/components/schemas/DrawingStroke" + clearedAt: + type: string + format: date-time + GuessHistoryEntry: + type: object + additionalProperties: false + required: + - id + - participantId + - participantName + - guess + - isCorrect + - scoreAwarded + - submittedAt + properties: + id: + type: string + participantId: + type: string + participantName: + type: string + guess: + type: string + isCorrect: + type: boolean + scoreAwarded: + type: integer + enum: [0, 100] + submittedAt: + type: string + format: date-time + RoomSnapshot: + type: object + additionalProperties: false + required: + - code + - status + - hostParticipantId + - participants + - viewerIsHost + - canStartGame + - minimumPlayersToStart + - viewerIsDrawer + properties: + code: + type: string + pattern: "^[A-Z0-9]{4}$" + status: + type: string + enum: [lobby, playing] + hostParticipantId: + type: string + participants: + type: array + items: + $ref: "#/components/schemas/Participant" + viewerIsHost: + type: boolean + canStartGame: + type: boolean + minimumPlayersToStart: + type: integer + enum: [2] + drawerParticipantId: + type: string + viewerIsDrawer: + type: boolean + viewerCanDraw: + type: boolean + viewerCanGuess: + type: boolean + wordVisibility: + type: string + enum: [visible, hidden] + secretWord: + type: string + canvas: + $ref: "#/components/schemas/CanvasState" + guessHistory: + type: array + items: + $ref: "#/components/schemas/GuessHistoryEntry" + RoomSessionResponse: + type: object + additionalProperties: false + required: [participantId, room] + properties: + participantId: + type: string + room: + $ref: "#/components/schemas/RoomSnapshot" + ErrorResponse: + type: object + additionalProperties: false + required: [message] + properties: + message: + type: string diff --git a/specs/003-drawing-guess-scoring/data-model.md b/specs/003-drawing-guess-scoring/data-model.md new file mode 100644 index 0000000..6598673 --- /dev/null +++ b/specs/003-drawing-guess-scoring/data-model.md @@ -0,0 +1,138 @@ +# Data Model: Scenario 3 Gameplay Interaction + +## Room + +**Purpose**: Represents one isolated multiplayer room that can host one active +Scenario 3 round with shared drawing and guess history. + +**Fields**: + +- `code`: unique 4-character room identifier +- `status`: `"lobby" | "playing"` +- `hostParticipantId`: participant ID for the room host +- `participants`: ordered room members with running score totals available in + gameplay snapshots +- `round`: current active round state, present when the room is playing +- `createdAt`: room creation timestamp +- `updatedAt`: last room mutation timestamp + +**Validation Rules**: + +- `hostParticipantId` should reference a participant while host state is healthy +- room mutations stay isolated by room code +- `round` remains the single source of truth for canvas, guess history, and + secret-word evaluation + +## Participant + +**Purpose**: Represents a room member visible in lobby and gameplay views. + +**Fields**: + +- `id`: unique participant identifier +- `name`: accepted trimmed display name +- `joinedAt`: timestamp when the participant entered the room +- `score`: running total from accepted guesses during the active round + +**Validation Rules**: + +- accepted names are already trimmed before storage +- `score` starts at `0` and increases only through accepted correct guesses +- the drawer's score is unaffected by drawing or clearing actions + +## Round State + +**Purpose**: Represents the active Scenario 2 and Scenario 3 gameplay state for +one room. + +**Fields**: + +- `drawerParticipantId`: participant assigned as drawer +- `secretWord`: deterministic word selected in Scenario 2 +- `startedAt`: timestamp for when the round began +- `canvas`: shared drawing state for the round +- `guessHistory`: ordered list of accepted guess entries + +**Validation Rules**: + +- `drawerParticipantId` must reference a participant in the room +- `secretWord` remains the active comparison target for guess evaluation +- `guessHistory` is append-only within this scenario + +## Canvas State + +**Purpose**: Represents the shared drawing surface visible to all players in the +same active room. + +**Fields**: + +- `strokes`: ordered list of drawing strokes +- `clearedAt`: optional timestamp for the most recent clear action + +**Validation Rules**: + +- only the assigned drawer may add or clear strokes +- clearing the canvas replaces `strokes` with an empty list +- canvas state is shared by all viewers in the same room + +## Drawing Stroke + +**Purpose**: Represents one continuous drawing action added by the drawer. + +**Fields**: + +- `id`: stable stroke identifier +- `points`: ordered list of normalized points +- `drawnByParticipantId`: drawer participant ID +- `createdAt`: timestamp for when the stroke was submitted + +**Validation Rules**: + +- `points` must contain at least one valid coordinate +- all coordinates are normalized to a stable range such as `0..1` +- `drawnByParticipantId` must match the current drawer + +## Guess History Entry + +**Purpose**: Represents one accepted guess and its deterministic score outcome. + +**Fields**: + +- `id`: stable guess identifier +- `participantId`: player who submitted the guess +- `participantName`: trimmed display name at submission time +- `guess`: trimmed accepted guess text +- `normalizedGuess`: comparison form used for case-insensitive matching +- `isCorrect`: whether the guess matched the active secret word +- `scoreAwarded`: `100` or `0` +- `submittedAt`: timestamp for when the guess was accepted + +**Validation Rules**: + +- whitespace-only guesses are rejected before entry creation +- drawer-submitted guesses are rejected and never create entries +- accepted entries are appended in submission order only +- `scoreAwarded` is `100` when `isCorrect` is true and `0` otherwise + +## Viewer Gameplay Snapshot + +**Purpose**: Represents the room snapshot returned to a specific participant +during Scenario 3 gameplay. + +**Fields**: + +- shared room fields from earlier scenarios +- `canvas`: shared current drawing state +- `guessHistory`: shared ordered guess history +- `viewerCanDraw`: whether the current participant may draw or clear +- `viewerCanGuess`: whether the current participant may submit guesses +- `secretWord`: actual word only when the viewer remains the drawer +- `wordVisibility`: viewer-facing word state from Scenario 2 + +**Derived Rules**: + +- `viewerCanDraw = participantId === drawerParticipantId` +- `viewerCanGuess = participantId !== drawerParticipantId` while the room is + actively playing +- all viewers share the same canvas, history, and scores +- only the drawer receives the actual `secretWord` diff --git a/specs/003-drawing-guess-scoring/plan.md b/specs/003-drawing-guess-scoring/plan.md new file mode 100644 index 0000000..c9b6843 --- /dev/null +++ b/specs/003-drawing-guess-scoring/plan.md @@ -0,0 +1,318 @@ +# Implementation Plan: Scenario 3 Gameplay Interaction + +**Branch**: `assignment` | **Date**: 2026-05-30 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/003-drawing-guess-scoring/spec.md` + +**Note**: This plan is limited to Scenario 3 gameplay interaction behavior. + +## Summary + +Extend the existing Scenario 2 active-round flow so the drawer can produce and +clear shared drawing data, guessers can submit trimmed guesses with blank-input +rejection, accepted guesses are evaluated case-insensitively against the active +secret word, and score outcomes are assigned deterministically as 100 for +correct guesses and 0 for incorrect guesses. The backend remains authoritative +for drawing permissions, guess validation, history ordering, and scoring, while +the frontend game view consumes richer room snapshots that show canvas state, +guess history, score totals, and viewer-specific drawing or guessing controls. + +## Technical Context + +**Language/Version**: TypeScript 5.x on Node.js 18+ (backend) and React 18 +with Vite (frontend) + +**Primary Dependencies**: Express, Zod, React, React Router, Vite, Vitest + +**Storage**: In-memory room and game state only + +**Testing**: `cd backend && npm test`, `cd frontend && npm test`, plus manual +two-tab browser validation for multiplayer flows + +**Target Platform**: Node.js backend and modern desktop browser clients + +**Project Type**: Monorepo web application (`backend/` + `frontend/`) + +**Performance Goals**: Canvas, guess-history, and score changes should appear to +other tabs within one polling interval after each action, with a default target +of about 2 seconds for cross-tab convergence + +**Constraints**: HTTP polling only; no WebSockets; no database/persistence; no +authentication/session layer; keep room memory footprint minimal; preserve the +starter architecture; keep scope strictly to Scenario 3 + +**Scale/Scope**: Small multiplayer rooms running a single active round with +shared drawing, guess history, and deterministic scoring validated in local +multi-tab testing + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] The change is scoped to a concrete scenario/user story and preserves the + README checkpoint order unless a deviation is justified. +- [x] All changed backend boundaries have explicit TypeScript types and Zod + validation for request/response payloads. +- [x] Multiplayer synchronization remains HTTP polling against in-memory state + only; no forbidden persistence or realtime transport is introduced. +- [x] The plan preserves the existing monorepo structure and documents any new + dependency or abstraction that materially expands the surface area. +- [x] Verification covers every touched surface, including affected builds, + affected tests, and manual two-tab validation for multiplayer/UI flows. + +**Post-Design Re-Check**: Pass. The design keeps Scenario 1 and Scenario 2 +contracts intact, extends the same room-scoped in-memory state with drawing and +guess history only, continues using polling-driven synchronization, and limits +new gameplay behavior to canvas mutation, guess evaluation, and deterministic +scoring without crossing into result or restart flows. + +## Project Structure + +### Documentation (this feature) + +```text +specs/003-drawing-guess-scoring/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── rooms-scenario3.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +backend/ +└── src/ + ├── api/ + │ ├── rooms.ts + │ ├── schemas.ts + │ └── schemas.test.ts + ├── models/ + │ └── game.ts + └── services/ + ├── roomStore.ts + └── roomStore.test.ts + +frontend/ +└── src/ + ├── components/ + │ ├── DrawingSurface.tsx + │ └── GuessForm.tsx + ├── pages/ + │ └── GamePage.tsx + ├── services/ + │ ├── api.ts + │ └── api.test.ts + ├── state/ + │ └── roomStore.ts + └── styles/ + └── app.css +``` + +**Structure Decision**: Keep backend gameplay rules, drawing state, guess +evaluation, and scoring in `backend/src/services`, request validation in +`backend/src/api`, and client drawing/guess UI state orchestration in +`frontend/src/state`, `frontend/src/pages`, and one focused reusable drawing +component under `frontend/src/components`. No new package, realtime layer, or +client state library is required. + +## Phase 0: Research Outcomes + +- Store drawing state directly inside the active round on the room model so the + canvas, guess history, and scores remain part of one atomic room snapshot. +- Represent the drawing surface as an ordered collection of normalized strokes + so the backend can stay UI-agnostic while the frontend renders the same data + in different screen sizes. +- Keep drawing, clear-canvas, and guess submission on the existing rooms router + as room-scoped actions that return the latest viewer-specific room snapshot. +- Trim and validate guesses at the backend boundary, reject whitespace-only + guesses, and reject drawer guess submissions so the player who already knows + the word cannot generate guess history noise. +- Evaluate accepted guesses with case-insensitive matching against the active + secret word and assign the score outcome at submission time so repeated room + fetches always return stable history and totals. +- Extend viewer-specific room snapshots to include canvas state, guess history, + viewer drawing/guessing permissions, and score totals while preserving + drawer-only secret-word visibility from Scenario 2. + +See [research.md](./research.md) for decisions, rationale, and alternatives. + +## Phase 1: Design + +### Backend Model Changes + +- Update [`backend/src/models/game.ts`](../../../backend/src/models/game.ts) to + extend active round state with: + - shared canvas state + - accepted guess history + - per-participant score totals +- Extend `Participant` or participant-adjacent room data to expose running + score totals in active gameplay snapshots. +- Extend `RoomSnapshot` with gameplay-specific shared and viewer-specific fields + such as: + - canvas state + - guess history entries + - viewer can-draw / can-guess flags + - shared score totals +- Preserve Scenario 2 drawer assignment and secret-word visibility rules. + +### Drawing-State Representation + +- Represent canvas data as ordered strokes rather than raw bitmap payloads. +- Each stroke should carry: + - a stable stroke identifier + - ordered normalized points + - drawer participant ID + - creation timestamp +- Canvas clear should be modeled as replacing the stored stroke list with an + empty list and updating room timestamps, not as a separate result state. +- Normalized coordinate storage keeps the backend independent from exact client + canvas pixel size while allowing consistent redraw after polling refreshes. + +### Backend Validation and Request Changes + +- Update [`backend/src/api/schemas.ts`](../../../backend/src/api/schemas.ts) to + add request schemas for: + - drawing-stroke submission + - clear-canvas action + - guess submission +- Continue using Zod to trim guess text and reject empty or whitespace-only + guesses before they reach the room store. +- Keep room-code and participant ID validation consistent with earlier + scenarios. + +### Backend Service Changes + +- Update [`backend/src/services/roomStore.ts`](../../../backend/src/services/roomStore.ts) + to add deterministic helpers for: + - adding a stroke to the active canvas + - clearing the active canvas + - normalizing and evaluating guesses + - awarding score outcomes and updating totals + - constructing gameplay-aware viewer snapshots +- Add backend enforcement so: + - only the assigned drawer can mutate or clear the canvas + - only non-drawers can submit guesses + - rejected guesses never enter shared history +- Keep all Scenario 3 state transitions room-scoped and isolated so actions in + one room never affect another active room. + +### Guess-History and Scoring Flow + +1. A room is already in the Scenario 2 playing state with an assigned drawer and + secret word. +2. The drawer submits one or more drawing strokes; the backend appends them to + the active canvas state. +3. The drawer may clear the canvas; the backend replaces the stored strokes with + an empty list. +4. A non-drawer submits a guess. +5. Backend trims the guess and rejects it if the result is empty. +6. Backend normalizes the trimmed guess and compares it to the secret word + without requiring exact casing. +7. Backend appends an accepted guess-history entry in submission order. +8. Backend assigns `100` for a correct guess and `0` for an incorrect guess, + updating the participant's running score total. +9. Backend returns the latest viewer-specific room snapshot to the submitter, + while other players observe the same shared history and totals through + polling. + +### Viewer-Specific Gameplay Behavior + +- `RoomSnapshot` remains the only room/game payload returned to the frontend. +- Shared fields visible to all viewers: + - room code and status + - participants and score totals + - drawer identity + - canvas state + - guess history and score outcomes +- Viewer-specific gameplay fields: + - drawer keeps secret-word visibility from Scenario 2 + - `viewerCanDraw` is true only for the drawer in an active round + - `viewerCanGuess` is true only for non-drawers in an active round +- This same enriched snapshot shape should be used for fetch, start, drawing, + clear, and guess responses so the frontend consumes one consistent contract. + +### Frontend Room Store and Page Changes + +- Extend [`frontend/src/services/api.ts`](../../../frontend/src/services/api.ts) + room snapshot types to include canvas state, guess history, score totals, and + drawing / guessing permissions. +- Add frontend API calls for: + - drawing-stroke submission + - clear-canvas action + - guess submission +- Keep [`frontend/src/state/roomStore.ts`](../../../frontend/src/state/roomStore.ts) + as the single source of room and game state, adding action methods for the new + room-scoped gameplay mutations. +- Add a focused drawing component such as + [`frontend/src/components/DrawingSurface.tsx`](../../../frontend/src/components/DrawingSurface.tsx) + to capture pointer input for the drawer and render shared strokes for all + viewers. +- Update [`frontend/src/components/GuessForm.tsx`](../../../frontend/src/components/GuessForm.tsx) + so it submits trimmed guesses and surfaces empty-guess errors cleanly. +- Update [`frontend/src/pages/GamePage.tsx`](../../../frontend/src/pages/GamePage.tsx) + to: + - render the shared drawing surface + - show clear-canvas control only to the drawer + - show guess submission only to eligible guessers + - show synced guess history and score outcomes + - avoid introducing result-state or restart UI +- Update [`frontend/src/styles/app.css`](../../../frontend/src/styles/app.css) + only as needed for canvas layout, guess history, scoring, and role-specific + controls. + +### File-Level Change Plan + +- `backend/src/models/game.ts`: add canvas, guess-history, viewer-permission, + and score-related types +- `backend/src/services/roomStore.ts`: implement drawing mutation, clear-canvas, + guess evaluation, score assignment, and enriched room snapshots +- `backend/src/services/roomStore.test.ts`: cover drawer-only drawing, + clear-canvas behavior, guess trimming, empty-guess rejection, case-insensitive + matching, room isolation, and deterministic score outcomes +- `backend/src/api/schemas.ts`: add request schemas for drawing, clearing, and + guess submission +- `backend/src/api/schemas.test.ts`: cover drawing/guess request validation and + trimmed empty-guess rejection +- `backend/src/api/rooms.ts`: add new room-scoped gameplay routes and return + enriched viewer-specific snapshots +- `frontend/src/services/api.ts`: extend room snapshot types and add gameplay + action methods +- `frontend/src/services/api.test.ts`: cover the new drawing, clear, and guess + request contracts plus updated playing-room snapshots +- `frontend/src/state/roomStore.ts`: add canvas, clear, and guess action wiring +- `frontend/src/components/DrawingSurface.tsx`: render shared strokes and emit + drawer-only pointer input +- `frontend/src/components/GuessForm.tsx`: submit guesses and present validation + feedback +- `frontend/src/pages/GamePage.tsx`: integrate drawing controls, guess flow, + score totals, and guess history +- `frontend/src/styles/app.css`: add gameplay interaction, history, and score + presentation states + +### Validation Strategy + +- Automated backend validation: + - schema tests for drawing, clear-canvas, and guess request validation + - room-store tests for drawer-only drawing enforcement + - room-store tests for empty-guess rejection and case-insensitive matching + - room-store tests for deterministic 100-or-0 score assignment and room + isolation +- Automated frontend validation: + - API service tests for drawing, clear, and guess actions + - API service tests for enriched gameplay snapshot shapes +- Manual two-tab validation: + - start a room and confirm only the drawer can draw + - clear the canvas from the drawer tab and confirm both tabs see the reset + - submit blank, incorrect, and correct guesses from a non-drawer tab + - confirm guess history stays in sync within the polling interval + - confirm correct guesses add 100 points, incorrect guesses add 0, and both + tabs show the same totals + +## Complexity Tracking + +No constitution exceptions or additional architectural complexity are required +for this feature. diff --git a/specs/003-drawing-guess-scoring/quickstart.md b/specs/003-drawing-guess-scoring/quickstart.md new file mode 100644 index 0000000..bd86b78 --- /dev/null +++ b/specs/003-drawing-guess-scoring/quickstart.md @@ -0,0 +1,57 @@ +# Quickstart: Scenario 3 Gameplay Interaction + +## Prerequisites + +- Node.js 18+ and npm 9+ +- At least two browser tabs +- Scenario 1 room flow and Scenario 2 round-start behavior working locally + +## Run the apps + +```bash +cd backend +npm install +npm run dev +``` + +```bash +cd frontend +npm install +npm run dev +``` + +## Validate Scenario 3 + +1. Open the frontend in Tab A and create a room. +2. Join the same room from Tab B. +3. Start the game from Tab A and confirm Tab A is the drawer. +4. Draw on the shared surface in Tab A and confirm Tab B sees the same drawing + within one polling cycle. +5. Attempt to draw from Tab B and confirm the shared canvas does not change. +6. Clear the canvas from Tab A and confirm both tabs return to an empty canvas. +7. Submit a whitespace-only guess from Tab B and confirm it is rejected with a + clear validation message. +8. Submit an incorrect trimmed guess from Tab B and confirm it appears once in + shared history with `0` points. +9. Submit the correct word from Tab B using different letter casing than the + drawer sees in Tab A. +10. Confirm the guess is treated as correct, appears in shared history, and + awards `100` points. +11. Refresh or wait for polling in both tabs and confirm canvas state, guess + history, and score totals stay aligned. +12. Repeat the same flow in a second room and confirm drawing, history, and + scores remain isolated by room. + +## Automated checks + +```bash +cd backend +npm test +npm run build +``` + +```bash +cd frontend +npm test +npm run build +``` diff --git a/specs/003-drawing-guess-scoring/research.md b/specs/003-drawing-guess-scoring/research.md new file mode 100644 index 0000000..558b49b --- /dev/null +++ b/specs/003-drawing-guess-scoring/research.md @@ -0,0 +1,100 @@ +# Research: Scenario 3 Gameplay Interaction + +## Decision: Store canvas and guess history inside the active round state + +**Rationale**: Scenario 3 extends the same single active round introduced in +Scenario 2. Keeping drawing data, guesses, and score totals on the room's round +state preserves atomic snapshots, avoids coordination between separate stores, +and keeps fetch responses stable across polling refreshes. + +**Alternatives considered**: + +- Separate drawing store keyed by room code + Rejected because it splits one room lifecycle across multiple in-memory + structures without adding value. +- Reconstruct guess history from transient frontend state + Rejected because multiplayer synchronization must remain backend-authoritative. + +## Decision: Represent drawing as normalized ordered strokes + +**Rationale**: Ordered strokes made of normalized points are deterministic, +compact enough for small in-memory rooms, and independent from exact browser +canvas dimensions. The frontend can redraw the same stroke data at different +viewport sizes without the backend knowing about pixels. + +**Alternatives considered**: + +- Store raw bitmap image blobs + Rejected because payload size grows quickly and ties the backend to rendering + details. +- Store arbitrary free-form drawing commands + Rejected because they are harder to validate and less predictable than points. + +## Decision: Keep gameplay mutations on the existing rooms router + +**Rationale**: Drawing, clearing, and guessing all mutate the active room state. +Adding room-scoped actions under the existing rooms API keeps authorization, +room lookup, and snapshot return behavior consistent with create/join/start. + +**Alternatives considered**: + +- Add a separate gameplay router + Rejected because the feature does not justify a new top-level API surface. +- Send drawing updates only through fetch polling without explicit mutation + endpoints + Rejected because the client still needs explicit commands to change room state. + +## Decision: Validate and normalize guesses at the API boundary + +**Rationale**: Scenario 3 requires trimming guesses and rejecting blank input. +Enforcing those rules before the room store runs keeps invalid guesses out of +history and ensures gameplay logic always receives a normalized value. + +**Alternatives considered**: + +- Validate guesses only in the frontend form + Rejected because backend correctness cannot depend on client behavior. +- Accept blank guesses and score them as incorrect + Rejected because the specification requires empty-guess rejection. + +## Decision: Reject drawer guess submissions and score accepted guesses at write time + +**Rationale**: The drawer already knows the secret word and should not create +guess history noise or self-score. Scoring accepted guesses at submission time +creates stable history entries and stable per-player totals for all later fetches. + +**Alternatives considered**: + +- Allow the drawer to submit guesses + Rejected because it undermines the drawer-vs-guesser gameplay split. +- Recompute scores on every fetch + Rejected because it increases repeated work and makes history less explicit. + +## Decision: Extend viewer-specific snapshots instead of adding a second gameplay payload + +**Rationale**: Scenario 2 already established viewer-specific room snapshots. +Adding canvas state, guess history, score totals, and viewer permissions to the +same payload keeps the frontend integration simple and preserves drawer-only +secret-word visibility without adding a parallel contract. + +**Alternatives considered**: + +- Add separate endpoints for canvas, history, and scores + Rejected because the frontend would need to coordinate multiple polls for one + game screen. +- Send the same gameplay controls to every client and hide them only in the UI + Rejected because backend permissions should remain authoritative. + +## Decision: Use action response + polling for synchronization + +**Rationale**: The acting tab should receive the updated room snapshot +immediately after drawing, clearing, or guessing, while other tabs catch up via +the existing 2-second polling flow. This preserves responsiveness without +introducing forbidden realtime transport. + +**Alternatives considered**: + +- Wait for polling even on the acting tab + Rejected because it makes direct interactions feel laggy without any benefit. +- Introduce push updates for canvas sync + Rejected because the constitution forbids realtime transports. diff --git a/specs/003-drawing-guess-scoring/spec.md b/specs/003-drawing-guess-scoring/spec.md new file mode 100644 index 0000000..d6ade01 --- /dev/null +++ b/specs/003-drawing-guess-scoring/spec.md @@ -0,0 +1,206 @@ +# Feature Specification: Scenario 3 Gameplay Interaction + +**Feature Branch**: `assignment` + +**Created**: 2026-05-30 + +**Status**: Draft + +**Input**: User description: "Scenario 3 gameplay interaction with a drawing +surface for the drawer, clear canvas action, trimmed guess submission, +empty-guess rejection, case-insensitive guess matching against the secret word, +synced guess history via polling, and deterministic scoring where correct +guesses score 100 and incorrect guesses score 0. Keep this limited to Scenario +3 only. Exclude result state display, restart flow, multiple rounds, timers, +bonuses, and any Scenario 4 behavior." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Drawer Sketches the Word (Priority: P1) + +When a round is active, the assigned drawer can use a shared drawing surface and +clear it when needed so the room has a usable sketch area for the guessing +phase. + +**Why this priority**: Scenario 3 must first make the active round interactive +before guesses or scores have practical value. + +**Independent Test**: Start a room, confirm only the drawer can add marks to the +shared drawing surface, and confirm clearing the canvas resets that shared view +for everyone in the room. + +**Acceptance Scenarios**: + +1. **Given** a round is active and a drawer is assigned, **When** the drawer + uses the drawing surface, **Then** the shared canvas reflects those marks for + the room. +2. **Given** a non-drawer is viewing the same round, **When** they interact with + the drawing surface, **Then** they cannot change the shared canvas state. +3. **Given** the drawer has already drawn on the canvas, **When** the drawer + chooses to clear it, **Then** the shared canvas returns to an empty state for + all players in that room. + +--- + +### User Story 2 - Players Submit and Track Guesses (Priority: P2) + +Players submit guesses against the active word, with blank guesses rejected, +accepted guesses normalized for matching, and shared history kept in sync for +the room. + +**Why this priority**: Guess submission is the core interaction that turns the +shared drawing surface into actual gameplay. + +**Independent Test**: Start a round in two tabs, submit trimmed, blank, correct, +and incorrect guesses, and confirm only valid guesses enter the room history in +the same order for both players. + +**Acceptance Scenarios**: + +1. **Given** a player enters a guess with extra spaces around the text, + **When** the guess is submitted, **Then** the stored guess uses the trimmed + value. +2. **Given** a player submits an empty or whitespace-only guess, **When** the + guess is processed, **Then** it is rejected with clear feedback and does not + appear in shared history. +3. **Given** a player submits a guess whose letters match the secret word with + different casing, **When** the guess is evaluated, **Then** it counts as a + correct match. +4. **Given** multiple accepted guesses are submitted in a room, **When** other + players refresh the round state, **Then** they see the same guess history in + the same order for that room only. + +--- + +### User Story 3 - Correct Guesses Score Deterministically (Priority: P3) + +The game awards a predictable score outcome for each accepted guess so players +can understand whether a guess succeeded and how many points it earned. + +**Why this priority**: Deterministic scoring completes the Scenario 3 gameplay +loop while staying short of result and restart behavior. + +**Independent Test**: Submit one correct guess and one incorrect guess in an +active room, then confirm the correct guess earns 100 points, the incorrect +guess earns 0 points, and all players see the same score outcome. + +**Acceptance Scenarios**: + +1. **Given** an accepted guess matches the secret word, **When** the guess is + scored, **Then** that guess earns 100 points. +2. **Given** an accepted guess does not match the secret word, **When** the + guess is scored, **Then** that guess earns 0 points. +3. **Given** multiple players view the same room after guesses have been + scored, **When** they load the round state, **Then** they receive the same + score outcomes for the same guess history entries. + +### Edge Cases + +- The drawer attempts to submit a guess during the active round. +- A player submits only spaces as a guess. +- A player submits the correct word with different capitalization than the + secret word. +- The drawer clears the canvas after guesses have already been submitted and the + guess history must remain intact. +- Two rooms are active at the same time and drawing updates, guess history, and + scores must remain isolated by room. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST provide a shared drawing surface during an active + round. +- **FR-002**: The system MUST allow only the assigned drawer to change the + shared drawing surface. +- **FR-003**: The system MUST allow the assigned drawer to clear the shared + canvas and MUST reset that canvas for every player in the same room. +- **FR-004**: The system MUST allow players to submit guesses during an active + round. +- **FR-005**: The system MUST trim leading and trailing whitespace from each + submitted guess before evaluating or storing it. +- **FR-006**: The system MUST reject empty or whitespace-only guesses with clear + feedback and MUST keep rejected guesses out of shared history. +- **FR-007**: The system MUST evaluate accepted guesses against the secret word + without requiring exact letter casing. +- **FR-008**: The system MUST add each accepted guess to room-specific shared + history in submission order. +- **FR-009**: The system MUST keep drawing state, clear-canvas state, and guess + history synchronized across players in the same room through scheduled + refreshes. +- **FR-010**: The system MUST award 100 points to each accepted guess that + matches the secret word. +- **FR-011**: The system MUST award 0 points to each accepted guess that does + not match the secret word. +- **FR-012**: The system MUST expose the same guess history and score outcomes + to every player viewing the same room state. +- **FR-013**: This feature MUST exclude result-state display, restart flow, + multiple rounds, timers, bonuses, and any Scenario 4 behavior. + +### Key Entities *(include if feature involves data)* + +- **Canvas State**: The shared round drawing data that the drawer can change and + clear while other players only observe it. +- **Guess Submission**: A player's trimmed guess attempt for the active secret + word, including whether it was accepted for evaluation. +- **Guess History Entry**: A room-specific record of one accepted guess, its + player identity, its normalized content, and its score outcome. +- **Round Score Outcome**: The deterministic point value assigned to an accepted + guess, which is either 100 for a correct guess or 0 for an incorrect guess. + +## Constraints & Non-Goals *(mandatory)* + +- **CN-001**: Round updates in this scenario MUST continue using scheduled + refreshes rather than instant push-based updates. +- **CN-002**: Canvas, guess, and score data for this scenario MUST remain + temporary for the current runtime only and are not expected to survive a + service restart. +- **CN-003**: Players MUST continue to access rooms without sign-in, account + creation, or identity verification features. +- **CN-004**: This feature MUST build directly on the existing Scenario 1 and + Scenario 2 round-start behavior without expanding into unrelated product + areas. +- **CN-005**: Guess matching and score assignment MUST be deterministic for the + same accepted guess content and active secret word. +- **CN-006**: The scope is limited to Scenario 3 gameplay interaction only. +- **CN-007**: Result-state display, restart flow, multiple rounds, timers, + bonuses, and any Scenario 4 behavior are explicit non-goals for this + specification. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In two-tab validation, a drawer can create and clear shared marks + and the observing player sees the same canvas state within one scheduled + refresh cycle each time. +- **SC-002**: In manual validation, 100% of empty or whitespace-only guess + submissions are rejected before they enter shared history. +- **SC-003**: In manual validation, guesses that differ from the secret word + only by letter casing are accepted as correct every time. +- **SC-004**: In repeated validation, every correct accepted guess awards 100 + points, every incorrect accepted guess awards 0 points, and all players in + the same room observe the same score outcomes. + +## Assumptions + +- Scenario 2 room start, drawer assignment, secret word selection, and + drawer-only word visibility already exist and remain the source of truth for + active rounds. +- One active round is sufficient for Scenario 3; end-of-round results, + progression to another round, and restart logic are deferred to later + scenarios. +- Guess history may be visible to all players in the room, but secret word + visibility remains limited according to Scenario 2 rules. +- A drawer can continue sketching or clear the canvas after guesses have been + submitted because this scenario does not yet end the round automatically. + +## Verification Plan *(mandatory)* + +- Validate drawer-only drawing and clear-canvas behavior across at least two + tabs in the same room. +- Validate trimmed, blank, correct, and incorrect guess submission behavior. +- Validate case-insensitive guess matching against the active secret word. +- Validate shared guess-history synchronization and room isolation across active + rooms. +- Validate deterministic 100-or-0 score outcomes for accepted guesses. From a56879a92758ff5ff0c18fd04a9fc7fb99393d15 Mon Sep 17 00:00:00 2001 From: Vishal S Date: Sat, 30 May 2026 13:59:23 +0530 Subject: [PATCH 6/9] Implement Scenario 3 gameplay interaction --- backend/src/api/rooms.ts | 86 +++++++- backend/src/api/schemas.test.ts | 66 +++++- backend/src/api/schemas.ts | 31 ++- backend/src/models/game.ts | 46 ++++ backend/src/services/roomStore.test.ts | 189 +++++++++++++++- backend/src/services/roomStore.ts | 238 +++++++++++++++++++- frontend/src/components/DrawingSurface.tsx | 199 +++++++++++++++++ frontend/src/components/GuessForm.tsx | 23 +- frontend/src/pages/GamePage.tsx | 115 ++++++++-- frontend/src/services/api.test.ts | 244 ++++++++++++++++++--- frontend/src/services/api.ts | 51 +++++ frontend/src/state/roomStore.ts | 43 +++- frontend/src/styles/app.css | 91 ++++++++ specs/003-drawing-guess-scoring/tasks.md | 244 +++++++++++++++++++++ 14 files changed, 1583 insertions(+), 83 deletions(-) create mode 100644 frontend/src/components/DrawingSurface.tsx create mode 100644 specs/003-drawing-guess-scoring/tasks.md diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 4a4903b..0e542d6 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -1,14 +1,36 @@ import { Router } from "express"; import type { Room } from "../models/game.js"; import { + clearCanvasSchema, createRoomSchema, + drawingStrokeSchema, HttpError, joinRoomSchema, roomCodeParamsSchema, roomViewerQuerySchema, - startRoomSchema + startRoomSchema, + submitGuessSchema } from "./schemas.js"; -import { createRoom, getRoom, joinRoom, startRoom, toRoomSnapshot } from "../services/roomStore.js"; +import { + addDrawingStroke, + clearRoomCanvas, + createRoom, + getRoom, + joinRoom, + startRoom, + submitGuess, + toRoomSnapshot +} from "../services/roomStore.js"; + +type FailureReason = "not-found" | "forbidden" | "conflict"; +type RoomActionSuccess = { ok: true; room: Room }; +type RoomActionFailure = { ok: false; reason: FailureReason; message: string }; + +const statusCodeByReason: Record = { + "not-found": 404, + forbidden: 403, + conflict: 409 +}; function createRoomSessionResponse(room: Room, participantId: string) { return { @@ -23,6 +45,16 @@ function createRoomSnapshotResponse(room: Room, participantId?: string) { }; } +function throwForActionFailure(result: RoomActionFailure) { + throw new HttpError(statusCodeByReason[result.reason], result.message); +} + +function assertRoomActionSucceeded(result: RoomActionSuccess | RoomActionFailure): asserts result is RoomActionSuccess { + if (!result.ok) { + throwForActionFailure(result); + } +} + export function createRoomsRouter() { const router = Router(); @@ -59,15 +91,49 @@ export function createRoomsRouter() { const { participantId } = startRoomSchema.parse(request.body); const result = startRoom(code, participantId); - if (!result.ok) { - const statusCodeByReason = { - "not-found": 404, - forbidden: 403, - conflict: 409 - } as const; + assertRoomActionSucceeded(result); - throw new HttpError(statusCodeByReason[result.reason], result.message); - } + response.json(createRoomSnapshotResponse(result.room, participantId)); + } catch (error) { + next(error); + } + }); + + router.post("/:code/drawing", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, points } = drawingStrokeSchema.parse(request.body); + const result = addDrawingStroke(code, participantId, points); + + assertRoomActionSucceeded(result); + + response.json(createRoomSnapshotResponse(result.room, participantId)); + } catch (error) { + next(error); + } + }); + + router.post("/:code/drawing/clear", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = clearCanvasSchema.parse(request.body); + const result = clearRoomCanvas(code, participantId); + + assertRoomActionSucceeded(result); + + response.json(createRoomSnapshotResponse(result.room, participantId)); + } catch (error) { + next(error); + } + }); + + router.post("/:code/guesses", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId, guess } = submitGuessSchema.parse(request.body); + const result = submitGuess(code, participantId, guess); + + assertRoomActionSucceeded(result); response.json(createRoomSnapshotResponse(result.room, participantId)); } catch (error) { diff --git a/backend/src/api/schemas.test.ts b/backend/src/api/schemas.test.ts index b1a1df1..20a92f7 100644 --- a/backend/src/api/schemas.test.ts +++ b/backend/src/api/schemas.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { createRoomSchema, joinRoomSchema, roomCodeParamsSchema, startRoomSchema } from "./schemas.js"; +import { + clearCanvasSchema, + createRoomSchema, + drawingStrokeSchema, + joinRoomSchema, + roomCodeParamsSchema, + startRoomSchema, + submitGuessSchema +} from "./schemas.js"; describe("schemas", () => { it("createRoomSchema trims a valid playerName", () => { @@ -45,6 +53,60 @@ describe("schemas", () => { }); it("startRoomSchema requires a participantId", () => { - expect(() => startRoomSchema.parse({ participantId: " " })).toThrow(); + expect(() => startRoomSchema.parse({ participantId: " " })).toThrow( + "Participant ID is required" + ); + }); + + it("drawingStrokeSchema accepts normalized drawing points", () => { + const result = drawingStrokeSchema.parse({ + participantId: "p1", + points: [ + { x: 0, y: 0 }, + { x: 0.5, y: 0.5 }, + { x: 1, y: 1 } + ] + }); + + expect(result.points).toHaveLength(3); + }); + + it("drawingStrokeSchema rejects empty point arrays", () => { + expect(() => drawingStrokeSchema.parse({ participantId: "p1", points: [] })).toThrow( + "At least one drawing point is required" + ); + }); + + it("drawingStrokeSchema rejects out-of-range points", () => { + expect(() => + drawingStrokeSchema.parse({ + participantId: "p1", + points: [{ x: -0.1, y: 1.2 }] + }) + ).toThrow(); + }); + + it("clearCanvasSchema requires a participantId", () => { + expect(() => clearCanvasSchema.parse({ participantId: " " })).toThrow( + "Participant ID is required" + ); + }); + + it("submitGuessSchema trims a valid guess", () => { + const result = submitGuessSchema.parse({ + participantId: "p2", + guess: " Rocket " + }); + + expect(result.guess).toBe("Rocket"); + }); + + it("submitGuessSchema rejects a whitespace-only guess", () => { + expect(() => + submitGuessSchema.parse({ + participantId: "p2", + guess: " " + }) + ).toThrow("Guess must include at least one non-space character"); }); }); diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index 2091d6c..046b84f 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -13,6 +13,19 @@ const playerNameSchema = z .trim() .min(1, { message: "Player name must include at least one non-space character" }); +const participantIdSchema = z.string().trim().min(1, { + message: "Participant ID is required" +}); + +const drawingPointSchema = z.object({ + x: z.number().min(0).max(1), + y: z.number().min(0).max(1) +}); + +const guessSchema = z.string().trim().min(1, { + message: "Guess must include at least one non-space character" +}); + export const createRoomSchema = z.object({ playerName: playerNameSchema.optional() }); @@ -30,7 +43,23 @@ export const roomViewerQuerySchema = z.object({ }); export const startRoomSchema = z.object({ - participantId: z.string().trim().min(1) + participantId: participantIdSchema +}); + +export const drawingStrokeSchema = z.object({ + participantId: participantIdSchema, + points: z.array(drawingPointSchema).min(1, { + message: "At least one drawing point is required" + }) +}); + +export const clearCanvasSchema = z.object({ + participantId: participantIdSchema +}); + +export const submitGuessSchema = z.object({ + participantId: participantIdSchema, + guess: guessSchema }); export class HttpError extends Error { diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 6406c9b..1d4d014 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,17 +1,59 @@ export type ParticipantRole = "drawer" | "guesser"; export type RoomStatus = "lobby" | "playing"; export type WordVisibility = "visible" | "hidden"; +export type ScoreAward = 0 | 100; export interface Participant { id: string; name: string; joinedAt: string; + score: number; +} + +export interface DrawingPoint { + x: number; + y: number; +} + +export interface DrawingStroke { + id: string; + points: DrawingPoint[]; + drawnByParticipantId: string; + createdAt: string; +} + +export interface CanvasState { + strokes: DrawingStroke[]; + clearedAt?: string; +} + +export interface StoredGuessEntry { + id: string; + participantId: string; + participantName: string; + guess: string; + normalizedGuess: string; + isCorrect: boolean; + scoreAwarded: ScoreAward; + submittedAt: string; +} + +export interface GuessHistoryEntry { + id: string; + participantId: string; + participantName: string; + guess: string; + isCorrect: boolean; + scoreAwarded: ScoreAward; + submittedAt: string; } export interface RoundState { drawerParticipantId: string; secretWord: string; startedAt: string; + canvas: CanvasState; + guessHistory: StoredGuessEntry[]; } export interface Room { @@ -34,8 +76,12 @@ export interface RoomSnapshot { minimumPlayersToStart: number; drawerParticipantId?: string; viewerIsDrawer: boolean; + viewerCanDraw: boolean; + viewerCanGuess: boolean; wordVisibility?: WordVisibility; secretWord?: string; + canvas?: CanvasState; + guessHistory?: GuessHistoryEntry[]; } export interface RoomSessionResponse { diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index bcde8dd..5d9d409 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from "vitest"; -import { createRoom, getRoom, joinRoom, saveRoom, startRoom, toRoomSnapshot } from "./roomStore.js"; +import { + addDrawingStroke, + clearRoomCanvas, + createRoom, + getRoom, + joinRoom, + saveRoom, + startRoom, + submitGuess, + toRoomSnapshot +} from "./roomStore.js"; describe("roomStore", () => { it("createRoom returns a room with a 4-character uppercase code", () => { @@ -8,6 +18,7 @@ describe("roomStore", () => { expect(result.room.code).toMatch(/^[A-Z0-9]{4}$/); expect(result.room.participants).toHaveLength(1); expect(result.room.participants[0].name).toBe("Alice"); + expect(result.room.participants[0].score).toBe(0); expect(result.room.hostParticipantId).toBe(result.participantId); expect(result.participantId).toBeDefined(); }); @@ -30,6 +41,7 @@ describe("roomStore", () => { expect(joined).not.toBeNull(); expect(joined?.room.participants.at(-1)?.name).toBe("Bob"); + expect(joined?.room.participants.at(-1)?.score).toBe(0); }); it("joinRoom updates only the targeted room and returns a new room session", () => { @@ -55,6 +67,8 @@ describe("roomStore", () => { expect(snapshot.hostParticipantId).toBe(result.participantId); expect(snapshot.viewerIsHost).toBe(true); expect(snapshot.viewerIsDrawer).toBe(false); + expect(snapshot.viewerCanDraw).toBe(false); + expect(snapshot.viewerCanGuess).toBe(false); expect(snapshot.canStartGame).toBe(false); expect(snapshot.minimumPlayersToStart).toBe(2); }); @@ -104,7 +118,7 @@ describe("roomStore", () => { }); }); - it("startRoom assigns the host as drawer and reveals the word only to the drawer", () => { + it("startRoom assigns the host as drawer and initializes Scenario 3 round state", () => { const host = createRoom("Alice"); const joined = joinRoom(host.room.code, "Bob"); @@ -120,18 +134,24 @@ describe("roomStore", () => { expect(started.room.status).toBe("playing"); expect(started.room.round?.drawerParticipantId).toBe(host.participantId); expect(started.room.round?.secretWord).toBeDefined(); + expect(started.room.round?.canvas.strokes).toHaveLength(0); + expect(started.room.round?.guessHistory).toHaveLength(0); const hostSnapshot = toRoomSnapshot(started.room, host.participantId); const guestSnapshot = toRoomSnapshot(started.room, joined!.participantId); expect(hostSnapshot.viewerIsHost).toBe(true); expect(hostSnapshot.viewerIsDrawer).toBe(true); + expect(hostSnapshot.viewerCanDraw).toBe(true); + expect(hostSnapshot.viewerCanGuess).toBe(false); expect(hostSnapshot.wordVisibility).toBe("visible"); expect(hostSnapshot.secretWord).toBe(started.room.round?.secretWord); - expect(hostSnapshot.canStartGame).toBe(false); + expect(hostSnapshot.guessHistory).toEqual([]); expect(guestSnapshot.viewerIsHost).toBe(false); expect(guestSnapshot.viewerIsDrawer).toBe(false); + expect(guestSnapshot.viewerCanDraw).toBe(false); + expect(guestSnapshot.viewerCanGuess).toBe(true); expect(guestSnapshot.drawerParticipantId).toBe(host.participantId); expect(guestSnapshot.wordVisibility).toBe("hidden"); expect(guestSnapshot.secretWord).toBeUndefined(); @@ -183,18 +203,167 @@ describe("roomStore", () => { expect(firstStarted.room.round?.secretWord).toBe(secondStarted.room.round?.secretWord); }); - it("starting one room does not affect other active rooms", () => { + it("addDrawingStroke rejects non-drawer participants", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + + expect(guest).not.toBeNull(); + startRoom(host.room.code, host.participantId); + + const result = addDrawingStroke(host.room.code, guest!.participantId, [{ x: 0.2, y: 0.3 }]); + + expect(result).toEqual({ + ok: false, + reason: "forbidden", + message: "Only the drawer can update the canvas" + }); + }); + + it("addDrawingStroke appends a stroke that all viewers can see", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + + expect(guest).not.toBeNull(); + startRoom(host.room.code, host.participantId); + + const result = addDrawingStroke(host.room.code, host.participantId, [ + { x: 0.1, y: 0.1 }, + { x: 0.4, y: 0.4 } + ]); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.room.round?.canvas.strokes).toHaveLength(1); + expect(result.room.round?.canvas.strokes[0].points).toEqual([ + { x: 0.1, y: 0.1 }, + { x: 0.4, y: 0.4 } + ]); + + const guestSnapshot = toRoomSnapshot(result.room, guest!.participantId); + expect(guestSnapshot.canvas?.strokes).toHaveLength(1); + }); + + it("clearRoomCanvas resets the stored strokes for the room", () => { + const host = createRoom("Alice"); + joinRoom(host.room.code, "Bob"); + startRoom(host.room.code, host.participantId); + addDrawingStroke(host.room.code, host.participantId, [{ x: 0.5, y: 0.5 }]); + + const result = clearRoomCanvas(host.room.code, host.participantId); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.room.round?.canvas.strokes).toEqual([]); + expect(result.room.round?.canvas.clearedAt).toBeDefined(); + }); + + it("submitGuess rejects drawer submissions", () => { + const host = createRoom("Alice"); + joinRoom(host.room.code, "Bob"); + startRoom(host.room.code, host.participantId); + + const result = submitGuess(host.room.code, host.participantId, "rocket"); + + expect(result).toEqual({ + ok: false, + reason: "forbidden", + message: "Drawer cannot submit guesses" + }); + }); + + it("submitGuess trims guesses, matches case-insensitively, and awards 100 points for correct guesses", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + + expect(guest).not.toBeNull(); + + const started = startRoom(host.room.code, host.participantId); + expect(started.ok).toBe(true); + if (!started.ok) { + return; + } + + const secretWord = started.room.round!.secretWord; + const result = submitGuess(host.room.code, guest!.participantId, ` ${secretWord.toUpperCase()} `); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.room.round?.guessHistory).toHaveLength(1); + expect(result.room.round?.guessHistory[0].guess).toBe(secretWord.toUpperCase()); + expect(result.room.round?.guessHistory[0].isCorrect).toBe(true); + expect(result.room.round?.guessHistory[0].scoreAwarded).toBe(100); + + const updatedGuest = result.room.participants.find((participant) => participant.id === guest!.participantId); + expect(updatedGuest?.score).toBe(100); + + const guestSnapshot = toRoomSnapshot(result.room, guest!.participantId); + expect(guestSnapshot.guessHistory?.[0].scoreAwarded).toBe(100); + }); + + it("submitGuess awards 0 points for incorrect guesses and preserves history order", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + const third = joinRoom(host.room.code, "Cara"); + + expect(guest).not.toBeNull(); + expect(third).not.toBeNull(); + + startRoom(host.room.code, host.participantId); + + const firstGuess = submitGuess(host.room.code, guest!.participantId, "wrong guess"); + const secondGuess = submitGuess(host.room.code, third!.participantId, "another miss"); + + expect(firstGuess.ok).toBe(true); + expect(secondGuess.ok).toBe(true); + + if (!secondGuess.ok) { + return; + } + + expect(secondGuess.room.round?.guessHistory).toHaveLength(2); + expect(secondGuess.room.round?.guessHistory[0].participantId).toBe(guest!.participantId); + expect(secondGuess.room.round?.guessHistory[1].participantId).toBe(third!.participantId); + expect(secondGuess.room.round?.guessHistory[0].scoreAwarded).toBe(0); + expect(secondGuess.room.round?.guessHistory[1].scoreAwarded).toBe(0); + + const guestParticipant = secondGuess.room.participants.find( + (participant) => participant.id === guest!.participantId + ); + const thirdParticipant = secondGuess.room.participants.find( + (participant) => participant.id === third!.participantId + ); + + expect(guestParticipant?.score).toBe(0); + expect(thirdParticipant?.score).toBe(0); + }); + + it("Scenario 3 drawing and guesses stay isolated per room", () => { const firstRoom = createRoom("Alice"); const secondRoom = createRoom("Cara"); - joinRoom(firstRoom.room.code, "Bob"); + const firstGuest = joinRoom(firstRoom.room.code, "Bob"); + const secondGuest = joinRoom(secondRoom.room.code, "Drew"); - const started = startRoom(firstRoom.room.code, firstRoom.participantId); + expect(firstGuest).not.toBeNull(); + expect(secondGuest).not.toBeNull(); - expect(started.ok).toBe(true); + startRoom(firstRoom.room.code, firstRoom.participantId); + startRoom(secondRoom.room.code, secondRoom.participantId); + + addDrawingStroke(firstRoom.room.code, firstRoom.participantId, [{ x: 0.2, y: 0.2 }]); + submitGuess(firstRoom.room.code, firstGuest!.participantId, "miss"); const untouchedRoom = getRoom(secondRoom.room.code); - expect(untouchedRoom?.status).toBe("lobby"); - expect(untouchedRoom?.participants).toHaveLength(1); - expect(untouchedRoom?.round).toBeUndefined(); + expect(untouchedRoom?.round?.canvas.strokes).toHaveLength(0); + expect(untouchedRoom?.round?.guessHistory).toHaveLength(0); + expect(untouchedRoom?.participants.every((participant) => participant.score === 0)).toBe(true); }); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 782dde9..867d63b 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -1,5 +1,14 @@ import { randomUUID } from "node:crypto"; -import type { Participant, Room, RoomSnapshot } from "../models/game.js"; +import type { + CanvasState, + DrawingPoint, + GuessHistoryEntry, + Participant, + Room, + RoomSnapshot, + ScoreAward, + StoredGuessEntry +} from "../models/game.js"; import { STARTER_WORDS } from "../seed/starterData.js"; const rooms = new Map(); @@ -39,7 +48,14 @@ function createParticipant(name?: string): Participant { return { id: randomUUID(), name: displayName(name), - joinedAt: now() + joinedAt: now(), + score: 0 + }; +} + +function createEmptyCanvas(): CanvasState { + return { + strokes: [] }; } @@ -51,6 +67,14 @@ function getDrawer(room: Room) { return room.participants.find((participant) => participant.id === room.hostParticipantId) ?? room.participants[0]; } +function getParticipant(room: Room, participantId?: string) { + if (!participantId) { + return undefined; + } + + return room.participants.find((participant) => participant.id === participantId); +} + function hashSeed(value: string) { let hash = 0; @@ -67,10 +91,32 @@ function getSecretWord(room: Room, drawer: Participant) { return STARTER_WORDS[hashSeed(selectionSeed) % STARTER_WORDS.length]; } -type StartRoomResult = +function normalizeGuess(guess: string) { + return guess.trim().toLowerCase(); +} + +function toGuessHistoryEntry(entry: StoredGuessEntry): GuessHistoryEntry { + return { + id: entry.id, + participantId: entry.participantId, + participantName: entry.participantName, + guess: entry.guess, + isCorrect: entry.isCorrect, + scoreAwarded: entry.scoreAwarded, + submittedAt: entry.submittedAt + }; +} + +type RoomActionResult = | { ok: true; room: Room } | { ok: false; reason: "not-found" | "forbidden" | "conflict"; message: string }; +type ActiveRoundRoom = Room & { round: NonNullable }; + +type ActiveRoundActionResult = + | { ok: true; room: ActiveRoundRoom } + | { ok: false; reason: "not-found" | "forbidden" | "conflict"; message: string }; + function canStartRoom(room: Room, viewerParticipantId?: string) { return ( room.status === "lobby" && @@ -79,6 +125,31 @@ function canStartRoom(room: Room, viewerParticipantId?: string) { ); } +function getActiveRoundRoom(code: string): ActiveRoundActionResult { + const room = rooms.get(code); + + if (!room) { + return { + ok: false, + reason: "not-found", + message: "Room code was not found" + }; + } + + if (room.status !== "playing" || !room.round) { + return { + ok: false, + reason: "conflict", + message: "Game is not in an active round" + }; + } + + return { + ok: true, + room: room as ActiveRoundRoom + }; +} + export function createRoom(playerName?: string) { const participant = createParticipant(playerName); const room: Room = { @@ -121,7 +192,7 @@ export function getRoom(code: string) { return room ? cloneRoom(room) : null; } -export function startRoom(code: string, participantId: string): StartRoomResult { +export function startRoom(code: string, participantId: string): RoomActionResult { const room = rooms.get(code); if (!room) { @@ -158,12 +229,152 @@ export function startRoom(code: string, participantId: string): StartRoomResult const drawer = getDrawer(room); + room.participants = room.participants.map((participant) => ({ + ...participant, + score: 0 + })); room.status = "playing"; room.round = { drawerParticipantId: drawer.id, secretWord: getSecretWord(room, drawer), - startedAt: now() + startedAt: now(), + canvas: createEmptyCanvas(), + guessHistory: [] + }; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { + ok: true, + room: cloneRoom(room) }; +} + +export function addDrawingStroke(code: string, participantId: string, points: DrawingPoint[]): RoomActionResult { + const activeRoom = getActiveRoundRoom(code); + + if (!activeRoom.ok) { + return activeRoom; + } + + const { room } = activeRoom; + const { round } = room; + const participant = getParticipant(room, participantId); + + if (!participant) { + return { + ok: false, + reason: "forbidden", + message: "Only room participants can draw on the canvas" + }; + } + + if (round.drawerParticipantId !== participantId) { + return { + ok: false, + reason: "forbidden", + message: "Only the drawer can update the canvas" + }; + } + + round.canvas.strokes.push({ + id: randomUUID(), + points: points.map((point) => ({ ...point })), + drawnByParticipantId: participantId, + createdAt: now() + }); + room.updatedAt = now(); + rooms.set(room.code, room); + + return { + ok: true, + room: cloneRoom(room) + }; +} + +export function clearRoomCanvas(code: string, participantId: string): RoomActionResult { + const activeRoom = getActiveRoundRoom(code); + + if (!activeRoom.ok) { + return activeRoom; + } + + const { room } = activeRoom; + const { round } = room; + const participant = getParticipant(room, participantId); + + if (!participant) { + return { + ok: false, + reason: "forbidden", + message: "Only room participants can clear the canvas" + }; + } + + if (round.drawerParticipantId !== participantId) { + return { + ok: false, + reason: "forbidden", + message: "Only the drawer can clear the canvas" + }; + } + + round.canvas = { + strokes: [], + clearedAt: now() + }; + room.updatedAt = now(); + rooms.set(room.code, room); + + return { + ok: true, + room: cloneRoom(room) + }; +} + +export function submitGuess(code: string, participantId: string, guess: string): RoomActionResult { + const activeRoom = getActiveRoundRoom(code); + + if (!activeRoom.ok) { + return activeRoom; + } + + const { room } = activeRoom; + const { round } = room; + const participant = getParticipant(room, participantId); + + if (!participant) { + return { + ok: false, + reason: "forbidden", + message: "Only room participants can submit guesses" + }; + } + + if (round.drawerParticipantId === participantId) { + return { + ok: false, + reason: "forbidden", + message: "Drawer cannot submit guesses" + }; + } + + const trimmedGuess = guess.trim(); + const normalizedGuess = normalizeGuess(trimmedGuess); + const isCorrect = normalizedGuess === normalizeGuess(round.secretWord); + const scoreAwarded: ScoreAward = isCorrect ? 100 : 0; + + round.guessHistory.push({ + id: randomUUID(), + participantId, + participantName: participant.name, + guess: trimmedGuess, + normalizedGuess, + isCorrect, + scoreAwarded, + submittedAt: now() + }); + participant.score += scoreAwarded; room.updatedAt = now(); rooms.set(room.code, room); @@ -180,8 +391,11 @@ export function saveRoom(room: Room) { } export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSnapshot { + const viewerParticipant = getParticipant(room, viewerParticipantId); const viewerIsHost = room.hostParticipantId === viewerParticipantId; const viewerIsDrawer = room.round?.drawerParticipantId === viewerParticipantId; + const viewerCanDraw = room.status === "playing" && viewerIsDrawer; + const viewerCanGuess = room.status === "playing" && Boolean(viewerParticipant) && !viewerIsDrawer; return { code: room.code, @@ -193,7 +407,19 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn minimumPlayersToStart: MINIMUM_PLAYERS_TO_START, drawerParticipantId: room.round?.drawerParticipantId, viewerIsDrawer, + viewerCanDraw, + viewerCanGuess, wordVisibility: room.round ? (viewerIsDrawer ? "visible" : "hidden") : undefined, - secretWord: viewerIsDrawer ? room.round?.secretWord : undefined + secretWord: viewerIsDrawer ? room.round?.secretWord : undefined, + canvas: room.round + ? { + strokes: room.round.canvas.strokes.map((stroke) => ({ + ...stroke, + points: stroke.points.map((point) => ({ ...point })) + })), + clearedAt: room.round.canvas.clearedAt + } + : undefined, + guessHistory: room.round ? room.round.guessHistory.map(toGuessHistoryEntry) : undefined }; } diff --git a/frontend/src/components/DrawingSurface.tsx b/frontend/src/components/DrawingSurface.tsx new file mode 100644 index 0000000..4b52075 --- /dev/null +++ b/frontend/src/components/DrawingSurface.tsx @@ -0,0 +1,199 @@ +import { useRef, useState, type PointerEvent } from "react"; +import type { CanvasState, DrawingPoint } from "../services/api"; + +interface DrawingSurfaceProps { + canvas?: CanvasState; + canDraw: boolean; + isBusy?: boolean; + onSubmitStroke: (points: DrawingPoint[]) => Promise; + onClearCanvas: () => Promise; +} + +const VIEWBOX_WIDTH = 1000; +const VIEWBOX_HEIGHT = 700; + +function clampCoordinate(value: number) { + return Math.min(1, Math.max(0, value)); +} + +function toPolylinePoints(points: DrawingPoint[]) { + return points + .map((point) => `${point.x * VIEWBOX_WIDTH},${point.y * VIEWBOX_HEIGHT}`) + .join(" "); +} + +export function DrawingSurface({ + canvas, + canDraw, + isBusy = false, + onSubmitStroke, + onClearCanvas +}: DrawingSurfaceProps) { + const canvasRef = useRef(null); + const draftPointsRef = useRef([]); + const [draftPoints, setDraftPoints] = useState([]); + const [isDrawing, setIsDrawing] = useState(false); + const [error, setError] = useState(null); + + function getNormalizedPoint(event: PointerEvent): DrawingPoint | null { + if (!canvasRef.current) { + return null; + } + + const bounds = canvasRef.current.getBoundingClientRect(); + + if (bounds.width === 0 || bounds.height === 0) { + return null; + } + + return { + x: clampCoordinate((event.clientX - bounds.left) / bounds.width), + y: clampCoordinate((event.clientY - bounds.top) / bounds.height) + }; + } + + function updateDraftPoints(nextPoints: DrawingPoint[]) { + draftPointsRef.current = nextPoints; + setDraftPoints(nextPoints); + } + + function handlePointerDown(event: PointerEvent) { + if (!canDraw || isBusy) { + return; + } + + const point = getNormalizedPoint(event); + + if (!point) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + setError(null); + setIsDrawing(true); + updateDraftPoints([point]); + } + + function handlePointerMove(event: PointerEvent) { + if (!isDrawing || !canDraw || isBusy) { + return; + } + + const point = getNormalizedPoint(event); + + if (!point) { + return; + } + + updateDraftPoints([...draftPointsRef.current, point]); + } + + async function finishStroke() { + if (!isDrawing) { + return; + } + + const points = draftPointsRef.current; + setIsDrawing(false); + + if (points.length === 0) { + updateDraftPoints([]); + return; + } + + try { + setError(null); + await onSubmitStroke(points); + updateDraftPoints([]); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Unable to update the canvas"); + } + } + + async function handleClearCanvas() { + try { + setError(null); + await onClearCanvas(); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Unable to clear the canvas"); + } + } + + const strokes = canvas?.strokes ?? []; + const hasCanvasMarks = strokes.length > 0; + + return ( +
    +
    +

    + {canDraw ? "Drawer controls enabled" : "Read-only canvas"} +

    + {canDraw ? ( + + ) : null} +
    + + { + void finishStroke(); + }} + onPointerCancel={() => { + void finishStroke(); + }} + > + + {strokes.map((stroke) => + stroke.points.length === 1 ? ( + + ) : ( + + ) + )} + {draftPoints.length === 1 ? ( + + ) : draftPoints.length > 1 ? ( + + ) : null} + + +

    + {canDraw + ? "Press and drag to add a stroke. Other players will see the update on their next refresh." + : "Watch the shared canvas update as the drawer sketches."} +

    + {error ?

    {error}

    : null} +
    + ); +} diff --git a/frontend/src/components/GuessForm.tsx b/frontend/src/components/GuessForm.tsx index 0a1ec47..44e79b6 100644 --- a/frontend/src/components/GuessForm.tsx +++ b/frontend/src/components/GuessForm.tsx @@ -2,18 +2,36 @@ import { useState } from "react"; interface GuessFormProps { disabled?: boolean; + onSubmitGuess: (guess: string) => Promise; } -export function GuessForm({ disabled = false }: GuessFormProps) { +export function GuessForm({ disabled = false, onSubmitGuess }: GuessFormProps) { const [guessText, setGuessText] = useState(""); + const [error, setError] = useState(null); - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); + + const trimmedGuess = guessText.trim(); + + if (!trimmedGuess) { + setError("Enter a guess before submitting."); + return; + } + + try { + setError(null); + await onSubmitGuess(guessText); + setGuessText(""); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Unable to submit guess"); + } } return (
    + {error ?

    {error}

    : null}
    +
    +
    Score
    +
    {viewer?.score ?? 0} pts
    +
    Host
    {room.viewerIsHost ? "Yes" : "No"}
    @@ -102,10 +131,18 @@ export function GamePage() {
    -

    +

    {wordStatus}

    -
    +
    {room.wordVisibility === "visible" ? "Your word" : "Word visibility"} @@ -114,9 +151,42 @@ export function GamePage() { -
    - Drawing interactions start in the next scenario. -
    + +
    + + + {history.length === 0 ? ( +
    +

    No guesses have been accepted yet.

    +
    + ) : ( +
      + {history.map((entry) => ( +
    • +
      + {entry.participantName} + {entry.guess} +
      +
      + + {entry.isCorrect ? "Correct" : "Incorrect"} + + {entry.scoreAwarded} pts +
      +
    • + ))} +
    + )}
    @@ -140,17 +210,30 @@ export function GamePage() { return (
  • - {participant.name} - {labels.join(" · ") || "joined"} +
    + {participant.name} + {labels.join(" · ") || "joined"} +
    + {participant.score} pts
  • ); })} + + {room.viewerCanGuess ? ( + + ) : ( +
    +

    {room.viewerCanDraw ? "Guess submission is disabled for the drawer." : "Waiting for gameplay access."}

    +
    + )} +
    +
    -
    diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index b4bb0be..f330216 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -1,6 +1,41 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "./api"; +function createPlayingSnapshot(overrides: Record = {}) { + return { + code: "ABCD", + status: "playing", + hostParticipantId: "p1", + participants: [ + { + id: "p1", + name: "Alice", + joinedAt: "2026-05-30T12:00:00.000Z", + score: 0 + }, + { + id: "p2", + name: "Bob", + joinedAt: "2026-05-30T12:00:01.000Z", + score: 0 + } + ], + viewerIsHost: false, + canStartGame: false, + minimumPlayersToStart: 2, + drawerParticipantId: "p1", + viewerIsDrawer: false, + viewerCanDraw: false, + viewerCanGuess: true, + wordVisibility: "hidden", + canvas: { + strokes: [] + }, + guessHistory: [], + ...overrides + }; +} + describe("api service", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); @@ -20,9 +55,11 @@ describe("api service", () => { viewerIsHost: true, canStartGame: false, minimumPlayersToStart: 2, - viewerIsDrawer: false - }, - }), + viewerIsDrawer: false, + viewerCanDraw: false, + viewerCanGuess: false + } + }) }; vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); @@ -32,7 +69,7 @@ describe("api service", () => { expect.stringContaining("/rooms"), expect.objectContaining({ method: "POST", - body: JSON.stringify({ playerName: "Alice" }), + body: JSON.stringify({ playerName: "Alice" }) }) ); }); @@ -50,9 +87,11 @@ describe("api service", () => { viewerIsHost: true, canStartGame: false, minimumPlayersToStart: 2, - viewerIsDrawer: false - }, - }), + viewerIsDrawer: false, + viewerCanDraw: false, + viewerCanGuess: false + } + }) }; vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); @@ -78,9 +117,11 @@ describe("api service", () => { viewerIsHost: false, canStartGame: false, minimumPlayersToStart: 2, - viewerIsDrawer: false + viewerIsDrawer: false, + viewerCanDraw: false, + viewerCanGuess: false } - }), + }) }; vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); @@ -90,7 +131,7 @@ describe("api service", () => { expect.stringContaining("/rooms/ABCD/join"), expect.objectContaining({ method: "POST", - body: JSON.stringify({ playerName: "Bob" }), + body: JSON.stringify({ playerName: "Bob" }) }) ); }); @@ -100,20 +141,15 @@ describe("api service", () => { ok: true, json: () => Promise.resolve({ - room: { - code: "ABCD", - status: "playing", - hostParticipantId: "p1", - participants: [], + room: createPlayingSnapshot({ viewerIsHost: true, - canStartGame: false, - minimumPlayersToStart: 2, - drawerParticipantId: "p1", viewerIsDrawer: true, + viewerCanDraw: true, + viewerCanGuess: false, wordVisibility: "visible", secretWord: "rocket" - } - }), + }) + }) }; vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); @@ -123,29 +159,165 @@ describe("api service", () => { expect.stringContaining("/rooms/ABCD/start"), expect.objectContaining({ method: "POST", - body: JSON.stringify({ participantId: "p1" }), + body: JSON.stringify({ participantId: "p1" }) }) ); }); - it("fetchRoom supports polling a viewer-specific playing room snapshot", async () => { + it("drawStroke sends POST to /rooms/:code/drawing with normalized points", async () => { const mockResponse = { ok: true, json: () => Promise.resolve({ - room: { - code: "ABCD", - status: "playing", - hostParticipantId: "p1", - participants: [], - viewerIsHost: false, - canStartGame: false, - minimumPlayersToStart: 2, - drawerParticipantId: "p1", - viewerIsDrawer: false, - wordVisibility: "hidden" - } - }), + room: createPlayingSnapshot({ + viewerIsHost: true, + viewerIsDrawer: true, + viewerCanDraw: true, + viewerCanGuess: false, + canvas: { + strokes: [ + { + id: "stroke-1", + drawnByParticipantId: "p1", + createdAt: "2026-05-30T12:01:00.000Z", + points: [ + { x: 0.1, y: 0.1 }, + { x: 0.3, y: 0.3 } + ] + } + ] + } + }) + }) + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.drawStroke("ABCD", "p1", [ + { x: 0.1, y: 0.1 }, + { x: 0.3, y: 0.3 } + ]); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/drawing"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + participantId: "p1", + points: [ + { x: 0.1, y: 0.1 }, + { x: 0.3, y: 0.3 } + ] + }) + }) + ); + }); + + it("clearCanvas sends POST to /rooms/:code/drawing/clear with participantId in body", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: createPlayingSnapshot({ + viewerIsHost: true, + viewerIsDrawer: true, + viewerCanDraw: true, + viewerCanGuess: false, + canvas: { + strokes: [], + clearedAt: "2026-05-30T12:02:00.000Z" + } + }) + }) + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.clearCanvas("ABCD", "p1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/drawing/clear"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p1" }) + }) + ); + }); + + it("submitGuess sends POST to /rooms/:code/guesses with guess text in body", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: createPlayingSnapshot({ + guessHistory: [ + { + id: "guess-1", + participantId: "p2", + participantName: "Bob", + guess: "Rocket", + isCorrect: true, + scoreAwarded: 100, + submittedAt: "2026-05-30T12:03:00.000Z" + } + ], + participants: [ + { + id: "p1", + name: "Alice", + joinedAt: "2026-05-30T12:00:00.000Z", + score: 0 + }, + { + id: "p2", + name: "Bob", + joinedAt: "2026-05-30T12:00:01.000Z", + score: 100 + } + ] + }) + }) + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.submitGuess("ABCD", "p2", "Rocket"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/guesses"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p2", guess: "Rocket" }) + }) + ); + }); + + it("fetchRoom supports polling a gameplay snapshot with canvas and guess history", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: createPlayingSnapshot({ + canvas: { + strokes: [ + { + id: "stroke-1", + drawnByParticipantId: "p1", + createdAt: "2026-05-30T12:01:00.000Z", + points: [{ x: 0.2, y: 0.4 }] + } + ] + }, + guessHistory: [ + { + id: "guess-1", + participantId: "p2", + participantName: "Bob", + guess: "wrong", + isCorrect: false, + scoreAwarded: 0, + submittedAt: "2026-05-30T12:03:00.000Z" + } + ] + }) + }) }; vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); @@ -153,6 +325,8 @@ describe("api service", () => { expect(response.room.status).toBe("playing"); expect(response.room.viewerIsDrawer).toBe(false); + expect(response.room.canvas?.strokes).toHaveLength(1); + expect(response.room.guessHistory?.[0].scoreAwarded).toBe(0); expect(response.room.secretWord).toBeUndefined(); }); }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 52579d0..3a53f69 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -2,9 +2,38 @@ export interface Participant { id: string; name: string; joinedAt: string; + score: number; } export type WordVisibility = "visible" | "hidden"; +export type ScoreAward = 0 | 100; + +export interface DrawingPoint { + x: number; + y: number; +} + +export interface DrawingStroke { + id: string; + points: DrawingPoint[]; + drawnByParticipantId: string; + createdAt: string; +} + +export interface CanvasState { + strokes: DrawingStroke[]; + clearedAt?: string; +} + +export interface GuessHistoryEntry { + id: string; + participantId: string; + participantName: string; + guess: string; + isCorrect: boolean; + scoreAwarded: ScoreAward; + submittedAt: string; +} export interface RoomSnapshot { code: string; @@ -16,8 +45,12 @@ export interface RoomSnapshot { minimumPlayersToStart: number; drawerParticipantId?: string; viewerIsDrawer: boolean; + viewerCanDraw: boolean; + viewerCanGuess: boolean; wordVisibility?: WordVisibility; secretWord?: string; + canvas?: CanvasState; + guessHistory?: GuessHistoryEntry[]; } export interface RoomSessionResponse { @@ -69,5 +102,23 @@ export const api = { fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); + }, + drawStroke(code: string, participantId: string, points: DrawingPoint[]) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/drawing`, { + method: "POST", + body: JSON.stringify({ participantId, points }) + }); + }, + clearCanvas(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/drawing/clear`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, + submitGuess(code: string, participantId: string, guess: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/guesses`, { + method: "POST", + body: JSON.stringify({ participantId, guess }) + }); } }; diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index 16bef61..565c0ac 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -7,7 +7,12 @@ import { useSyncExternalStore, type PropsWithChildren } from "react"; -import { api, type RoomSessionResponse, type RoomSnapshot } from "../services/api"; +import { + api, + type DrawingPoint, + type RoomSessionResponse, + type RoomSnapshot +} from "../services/api"; export interface RoomState { room: RoomSnapshot | null; @@ -102,6 +107,42 @@ class RoomStore { return response.room; } + async drawStroke(points: DrawingPoint[]) { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await this.withLoading(() => + api.drawStroke(this.state.room!.code, this.state.participantId!, points) + ); + this.setRoomSnapshot(response.room); + return response.room; + } + + async clearCanvas() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await this.withLoading(() => + api.clearCanvas(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } + + async submitGuess(guess: string) { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await this.withLoading(() => + api.submitGuess(this.state.room!.code, this.state.participantId!, guess) + ); + this.setRoomSnapshot(response.room); + return response.room; + } + async fetchRoom() { if (!this.state.room) { return null; diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 40a21aa..d09b218 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -398,12 +398,22 @@ input { font-weight: 500; } +.player-list__player { + display: grid; + gap: 4px; +} + .player-list__meta { color: var(--ink-soft); font-size: 0.875rem; font-weight: 400; } +.player-list__score { + color: var(--ink); + font-size: 0.95rem; +} + .status-line, .placeholder-note { width: max-content; @@ -471,6 +481,53 @@ input { font-weight: 500; } +.drawing-surface { + display: grid; + gap: 16px; +} + +.drawing-surface__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.drawing-surface__canvas { + width: 100%; + min-height: 420px; + border: 1px solid var(--line); + border-radius: 12px; + background: #ffffff; +} + +.drawing-surface__canvas--interactive { + cursor: crosshair; + touch-action: none; +} + +.drawing-surface__background { + fill: #ffffff; +} + +.drawing-surface__stroke { + fill: none; + stroke: var(--ink); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 8; +} + +.drawing-surface__stroke--draft { + stroke: var(--brand); +} + +.drawing-surface__hint { + color: var(--ink-soft); + font-size: 0.95rem; +} + /* --- GAME PAGE LAYOUT --- */ /* Changed layout structure to be much wider and utilize screen space */ .game-page { @@ -594,6 +651,40 @@ input { line-height: 1.2; } +.history-list { + display: grid; + gap: 12px; + padding: 0; + margin: 0; + list-style: none; +} + +.history-list__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 20px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-strong); +} + +.history-list__content { + display: grid; + gap: 4px; +} + +.history-list__content span { + color: var(--ink-soft); +} + +.history-list__meta { + display: grid; + justify-items: end; + gap: 8px; +} + @media (max-width: 720px) { .app-shell { padding: 24px 16px; diff --git a/specs/003-drawing-guess-scoring/tasks.md b/specs/003-drawing-guess-scoring/tasks.md new file mode 100644 index 0000000..fae1561 --- /dev/null +++ b/specs/003-drawing-guess-scoring/tasks.md @@ -0,0 +1,244 @@ +--- + +description: "Task list for Scenario 3 gameplay interaction implementation" + +--- + +# Tasks: Scenario 3 Gameplay Interaction + +**Input**: Design documents from `/specs/003-drawing-guess-scoring/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Include automated backend and frontend API coverage plus manual two-tab validation for each user story. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Web app**: `backend/src/`, `frontend/src/` +- Paths below follow this repository's monorepo layout + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm the active Scenario 3 artifacts and validation targets before editing code + +- [X] T001 Review implementation inputs in `specs/003-drawing-guess-scoring/spec.md`, `specs/003-drawing-guess-scoring/plan.md`, and `specs/003-drawing-guess-scoring/contracts/rooms-scenario3.openapi.yaml` +- [X] T002 Confirm manual and automated validation steps in `specs/003-drawing-guess-scoring/quickstart.md`, `backend/src/api/rooms.ts`, and `frontend/src/pages/GamePage.tsx` +- [X] T003 [P] Capture shared canvas, guess-history, and score expectations from `specs/003-drawing-guess-scoring/data-model.md` and `specs/003-drawing-guess-scoring/research.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared gameplay-state, validation, and snapshot contract changes that all Scenario 3 stories depend on + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 Update shared gameplay room, round, canvas, and guess-history types in `backend/src/models/game.ts` +- [X] T005 [P] Extend shared gameplay room snapshot and session response types in `frontend/src/services/api.ts` +- [X] T006 [P] Add reusable gameplay request schemas for drawing, clear-canvas, and guess submission in `backend/src/api/schemas.ts` +- [X] T007 [P] Add shared gameplay action support in `frontend/src/state/roomStore.ts` +- [X] T008 Implement shared gameplay snapshot derivation and room action helpers in `backend/src/services/roomStore.ts` +- [X] T009 Implement shared gameplay response mapping for fetch and room action snapshots in `backend/src/api/rooms.ts` + +**Checkpoint**: Foundation ready - user story implementation can now begin in priority order + +--- + +## Phase 3: User Story 1 - Drawer Sketches the Word (Priority: P1) 🎯 MVP + +**Goal**: The assigned drawer can update a shared drawing surface and clear it for everyone in the room + +**Independent Test**: Start a room, confirm only the drawer can add marks to the shared drawing surface, and confirm clearing the canvas resets it for both tabs + +### Verification for User Story 1 ⚠️ + +- [X] T010 [P] [US1] Add drawer-only drawing and clear-canvas coverage in `backend/src/services/roomStore.test.ts` +- [X] T011 [P] [US1] Add drawing and clear-canvas request coverage in `frontend/src/services/api.test.ts` +- [ ] T012 [US1] Validate drawer-only drawing and shared clear-canvas behavior with `specs/003-drawing-guess-scoring/quickstart.md` + +### Implementation for User Story 1 + +- [X] T013 [US1] Add canvas and stroke state fields in `backend/src/models/game.ts` +- [X] T014 [US1] Implement drawer-only stroke append and clear-canvas behavior in `backend/src/services/roomStore.ts` +- [X] T015 [US1] Add `/rooms/:code/drawing` and `/rooms/:code/drawing/clear` actions in `backend/src/api/rooms.ts` +- [X] T016 [P] [US1] Add draw and clear-canvas API/store methods in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` +- [X] T017 [US1] Build the shared drawing surface and drawer clear control in `frontend/src/components/DrawingSurface.tsx` and `frontend/src/pages/GamePage.tsx` + +**Checkpoint**: At this point, the drawer can sketch and clear a shared canvas without opening guess or scoring behavior + +--- + +## Phase 4: User Story 2 - Players Submit and Track Guesses (Priority: P2) + +**Goal**: Non-drawers can submit trimmed guesses, blank guesses are rejected, and accepted guesses stay synced in room history + +**Independent Test**: Start a round in two tabs, submit trimmed, blank, correct, and incorrect guesses, and confirm only valid guesses enter shared history in the same order for both players + +### Verification for User Story 2 ⚠️ + +- [X] T018 [P] [US2] Add guess-trimming, empty-guess rejection, and ordered history coverage in `backend/src/api/schemas.test.ts` and `backend/src/services/roomStore.test.ts` +- [X] T019 [P] [US2] Add guess-submission request and history snapshot coverage in `frontend/src/services/api.test.ts` +- [ ] T020 [US2] Validate trimmed, rejected, and synced guess-history behavior with `specs/003-drawing-guess-scoring/quickstart.md` + +### Implementation for User Story 2 + +- [X] T021 [US2] Enforce trimmed guess parsing and whitespace-only rejection in `backend/src/api/schemas.ts` +- [X] T022 [US2] Implement non-drawer guess submission and ordered guess-history append in `backend/src/services/roomStore.ts` +- [X] T023 [US2] Add `/rooms/:code/guesses` handling and guess error mapping in `backend/src/api/rooms.ts` +- [X] T024 [P] [US2] Add guess submission API/store methods in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` +- [X] T025 [US2] Update guess submission UI and synced history rendering in `frontend/src/components/GuessForm.tsx` and `frontend/src/pages/GamePage.tsx` + +**Checkpoint**: At this point, guessers can submit valid guesses, rejected guesses stay out of history, and shared history remains synchronized + +--- + +## Phase 5: User Story 3 - Correct Guesses Score Deterministically (Priority: P3) + +**Goal**: Accepted guesses score 100 when correct and 0 when incorrect, and all players see the same score outcomes + +**Independent Test**: Submit one correct guess and one incorrect guess in an active room, then confirm the correct guess earns 100 points, the incorrect guess earns 0 points, and both tabs show the same totals + +### Verification for User Story 3 ⚠️ + +- [X] T026 [P] [US3] Add case-insensitive matching and deterministic 100-or-0 score coverage in `backend/src/services/roomStore.test.ts` +- [X] T027 [P] [US3] Add score-bearing gameplay snapshot coverage in `frontend/src/services/api.test.ts` +- [ ] T028 [US3] Validate score outcomes and room isolation with `specs/003-drawing-guess-scoring/quickstart.md` + +### Implementation for User Story 3 + +- [X] T029 [US3] Add score totals, guess outcome fields, and viewer gameplay permission fields in `backend/src/models/game.ts` +- [X] T030 [US3] Implement case-insensitive guess evaluation and deterministic score assignment in `backend/src/services/roomStore.ts` +- [X] T031 [P] [US3] Expose score totals and viewer can-draw/can-guess fields in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` +- [X] T032 [US3] Render score totals, role-specific gameplay states, and guess score outcomes in `frontend/src/pages/GamePage.tsx` and `frontend/src/styles/app.css` + +**Checkpoint**: All Scenario 3 drawing, guess-history, and deterministic scoring behavior should now be independently functional + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation and artifact alignment across the completed Scenario 3 slice + +- [ ] T033 [P] Refresh Scenario 3 behavior notes in `specs/003-drawing-guess-scoring/quickstart.md` and `specs/003-drawing-guess-scoring/contracts/rooms-scenario3.openapi.yaml` if implementation wording changed +- [X] T034 Run backend validation for `backend/src/models/game.ts`, `backend/src/services/roomStore.ts`, `backend/src/api/schemas.ts`, and `backend/src/api/rooms.ts` with `cd backend && npm test && npm run build` +- [X] T035 Run frontend validation for `frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts`, `frontend/src/components/DrawingSurface.tsx`, `frontend/src/components/GuessForm.tsx`, and `frontend/src/pages/GamePage.tsx` with `cd frontend && npm test && npm run build` +- [ ] T036 Run the final end-to-end multi-tab Scenario 3 checks in `specs/003-drawing-guess-scoring/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Foundational completion and defines the MVP slice +- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow User Story 1 so guesses operate against the shared canvas and active round state +- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from User Stories 1 and 2 being in place so score outcomes can be validated end to end +- **Polish (Phase 6)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: No dependency on other user stories; establishes the shared drawing surface and drawer-only canvas control +- **User Story 2 (P2)**: Uses the active round and canvas flow from User Story 1 to support synchronized guess submission and history +- **User Story 3 (P3)**: Uses the accepted guess flow from User Story 2 to assign and display deterministic score outcomes + +### Within Each User Story + +- Verification tasks MUST be completed before the story is treated as done +- Shared types before services +- Services before routes or client state integration +- Client state before page-level UI behavior +- Manual two-tab validation before moving to the next priority + +### Parallel Opportunities + +- `T003` can run in parallel with `T001-T002` +- `T005-T007` can run in parallel once `T004` is defined +- `T010-T011`, `T018-T019`, and `T026-T027` can run in parallel within their user stories +- `T016`, `T024`, and `T031` can run in parallel with their paired backend work once the backend contract is stable +- `T033` can run in parallel with final validation once implementation is complete + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch User Story 1 automated verification together: +Task: "Add drawer-only drawing and clear-canvas coverage in backend/src/services/roomStore.test.ts" +Task: "Add drawing and clear-canvas request coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 1 implementation work together: +Task: "Implement drawer-only stroke append and clear-canvas behavior in backend/src/services/roomStore.ts" +Task: "Add draw and clear-canvas API/store methods in frontend/src/services/api.ts and frontend/src/state/roomStore.ts" +``` + +## Parallel Example: User Story 2 + +```bash +# Launch User Story 2 automated verification together: +Task: "Add guess-trimming, empty-guess rejection, and ordered history coverage in backend/src/api/schemas.test.ts and backend/src/services/roomStore.test.ts" +Task: "Add guess-submission request and history snapshot coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 2 implementation work together: +Task: "Implement non-drawer guess submission and ordered guess-history append in backend/src/services/roomStore.ts" +Task: "Add guess submission API/store methods in frontend/src/services/api.ts and frontend/src/state/roomStore.ts" +``` + +## Parallel Example: User Story 3 + +```bash +# Launch User Story 3 automated verification together: +Task: "Add case-insensitive matching and deterministic 100-or-0 score coverage in backend/src/services/roomStore.test.ts" +Task: "Add score-bearing gameplay snapshot coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 3 implementation work together: +Task: "Implement case-insensitive guess evaluation and deterministic score assignment in backend/src/services/roomStore.ts" +Task: "Expose score totals and viewer can-draw/can-guess fields in frontend/src/services/api.ts and frontend/src/state/roomStore.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Confirm drawer-only drawing and shared clear-canvas behavior with two tabs +5. Demo the active-round canvas interaction on top of Scenario 2 + +### Incremental Delivery + +1. Complete Setup + Foundational -> shared gameplay contract ready +2. Add User Story 1 -> validate shared drawing and clear-canvas -> MVP complete +3. Add User Story 2 -> validate trimmed guesses, blank-guess rejection, and synced history +4. Add User Story 3 -> validate deterministic 100-or-0 scoring and shared totals +5. Finish with Phase 6 validation and artifact cleanup + +### Parallel Team Strategy + +1. Team completes Setup + Foundational together +2. After foundation is stable: + - Developer A: backend service and route changes for the active story + - Developer B: frontend store, component, and page changes for the active story + - Developer C: automated verification updates in `backend/src/**/*.test.ts` and `frontend/src/services/api.test.ts` +3. Rejoin for manual two-tab validation at the end of each story + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] labels map tasks to specific user stories for traceability +- Every task includes an exact file path and can be executed without additional artifact discovery +- Suggested MVP scope: Phase 3 / User Story 1 only From 57d76b501ab7ab0f01bde3688407be6973b93cb2 Mon Sep 17 00:00:00 2001 From: Vishal S Date: Sat, 30 May 2026 15:08:34 +0530 Subject: [PATCH 7/9] Add Scenario 4 spec artifacts --- .specify/feature.json | 2 +- .../checklists/requirements.md | 36 ++ .../contracts/rooms-scenario4.openapi.yaml | 461 ++++++++++++++++++ specs/004-result-restart-flow/data-model.md | 144 ++++++ specs/004-result-restart-flow/plan.md | 323 ++++++++++++ specs/004-result-restart-flow/quickstart.md | 56 +++ specs/004-result-restart-flow/research.md | 104 ++++ specs/004-result-restart-flow/spec.md | 196 ++++++++ 8 files changed, 1321 insertions(+), 1 deletion(-) create mode 100644 specs/004-result-restart-flow/checklists/requirements.md create mode 100644 specs/004-result-restart-flow/contracts/rooms-scenario4.openapi.yaml create mode 100644 specs/004-result-restart-flow/data-model.md create mode 100644 specs/004-result-restart-flow/plan.md create mode 100644 specs/004-result-restart-flow/quickstart.md create mode 100644 specs/004-result-restart-flow/research.md create mode 100644 specs/004-result-restart-flow/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index dcb05c9..7e1bf4f 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/003-drawing-guess-scoring" + "feature_directory": "specs/004-result-restart-flow" } diff --git a/specs/004-result-restart-flow/checklists/requirements.md b/specs/004-result-restart-flow/checklists/requirements.md new file mode 100644 index 0000000..7ac45fd --- /dev/null +++ b/specs/004-result-restart-flow/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Scenario 4 Result State and Restart + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-30 +**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 + +- [x] Specification is limited to Scenario 4 result-state and restart behavior. +- [x] Exclusions for multiple rounds, drawer rotation, timers, bonuses, + persistence, and new out-of-scope features are explicitly documented. diff --git a/specs/004-result-restart-flow/contracts/rooms-scenario4.openapi.yaml b/specs/004-result-restart-flow/contracts/rooms-scenario4.openapi.yaml new file mode 100644 index 0000000..5406c7a --- /dev/null +++ b/specs/004-result-restart-flow/contracts/rooms-scenario4.openapi.yaml @@ -0,0 +1,461 @@ +openapi: 3.1.0 +info: + title: Scribble Scenario 4 Rooms API + version: 1.0.0 + description: > + Contract for Scenario 4 result-state transition, completed-round review, + and host-only restart. The round ends on the first correct accepted guess. + Multiple rounds, drawer rotation, timers, bonuses, persistence, and new + out-of-scope features are excluded. +paths: + /rooms: + post: + summary: Create a room with a validated player name + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + playerName: + type: string + responses: + "201": + description: Room created + content: + application/json: + schema: + $ref: "#/components/schemas/RoomSessionResponse" + "400": + $ref: "#/components/responses/BadRequest" + /rooms/{code}/join: + post: + summary: Join an existing room with a validated player name + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + playerName: + type: string + responses: + "200": + description: Room joined + content: + application/json: + schema: + $ref: "#/components/schemas/RoomSessionResponse" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + /rooms/{code}: + get: + summary: Fetch the latest viewer-specific room snapshot + parameters: + - $ref: "#/components/parameters/RoomCode" + - name: participantId + in: query + required: false + schema: + type: string + responses: + "200": + description: Room snapshot loaded + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + /rooms/{code}/start: + post: + summary: Start the room and initialize the deterministic round state + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [participantId] + properties: + participantId: + type: string + responses: + "200": + description: Room entered playing state + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /rooms/{code}/drawing: + post: + summary: Append one drawer-authored stroke to the shared canvas + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [participantId, points] + properties: + participantId: + type: string + points: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/DrawingPoint" + responses: + "200": + description: Stroke accepted and snapshot updated + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /rooms/{code}/drawing/clear: + post: + summary: Clear the shared canvas for the active room + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [participantId] + properties: + participantId: + type: string + responses: + "200": + description: Canvas cleared and snapshot updated + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /rooms/{code}/guesses: + post: + summary: Submit a trimmed guess; the first correct accepted guess ends the round + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [participantId, guess] + properties: + participantId: + type: string + guess: + type: string + responses: + "200": + description: Guess accepted and room snapshot updated + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /rooms/{code}/restart: + post: + summary: Restart a completed room back to the lobby while preserving players + parameters: + - $ref: "#/components/parameters/RoomCode" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [participantId] + properties: + participantId: + type: string + responses: + "200": + description: Room restarted to lobby + content: + application/json: + schema: + type: object + additionalProperties: false + required: [room] + properties: + room: + $ref: "#/components/schemas/RoomSnapshot" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" +components: + parameters: + RoomCode: + name: code + in: path + required: true + schema: + type: string + pattern: "^[A-Z0-9]{4}$" + responses: + BadRequest: + description: Invalid request payload or malformed room data + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Forbidden: + description: Viewer is not allowed to perform this room action + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: Room not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Conflict: + description: Room state does not allow the requested action + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + schemas: + Participant: + type: object + additionalProperties: false + required: [id, name, joinedAt, score] + properties: + id: + type: string + name: + type: string + joinedAt: + type: string + format: date-time + score: + type: integer + minimum: 0 + DrawingPoint: + type: object + additionalProperties: false + required: [x, y] + properties: + x: + type: number + y: + type: number + DrawingStroke: + type: object + additionalProperties: false + required: [id, points, drawnByParticipantId, createdAt] + properties: + id: + type: string + points: + type: array + items: + $ref: "#/components/schemas/DrawingPoint" + drawnByParticipantId: + type: string + createdAt: + type: string + format: date-time + CanvasState: + type: object + additionalProperties: false + required: [strokes] + properties: + strokes: + type: array + items: + $ref: "#/components/schemas/DrawingStroke" + clearedAt: + type: string + format: date-time + GuessHistoryEntry: + type: object + additionalProperties: false + required: + - id + - participantId + - participantName + - guess + - isCorrect + - scoreAwarded + - submittedAt + properties: + id: + type: string + participantId: + type: string + participantName: + type: string + guess: + type: string + isCorrect: + type: boolean + scoreAwarded: + type: integer + enum: [0, 100] + submittedAt: + type: string + format: date-time + RoomSnapshot: + type: object + additionalProperties: false + required: + - code + - status + - hostParticipantId + - participants + - viewerIsHost + - canStartGame + - canRestartGame + - minimumPlayersToStart + - viewerIsDrawer + - viewerCanDraw + - viewerCanGuess + properties: + code: + type: string + pattern: "^[A-Z0-9]{4}$" + status: + type: string + enum: [lobby, playing, results] + hostParticipantId: + type: string + participants: + type: array + items: + $ref: "#/components/schemas/Participant" + viewerIsHost: + type: boolean + canStartGame: + type: boolean + canRestartGame: + type: boolean + minimumPlayersToStart: + type: integer + enum: [2] + drawerParticipantId: + type: string + viewerIsDrawer: + type: boolean + viewerCanDraw: + type: boolean + viewerCanGuess: + type: boolean + wordVisibility: + type: string + enum: [visible, hidden] + secretWord: + type: string + roundEndedAt: + type: string + format: date-time + canvas: + $ref: "#/components/schemas/CanvasState" + guessHistory: + type: array + items: + $ref: "#/components/schemas/GuessHistoryEntry" + RoomSessionResponse: + type: object + additionalProperties: false + required: [participantId, room] + properties: + participantId: + type: string + room: + $ref: "#/components/schemas/RoomSnapshot" + ErrorResponse: + type: object + additionalProperties: false + required: [message] + properties: + message: + type: string diff --git a/specs/004-result-restart-flow/data-model.md b/specs/004-result-restart-flow/data-model.md new file mode 100644 index 0000000..610fbb7 --- /dev/null +++ b/specs/004-result-restart-flow/data-model.md @@ -0,0 +1,144 @@ +# Data Model: Scenario 4 Result State and Restart + +## Room + +**Purpose**: Represents one isolated multiplayer room that can move from lobby, +to active play, to post-round results, and back to lobby on restart. + +**Fields**: + +- `code`: unique 4-character room identifier +- `status`: `"lobby" | "playing" | "results"` +- `hostParticipantId`: participant ID for the room host +- `participants`: ordered room members with score totals +- `round`: current round state, present during `playing` and `results` +- `createdAt`: room creation timestamp +- `updatedAt`: last room mutation timestamp + +**Validation Rules**: + +- `hostParticipantId` must reference a participant while the room is healthy +- room mutations stay isolated by room code +- `round` is absent only in `lobby` +- `status = "results"` requires a completed `round` + +## Participant + +**Purpose**: Represents a room member visible in lobby, gameplay, and results. + +**Fields**: + +- `id`: unique participant identifier +- `name`: accepted trimmed display name +- `joinedAt`: timestamp when the participant entered the room +- `score`: running total for the current or most recently completed round + +**Validation Rules**: + +- accepted names are already trimmed before storage +- `score` starts at `0` +- `score` increases only through accepted correct guesses during play +- `score` resets to `0` when a room restarts to lobby + +## Round State + +**Purpose**: Represents the active or completed round attached to a room during +Scenario 3 and Scenario 4. + +**Fields**: + +- `drawerParticipantId`: participant assigned as drawer +- `secretWord`: deterministic word selected at round start +- `startedAt`: timestamp for when the round began +- `endedAt`: timestamp for when the first correct accepted guess ended the + round, absent while still playing +- `canvas`: shared drawing state for the round +- `guessHistory`: ordered list of accepted guess entries + +**Validation Rules**: + +- `drawerParticipantId` must reference a participant in the room +- `endedAt` is absent during `playing` and present during `results` +- once `endedAt` is set, draw and guess mutations are rejected +- `guessHistory` remains the source of truth for final result review + +## Guess History Entry + +**Purpose**: Represents one accepted guess and its deterministic score outcome. + +**Fields**: + +- `id`: stable guess identifier +- `participantId`: player who submitted the guess +- `participantName`: trimmed display name at submission time +- `guess`: trimmed accepted guess text +- `normalizedGuess`: comparison form used for case-insensitive matching +- `isCorrect`: whether the guess matched the active secret word +- `scoreAwarded`: `100` or `0` +- `submittedAt`: timestamp for when the guess was accepted + +**Validation Rules**: + +- whitespace-only guesses are rejected before entry creation +- drawer-submitted guesses are rejected and never create entries +- accepted entries are appended in submission order only +- the first entry with `isCorrect = true` is also the entry that triggers + `playing -> results` + +## Result Snapshot + +**Purpose**: Represents the viewer-visible room payload returned while a room is +in `results`. + +**Fields**: + +- shared room fields from earlier scenarios +- `status = "results"` +- `participants`: final score totals for the completed round +- `drawerParticipantId`: completed round drawer +- `secretWord`: visible to every viewer +- `wordVisibility = "visible"` for every viewer +- `guessHistory`: full accepted guess history from the completed round +- `viewerCanDraw = false` +- `viewerCanGuess = false` +- `canRestartGame`: whether the viewer may restart the room +- `roundEndedAt`: timestamp carried from the completed round + +**Derived Rules**: + +- `canRestartGame = viewerParticipantId === hostParticipantId && status === "results"` +- `secretWord` is visible to all viewers only in `results` +- result snapshots remain stable until the room is restarted + +## Restarted Lobby State + +**Purpose**: Represents the room immediately after a successful Scenario 4 +restart. + +**Fields**: + +- same `code` +- same `hostParticipantId` +- same ordered `participants` +- `status = "lobby"` +- no `round` +- participant `score = 0` for every member + +**Validation Rules**: + +- only the host can trigger this state +- restart is valid only from `results` +- no prior secret word, canvas, guess history, or drawer assignment may remain + +## State Transitions + +- `lobby -> playing`: host starts a room with at least two players +- `playing -> results`: first correct accepted guess is recorded +- `results -> lobby`: host restarts the room + +## Room Snapshot Permissions + +- `canStartGame = true` only for the host in `lobby` with enough players +- `viewerCanDraw = true` only for the drawer in `playing` +- `viewerCanGuess = true` only for non-drawers in `playing` +- `canRestartGame = true` only for the host in `results` diff --git a/specs/004-result-restart-flow/plan.md b/specs/004-result-restart-flow/plan.md new file mode 100644 index 0000000..0dfa35b --- /dev/null +++ b/specs/004-result-restart-flow/plan.md @@ -0,0 +1,323 @@ +# Implementation Plan: Scenario 4 Result State and Restart + +**Branch**: `assignment` | **Date**: 2026-05-30 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/004-result-restart-flow/spec.md` + +**Note**: This plan is limited to Scenario 4 result-state and restart behavior. + +## Summary + +Extend the existing Scenario 3 gameplay flow so the first correct accepted +guess ends the round, preserves the completed round data for a shared result +state, reveals the secret word and final scores to all players, and then allows +only the host to restart the room back to a clean lobby with the same roster. +The backend remains authoritative for result transitions, restart permissions, +and round-state cleanup, while the frontend continues to consume one viewer- +specific room snapshot contract that now supports `results` and host restart +controls. + +## Technical Context + +**Language/Version**: TypeScript 5.x on Node.js 18+ (backend) and React 18 +with Vite (frontend) + +**Primary Dependencies**: Express, Zod, React, React Router, Vite, Vitest + +**Storage**: In-memory room and game state only + +**Testing**: `cd backend && npm test`, `cd frontend && npm test`, plus manual +two-tab browser validation for multiplayer flows + +**Target Platform**: Node.js backend and modern desktop browser clients + +**Project Type**: Monorepo web application (`backend/` + `frontend/`) + +**Performance Goals**: Result-state transitions and restart resets should appear +to the acting tab immediately and to other tabs within one polling interval, +with a default target of about 2 seconds for cross-tab convergence + +**Constraints**: HTTP polling only; no WebSockets; no database/persistence; no +authentication/session layer; keep room memory footprint minimal; preserve the +starter architecture; keep scope strictly to Scenario 4 + +**Scale/Scope**: Small multiplayer rooms running one completed round at a time, +with post-round review and manual restart validated through local multi-tab +testing + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] The change is scoped to a concrete scenario/user story and preserves the + README checkpoint order unless a deviation is justified. +- [x] All changed backend boundaries have explicit TypeScript types and Zod + validation for request/response payloads. +- [x] Multiplayer synchronization remains HTTP polling against in-memory state + only; no forbidden persistence or realtime transport is introduced. +- [x] The plan preserves the existing monorepo structure and documents any new + dependency or abstraction that materially expands the surface area. +- [x] Verification covers every touched surface, including affected builds, + affected tests, and manual two-tab validation for multiplayer/UI flows. + +**Post-Design Re-Check**: Pass. The design keeps the existing room-centric +in-memory model, extends the same viewer-specific snapshot contract to cover +`results`, uses one additional host-only room action for restart, and stays +within the assignment’s polling-based single-round progression without adding +multiple rounds, timers, persistence, or new infrastructure. + +## Project Structure + +### Documentation (this feature) + +```text +specs/004-result-restart-flow/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── rooms-scenario4.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +backend/ +└── src/ + ├── api/ + │ ├── rooms.ts + │ ├── schemas.ts + │ └── schemas.test.ts + ├── models/ + │ └── game.ts + └── services/ + ├── roomStore.ts + └── roomStore.test.ts + +frontend/ +└── src/ + ├── pages/ + │ ├── GamePage.tsx + │ └── LobbyPage.tsx + ├── services/ + │ ├── api.ts + │ └── api.test.ts + ├── state/ + │ └── roomStore.ts + └── styles/ + └── app.css +``` + +**Structure Decision**: Keep result transitions and restart rules in +`backend/src/services`, request validation in `backend/src/api`, and all +client-facing result/lobby state orchestration in the existing frontend store +and pages. No new package, router namespace, or client state library is needed. + +## Phase 0: Research Outcomes + +- Extend room status from `lobby | playing` to `lobby | playing | results` + instead of introducing a second room-like entity or archived results store. +- End the round inside the existing guess-submission path when the first correct + accepted guess is recorded so scoring and status transition remain one atomic + backend mutation. +- Preserve the completed round on `room.round` during `results` so the correct + word, final scores, guess history, and any retained canvas state are all + served from one stable snapshot until restart. +- Expose result-state behavior through the same `RoomSnapshot` contract used by + create, join, start, fetch, drawing, clearing, and guessing, adding only the + minimal new viewer fields needed for restart and result display. +- Implement restart as one host-only room-scoped action that resets the room to + lobby state in place, preserving room code, host, and roster while clearing + round-specific fields and zeroing participant scores. +- Keep the frontend on the existing route structure by rendering both active + gameplay and result-state variants from `GamePage`, then relying on the + existing lobby route after restart. +- Continue to use action responses for the acting tab and polling for other tabs + so result transitions and restart propagation remain responsive without + introducing forbidden realtime transport. + +See [research.md](./research.md) for decisions, rationale, and alternatives. + +## Phase 1: Design + +### Backend Model Changes + +- Update `backend/src/models/game.ts` to + extend `RoomStatus` with a `results` state. +- Keep `round` present while the room is in `playing` or `results`, and extend + `RoundState` with explicit completion metadata such as: + - `endedAt` + - any derived completion marker needed to distinguish active play from + completed results within the same round object +- Extend `RoomSnapshot` with result-aware fields such as: + - `canRestartGame` + - `roundEndedAt` + - existing `secretWord` visibility rules adapted so all viewers receive the + completed word in `results` +- Preserve participant score totals during `results` and reset them only when a + new round starts or a restart returns the room to the lobby. + +### Result-State Transition Flow + +1. A room begins in the existing Scenario 3 `playing` state. +2. A non-drawer submits a trimmed accepted guess. +3. The backend evaluates the guess case-insensitively against the active secret + word. +4. If the guess is incorrect, the backend appends guess history, awards `0`, + keeps the room in `playing`, and returns the updated gameplay snapshot. +5. If the guess is correct, the backend appends guess history, awards `100`, + updates the participant score, stamps the round as ended, and changes + `room.status` to `results` in the same mutation. +6. Once in `results`, drawing and guessing actions are no longer valid and must + return a room-state conflict error. +7. Fetch responses for all viewers in the room now show the completed word, + final scores, and full guess history until the host restarts. + +### Restart and Reset Design + +- Add a host-only restart action on the existing rooms API rather than creating + a separate session or room-management surface. +- Restart is allowed only when: + - the requester is the room host + - the room is currently in `results` +- A successful restart must: + - keep the same room code + - keep the same host participant ID + - keep the current ordered player roster + - reset all participant scores to `0` + - clear `round` + - set `status` back to `lobby` + - update timestamps +- Restart must not: + - auto-start a new round + - retain prior secret word visibility + - retain prior guess history, drawer assignment, or drawing state + - affect other rooms in any state + +### Backend Validation and Request Changes + +- Update `backend/src/api/schemas.ts` to + add a restart request schema using the same participant identity rules as + start. +- Keep existing draw, clear-canvas, and guess payload shapes unchanged. +- Extend `backend/src/api/rooms.ts` with a + `POST /rooms/:code/restart` route that returns the updated viewer-specific + room snapshot. +- Preserve consistent error mapping: + - `403` for non-host restart attempts + - `409` for restart attempts before `results` + - `409` for draw or guess attempts after the room has already ended + +### Backend Service Changes + +- Update `backend/src/services/roomStore.ts` + to add deterministic helpers for: + - transitioning a room from `playing` to `results` + - restarting a room from `results` to `lobby` + - constructing result-aware room snapshots +- Keep guess submission as the authoritative place where completion occurs so + the winning guess, final score change, and status transition remain atomic. +- Reuse the existing active-room lookup pattern, but split active-play checks + from result-state checks where needed so restart and result fetches remain + explicit. +- Ensure score resets happen on restart and on future new-round starts so stale + totals never leak across rounds. + +### Viewer-Visible Result Snapshot Behavior + +- `RoomSnapshot` remains the only room/game payload returned to the frontend. +- Shared result fields visible to every viewer: + - `status = "results"` + - completed `secretWord` + - final participant scores + - full accepted guess history + - preserved drawer identity from the completed round +- Viewer-specific fields: + - `viewerCanDraw = false` in `results` + - `viewerCanGuess = false` in `results` + - `canRestartGame = true` only for the host while the room is in `results` + - `wordVisibility = "visible"` for all viewers in `results` +- Lobby snapshots after restart should return: + - no `round` + - no result data + - reset participant scores + - normal Scenario 1 start eligibility rules + +### Frontend Room Store, Game, Result, and Lobby Impacts + +- Extend `frontend/src/services/api.ts` + room snapshot types to include: + - `status: "lobby" | "playing" | "results"` + - `canRestartGame` + - any round completion metadata exposed by the backend +- Add a `restartGame` API method and matching action in + `frontend/src/state/roomStore.ts`. +- Update `frontend/src/pages/GamePage.tsx` + to support two view modes from the same route: + - active `playing` gameplay + - read-only `results` review with secret word, final scores, full guess + history, and host-only restart control +- Keep polling active on `GamePage` for both `playing` and `results` so the + observing tab converges on the result transition and on restart. +- Update `frontend/src/pages/LobbyPage.tsx` + so non-lobby room states continue routing users to the game/result screen, + while restarted rooms settle back into the preserved lobby roster and reset + start controls. +- Update `frontend/src/styles/app.css` + only as needed for result banners, score emphasis, restart controls, and + lobby-after-restart messaging. + +### File-Level Change Plan + +- `backend/src/models/game.ts`: add `results` status and result-state snapshot + fields +- `backend/src/services/roomStore.ts`: implement correct-guess result + transition, host-only restart, round reset, and result-aware snapshots +- `backend/src/services/roomStore.test.ts`: cover first-correct-guess round + ending, result visibility, restart permissions, reset behavior, and room + isolation +- `backend/src/api/schemas.ts`: add restart request validation +- `backend/src/api/schemas.test.ts`: cover restart request validation and any + updated status-specific schema assumptions +- `backend/src/api/rooms.ts`: add restart route and result/restart conflict + handling +- `frontend/src/services/api.ts`: extend snapshot types and add restart action +- `frontend/src/services/api.test.ts`: cover result snapshots and restart + request behavior +- `frontend/src/state/roomStore.ts`: add restart action wiring and status-aware + snapshot updates +- `frontend/src/pages/GamePage.tsx`: render result state, disable gameplay + controls in results, and expose host-only restart +- `frontend/src/pages/LobbyPage.tsx`: handle post-restart lobby recovery and + route non-lobby states back to `/game` +- `frontend/src/styles/app.css`: add result/restart presentation states + +### Validation Strategy + +- Automated backend validation: + - room-store tests for correct guess transitioning `playing -> results` + - room-store tests for word visibility and final-score consistency in results + - room-store tests for host-only restart and restart-before-results rejection + - room-store tests for score reset, round clear, and room isolation after + restart + - schema tests for restart request validation +- Automated frontend validation: + - API service tests for result-state fetch and restart action responses + - API service tests for `results` snapshot typing and lobby-after-restart + snapshots +- Manual two-tab validation: + - start a room and submit a correct guess to end the round + - confirm both tabs enter `results` and reveal the same word, final scores, + and full guess history + - confirm guessing and drawing controls are no longer usable in `results` + - attempt restart from the non-host tab and confirm rejection + - restart from the host tab and confirm both tabs return to the same lobby + roster with cleared round data and reset scores + - repeat the flow with a second room active and confirm restart isolation + +## Complexity Tracking + +No constitution exceptions or additional architectural complexity are required +for this feature. diff --git a/specs/004-result-restart-flow/quickstart.md b/specs/004-result-restart-flow/quickstart.md new file mode 100644 index 0000000..eb041e3 --- /dev/null +++ b/specs/004-result-restart-flow/quickstart.md @@ -0,0 +1,56 @@ +# Quickstart: Scenario 4 Result State and Restart + +## Prerequisites + +- Node.js 18+ and npm 9+ +- At least two browser tabs +- Scenario 1, Scenario 2, and Scenario 3 behavior working locally + +## Run the apps + +```bash +cd backend +npm install +npm run dev +``` + +```bash +cd frontend +npm install +npm run dev +``` + +## Validate Scenario 4 + +1. Open the frontend in Tab A and create a room. +2. Join the same room from Tab B. +3. Start the game from Tab A and confirm Tab A is the drawer. +4. Submit one incorrect guess from Tab B and confirm the room stays in active + play. +5. Submit the correct word from Tab B. +6. Confirm both tabs enter the result state within one polling cycle. +7. Confirm both tabs now show the correct word, the same final scores, and the + full accepted guess history. +8. Confirm drawing and guess submission are no longer available in the result + state. +9. Attempt restart from Tab B and confirm it is rejected with clear feedback. +10. Restart from Tab A and confirm both tabs return to the lobby with the same + player roster and room code. +11. Confirm the restarted lobby no longer exposes the previous word, drawing + state, guess history, drawer assignment, or prior scores. +12. Repeat the flow with a second room active and confirm ending or restarting + one room does not affect the other. + +## Automated checks + +```bash +cd backend +npm test +npm run build +``` + +```bash +cd frontend +npm test +npm run build +``` diff --git a/specs/004-result-restart-flow/research.md b/specs/004-result-restart-flow/research.md new file mode 100644 index 0000000..22a86d8 --- /dev/null +++ b/specs/004-result-restart-flow/research.md @@ -0,0 +1,104 @@ +# Research: Scenario 4 Result State and Restart + +## Decision: Model post-round review as a third room status + +**Rationale**: Scenario 4 needs a room-wide state that is neither lobby nor +active play. Extending the existing room status to include `results` preserves +the same room lifecycle, keeps polling behavior unchanged, and avoids creating +an archived-results store or separate result session object. + +**Alternatives considered**: + +- Infer results from `playing` plus a hidden round-ended flag + Rejected because the client and routes still need a first-class state change + for permissions and navigation. +- Create a second room-like result entity + Rejected because it duplicates room identity and complicates restart. + +## Decision: End the round inside correct guess submission + +**Rationale**: The first correct accepted guess is the only specified round-end +trigger. Performing score update, winning guess append, and `playing -> results` +transition in one backend mutation keeps result snapshots deterministic and +prevents transient states where a guess is marked correct but the room has not +yet ended. + +**Alternatives considered**: + +- End the round in a separate follow-up action + Rejected because it introduces a race between scoring and result transition. +- End the round on a timer + Rejected because timers are explicitly out of scope. + +## Decision: Preserve completed round data on the room until restart + +**Rationale**: Result state requires the correct word, final scores, and full +guess history to remain visible to all players after play stops. Keeping the +completed round on `room.round` preserves one authoritative snapshot for polling +clients and makes restart cleanup a single reset operation. + +**Alternatives considered**: + +- Copy final results into a separate summary object and discard the round + Rejected because it duplicates state and adds translation logic without user + value. +- Hide the round immediately after the correct guess + Rejected because it prevents players from reviewing the completed outcome. + +## Decision: Add a host-only restart action on the existing rooms API + +**Rationale**: Restart is a room-scoped permissioned mutation, just like start, +drawing, clearing, and guessing. Adding `POST /rooms/{code}/restart` keeps room +lookup, participant checks, and snapshot responses consistent with the existing +API design. + +**Alternatives considered**: + +- Restart by creating a new room + Rejected because the specification requires preserving room code and roster. +- Restart through a frontend-only state reset + Rejected because multiplayer state must remain backend-authoritative. + +## Decision: Extend the viewer-specific room snapshot instead of creating a separate result payload + +**Rationale**: The frontend already depends on one viewer-specific `RoomSnapshot` +for lobby and gameplay flows. Extending that snapshot with `results`, +`canRestartGame`, and shared word visibility keeps the client contract simple +and ensures fetch, guess, and restart responses all converge on the same shape. + +**Alternatives considered**: + +- Add a separate `/results` fetch payload + Rejected because the client would need to coordinate multiple polling shapes + for one room. +- Keep result permissions implicit in frontend logic only + Rejected because restart permission should remain explicit and backend-driven. + +## Decision: Reuse the existing game route for results and the existing lobby route after restart + +**Rationale**: Scenario 4 adds a new state to the same room flow, not a new top- +level product surface. Rendering `playing` and `results` from `GamePage` keeps +the route graph stable, minimizes state duplication, and allows the existing +post-restart redirect back to lobby to stay simple. + +**Alternatives considered**: + +- Add a dedicated `/results` route + Rejected because it adds route branching without new scenario value. +- Keep users on the lobby page during results + Rejected because result review is an extension of the active round, not lobby + setup. + +## Decision: Keep synchronization as action-response plus polling + +**Rationale**: The acting tab should see result and restart changes immediately +from the action response, while other tabs catch up through the existing +polling interval. This preserves acceptable responsiveness and remains fully +within the polling-only constitution constraint. + +**Alternatives considered**: + +- Wait for polling even on the acting tab + Rejected because it adds unnecessary delay to obvious state changes. +- Introduce push-based result notifications + Rejected because realtime transport is forbidden. diff --git a/specs/004-result-restart-flow/spec.md b/specs/004-result-restart-flow/spec.md new file mode 100644 index 0000000..6f513e7 --- /dev/null +++ b/specs/004-result-restart-flow/spec.md @@ -0,0 +1,196 @@ +# Feature Specification: Scenario 4 Result State and Restart + +**Feature Branch**: `assignment` + +**Created**: 2026-05-30 + +**Status**: Draft + +**Input**: User description: "Scenario 4 result state, restart flow, and final +validation where all players see the correct word, final scores, and full guess +history after the round ends, and the host can restart to return everyone to +the lobby with players preserved and all round state cleared. Keep this limited +to Scenario 4 only. Exclude multiple rounds, drawer rotation, timers, bonuses, +persistence, and any new out-of-scope features." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Players Review Round Results (Priority: P1) + +When the active round ends, every player in the room can review the completed +round outcome, including the correct word, the final scores, and the full guess +history. + +**Why this priority**: Scenario 4 has no value unless players can reliably see +how the finished round resolved before any restart behavior is introduced. + +**Independent Test**: Complete a round with a correct guess, then confirm in at +least two tabs that every player sees the same correct word, the same final +scores, and the same full guess history for that room. + +**Acceptance Scenarios**: + +1. **Given** a round is in progress, **When** a correct guess ends that round, + **Then** the room enters a result state instead of remaining in active play. +2. **Given** a room is in the result state, **When** any player views that room, + **Then** they see the correct word from the completed round. +3. **Given** a room is in the result state, **When** any player reviews the + round outcome, **Then** they see the final scores and the full accepted guess + history from that completed round. + +--- + +### User Story 2 - Host Restarts the Room (Priority: P2) + +After players review the completed round, the host can restart the room so the +same group returns to the lobby and can begin a fresh game setup. + +**Why this priority**: Restart is the control that lets the room continue after +results are reviewed without forcing players to create or join a new room. + +**Independent Test**: Finish a round, restart from the host tab, and confirm +that every player returns to the lobby in the same room with the same player +list still present. + +**Acceptance Scenarios**: + +1. **Given** a room is in the result state, **When** the host restarts the + room, **Then** the room returns to the lobby and preserves the existing + player roster. +2. **Given** a non-host player is viewing the result state, **When** they try + to restart the room, **Then** the restart is rejected with clear feedback. +3. **Given** a round has not finished yet, **When** any player attempts a + restart, **Then** the restart is rejected and active play continues. + +--- + +### User Story 3 - Restart Clears Round State Cleanly (Priority: P3) + +When the host restarts, all round-specific data is removed so the room is ready +for a clean return to the lobby without leaking prior gameplay details. + +**Why this priority**: Preserving players is only useful if the next lobby state +starts cleanly and does not carry over outdated round data. + +**Independent Test**: Finish a round, restart it, and confirm in at least two +tabs that the room keeps the same players but no longer exposes the finished +word, round scores, guess history, or drawing state. + +**Acceptance Scenarios**: + +1. **Given** a room has finished a round and entered the result state, **When** + the host restarts it, **Then** the room clears the completed round's word, + drawing state, guess history, and score totals. +2. **Given** a room has restarted to the lobby, **When** players refresh that + room, **Then** they all receive the same clean lobby state for that room. +3. **Given** another room is still in play or in results, **When** one room is + restarted, **Then** the other room's state is unchanged. + +### Edge Cases + +- A player opens the result state after the round ended and must still see the + same final word, scores, and guess history as the rest of the room. +- A non-host attempts to restart from the result state. +- The host attempts to restart before the room has reached the result state. +- A restarted room must not retain the previous round's drawing surface, secret + word, guess history, or score totals. +- Restarting one room must not affect another room that is still playing or has + already finished. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST end the active round and transition the room into + a result state when a correct accepted guess is recorded. +- **FR-002**: The system MUST reveal the completed round's correct word to every + player while the room is in the result state. +- **FR-003**: The system MUST display the final score totals for every player in + the room while the room is in the result state. +- **FR-004**: The system MUST preserve and display the full accepted guess + history from the completed round while the room is in the result state. +- **FR-005**: The system MUST keep the result state synchronized for all players + in the same room through scheduled refreshes. +- **FR-006**: The system MUST allow only the host to restart a room. +- **FR-007**: The system MUST allow restart only after the room has entered the + result state. +- **FR-008**: The system MUST return a restarted room to the lobby while + preserving the room code, host identity, and current player roster. +- **FR-009**: The system MUST clear all round-specific state when a room is + restarted, including the completed word, drawer assignment, drawing state, + guess history, and score totals. +- **FR-010**: The system MUST provide clear feedback when a restart is rejected + because the requester is not the host or the room is not ready to restart. +- **FR-011**: The system MUST keep result-state and restart behavior isolated to + the affected room only. +- **FR-012**: This feature MUST exclude multiple rounds, drawer rotation, + timers, bonuses, persistence, and any new out-of-scope features. + +### Key Entities *(include if feature involves data)* + +- **Result State**: The post-round room state that reveals the completed round + outcome to every player before a restart occurs. +- **Final Scoreboard**: The ordered set of player score totals captured at the + end of the completed round. +- **Completed Guess History**: The full room-specific record of accepted guesses + from the finished round that remains visible during results. +- **Restarted Lobby State**: The preserved room roster and host ownership after + round-specific gameplay data has been cleared. + +## Constraints & Non-Goals *(mandatory)* + +- **CN-001**: Result-state and restart updates in this scenario MUST continue + using scheduled refreshes rather than instant push-based updates. +- **CN-002**: Result-state and restart data in this scenario MUST remain + temporary for the current runtime only and are not expected to survive a + service restart. +- **CN-003**: Players MUST continue to access rooms without sign-in, account + creation, or identity verification features. +- **CN-004**: This feature MUST build directly on the existing Scenario 1, + Scenario 2, and Scenario 3 gameplay flow without introducing unrelated + product areas. +- **CN-005**: The scope is limited to Scenario 4 result-state and restart + behavior only. +- **CN-006**: Multiple rounds, drawer rotation, timers, bonuses, persistence, + and any new out-of-scope features are explicit non-goals for this + specification. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In two-tab validation, once a correct guess ends the round, all + players in the same room see the same correct word, final scores, and full + guess history within one scheduled refresh cycle. +- **SC-002**: In repeated validation, 100% of restart attempts by non-host + players are rejected and do not change room state. +- **SC-003**: In two-tab validation, a host-triggered restart returns every + player in the room to the lobby with the same player roster within one + scheduled refresh cycle. +- **SC-004**: In repeated validation, every successful restart clears prior + round word visibility, round scores, guess history, and drawing state while + leaving other rooms unchanged. + +## Assumptions + +- Scenario 3 already provides the active round, guess evaluation, accepted guess + history, and score totals that Scenario 4 will expose after the round ends. +- Because timers and manual round ending are out of scope, the first correct + accepted guess is the event that ends the round and opens the result state. +- Restart returns players to the same room code and preserves the same host so + the existing group can begin again from the lobby. +- Players can keep at least two browser tabs open during multiplayer + validation. + +## Verification Plan *(mandatory)* + +- Validate that a correct guess transitions the room from active play into a + shared result state. +- Validate that every player in the same room sees the correct word, final + scores, and full guess history after the round ends. +- Validate that only the host can restart and that restart is rejected before + the room reaches the result state. +- Validate that restarting returns all players to the lobby with the same room + roster and no leftover round data. +- Validate that restarting one room does not change another room's active or + finished state. From 93ce435995129d1a2ad8b29ad0ff2cc5c460f809 Mon Sep 17 00:00:00 2001 From: Vishal S Date: Sat, 30 May 2026 15:08:42 +0530 Subject: [PATCH 8/9] Implement Scenario 4 result and restart flow --- backend/src/api/rooms.ts | 16 ++ backend/src/api/schemas.test.ts | 7 + backend/src/api/schemas.ts | 4 + backend/src/models/game.ts | 5 +- backend/src/services/roomStore.test.ts | 156 +++++++++++++++- backend/src/services/roomStore.ts | 138 +++++++++++--- frontend/src/pages/GamePage.tsx | 87 +++++++-- frontend/src/pages/LobbyPage.tsx | 2 +- frontend/src/services/api.test.ts | 117 ++++++++++-- frontend/src/services/api.ts | 11 +- frontend/src/state/roomStore.ts | 12 ++ frontend/src/styles/app.css | 29 +++ specs/004-result-restart-flow/tasks.md | 244 +++++++++++++++++++++++++ 13 files changed, 764 insertions(+), 64 deletions(-) create mode 100644 specs/004-result-restart-flow/tasks.md diff --git a/backend/src/api/rooms.ts b/backend/src/api/rooms.ts index 0e542d6..19faa13 100644 --- a/backend/src/api/rooms.ts +++ b/backend/src/api/rooms.ts @@ -6,6 +6,7 @@ import { drawingStrokeSchema, HttpError, joinRoomSchema, + restartRoomSchema, roomCodeParamsSchema, roomViewerQuerySchema, startRoomSchema, @@ -17,6 +18,7 @@ import { createRoom, getRoom, joinRoom, + restartRoom, startRoom, submitGuess, toRoomSnapshot @@ -141,6 +143,20 @@ export function createRoomsRouter() { } }); + router.post("/:code/restart", (request, response, next) => { + try { + const { code } = roomCodeParamsSchema.parse(request.params); + const { participantId } = restartRoomSchema.parse(request.body); + const result = restartRoom(code, participantId); + + assertRoomActionSucceeded(result); + + response.json(createRoomSnapshotResponse(result.room, participantId)); + } catch (error) { + 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 20a92f7..985ec81 100644 --- a/backend/src/api/schemas.test.ts +++ b/backend/src/api/schemas.test.ts @@ -4,6 +4,7 @@ import { createRoomSchema, drawingStrokeSchema, joinRoomSchema, + restartRoomSchema, roomCodeParamsSchema, startRoomSchema, submitGuessSchema @@ -58,6 +59,12 @@ describe("schemas", () => { ); }); + it("restartRoomSchema requires a participantId", () => { + expect(() => restartRoomSchema.parse({ participantId: " " })).toThrow( + "Participant ID is required" + ); + }); + it("drawingStrokeSchema accepts normalized drawing points", () => { const result = drawingStrokeSchema.parse({ participantId: "p1", diff --git a/backend/src/api/schemas.ts b/backend/src/api/schemas.ts index 046b84f..4ed1d31 100644 --- a/backend/src/api/schemas.ts +++ b/backend/src/api/schemas.ts @@ -46,6 +46,10 @@ export const startRoomSchema = z.object({ participantId: participantIdSchema }); +export const restartRoomSchema = z.object({ + participantId: participantIdSchema +}); + export const drawingStrokeSchema = z.object({ participantId: participantIdSchema, points: z.array(drawingPointSchema).min(1, { diff --git a/backend/src/models/game.ts b/backend/src/models/game.ts index 1d4d014..d502824 100644 --- a/backend/src/models/game.ts +++ b/backend/src/models/game.ts @@ -1,5 +1,5 @@ export type ParticipantRole = "drawer" | "guesser"; -export type RoomStatus = "lobby" | "playing"; +export type RoomStatus = "lobby" | "playing" | "results"; export type WordVisibility = "visible" | "hidden"; export type ScoreAward = 0 | 100; @@ -52,6 +52,7 @@ export interface RoundState { drawerParticipantId: string; secretWord: string; startedAt: string; + endedAt?: string; canvas: CanvasState; guessHistory: StoredGuessEntry[]; } @@ -73,6 +74,7 @@ export interface RoomSnapshot { participants: Participant[]; viewerIsHost: boolean; canStartGame: boolean; + canRestartGame: boolean; minimumPlayersToStart: number; drawerParticipantId?: string; viewerIsDrawer: boolean; @@ -80,6 +82,7 @@ export interface RoomSnapshot { viewerCanGuess: boolean; wordVisibility?: WordVisibility; secretWord?: string; + roundEndedAt?: string; canvas?: CanvasState; guessHistory?: GuessHistoryEntry[]; } diff --git a/backend/src/services/roomStore.test.ts b/backend/src/services/roomStore.test.ts index 5d9d409..5d27586 100644 --- a/backend/src/services/roomStore.test.ts +++ b/backend/src/services/roomStore.test.ts @@ -5,6 +5,7 @@ import { createRoom, getRoom, joinRoom, + restartRoom, saveRoom, startRoom, submitGuess, @@ -70,6 +71,7 @@ describe("roomStore", () => { expect(snapshot.viewerCanDraw).toBe(false); expect(snapshot.viewerCanGuess).toBe(false); expect(snapshot.canStartGame).toBe(false); + expect(snapshot.canRestartGame).toBe(false); expect(snapshot.minimumPlayersToStart).toBe(2); }); @@ -118,7 +120,7 @@ describe("roomStore", () => { }); }); - it("startRoom assigns the host as drawer and initializes Scenario 3 round state", () => { + it("startRoom assigns the host as drawer and initializes round state", () => { const host = createRoom("Alice"); const joined = joinRoom(host.room.code, "Bob"); @@ -134,6 +136,7 @@ describe("roomStore", () => { expect(started.room.status).toBe("playing"); expect(started.room.round?.drawerParticipantId).toBe(host.participantId); expect(started.room.round?.secretWord).toBeDefined(); + expect(started.room.round?.endedAt).toBeUndefined(); expect(started.room.round?.canvas.strokes).toHaveLength(0); expect(started.room.round?.guessHistory).toHaveLength(0); @@ -144,6 +147,7 @@ describe("roomStore", () => { expect(hostSnapshot.viewerIsDrawer).toBe(true); expect(hostSnapshot.viewerCanDraw).toBe(true); expect(hostSnapshot.viewerCanGuess).toBe(false); + expect(hostSnapshot.canRestartGame).toBe(false); expect(hostSnapshot.wordVisibility).toBe("visible"); expect(hostSnapshot.secretWord).toBe(started.room.round?.secretWord); expect(hostSnapshot.guessHistory).toEqual([]); @@ -297,6 +301,8 @@ describe("roomStore", () => { return; } + expect(result.room.status).toBe("results"); + expect(result.room.round?.endedAt).toBeDefined(); expect(result.room.round?.guessHistory).toHaveLength(1); expect(result.room.round?.guessHistory[0].guess).toBe(secretWord.toUpperCase()); expect(result.room.round?.guessHistory[0].isCorrect).toBe(true); @@ -305,7 +311,21 @@ describe("roomStore", () => { const updatedGuest = result.room.participants.find((participant) => participant.id === guest!.participantId); expect(updatedGuest?.score).toBe(100); + const hostSnapshot = toRoomSnapshot(result.room, host.participantId); const guestSnapshot = toRoomSnapshot(result.room, guest!.participantId); + + expect(hostSnapshot.wordVisibility).toBe("visible"); + expect(hostSnapshot.secretWord).toBe(secretWord); + expect(hostSnapshot.viewerCanDraw).toBe(false); + expect(hostSnapshot.viewerCanGuess).toBe(false); + expect(hostSnapshot.canRestartGame).toBe(true); + expect(hostSnapshot.roundEndedAt).toBeDefined(); + + expect(guestSnapshot.wordVisibility).toBe("visible"); + expect(guestSnapshot.secretWord).toBe(secretWord); + expect(guestSnapshot.viewerCanDraw).toBe(false); + expect(guestSnapshot.viewerCanGuess).toBe(false); + expect(guestSnapshot.canRestartGame).toBe(false); expect(guestSnapshot.guessHistory?.[0].scoreAwarded).toBe(100); }); @@ -329,6 +349,7 @@ describe("roomStore", () => { return; } + expect(secondGuess.room.status).toBe("playing"); expect(secondGuess.room.round?.guessHistory).toHaveLength(2); expect(secondGuess.room.round?.guessHistory[0].participantId).toBe(guest!.participantId); expect(secondGuess.room.round?.guessHistory[1].participantId).toBe(third!.participantId); @@ -346,7 +367,121 @@ describe("roomStore", () => { expect(thirdParticipant?.score).toBe(0); }); - it("Scenario 3 drawing and guesses stay isolated per room", () => { + it("restartRoom rejects non-host participants", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + + expect(guest).not.toBeNull(); + + const started = startRoom(host.room.code, host.participantId); + expect(started.ok).toBe(true); + if (!started.ok) { + return; + } + + submitGuess(host.room.code, guest!.participantId, started.room.round!.secretWord); + + const result = restartRoom(host.room.code, guest!.participantId); + + expect(result).toEqual({ + ok: false, + reason: "forbidden", + message: "Only the host can restart the game" + }); + }); + + it("restartRoom rejects attempts before the room has reached results", () => { + const host = createRoom("Alice"); + joinRoom(host.room.code, "Bob"); + startRoom(host.room.code, host.participantId); + + const result = restartRoom(host.room.code, host.participantId); + + expect(result).toEqual({ + ok: false, + reason: "conflict", + message: "Game is not ready to restart" + }); + }); + + it("restartRoom returns the room to the lobby with the same roster and cleared round state", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + + expect(guest).not.toBeNull(); + + const started = startRoom(host.room.code, host.participantId); + expect(started.ok).toBe(true); + if (!started.ok) { + return; + } + + addDrawingStroke(host.room.code, host.participantId, [{ x: 0.5, y: 0.5 }]); + const completed = submitGuess(host.room.code, guest!.participantId, started.room.round!.secretWord); + + expect(completed.ok).toBe(true); + if (!completed.ok) { + return; + } + + const restarted = restartRoom(host.room.code, host.participantId); + + expect(restarted.ok).toBe(true); + if (!restarted.ok) { + return; + } + + expect(restarted.room.status).toBe("lobby"); + expect(restarted.room.code).toBe(host.room.code); + expect(restarted.room.hostParticipantId).toBe(host.participantId); + expect(restarted.room.participants).toHaveLength(2); + expect(restarted.room.participants.every((participant) => participant.score === 0)).toBe(true); + expect(restarted.room.round).toBeUndefined(); + + const hostSnapshot = toRoomSnapshot(restarted.room, host.participantId); + const guestSnapshot = toRoomSnapshot(restarted.room, guest!.participantId); + + expect(hostSnapshot.canStartGame).toBe(true); + expect(hostSnapshot.canRestartGame).toBe(false); + expect(hostSnapshot.secretWord).toBeUndefined(); + expect(hostSnapshot.guessHistory).toBeUndefined(); + expect(hostSnapshot.canvas).toBeUndefined(); + + expect(guestSnapshot.canStartGame).toBe(false); + expect(guestSnapshot.secretWord).toBeUndefined(); + expect(guestSnapshot.guessHistory).toBeUndefined(); + }); + + it("drawing and guessing are rejected once a room has entered results", () => { + const host = createRoom("Alice"); + const guest = joinRoom(host.room.code, "Bob"); + + expect(guest).not.toBeNull(); + + const started = startRoom(host.room.code, host.participantId); + expect(started.ok).toBe(true); + if (!started.ok) { + return; + } + + submitGuess(host.room.code, guest!.participantId, started.room.round!.secretWord); + + const drawResult = addDrawingStroke(host.room.code, host.participantId, [{ x: 0.1, y: 0.1 }]); + const guessResult = submitGuess(host.room.code, guest!.participantId, "late guess"); + + expect(drawResult).toEqual({ + ok: false, + reason: "conflict", + message: "Game is not in an active round" + }); + expect(guessResult).toEqual({ + ok: false, + reason: "conflict", + message: "Game is not in an active round" + }); + }); + + it("Scenario 4 result transitions and restart stay isolated per room", () => { const firstRoom = createRoom("Alice"); const secondRoom = createRoom("Cara"); const firstGuest = joinRoom(firstRoom.room.code, "Bob"); @@ -355,14 +490,21 @@ describe("roomStore", () => { expect(firstGuest).not.toBeNull(); expect(secondGuest).not.toBeNull(); - startRoom(firstRoom.room.code, firstRoom.participantId); - startRoom(secondRoom.room.code, secondRoom.participantId); + const firstStarted = startRoom(firstRoom.room.code, firstRoom.participantId); + const secondStarted = startRoom(secondRoom.room.code, secondRoom.participantId); + + expect(firstStarted.ok).toBe(true); + expect(secondStarted.ok).toBe(true); + if (!firstStarted.ok || !secondStarted.ok) { + return; + } - addDrawingStroke(firstRoom.room.code, firstRoom.participantId, [{ x: 0.2, y: 0.2 }]); - submitGuess(firstRoom.room.code, firstGuest!.participantId, "miss"); + submitGuess(firstRoom.room.code, firstGuest!.participantId, firstStarted.room.round!.secretWord); + restartRoom(firstRoom.room.code, firstRoom.participantId); const untouchedRoom = getRoom(secondRoom.room.code); - expect(untouchedRoom?.round?.canvas.strokes).toHaveLength(0); + expect(untouchedRoom?.status).toBe("playing"); + expect(untouchedRoom?.round?.endedAt).toBeUndefined(); expect(untouchedRoom?.round?.guessHistory).toHaveLength(0); expect(untouchedRoom?.participants.every((participant) => participant.score === 0)).toBe(true); }); diff --git a/backend/src/services/roomStore.ts b/backend/src/services/roomStore.ts index 867d63b..98df941 100644 --- a/backend/src/services/roomStore.ts +++ b/backend/src/services/roomStore.ts @@ -6,6 +6,7 @@ import type { Participant, Room, RoomSnapshot, + RoundState, ScoreAward, StoredGuessEntry } from "../models/game.js"; @@ -107,15 +108,28 @@ function toGuessHistoryEntry(entry: StoredGuessEntry): GuessHistoryEntry { }; } +function resetParticipantScores(participants: Participant[]) { + return participants.map((participant) => ({ + ...participant, + score: 0 + })); +} + +type FailureReason = "not-found" | "forbidden" | "conflict"; type RoomActionResult = | { ok: true; room: Room } - | { ok: false; reason: "not-found" | "forbidden" | "conflict"; message: string }; + | { ok: false; reason: FailureReason; message: string }; -type ActiveRoundRoom = Room & { round: NonNullable }; +type ActiveRoundRoom = Room & { status: "playing"; round: NonNullable & { endedAt?: undefined } }; +type ResultsRoom = Room & { status: "results"; round: NonNullable & { endedAt: string } }; type ActiveRoundActionResult = | { ok: true; room: ActiveRoundRoom } - | { ok: false; reason: "not-found" | "forbidden" | "conflict"; message: string }; + | { ok: false; reason: FailureReason; message: string }; + +type ResultsActionResult = + | { ok: true; room: ResultsRoom } + | { ok: false; reason: FailureReason; message: string }; function canStartRoom(room: Room, viewerParticipantId?: string) { return ( @@ -125,7 +139,11 @@ function canStartRoom(room: Room, viewerParticipantId?: string) { ); } -function getActiveRoundRoom(code: string): ActiveRoundActionResult { +function canRestartRoom(room: Room, viewerParticipantId?: string) { + return room.status === "results" && room.hostParticipantId === viewerParticipantId; +} + +function getPlayingRoom(code: string): ActiveRoundActionResult { const room = rooms.get(code); if (!room) { @@ -136,7 +154,7 @@ function getActiveRoundRoom(code: string): ActiveRoundActionResult { }; } - if (room.status !== "playing" || !room.round) { + if (room.status !== "playing" || !room.round || room.round.endedAt) { return { ok: false, reason: "conflict", @@ -150,6 +168,41 @@ function getActiveRoundRoom(code: string): ActiveRoundActionResult { }; } +function getResultsRoom(code: string): ResultsActionResult { + const room = rooms.get(code); + + if (!room) { + return { + ok: false, + reason: "not-found", + message: "Room code was not found" + }; + } + + if (room.status !== "results" || !room.round || !room.round.endedAt) { + return { + ok: false, + reason: "conflict", + message: "Game is not ready to restart" + }; + } + + return { + ok: true, + room: room as ResultsRoom + }; +} + +function createRoundState(room: Room, drawer: Participant): RoundState { + return { + drawerParticipantId: drawer.id, + secretWord: getSecretWord(room, drawer), + startedAt: now(), + canvas: createEmptyCanvas(), + guessHistory: [] + }; +} + export function createRoom(playerName?: string) { const participant = createParticipant(playerName); const room: Room = { @@ -229,18 +282,9 @@ export function startRoom(code: string, participantId: string): RoomActionResult const drawer = getDrawer(room); - room.participants = room.participants.map((participant) => ({ - ...participant, - score: 0 - })); + room.participants = resetParticipantScores(room.participants); room.status = "playing"; - room.round = { - drawerParticipantId: drawer.id, - secretWord: getSecretWord(room, drawer), - startedAt: now(), - canvas: createEmptyCanvas(), - guessHistory: [] - }; + room.round = createRoundState(room, drawer); room.updatedAt = now(); rooms.set(room.code, room); @@ -251,13 +295,13 @@ export function startRoom(code: string, participantId: string): RoomActionResult } export function addDrawingStroke(code: string, participantId: string, points: DrawingPoint[]): RoomActionResult { - const activeRoom = getActiveRoundRoom(code); + const activeRoom = getPlayingRoom(code); if (!activeRoom.ok) { return activeRoom; } - const { room } = activeRoom; + const room = activeRoom.room as Room & { round: RoundState }; const { round } = room; const participant = getParticipant(room, participantId); @@ -293,13 +337,13 @@ export function addDrawingStroke(code: string, participantId: string, points: Dr } export function clearRoomCanvas(code: string, participantId: string): RoomActionResult { - const activeRoom = getActiveRoundRoom(code); + const activeRoom = getPlayingRoom(code); if (!activeRoom.ok) { return activeRoom; } - const { room } = activeRoom; + const room = activeRoom.room as Room & { round: RoundState }; const { round } = room; const participant = getParticipant(room, participantId); @@ -333,13 +377,13 @@ export function clearRoomCanvas(code: string, participantId: string): RoomAction } export function submitGuess(code: string, participantId: string, guess: string): RoomActionResult { - const activeRoom = getActiveRoundRoom(code); + const activeRoom = getPlayingRoom(code); if (!activeRoom.ok) { return activeRoom; } - const { room } = activeRoom; + const room = activeRoom.room as Room & { round: RoundState }; const { round } = room; const participant = getParticipant(room, participantId); @@ -363,6 +407,7 @@ export function submitGuess(code: string, participantId: string, guess: string): const normalizedGuess = normalizeGuess(trimmedGuess); const isCorrect = normalizedGuess === normalizeGuess(round.secretWord); const scoreAwarded: ScoreAward = isCorrect ? 100 : 0; + const submittedAt = now(); round.guessHistory.push({ id: randomUUID(), @@ -372,9 +417,44 @@ export function submitGuess(code: string, participantId: string, guess: string): normalizedGuess, isCorrect, scoreAwarded, - submittedAt: now() + submittedAt }); participant.score += scoreAwarded; + + if (isCorrect) { + round.endedAt = submittedAt; + room.status = "results"; + } + + room.updatedAt = submittedAt; + rooms.set(room.code, room); + + return { + ok: true, + room: cloneRoom(room) + }; +} + +export function restartRoom(code: string, participantId: string): RoomActionResult { + const completedRoom = getResultsRoom(code); + + if (!completedRoom.ok) { + return completedRoom; + } + + const room: Room = completedRoom.room; + + if (room.hostParticipantId !== participantId) { + return { + ok: false, + reason: "forbidden", + message: "Only the host can restart the game" + }; + } + + room.participants = resetParticipantScores(room.participants); + room.status = "lobby"; + room.round = undefined; room.updatedAt = now(); rooms.set(room.code, room); @@ -396,6 +476,12 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn const viewerIsDrawer = room.round?.drawerParticipantId === viewerParticipantId; const viewerCanDraw = room.status === "playing" && viewerIsDrawer; const viewerCanGuess = room.status === "playing" && Boolean(viewerParticipant) && !viewerIsDrawer; + const wordVisibility = room.round + ? room.status === "results" || viewerIsDrawer + ? "visible" + : "hidden" + : undefined; + const secretWord = room.round && wordVisibility === "visible" ? room.round.secretWord : undefined; return { code: room.code, @@ -404,13 +490,15 @@ export function toRoomSnapshot(room: Room, viewerParticipantId?: string): RoomSn participants: room.participants.map((participant) => ({ ...participant })), viewerIsHost, canStartGame: canStartRoom(room, viewerParticipantId), + canRestartGame: canRestartRoom(room, viewerParticipantId), minimumPlayersToStart: MINIMUM_PLAYERS_TO_START, drawerParticipantId: room.round?.drawerParticipantId, viewerIsDrawer, viewerCanDraw, viewerCanGuess, - wordVisibility: room.round ? (viewerIsDrawer ? "visible" : "hidden") : undefined, - secretWord: viewerIsDrawer ? room.round?.secretWord : undefined, + wordVisibility, + secretWord, + roundEndedAt: room.round?.endedAt, canvas: room.round ? { strokes: room.round.canvas.strokes.map((stroke) => ({ diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index bfa5eb8..421d2a7 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -12,6 +12,7 @@ export function GamePage() { const roomStore = useRoomStore(); const { room, participantId, error, isLoading } = useRoomState(); const [refreshError, setRefreshError] = useState(null); + const [restartError, setRestartError] = useState(null); useEffect(() => { if (!room) { @@ -25,7 +26,7 @@ export function GamePage() { }, [navigate, room]); useEffect(() => { - if (!room || room.status !== "playing") { + if (!room || room.status === "lobby") { return undefined; } @@ -66,42 +67,75 @@ export function GamePage() { await roomStore.submitGuess(guess); } + async function handleRestartGame() { + try { + setRestartError(null); + await roomStore.restartGame(); + } catch (caughtError) { + setRestartError(caughtError instanceof Error ? caughtError.message : "Unable to restart game"); + } + } + if (!room) { return null; } + const isResults = room.status === "results"; const viewer = room.participants.find((participant) => participant.id === participantId) ?? null; const drawer = room.participants.find((participant) => participant.id === room.drawerParticipantId) ?? null; const wordStatus = - room.wordVisibility === "visible" ? "Secret word visible" : "Secret word hidden"; + room.wordVisibility === "visible" + ? isResults + ? "Correct word revealed" + : "Secret word visible" + : "Secret word hidden"; const wordValue = room.wordVisibility === "visible" ? room.secretWord ?? "Unavailable" : "Only the drawer can see the word right now."; const drawerStatus = room.viewerIsDrawer - ? "You are the drawer for this round." - : `${drawer?.name ?? "Another player"} is drawing this round.`; + ? isResults + ? "You were the drawer for this completed round." + : "You are the drawer for this round." + : `${drawer?.name ?? "Another player"} ${isResults ? "drew the completed round." : "is drawing this round."}`; const history = room.guessHistory ?? []; const canvas = room.canvas ?? { strokes: [] }; - const statusMessage = - refreshError ?? error ?? (room.viewerCanDraw ? "Draw something for the guessers." : "Watch the sketch and submit guesses."); + const statusMessage = isResults + ? restartError ?? + refreshError ?? + error ?? + (room.viewerIsHost + ? "Round complete. Review the results and restart when everyone is ready." + : "Round complete. Waiting for the host to restart the room.") + : refreshError ?? error ?? (room.viewerCanDraw ? "Draw something for the guessers." : "Watch the sketch and submit guesses."); return (
    - Round 1 -

    Live Gameplay

    + {isResults ? "Round complete" : "Round 1"} +

    {isResults ? "Round Results" : "Live Gameplay"}

    + {isResults ? ( +
    +

    Results synchronized

    +

    {room.roundEndedAt ? "The round ended on the first correct accepted guess." : "The completed round is ready for review."}

    +
    + ) : null} +
    - + - + {history.length === 0 ? (
    -

    No guesses have been accepted yet.

    +

    {isResults ? "No accepted guesses were recorded before the round ended." : "No guesses have been accepted yet."}

    ) : (
      @@ -191,7 +229,7 @@ export function GamePage() {
    - {participant.score} pts + + {participant.score} pts + ); })} - - {room.viewerCanGuess ? ( + + {isResults ? ( +
    +

    {room.canRestartGame ? "You can restart the room when everyone is ready." : "Only the host can restart this completed room."}

    + +
    + ) : room.viewerCanGuess ? ( ) : (
    diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index c563e82..bf9d554 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -19,7 +19,7 @@ export function LobbyPage() { }, [navigate, room]); useEffect(() => { - if (room?.status === "playing") { + if (room?.status === "playing" || room?.status === "results") { navigate("/game", { replace: true }); } }, [navigate, room?.status]); diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index f330216..4383561 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -1,33 +1,37 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "./api"; -function createPlayingSnapshot(overrides: Record = {}) { +function createSnapshot( + status: "lobby" | "playing" | "results", + overrides: Record = {} +) { return { code: "ABCD", - status: "playing", + status, hostParticipantId: "p1", participants: [ { id: "p1", name: "Alice", joinedAt: "2026-05-30T12:00:00.000Z", - score: 0 + score: status === "results" ? 0 : 0 }, { id: "p2", name: "Bob", joinedAt: "2026-05-30T12:00:01.000Z", - score: 0 + score: status === "results" ? 100 : 0 } ], viewerIsHost: false, canStartGame: false, + canRestartGame: false, minimumPlayersToStart: 2, drawerParticipantId: "p1", viewerIsDrawer: false, viewerCanDraw: false, - viewerCanGuess: true, - wordVisibility: "hidden", + viewerCanGuess: status === "playing", + wordVisibility: status === "results" ? "visible" : "hidden", canvas: { strokes: [] }, @@ -54,6 +58,7 @@ describe("api service", () => { participants: [], viewerIsHost: true, canStartGame: false, + canRestartGame: false, minimumPlayersToStart: 2, viewerIsDrawer: false, viewerCanDraw: false, @@ -86,6 +91,7 @@ describe("api service", () => { participants: [], viewerIsHost: true, canStartGame: false, + canRestartGame: false, minimumPlayersToStart: 2, viewerIsDrawer: false, viewerCanDraw: false, @@ -116,6 +122,7 @@ describe("api service", () => { participants: [], viewerIsHost: false, canStartGame: false, + canRestartGame: false, minimumPlayersToStart: 2, viewerIsDrawer: false, viewerCanDraw: false, @@ -141,7 +148,7 @@ describe("api service", () => { ok: true, json: () => Promise.resolve({ - room: createPlayingSnapshot({ + room: createSnapshot("playing", { viewerIsHost: true, viewerIsDrawer: true, viewerCanDraw: true, @@ -164,12 +171,59 @@ describe("api service", () => { ); }); + it("restartGame sends POST to /rooms/:code/restart with participantId in body", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: createSnapshot("lobby", { + viewerIsHost: true, + canStartGame: true, + canRestartGame: false, + viewerCanDraw: false, + viewerCanGuess: false, + drawerParticipantId: undefined, + wordVisibility: undefined, + secretWord: undefined, + roundEndedAt: undefined, + canvas: undefined, + guessHistory: undefined, + participants: [ + { + id: "p1", + name: "Alice", + joinedAt: "2026-05-30T12:00:00.000Z", + score: 0 + }, + { + id: "p2", + name: "Bob", + joinedAt: "2026-05-30T12:00:01.000Z", + score: 0 + } + ] + }) + }) + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + await api.restartGame("ABCD", "p1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/rooms/ABCD/restart"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ participantId: "p1" }) + }) + ); + }); + it("drawStroke sends POST to /rooms/:code/drawing with normalized points", async () => { const mockResponse = { ok: true, json: () => Promise.resolve({ - room: createPlayingSnapshot({ + room: createSnapshot("playing", { viewerIsHost: true, viewerIsDrawer: true, viewerCanDraw: true, @@ -217,7 +271,7 @@ describe("api service", () => { ok: true, json: () => Promise.resolve({ - room: createPlayingSnapshot({ + room: createSnapshot("playing", { viewerIsHost: true, viewerIsDrawer: true, viewerCanDraw: true, @@ -247,7 +301,9 @@ describe("api service", () => { ok: true, json: () => Promise.resolve({ - room: createPlayingSnapshot({ + room: createSnapshot("results", { + secretWord: "rocket", + roundEndedAt: "2026-05-30T12:03:00.000Z", guessHistory: [ { id: "guess-1", @@ -294,7 +350,7 @@ describe("api service", () => { ok: true, json: () => Promise.resolve({ - room: createPlayingSnapshot({ + room: createSnapshot("playing", { canvas: { strokes: [ { @@ -329,4 +385,43 @@ describe("api service", () => { expect(response.room.guessHistory?.[0].scoreAwarded).toBe(0); expect(response.room.secretWord).toBeUndefined(); }); + + it("fetchRoom supports polling a result snapshot with shared word and restart metadata", async () => { + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + room: createSnapshot("results", { + viewerIsHost: true, + canRestartGame: true, + viewerCanDraw: false, + viewerCanGuess: false, + wordVisibility: "visible", + secretWord: "rocket", + roundEndedAt: "2026-05-30T12:03:00.000Z", + guessHistory: [ + { + id: "guess-1", + participantId: "p2", + participantName: "Bob", + guess: "Rocket", + isCorrect: true, + scoreAwarded: 100, + submittedAt: "2026-05-30T12:03:00.000Z" + } + ] + }) + }) + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response); + + const response = await api.fetchRoom("ABCD", "p1"); + + expect(response.room.status).toBe("results"); + expect(response.room.canRestartGame).toBe(true); + expect(response.room.secretWord).toBe("rocket"); + expect(response.room.roundEndedAt).toBeDefined(); + expect(response.room.viewerCanDraw).toBe(false); + expect(response.room.viewerCanGuess).toBe(false); + }); }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3a53f69..bc56af1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -5,6 +5,7 @@ export interface Participant { score: number; } +export type RoomStatus = "lobby" | "playing" | "results"; export type WordVisibility = "visible" | "hidden"; export type ScoreAward = 0 | 100; @@ -37,11 +38,12 @@ export interface GuessHistoryEntry { export interface RoomSnapshot { code: string; - status: "lobby" | "playing"; + status: RoomStatus; hostParticipantId: string; participants: Participant[]; viewerIsHost: boolean; canStartGame: boolean; + canRestartGame: boolean; minimumPlayersToStart: number; drawerParticipantId?: string; viewerIsDrawer: boolean; @@ -49,6 +51,7 @@ export interface RoomSnapshot { viewerCanGuess: boolean; wordVisibility?: WordVisibility; secretWord?: string; + roundEndedAt?: string; canvas?: CanvasState; guessHistory?: GuessHistoryEntry[]; } @@ -99,6 +102,12 @@ export const api = { body: JSON.stringify({ participantId }) }); }, + restartGame(code: string, participantId: string) { + return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}/restart`, { + method: "POST", + body: JSON.stringify({ participantId }) + }); + }, fetchRoom(code: string, participantId?: string) { const query = participantId ? `?participantId=${encodeURIComponent(participantId)}` : ""; return request<{ room: RoomSnapshot }>(`/rooms/${encodeURIComponent(code)}${query}`); diff --git a/frontend/src/state/roomStore.ts b/frontend/src/state/roomStore.ts index 565c0ac..222e0a4 100644 --- a/frontend/src/state/roomStore.ts +++ b/frontend/src/state/roomStore.ts @@ -107,6 +107,18 @@ class RoomStore { return response.room; } + async restartGame() { + if (!this.state.room || !this.state.participantId) { + throw new Error("No active room session"); + } + + const response = await this.withLoading(() => + api.restartGame(this.state.room!.code, this.state.participantId!) + ); + this.setRoomSnapshot(response.room); + return response.room; + } + async drawStroke(points: DrawingPoint[]) { if (!this.state.room || !this.state.participantId) { throw new Error("No active room session"); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index d09b218..0b69270 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -414,6 +414,10 @@ input { font-size: 0.95rem; } +.player-list__score--final { + color: #166534; +} + .status-line, .placeholder-note { width: max-content; @@ -563,6 +567,21 @@ input { letter-spacing: -0.025em; } +.result-banner { + display: grid; + gap: 12px; + padding: 20px 24px; + border: 1px solid #bbf7d0; + border-radius: 16px; + background: #f0fdf4; +} + +.result-banner p:last-child { + margin: 0; + color: #166534; + font-size: 1rem; +} + /* Responsive grid: 3-column layout */ .game-page__layout { display: grid; @@ -685,6 +704,16 @@ input { gap: 8px; } +.result-actions { + display: grid; + gap: 16px; +} + +.result-actions p { + margin: 0; + color: var(--ink-soft); +} + @media (max-width: 720px) { .app-shell { padding: 24px 16px; diff --git a/specs/004-result-restart-flow/tasks.md b/specs/004-result-restart-flow/tasks.md new file mode 100644 index 0000000..dc22605 --- /dev/null +++ b/specs/004-result-restart-flow/tasks.md @@ -0,0 +1,244 @@ +--- + +description: "Task list for Scenario 4 result state and restart implementation" + +--- + +# Tasks: Scenario 4 Result State and Restart + +**Input**: Design documents from `/specs/004-result-restart-flow/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Include automated backend and frontend API coverage plus manual two-tab validation for each user story. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Web app**: `backend/src/`, `frontend/src/` +- Paths below follow this repository's monorepo layout + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm the active Scenario 4 artifacts, validation targets, and result/restart boundaries before editing code + +- [X] T001 Review implementation inputs in `specs/004-result-restart-flow/spec.md`, `specs/004-result-restart-flow/plan.md`, and `specs/004-result-restart-flow/contracts/rooms-scenario4.openapi.yaml` +- [X] T002 Confirm manual and automated validation steps in `specs/004-result-restart-flow/quickstart.md`, `backend/src/api/rooms.ts`, `frontend/src/pages/GamePage.tsx`, and `frontend/src/pages/LobbyPage.tsx` +- [X] T003 [P] Capture shared result-state, restart, and reset expectations from `specs/004-result-restart-flow/data-model.md` and `specs/004-result-restart-flow/research.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared result-state, restart contract, and status-model changes that all Scenario 4 stories depend on + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 Update shared room status, round completion, and result-snapshot types in `backend/src/models/game.ts` +- [X] T005 [P] Extend shared result/restart room snapshot and session response types in `frontend/src/services/api.ts` +- [X] T006 [P] Add reusable restart request schema support in `backend/src/api/schemas.ts` +- [X] T007 [P] Add shared restart action support in `frontend/src/state/roomStore.ts` +- [X] T008 Implement shared result-state snapshot derivation and restart/reset helpers in `backend/src/services/roomStore.ts` +- [X] T009 Implement shared result/restart response mapping in `backend/src/api/rooms.ts` + +**Checkpoint**: Foundation ready - user story implementation can now begin in priority order + +--- + +## Phase 3: User Story 1 - Players Review Round Results (Priority: P1) 🎯 MVP + +**Goal**: When the first correct accepted guess ends the round, every player can review the correct word, final scores, and full guess history in a shared result state + +**Independent Test**: Complete a round with a correct guess, then confirm in at least two tabs that every player sees the same correct word, final scores, and full guess history for that room + +### Verification for User Story 1 ⚠️ + +- [X] T010 [P] [US1] Add correct-guess result-transition and shared result-visibility coverage in `backend/src/services/roomStore.test.ts` +- [X] T011 [P] [US1] Add result-state snapshot and result-fetch coverage in `frontend/src/services/api.test.ts` +- [ ] T012 [US1] Validate shared result-state transition and completed-round review with `specs/004-result-restart-flow/quickstart.md` + +### Implementation for User Story 1 + +- [X] T013 [US1] Add result-state fields and round completion metadata in `backend/src/models/game.ts` +- [X] T014 [US1] Implement first-correct-guess transition from `playing` to `results` in `backend/src/services/roomStore.ts` +- [X] T015 [US1] Expose shared result snapshots for fetch and guess responses in `backend/src/api/rooms.ts` +- [X] T016 [P] [US1] Extend result-state API/store mapping in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` +- [X] T017 [US1] Render result-state review with shared word, final scores, and full guess history in `frontend/src/pages/GamePage.tsx` and `frontend/src/styles/app.css` + +**Checkpoint**: At this point, completed rounds should transition into a shared read-only result state without restart behavior + +--- + +## Phase 4: User Story 2 - Host Restarts the Room (Priority: P2) + +**Goal**: Only the host can restart a completed room and return all players to the lobby with the same room and roster + +**Independent Test**: Finish a round, restart from the host tab, and confirm that every player returns to the lobby in the same room with the same player list still present + +### Verification for User Story 2 ⚠️ + +- [X] T018 [P] [US2] Add host-only restart and pre-results rejection coverage in `backend/src/api/schemas.test.ts` and `backend/src/services/roomStore.test.ts` +- [X] T019 [P] [US2] Add restart request and lobby-after-restart snapshot coverage in `frontend/src/services/api.test.ts` +- [ ] T020 [US2] Validate host-only restart behavior and rejection paths with `specs/004-result-restart-flow/quickstart.md` + +### Implementation for User Story 2 + +- [X] T021 [US2] Add restart request validation in `backend/src/api/schemas.ts` +- [X] T022 [US2] Implement host-only restart flow and restart eligibility checks in `backend/src/services/roomStore.ts` +- [X] T023 [US2] Add `POST /rooms/:code/restart` handling and restart conflict mapping in `backend/src/api/rooms.ts` +- [X] T024 [P] [US2] Add restart API/store methods in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` +- [X] T025 [US2] Render host-only restart control and post-restart lobby routing behavior in `frontend/src/pages/GamePage.tsx` and `frontend/src/pages/LobbyPage.tsx` + +**Checkpoint**: At this point, hosts can restart completed rooms and non-host or too-early restart attempts are rejected + +--- + +## Phase 5: User Story 3 - Restart Clears Round State Cleanly (Priority: P3) + +**Goal**: Restart removes prior round data, resets scores, and keeps the next lobby state isolated and clean + +**Independent Test**: Finish a round, restart it, and confirm in at least two tabs that the room keeps the same players but no longer exposes the finished word, round scores, guess history, or drawing state + +### Verification for User Story 3 ⚠️ + +- [X] T026 [P] [US3] Add restart reset and room-isolation coverage in `backend/src/services/roomStore.test.ts` +- [X] T027 [P] [US3] Add cleared-lobby snapshot and result-control disablement coverage in `frontend/src/services/api.test.ts` +- [ ] T028 [US3] Validate round-state clearing, score reset, and room isolation with `specs/004-result-restart-flow/quickstart.md` + +### Implementation for User Story 3 + +- [X] T029 [US3] Finalize restart reset rules for scores, round state, and result visibility in `backend/src/models/game.ts` and `backend/src/services/roomStore.ts` +- [X] T030 [US3] Enforce no drawing or guessing after results and preserve room isolation in `backend/src/services/roomStore.ts` and `backend/src/api/rooms.ts` +- [X] T031 [P] [US3] Expose cleared lobby snapshots and disabled result-state controls in `frontend/src/services/api.ts` and `frontend/src/state/roomStore.ts` +- [X] T032 [US3] Update result and lobby UI states for cleared round data and reset participant scores in `frontend/src/pages/GamePage.tsx`, `frontend/src/pages/LobbyPage.tsx`, and `frontend/src/styles/app.css` + +**Checkpoint**: All Scenario 4 result-state, restart, and reset behavior should now be independently functional + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation and artifact alignment across the completed Scenario 4 slice + +- [ ] T033 [P] Refresh Scenario 4 behavior notes in `specs/004-result-restart-flow/quickstart.md` and `specs/004-result-restart-flow/contracts/rooms-scenario4.openapi.yaml` if implementation wording changed +- [X] T034 Run backend validation for `backend/src/models/game.ts`, `backend/src/services/roomStore.ts`, `backend/src/api/schemas.ts`, and `backend/src/api/rooms.ts` with `cd backend && npm test && npm run build` +- [X] T035 Run frontend validation for `frontend/src/services/api.ts`, `frontend/src/state/roomStore.ts`, `frontend/src/pages/GamePage.tsx`, `frontend/src/pages/LobbyPage.tsx`, and `frontend/src/styles/app.css` with `cd frontend && npm test && npm run build` +- [ ] T036 Run the final end-to-end multi-tab Scenario 4 checks in `specs/004-result-restart-flow/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Foundational completion and defines the MVP slice +- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow User Story 1 so restart works against a real result state +- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from User Stories 1 and 2 being in place so reset behavior can be validated end to end +- **Polish (Phase 6)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: No dependency on other user stories; establishes the shared result-state transition and result review +- **User Story 2 (P2)**: Uses the completed result state from User Story 1 to allow host-only restart +- **User Story 3 (P3)**: Uses the restart flow from User Story 2 to validate that all prior round state is cleared cleanly + +### Within Each User Story + +- Verification tasks MUST be completed before the story is treated as done +- Shared types before services +- Services before routes or client state integration +- Client state before page-level UI behavior +- Manual two-tab validation before moving to the next priority + +### Parallel Opportunities + +- `T003` can run in parallel with `T001-T002` +- `T005-T007` can run in parallel once `T004` is defined +- `T010-T011`, `T018-T019`, and `T026-T027` can run in parallel within their user stories +- `T016`, `T024`, and `T031` can run in parallel with their paired backend work once the backend contract is stable +- `T033` can run in parallel with final validation once implementation is complete + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch User Story 1 automated verification together: +Task: "Add correct-guess result-transition and shared result-visibility coverage in backend/src/services/roomStore.test.ts" +Task: "Add result-state snapshot and result-fetch coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 1 implementation work together: +Task: "Implement first-correct-guess transition from playing to results in backend/src/services/roomStore.ts" +Task: "Extend result-state API/store mapping in frontend/src/services/api.ts and frontend/src/state/roomStore.ts" +``` + +## Parallel Example: User Story 2 + +```bash +# Launch User Story 2 automated verification together: +Task: "Add host-only restart and pre-results rejection coverage in backend/src/api/schemas.test.ts and backend/src/services/roomStore.test.ts" +Task: "Add restart request and lobby-after-restart snapshot coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 2 implementation work together: +Task: "Implement host-only restart flow and restart eligibility checks in backend/src/services/roomStore.ts" +Task: "Add restart API/store methods in frontend/src/services/api.ts and frontend/src/state/roomStore.ts" +``` + +## Parallel Example: User Story 3 + +```bash +# Launch User Story 3 automated verification together: +Task: "Add restart reset and room-isolation coverage in backend/src/services/roomStore.test.ts" +Task: "Add cleared-lobby snapshot and result-control disablement coverage in frontend/src/services/api.test.ts" + +# Launch independent User Story 3 implementation work together: +Task: "Finalize restart reset rules for scores, round state, and result visibility in backend/src/models/game.ts and backend/src/services/roomStore.ts" +Task: "Expose cleared lobby snapshots and disabled result-state controls in frontend/src/services/api.ts and frontend/src/state/roomStore.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Confirm the room ends on the first correct accepted guess and every player sees the same completed-round results +5. Demo the result-state review on top of Scenario 3 gameplay + +### Incremental Delivery + +1. Complete Setup + Foundational -> shared result/restart contract ready +2. Add User Story 1 -> validate result-state transition and shared completed-round review -> MVP complete +3. Add User Story 2 -> validate host-only restart and rejection behavior +4. Add User Story 3 -> validate clean round-state reset and room isolation +5. Finish with Phase 6 validation and artifact cleanup + +### Parallel Team Strategy + +1. Team completes Setup + Foundational together +2. After foundation is stable: + - Developer A: backend service and route changes for the active story + - Developer B: frontend store and page changes for the active story + - Developer C: automated verification updates in `backend/src/**/*.test.ts` and `frontend/src/services/api.test.ts` +3. Rejoin for manual two-tab validation at the end of each story + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] labels map tasks to specific user stories for traceability +- Every task includes an exact file path and can be executed without additional artifact discovery +- Suggested MVP scope: Phase 3 / User Story 1 only From 6cd592e141932ffc759034d837f9346988149ea3 Mon Sep 17 00:00:00 2001 From: Vishal S Date: Sat, 30 May 2026 15:17:47 +0530 Subject: [PATCH 9/9] Add assignment reflection report --- reflection-report.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 reflection-report.md diff --git a/reflection-report.md b/reflection-report.md new file mode 100644 index 0000000..c495ecd --- /dev/null +++ b/reflection-report.md @@ -0,0 +1,37 @@ +# Scribble Assignment Reflection + +## 1. What the Starter App Already Had + +The starter app provided a TypeScript monorepo with an Express backend and React frontend, basic room creation and join flow scaffolding, polling-based room fetching, shared room state wiring on the client, and the core project structure for routes, services, models, pages, and tests. + +## 2. What Was Added Across Scenarios 1-4 + +- **Scenario 1**: Room setup and lobby flow, including host tracking, room validation, lobby polling, host-only game start, and minimum-player enforcement. +- **Scenario 2**: Deterministic game start behavior, trimmed player names, whitespace rejection, deterministic drawer assignment, deterministic secret word selection, and drawer-only word visibility. +- **Scenario 3**: Shared drawing surface, clear canvas action, trimmed guess submission, empty-guess rejection, case-insensitive guess matching, synced guess history, and deterministic `100`/`0` scoring. +- **Scenario 4**: Result-state transition on the first correct accepted guess, shared result review for all players, host-only restart, and full room reset back to lobby with roster preserved and round state cleared. + +## 3. How Spec Kit Guided the Work + +Spec Kit imposed a clear flow: specify the scenario, plan the implementation, generate tasks, and then implement against that task list. That kept scope bounded per scenario, made acceptance criteria explicit before coding, and provided traceability from requirements to plan to concrete file-level tasks. + +## 4. How AI Was Used + +AI was used throughout the workflow: +- **Discovery**: to inspect the starter app structure and existing code paths before changes. +- **Specification**: to turn scenario prompts into scoped feature specs with assumptions, edge cases, and measurable outcomes. +- **Planning**: to produce design artifacts such as research notes, data model updates, contracts, and validation strategy. +- **Tasks**: to generate dependency-ordered, file-aware task lists grouped by user story. +- **Implementation**: to apply incremental code changes, extend tests, run builds and tests, and keep task bookkeeping aligned with completed work. + +## 5. Tradeoffs and Constraints + +The main constraints shaped the design: no WebSockets, no database, no authentication, and polling-only synchronization with in-memory state. That led to deliberately simple room-scoped backend state, deterministic gameplay rules, and viewer-specific room snapshots instead of introducing extra infrastructure. The main tradeoff was accepting polling latency and a single-runtime memory model in exchange for simpler architecture and clearer traceability. + +## 6. Validation Status + +During implementation, the affected backend and frontend automated checks were run and passed, including: +- backend tests +- backend build +- frontend tests +- frontend build