diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2202496..d1b4ec2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -207,6 +207,8 @@ Adding a new level is one of the best ways to contribute. See [docs/LEVELS.md](d - **Target state uses `branches: string[]`** -- just branch names. Commit messages are NOT checked by the win condition. - **Slack messages use character personalities:** Alex (casual), Sarah (thorough), Marcus (terse). - **Starting states should feel lived-in** -- realistic branch names and commit histories. +- **Use command constraints to prevent cheating.** If a level teaches a specific command (e.g., `cherry-pick`), add `requiredCommands` and/or `forbiddenCommands` to ensure the player uses the intended approach. +- **Add warning Slack messages for constrained levels.** Players who use a forbidden or wrong approach should get an in-character nudge in the Slack panel. ### Level File Template @@ -237,6 +239,13 @@ export const level: Scenario = { // remoteBranches: ['main'], // optional head: { type: 'branch', name: 'main' }, workingTreeClean: true, + // requiredCommands: ['cherry-pick'], // player MUST use these + // forbiddenCommands: ['merge'], // player must NOT use these + // descriptions: { // shown in ghost overlay + // branches: { main: 'cherry-picked fix applied' }, + // requiredCommands: { 'cherry-pick': 'apply a single commit' }, + // forbiddenCommands: { merge: 'do NOT merge the whole branch' }, + // }, }, slackThread: [ @@ -245,6 +254,20 @@ export const level: Scenario = { trigger: { type: 'level_start' }, text: 'hey! ...', }, + // Warning when player uses wrong approach (forbidden command): + // { + // from: 'sarah', + // trigger: { type: 'after_command', command: 'merge' }, + // text: 'hold on — you merged the whole branch...', + // variant: 'warning', + // }, + // Warning when player goes off-track (missing required command): + // { + // from: 'sarah', + // trigger: { type: 'after_command_without', command: 'add', without: 'stash' }, + // text: 'you should stash first...', + // variant: 'warning', + // }, ], hints: [ @@ -254,6 +277,49 @@ export const level: Scenario = { }; ``` +### Command Constraints + +Levels that teach a specific git command should use `requiredCommands` and/or `forbiddenCommands` in `targetState` to prevent players from bypassing the intended approach: + +- **`requiredCommands: string[]`** — Git subcommand names that MUST appear in the player's command history to win. Example: `['cherry-pick']` requires the player to have used `git cherry-pick` at least once. +- **`forbiddenCommands: string[]`** — Git subcommand names that must NOT appear in the player's command history. Example: `['merge']` blocks winning if the player used `git merge`. +- **`descriptions.requiredCommands`** and **`descriptions.forbiddenCommands`** — Human-readable descriptions shown in the ghost overlay so the player knows what's expected. Key is the command name, value is a short explanation. + +Command names are the git subcommand only (e.g., `'commit'`, `'stash'`, `'rebase'`), NOT including flags. `git commit --amend` is tracked as `'commit'`, so flag-level distinctions cannot be enforced. + +### Warning Slack Messages + +When a level has command constraints, add Slack messages with `variant: 'warning'` to nudge the player back on track. Warning messages render with an amber left border and tinted background, visually distinct from normal messages. + +**Two patterns:** + +1. **Forbidden command warning** — Fires when the player uses a forbidden command. Use `after_command` trigger: + ```typescript + { + from: 'sarah', + trigger: { type: 'after_command', command: 'merge' }, + text: 'hold on — you merged the whole branch. use cherry-pick instead.', + variant: 'warning', + } + ``` + +2. **Missing required command nudge** — Fires when the player uses command X but hasn't used required command Y yet. Use `after_command_without` trigger: + ```typescript + { + from: 'sarah', + trigger: { type: 'after_command_without', command: 'add', without: 'stash' }, + text: 'you should stash your changes first before switching branches.', + variant: 'warning', + } + ``` + This message automatically disappears once the player uses `stash` (the `without` command), so it's self-correcting. + +**Guidelines for warning messages:** +- Stay in character — Alex is casual, Sarah is thorough, Marcus is terse +- Be specific about what went wrong AND what to do instead +- Don't repeat the level instructions verbatim — add context the player wouldn't get elsewhere +- One warning per constraint is sufficient; don't overwhelm with multiple warnings + --- ## Implementing New Git Commands diff --git a/README.md b/README.md index c3e7621..12d31ef 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,19 @@ A browser-based puzzle game for learning git. Players type real git commands in Read Slack-style conversations from fictional coworkers, observe an interactive SVG git graph, and solve the puzzle with the fewest commands possible. - +**Play now:** [https://massimogennaro.github.io/git-quest/](https://massimogennaro.github.io/git-quest/) + +### Level Select + +Browse 20 levels across 4 difficulty tiers -- Easy, Medium, Hard, and Pro. Each card shows the git commands involved, par score, and your best rating. + +![Level select screen showing 4 tiers of git puzzles](docs/assets/screenshot-levels.png) + +### Gameplay + +Each level presents a Slack-style briefing, an interactive SVG git graph, a working tree panel, and a terminal. Type real git commands to reach the target state. + +![Gameplay screen showing the Slack panel, git graph, and terminal](docs/assets/screenshot-gameplay.png) ## Features @@ -18,7 +30,7 @@ Read Slack-style conversations from fictional coworkers, observe an interactive ## Quick Start ```bash -git clone https://github.com//git-quest.git +git clone https://github.com/MassimoGennaro/git-quest.git cd git-quest npm install npm run dev # http://localhost:5173 @@ -46,10 +58,20 @@ npm run dev # http://localhost:5173 | State management | React Context + `useReducer` | | Build | Vite | | Testing | Vitest | -| Persistence | `localStorage` (scores only) | +| Persistence | `localStorage` | No backend. Everything runs in the browser. +### Browser State + +GitQuest saves your progress to `localStorage` so it persists between sessions. The following data is stored: + +- **Level completion status** -- which levels you have finished. +- **Star ratings** -- best score (1-3 stars) for each completed level. +- **Command count** -- fewest commands used per level (used for par comparison). + +This data never leaves your browser -- there are no accounts, no server calls, and no cookies. Clearing your browser's site data will reset all progress. + ## Project Structure ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d1adea9..07e7618 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -276,14 +276,19 @@ export interface TargetStateSpec { remoteBranches?: string[]; // remote branch names that must exist AND have advanced head: HeadState; workingTreeClean: boolean; + requiredCommands?: string[]; // git subcommands the player MUST use to win + forbiddenCommands?: string[]; // git subcommands the player must NOT use + descriptions?: TargetDescriptions; // human-readable labels for ghost overlay } // NOTE: Only structural properties are checked. Commit messages are NOT verified. // "Advanced" means the branch tip hash differs from its starting state hash. // New branches (not present at start) only need to exist. +// Command tracking stores subcommand names only (e.g., "commit"), not flags. export type SlackTrigger = | { type: 'level_start' } | { type: 'after_command'; command: string } + | { type: 'after_command_without'; command: string; without: string } | { type: 'after_branch_created'; name: string } | { type: 'after_commit' } | { type: 'conflict_triggered' }; @@ -292,6 +297,7 @@ export interface SlackMessage { from: 'alex' | 'sarah' | 'marcus'; text: string; trigger: SlackTrigger; + variant?: 'normal' | 'warning'; // warning renders with amber styling } ``` @@ -370,11 +376,12 @@ Loads the scenario, feeds the initial state to the engine, watches for win, and function useLevel(scenario: Scenario) { const engine = useGitEngine(scenario.startingState); - // Win condition checks branch existence + advancement from starting state + // Win condition checks branch existence + advancement + command constraints const isWon = checkWinCondition( engine.state, scenario.targetState, - scenario.startingState // third param: used to detect branch advancement + scenario.startingState, // third param: used to detect branch advancement + engine.executedCommands // fourth param: checked against required/forbidden commands ); // Trigger Slack messages based on engine state changes @@ -385,6 +392,10 @@ function useLevel(scenario: Scenario) { engine.commitCount, engine.hasConflicts ); + // Supports 6 trigger types: level_start, after_command, after_command_without, + // after_branch_created, after_commit, conflict_triggered. + // after_command_without fires when command was used but without-command was NOT, + // enabling self-correcting warning messages. // Par-based scoring: 3 stars at/under par, 2 stars par+1-2, 1 star par+3+ const score = computeScore(engine.commandCount, scenario.par); @@ -451,13 +462,14 @@ gitquest/ │ ├── stash.test.ts # 16 tests │ ├── reset.test.ts # 15 tests │ ├── reflog.test.ts # 5 tests - │ ├── cherryPick.test.ts # 8 tests - │ └── rebase.test.ts # 16 tests - └── levels/ - └── winCondition.test.ts # 26 tests +│ ├── cherryPick.test.ts # 8 tests +│ └── rebase.test.ts # 16 tests +└── levels/ + ├── winCondition.test.ts # 34 tests + └── useLevel.test.ts # 20 tests (getVisibleMessages + computeScore) ``` -**Total: 195 tests across 17 test files.** +**Total: 223 tests across 18 test files.** --- diff --git a/docs/LEVELS.md b/docs/LEVELS.md index 2ba8144..68807d4 100644 --- a/docs/LEVELS.md +++ b/docs/LEVELS.md @@ -25,6 +25,7 @@ Each level is a TypeScript file in `src/levels/tierN/`. Copy the template below. - Add a mid-level message triggered `after_commit` for longer levels — it rewards progress and can introduce a complication - Keep messages short. Alex sends 1–2 sentences. Sarah can send 3–4. - Don't repeat the level instructions verbatim across messages — each message adds something +- Add `variant: 'warning'` messages for constrained levels to nudge players who go off-track (see [Warning Slack Messages](#26-warning-slack-messages) below) ### Target State Tips - Define target branches as a list of branch names (`string[]`) — the win condition checks that each branch **exists** and has **advanced** (tip hash differs from starting state) @@ -32,6 +33,115 @@ Each level is a TypeScript file in `src/levels/tierN/`. Copy the template below. - `remoteBranches?: string[]` can optionally specify remote branches that must also exist and advance - Working tree clean unless there's a good reason - Always double-check par by mentally executing the commands yourself +- Use `requiredCommands` and `forbiddenCommands` to prevent cheating on levels that teach a specific command (see [Command Constraints](#25-command-constraints) below) + +--- + +## 2.5 Command Constraints + +Levels that teach a specific git workflow should enforce that the player actually uses the intended commands. Without constraints, many levels can be "cheated" — e.g., using `git merge` instead of `git cherry-pick` on the cherry-pick level. + +### `requiredCommands` + +An array of git subcommand names that **must** appear in the player's command history for the win condition to pass. + +```typescript +targetState: { + branches: ['main'], + head: { type: 'branch', name: 'main' }, + workingTreeClean: true, + requiredCommands: ['cherry-pick'], + descriptions: { + requiredCommands: { 'cherry-pick': 'apply a single commit from another branch' }, + }, +} +``` + +The ghost overlay displays required commands with a checkmark/cross so the player knows what's expected. + +### `forbiddenCommands` + +An array of git subcommand names that must **not** appear in the player's command history. + +```typescript +targetState: { + // ... + forbiddenCommands: ['merge'], + descriptions: { + forbiddenCommands: { merge: 'do NOT merge the whole feature branch' }, + }, +} +``` + +### Limitations + +Command tracking stores the **subcommand name only** (e.g., `'commit'`, `'reset'`), not flags. This means `git commit --amend` is tracked as `'commit'`, making it impossible to distinguish from a regular commit. Level 1-03 (Amend a Commit) is not constrained for this reason. + +### Levels with constraints + +| Level | Required | Forbidden | +|---|---|---| +| 2-04 Stash Your Work | `stash` | — | +| 3-03 Stash Before Merge | `stash`, `merge` | — | +| 3-04 Fix the Last Commit | `reset` | — | +| 4-01 The Cleanup | `rebase` | — | +| 4-02 Cherry-Pick a Fix | `cherry-pick` | `merge` | +| 4-03 Rebase onto Main | `rebase` | `merge` | +| 4-05 The Full Workflow | `rebase`, `merge` | — | + +--- + +## 2.6 Warning Slack Messages + +When a player goes off-track on a constrained level, the Slack panel shows a **warning message** — visually distinct with an amber left border and tinted background. These messages are in-character nudges that tell the player what went wrong and what to do instead. + +### The `variant` field + +`SlackMessage` has an optional `variant` field. Set it to `'warning'` to render the message with amber warning styling: + +```typescript +{ + from: 'sarah', + trigger: { type: 'after_command', command: 'merge' }, + text: 'hold on — you merged the whole branch. use cherry-pick instead.', + variant: 'warning', +} +``` + +Messages without a `variant` (or with `variant: 'normal'`) render normally. + +### Trigger patterns for warnings + +**Pattern 1: Forbidden command used** — Use `after_command` trigger. Fires permanently once the command appears in history (the player can't un-use a command): + +```typescript +{ + from: 'marcus', + trigger: { type: 'after_command', command: 'merge' }, + text: 'merge creates merge commits. we need linear history. use rebase.', + variant: 'warning', +} +``` + +**Pattern 2: Wrong approach before correct command** — Use `after_command_without` trigger. Fires when the player uses `command` but has NOT yet used `without`. **Self-correcting** — the message disappears once the player uses the required command: + +```typescript +{ + from: 'sarah', + trigger: { type: 'after_command_without', command: 'add', without: 'stash' }, + text: 'stash your changes first before switching branches.', + variant: 'warning', +} +``` + +This example fires when the player uses `git add` without having used `git stash` yet. Once they use `git stash`, the warning disappears automatically. + +### Writing good warning messages + +- **Stay in character.** Alex uses emoji, Sarah explains, Marcus is blunt. +- **Be specific.** Say what went wrong AND what to do instead: "you merged — undo and cherry-pick". +- **Don't repeat level_start instructions.** The warning adds new information. +- **One warning per constraint.** Don't stack multiple warnings for the same mistake. --- @@ -262,6 +372,7 @@ Working tree: clean branches: ["feature/user-profile"] # must have advanced (squashed commits) head: { type: 'branch', name: 'feature/user-profile' } workingTreeClean: true +requiredCommands: ["rebase"] # player must use rebase -i Narrative (not checked by win condition): Player squashes 4 WIP commits into 1 clean commit via interactive rebase. @@ -280,10 +391,15 @@ git rebase -i HEAD~4 before you open the PR, squash those commits on feature/user-profile. 4 commits for one feature is noise. make it one clean commit. +[Marcus, after_command_without: "commit" without: "rebase", variant: warning] +more commits? that's the opposite of cleanup. use git rebase -i to squash them down. + [Marcus, after_command: "rebase"] better. ``` +Note the `after_command_without` trigger: the warning fires when the player commits without having used rebase, nudging them toward the correct approach. It disappears once they use `git rebase`. + --- ## 4. Level Progression Map (Full Game) diff --git a/docs/SPEC.md b/docs/SPEC.md index cf854c7..a58c690 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -85,6 +85,8 @@ Files animate (highlight) when they transition between states after a command. - Messages appear at level start; some are triggered by player actions mid-level - Characters have names, avatars (initials + color), and consistent personalities - Panel can be collapsed to a single-line summary to give more graph space +- **Warning messages** render with amber left border and tinted background when the player goes off-track on constrained levels (e.g., using `merge` when the level requires `cherry-pick`) +- 6 trigger types: `level_start`, `after_command`, `after_command_without`, `after_branch_created`, `after_commit`, `conflict_triggered` ### 3.5 Terminal - Always visible at the bottom @@ -200,10 +202,13 @@ A level is won when **all** checks pass: 2. **Branch advancement** — each target branch that existed at level start must have a different tip hash than it started with (i.e., the player did work on it). New branches just need to exist. 3. **HEAD position** — HEAD matches the target (attached to the right branch, or detached at the right commit) 4. **Working tree clean** — no staged, modified, or untracked files (unless the scenario explicitly allows them) +5. **Command constraints** (optional) — if the level defines `requiredCommands`, every listed subcommand must appear in the player's command history. If it defines `forbiddenCommands`, none of the listed subcommands may appear. -**Commit messages are NOT checked.** The win condition verifies structural properties only — branch names, advancement from starting state, HEAD, and working tree cleanliness. This allows players creative freedom in their commit messages. +**Commit messages are NOT checked.** The win condition verifies structural properties and command usage only — branch names, advancement from starting state, HEAD, working tree cleanliness, and command constraints. This allows players creative freedom in their commit messages. -The ghost overlay in the graph provides a continuous visual indication of how close the player is. Branch labels in the ghost show checkmarks when the corresponding branch has advanced from its starting state. +Command names are tracked at the subcommand level (e.g., `'commit'`, `'cherry-pick'`), not including flags. This means `git commit --amend` is tracked as `'commit'`. + +The ghost overlay in the graph provides a continuous visual indication of how close the player is. Branch labels in the ghost show checkmarks when the corresponding branch has advanced from its starting state. Required and forbidden commands are shown with check/cross indicators. --- @@ -214,11 +219,13 @@ The ghost overlay in the graph provides a continuous visual indication of how cl - Full-screen level selector with difficulty grouping, star display, and best move tracking - SVG graph renderer with ghost overlay - Working tree panel (staged / modified / untracked / conflicted) -- Slack panel with triggered messages (5 trigger types) +- Slack panel with triggered messages (6 trigger types, including warning variant) - Terminal with command history (↑/↓) - Simulation engine: 14 git commands — `add`, `branch`, `checkout`, `cherry-pick`, `commit`, `diff`, `log`, `merge`, `push`, `rebase` (regular + interactive), `reflog`, `reset`, `stash`, `status` - Interactive rebase picker UI (pick / squash / drop) - Three-panel merge conflict editor (ours | result | theirs, with free-form editing) +- Command constraint system — `requiredCommands` / `forbiddenCommands` on 7 levels to prevent bypassing intended git concepts +- Warning Slack messages — amber-styled nudges that fire when players go off-track on constrained levels - Local progress storage (`localStorage`) — stars and best move count per level - Undo last command + retry level - Move counter with par-based color coding diff --git a/docs/assets/screenshot-gameplay.png b/docs/assets/screenshot-gameplay.png new file mode 100644 index 0000000..7069121 Binary files /dev/null and b/docs/assets/screenshot-gameplay.png differ diff --git a/docs/assets/screenshot-levels.png b/docs/assets/screenshot-levels.png new file mode 100644 index 0000000..b0dc80e Binary files /dev/null and b/docs/assets/screenshot-levels.png differ diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index da6bcd6..ad08029 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -109,7 +109,7 @@ export function AppLayout({ level, onNextLevel, onRetry, onShowSelector }: AppLa {/* Main content: Working Tree + Graph */}
- +
{/* Terminal */} diff --git a/src/components/panels/GraphPanel/GhostOverlay.tsx b/src/components/panels/GraphPanel/GhostOverlay.tsx index 7fd8606..d6bff6e 100644 --- a/src/components/panels/GraphPanel/GhostOverlay.tsx +++ b/src/components/panels/GraphPanel/GhostOverlay.tsx @@ -12,11 +12,13 @@ interface GhostOverlayProps { yOffset: number; /** X position (left margin) */ xOffset: number; + /** Commands the player has executed so far (git subcommand names) */ + executedCommands?: string[]; } /** * Render a simplified ghost visualization of the target state. - * Shows expected branch names using dashed strokes and 40% opacity. + * Shows expected branch names with optional descriptions using dashed strokes. * Amber/green checkmarks appear when a branch requirement is satisfied. */ export function GhostOverlay({ @@ -25,9 +27,13 @@ export function GhostOverlay({ startingState, yOffset, xOffset, + executedCommands = [], }: GhostOverlayProps) { + const descriptions = target.descriptions; + const branchItems: Array<{ name: string; + description?: string; isSatisfied: boolean; }> = target.branches.map((branchName) => { const exists = !!state.branches[branchName]; @@ -36,12 +42,14 @@ export function GhostOverlay({ const advanced = !startHash || state.branches[branchName] !== startHash; return { name: branchName, + description: descriptions?.branches?.[branchName], isSatisfied: exists && advanced, }; }); const remoteItems: Array<{ name: string; + description?: string; isSatisfied: boolean; }> = (target.remoteBranches ?? []).map((branchName) => { const exists = !!state.remote.branches[branchName]; @@ -49,12 +57,116 @@ export function GhostOverlay({ const advanced = !startHash || state.remote.branches[branchName] !== startHash; return { name: `origin/${branchName}`, + description: descriptions?.remoteBranches?.[branchName], isSatisfied: exists && advanced, }; }); + // Working tree satisfaction check + const showWorkingTree = target.workingTreeClean; + const isWorkingTreeSatisfied = showWorkingTree + ? Object.keys(state.index).length === 0 && + Object.keys(state.workingTree.files).length === 0 + : false; + + // Command constraint items + const requiredCommandItems: Array<{ + name: string; + description?: string; + isSatisfied: boolean; + }> = (target.requiredCommands ?? []).map((cmd) => ({ + name: `must use: ${cmd}`, + description: descriptions?.requiredCommands?.[cmd], + isSatisfied: executedCommands.includes(cmd), + })); + + const forbiddenCommandItems: Array<{ + name: string; + description?: string; + isSatisfied: boolean; + }> = (target.forbiddenCommands ?? []).map((cmd) => ({ + name: `must NOT use: ${cmd}`, + description: descriptions?.forbiddenCommands?.[cmd], + isSatisfied: !executedCommands.includes(cmd), + })); + const ROW_HEIGHT = 28; const GHOST_X = xOffset + 10; + const LABEL_X = GHOST_X + 14; + const FONT = "'JetBrains Mono', monospace"; + + // Colors + const SATISFIED_COLOR = '#34d399'; + const UNSATISFIED_COLOR = '#3d4059'; + const UNSATISFIED_TEXT = '#555873'; + const DESC_UNSATISFIED = '#44475a'; + const DESC_SATISFIED = '#2ab383'; + const CHECK_BG = '#0c0d11'; + + const renderRow = ( + y: number, + name: string, + description: string | undefined, + isSatisfied: boolean, + key: string, + ) => ( + + {/* Check circle */} + + {isSatisfied && ( + + ✓ + + )} + {/* Label */} + + {name} + + {/* Description */} + {description && ( + + {description} + + )} + + ); + + // Use a taller row height when descriptions are present to fit the second line + const hasDescriptions = !!descriptions; + const EFFECTIVE_ROW_HEIGHT = hasDescriptions ? 38 : ROW_HEIGHT; return ( @@ -63,128 +175,110 @@ export function GhostOverlay({ x={GHOST_X} y={yOffset} fill="#f59e0b" - className="text-[10px] font-bold uppercase tracking-wider" - fontFamily="'JetBrains Mono', monospace" + fontSize={10} + fontWeight="bold" + fontFamily={FONT} dominantBaseline="middle" + letterSpacing="0.05em" + style={{ textTransform: 'uppercase' }} > ✶ TARGET STATE {/* Ghost branch nodes */} - {branchItems.map((branch, i) => { - const ghostY = yOffset + 24 + i * ROW_HEIGHT; - - return ( - - - - {branch.isSatisfied && ( - - ✓ - - )} - - - {branch.name} - - - ); - })} + {branchItems.map((branch, i) => + renderRow( + yOffset + 24 + i * EFFECTIVE_ROW_HEIGHT, + branch.name, + branch.description, + branch.isSatisfied, + `ghost-branch-${branch.name}`, + ), + )} {/* Ghost remote branches */} - {remoteItems.map((remote, i) => { - const ghostY = - yOffset + 24 + branchItems.length * ROW_HEIGHT + i * ROW_HEIGHT; - - return ( - - - - {remote.isSatisfied && ( - - ✓ - - )} - - - {remote.name} - - + {remoteItems.map((remote, i) => + renderRow( + yOffset + 24 + (branchItems.length + i) * EFFECTIVE_ROW_HEIGHT, + remote.name, + remote.description, + remote.isSatisfied, + `ghost-remote-${remote.name}`, + ), + )} + + {/* Working tree row */} + {showWorkingTree && (() => { + const rowIndex = branchItems.length + remoteItems.length; + const ghostY = yOffset + 24 + rowIndex * EFFECTIVE_ROW_HEIGHT; + const wtDesc = descriptions?.workingTree ?? 'clean working tree'; + + return renderRow( + ghostY, + 'working tree', + wtDesc, + isWorkingTreeSatisfied, + 'ghost-working-tree', ); - })} + })()} {/* HEAD target */} - {target.head.type === 'branch' && ( - - {(() => { - const allItems = [...branchItems, ...remoteItems]; - const ghostY = yOffset + 24 + allItems.length * ROW_HEIGHT; - const isHeadSatisfied = - state.head.type === 'branch' && - state.head.name === target.head.name; - - return ( - - HEAD → {target.head.name}{' '} - {isHeadSatisfied ? '\u2713' : ''} - - ); - })()} - - )} + {target.head.type === 'branch' && (() => { + const rowIndex = + branchItems.length + + remoteItems.length + + (showWorkingTree ? 1 : 0); + const ghostY = yOffset + 24 + rowIndex * EFFECTIVE_ROW_HEIGHT; + const isHeadSatisfied = + state.head.type === 'branch' && + state.head.name === target.head.name; + const headLabel = `HEAD \u2192 ${target.head.name}`; + + return renderRow( + ghostY, + headLabel, + descriptions?.head, + isHeadSatisfied, + 'ghost-head', + ); + })()} + + {/* Required command constraints */} + {requiredCommandItems.map((item, i) => { + const rowIndex = + branchItems.length + + remoteItems.length + + (showWorkingTree ? 1 : 0) + + (target.head.type === 'branch' ? 1 : 0) + + i; + const ghostY = yOffset + 24 + rowIndex * EFFECTIVE_ROW_HEIGHT; + return renderRow( + ghostY, + item.name, + item.description, + item.isSatisfied, + `ghost-required-${i}`, + ); + })} + + {/* Forbidden command constraints */} + {forbiddenCommandItems.map((item, i) => { + const rowIndex = + branchItems.length + + remoteItems.length + + (showWorkingTree ? 1 : 0) + + (target.head.type === 'branch' ? 1 : 0) + + requiredCommandItems.length + + i; + const ghostY = yOffset + 24 + rowIndex * EFFECTIVE_ROW_HEIGHT; + return renderRow( + ghostY, + item.name, + item.description, + item.isSatisfied, + `ghost-forbidden-${i}`, + ); + })} ); } diff --git a/src/components/panels/GraphPanel/GraphPanel.tsx b/src/components/panels/GraphPanel/GraphPanel.tsx index 2c8d573..0c066d2 100644 --- a/src/components/panels/GraphPanel/GraphPanel.tsx +++ b/src/components/panels/GraphPanel/GraphPanel.tsx @@ -12,6 +12,8 @@ interface GraphPanelProps { state: RepoState; targetState?: TargetStateSpec; startingState?: RepoState; + /** Commands the player has executed so far (git subcommand names) */ + executedCommands?: string[]; } /** Warm-shifted lane colors */ @@ -227,7 +229,7 @@ function layoutGraph( }); } -export function GraphPanel({ state, targetState, startingState }: GraphPanelProps) { +export function GraphPanel({ state, targetState, startingState, executedCommands }: GraphPanelProps) { // Keep a persistent ref of all commits ever seen during this level session. // This prevents commits from vanishing when HEAD moves away or branches are deleted. const allCommitsRef = useRef>({}); @@ -261,9 +263,13 @@ export function GraphPanel({ state, targetState, startingState }: GraphPanelProp const ghostTargetCount = targetState ? targetState.branches.length + (targetState.remoteBranches?.length ?? 0) + - 1 // HEAD line + (targetState.workingTreeClean ? 1 : 0) + // working tree row + 1 + // HEAD line + (targetState.requiredCommands?.length ?? 0) + // required command rows + (targetState.forbiddenCommands?.length ?? 0) // forbidden command rows : 0; - const ghostHeight = targetState ? ghostTargetCount * 32 + 40 : 0; + const ghostRowHeight = targetState?.descriptions ? 38 : 32; + const ghostHeight = targetState ? ghostTargetCount * ghostRowHeight + 40 : 0; const graphBottomY = nodes.length * 56 + 60; const svgHeight = graphBottomY + ghostHeight; @@ -351,6 +357,7 @@ export function GraphPanel({ state, targetState, startingState }: GraphPanelProp startingState={startingState} yOffset={graphBottomY} xOffset={30} + executedCommands={executedCommands} /> )} diff --git a/src/components/panels/SlackPanel/SlackMessageItem.tsx b/src/components/panels/SlackPanel/SlackMessageItem.tsx index 086a837..2fca69f 100644 --- a/src/components/panels/SlackPanel/SlackMessageItem.tsx +++ b/src/components/panels/SlackPanel/SlackMessageItem.tsx @@ -18,9 +18,18 @@ interface SlackMessageItemProps { export function SlackMessageItem({ message }: SlackMessageItemProps) { const config = CHARACTER_CONFIG[message.from]; + const isWarning = message.variant === 'warning'; + + const containerClasses = isWarning + ? 'flex gap-2 px-3 py-2 hover:bg-amber-500/10 transition-colors border-l-2 border-amber-500 bg-amber-500/5' + : 'flex gap-2 px-3 py-2 hover:bg-panel-800/50 transition-colors'; + + const textClasses = isWarning + ? 'text-sm text-amber-300/90 leading-relaxed whitespace-pre-wrap break-words' + : 'text-sm text-panel-400 leading-relaxed whitespace-pre-wrap break-words'; return ( -
+
{/* Avatar */}
{config.displayName} -

+

{message.text}

diff --git a/src/hooks/useLevel.ts b/src/hooks/useLevel.ts index 427763e..4ea9183 100644 --- a/src/hooks/useLevel.ts +++ b/src/hooks/useLevel.ts @@ -46,6 +46,12 @@ export function getVisibleMessages( case 'after_command': return executedCommands.includes(trigger.command); + case 'after_command_without': + return ( + executedCommands.includes(trigger.command) && + !executedCommands.includes(trigger.without) + ); + case 'after_branch_created': return createdBranches.has(trigger.name); @@ -66,8 +72,13 @@ export function useLevel(scenario: Scenario): LevelState { const isWon = useMemo( () => - checkWinCondition(engine.state, scenario.targetState, scenario.startingState), - [engine.state, scenario.targetState, scenario.startingState], + checkWinCondition( + engine.state, + scenario.targetState, + scenario.startingState, + engine.executedCommands, + ), + [engine.state, scenario.targetState, scenario.startingState, engine.executedCommands], ); const visibleMessages = useMemo( diff --git a/src/levels/schema.ts b/src/levels/schema.ts index 5c8dc12..48c9a4a 100644 --- a/src/levels/schema.ts +++ b/src/levels/schema.ts @@ -25,6 +25,7 @@ export const DIFFICULTY_LABELS: Record = { export type SlackTrigger = | { type: 'level_start' } | { type: 'after_command'; command: string } + | { type: 'after_command_without'; command: string; without: string } | { type: 'after_branch_created'; name: string } | { type: 'after_commit' } | { type: 'conflict_triggered' }; @@ -34,6 +35,8 @@ export interface SlackMessage { from: 'alex' | 'sarah' | 'marcus'; text: string; trigger: SlackTrigger; + /** Visual variant — 'warning' renders with amber styling for nudges/alerts */ + variant?: 'normal' | 'warning'; } /** @@ -41,6 +44,22 @@ export interface SlackMessage { * Only checks structural properties (branch existence, HEAD, working tree) — * commit messages are NOT verified. */ +/** Per-item descriptions shown in the target state overlay */ +export interface TargetDescriptions { + /** Description for each local branch, keyed by branch name */ + branches?: Record; + /** Description for each remote branch, keyed by branch name */ + remoteBranches?: Record; + /** Description for the expected HEAD position */ + head?: string; + /** Description for the working tree requirement */ + workingTree?: string; + /** Description for each required command, keyed by command name */ + requiredCommands?: Record; + /** Description for each forbidden command, keyed by command name */ + forbiddenCommands?: Record; +} + export interface TargetStateSpec { /** Local branch names that must exist */ branches: string[]; @@ -50,6 +69,12 @@ export interface TargetStateSpec { head: HeadState; /** Whether the working tree must be clean (no staged, modified, or untracked files) */ workingTreeClean: boolean; + /** Git subcommands that MUST appear in the player's command history to win */ + requiredCommands?: string[]; + /** Git subcommands that must NOT appear in the player's command history */ + forbiddenCommands?: string[]; + /** Optional human-readable descriptions for each target requirement */ + descriptions?: TargetDescriptions; } /** A complete level definition */ diff --git a/src/levels/tier1/level-1-01.ts b/src/levels/tier1/level-1-01.ts index 385b21a..a8f7033 100644 --- a/src/levels/tier1/level-1-01.ts +++ b/src/levels/tier1/level-1-01.ts @@ -48,6 +48,12 @@ export const level1_01: Scenario = { remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: true, + descriptions: { + branches: { main: 'new commit with README.md' }, + remoteBranches: { main: 'pushed to remote' }, + head: 'on main', + workingTree: 'all files committed', + }, }, slackThread: [ diff --git a/src/levels/tier1/level-1-02.ts b/src/levels/tier1/level-1-02.ts index 210e241..f590bb1 100644 --- a/src/levels/tier1/level-1-02.ts +++ b/src/levels/tier1/level-1-02.ts @@ -69,6 +69,12 @@ export const level1_02: Scenario = { remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: false, + descriptions: { + branches: { main: 'commit with auth.js and routes.js only' }, + remoteBranches: { main: 'pushed to remote' }, + head: 'on main', + workingTree: 'debug.log remains untracked', + }, }, slackThread: [ diff --git a/src/levels/tier1/level-1-03.ts b/src/levels/tier1/level-1-03.ts index 7d3d9e4..99e3cc8 100644 --- a/src/levels/tier1/level-1-03.ts +++ b/src/levels/tier1/level-1-03.ts @@ -61,6 +61,12 @@ export const level1_03: Scenario = { remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: true, + descriptions: { + branches: { main: 'amended commit includes auth.test.js' }, + remoteBranches: { main: 'pushed to remote' }, + head: 'on main', + workingTree: 'all files committed', + }, }, slackThread: [ diff --git a/src/levels/tier1/level-1-04.ts b/src/levels/tier1/level-1-04.ts index fe9061e..0b6c522 100644 --- a/src/levels/tier1/level-1-04.ts +++ b/src/levels/tier1/level-1-04.ts @@ -64,6 +64,12 @@ export const level1_04: Scenario = { remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: false, + descriptions: { + branches: { main: 'commit with config.js only' }, + remoteBranches: { main: 'pushed to remote' }, + head: 'on main', + workingTree: 'secrets.env unstaged, not committed', + }, }, slackThread: [ diff --git a/src/levels/tier1/level-1-05.ts b/src/levels/tier1/level-1-05.ts index a84a738..c7c8119 100644 --- a/src/levels/tier1/level-1-05.ts +++ b/src/levels/tier1/level-1-05.ts @@ -74,6 +74,12 @@ export const level1_05: Scenario = { remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: true, + descriptions: { + branches: { main: 'routes.js changes committed' }, + remoteBranches: { main: 'pushed to remote' }, + head: 'on main', + workingTree: 'all changes committed', + }, }, slackThread: [ diff --git a/src/levels/tier2/level-2-01.ts b/src/levels/tier2/level-2-01.ts index 8b977f5..c3997e0 100644 --- a/src/levels/tier2/level-2-01.ts +++ b/src/levels/tier2/level-2-01.ts @@ -77,6 +77,12 @@ export const level2_01: Scenario = { remoteBranches: ['feature/login-validation'], head: { type: 'branch', name: 'feature/login-validation' }, workingTreeClean: true, + descriptions: { + branches: { 'feature/login-validation': 'new branch created from develop' }, + remoteBranches: { 'feature/login-validation': 'pushed to remote' }, + head: 'on the feature branch', + workingTree: 'no uncommitted changes', + }, }, slackThread: [ diff --git a/src/levels/tier2/level-2-02.ts b/src/levels/tier2/level-2-02.ts index 1737bc7..f9f5cd1 100644 --- a/src/levels/tier2/level-2-02.ts +++ b/src/levels/tier2/level-2-02.ts @@ -78,6 +78,12 @@ export const level2_02: Scenario = { remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: true, + descriptions: { + branches: { 'feature/sidebar': 'staged changes committed' }, + remoteBranches: { main: 'pushed to remote' }, + head: 'switched to main', + workingTree: 'no uncommitted changes', + }, }, slackThread: [ diff --git a/src/levels/tier2/level-2-03.ts b/src/levels/tier2/level-2-03.ts index b06e279..a35dd85 100644 --- a/src/levels/tier2/level-2-03.ts +++ b/src/levels/tier2/level-2-03.ts @@ -80,6 +80,12 @@ export const level2_03: Scenario = { remoteBranches: ['develop'], head: { type: 'branch', name: 'develop' }, workingTreeClean: true, + descriptions: { + branches: { develop: 'fast-forward merged with feature/dark-mode' }, + remoteBranches: { develop: 'pushed to remote' }, + head: 'on develop', + workingTree: 'no uncommitted changes', + }, }, slackThread: [ diff --git a/src/levels/tier2/level-2-04.ts b/src/levels/tier2/level-2-04.ts index b7bb66a..3b4e5db 100644 --- a/src/levels/tier2/level-2-04.ts +++ b/src/levels/tier2/level-2-04.ts @@ -13,8 +13,8 @@ export const level2_04: Scenario = { title: 'Stash Your Work', description: 'Stash uncommitted changes, switch branches, then pop and commit.', - concepts: ['stash', 'stash pop', 'checkout'], - par: 5, + concepts: ['stash', 'stash pop', 'checkout', 'push'], + par: 6, startingState: { commits: { @@ -68,8 +68,17 @@ export const level2_04: Scenario = { targetState: { branches: ['feature/dashboard'], + remoteBranches: ['feature/dashboard'], head: { type: 'branch', name: 'feature/dashboard' }, workingTreeClean: true, + requiredCommands: ['stash'], + descriptions: { + branches: { 'feature/dashboard': 'stashed changes committed here' }, + remoteBranches: { 'feature/dashboard': 'pushed to remote' }, + head: 'on the feature branch', + workingTree: 'stash applied and committed', + requiredCommands: { stash: 'use stash to save and restore work' }, + }, }, slackThread: [ @@ -83,15 +92,26 @@ export const level2_04: Scenario = { text: 'Use "git stash" to temporarily save your changes. Your working tree will be clean so you can switch branches safely.', trigger: { type: 'level_start' }, }, + { + from: 'sarah', + text: "wait — you should stash your changes first before switching branches. try git stash.", + trigger: { type: 'after_command_without', command: 'add', without: 'stash' }, + variant: 'warning', + }, { from: 'marcus', text: 'Good. Pop and commit.', trigger: { type: 'after_command', command: 'checkout' }, }, + { + from: 'marcus', + text: 'committed. now push it.', + trigger: { type: 'after_commit' }, + }, { from: 'alex', text: 'stash workflow nailed it! 🎯', - trigger: { type: 'after_commit' }, + trigger: { type: 'after_command', command: 'push' }, }, ], @@ -101,5 +121,6 @@ export const level2_04: Scenario = { 'Restore your changes with "git stash pop"', 'Stage the file with "git add dashboard.js"', 'Commit with "git commit -m \\"add charts to dashboard\\""', + 'Push with "git push"', ], }; diff --git a/src/levels/tier2/level-2-05.ts b/src/levels/tier2/level-2-05.ts index 295361c..0aed60c 100644 --- a/src/levels/tier2/level-2-05.ts +++ b/src/levels/tier2/level-2-05.ts @@ -78,6 +78,11 @@ export const level2_05: Scenario = { remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: true, + descriptions: { + remoteBranches: { main: 'pushed to remote' }, + head: 'on main', + workingTree: 'no uncommitted changes', + }, }, slackThread: [ diff --git a/src/levels/tier3/level-3-01.ts b/src/levels/tier3/level-3-01.ts index 12266b8..2c846ba 100644 --- a/src/levels/tier3/level-3-01.ts +++ b/src/levels/tier3/level-3-01.ts @@ -17,8 +17,8 @@ export const level3_01: Scenario = { tier: 3, title: 'Merge Conflict', description: 'Merge a branch with a conflict and resolve it.', - concepts: ['merge', 'conflict resolution', 'add', 'commit'], - par: 5, + concepts: ['merge', 'conflict resolution', 'add', 'commit', 'push'], + par: 6, startingState: { commits: { @@ -73,8 +73,15 @@ export const level3_01: Scenario = { targetState: { branches: ['develop'], + remoteBranches: ['develop'], head: { type: 'branch', name: 'develop' }, workingTreeClean: true, + descriptions: { + branches: { develop: 'merged feature/auth, conflict resolved' }, + remoteBranches: { develop: 'pushed to remote' }, + head: 'on develop', + workingTree: 'merge completed cleanly', + }, }, slackThread: [ @@ -90,9 +97,14 @@ export const level3_01: Scenario = { }, { from: 'alex', - text: 'merged! thanks 🙌', + text: 'merged! now push it so everyone gets the fix 🙌', trigger: { type: 'after_commit' }, }, + { + from: 'marcus', + text: 'pushed. good.', + trigger: { type: 'after_command', command: 'push' }, + }, ], hints: [ @@ -100,5 +112,6 @@ export const level3_01: Scenario = { 'Use the conflict picker to keep "ours" (develop) version — timeout: 3000', 'Stage the resolved file with "git add config.js"', 'Complete the merge with "git commit"', + 'Push with "git push"', ], }; diff --git a/src/levels/tier3/level-3-02.ts b/src/levels/tier3/level-3-02.ts index 636fe29..d02b572 100644 --- a/src/levels/tier3/level-3-02.ts +++ b/src/levels/tier3/level-3-02.ts @@ -18,8 +18,8 @@ export const level3_02: Scenario = { title: 'Conflict: No Hints', description: 'Merge a conflicting branch with no Slack guidance on which version to keep.', - concepts: ['merge', 'conflict resolution'], - par: 5, + concepts: ['merge', 'conflict resolution', 'push'], + par: 6, startingState: { commits: { @@ -92,8 +92,15 @@ export const level3_02: Scenario = { targetState: { branches: ['main'], + remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: true, + descriptions: { + branches: { main: 'merged feature/error-handling, conflict resolved' }, + remoteBranches: { main: 'pushed to remote' }, + head: 'on main', + workingTree: 'merge completed cleanly', + }, }, slackThread: [ @@ -109,9 +116,14 @@ export const level3_02: Scenario = { }, { from: 'sarah', - text: 'done. nice work handling that on your own.', + text: 'done. now push so the team has it.', trigger: { type: 'after_commit' }, }, + { + from: 'marcus', + text: 'pushed. nice work handling that on your own.', + trigger: { type: 'after_command', command: 'push' }, + }, ], hints: [ @@ -119,5 +131,6 @@ export const level3_02: Scenario = { 'Resolve the conflict in the picker — choose whichever version makes sense', 'Stage the resolved file with "git add errors.js"', 'Complete the merge with "git commit"', + 'Push with "git push"', ], }; diff --git a/src/levels/tier3/level-3-03.ts b/src/levels/tier3/level-3-03.ts index 58da547..1a20be1 100644 --- a/src/levels/tier3/level-3-03.ts +++ b/src/levels/tier3/level-3-03.ts @@ -14,8 +14,8 @@ export const level3_03: Scenario = { title: 'Stash Before Merge', description: 'Stash your uncommitted work, perform a merge, then restore your stash.', - concepts: ['stash', 'merge', 'stash pop'], - par: 5, + concepts: ['stash', 'merge', 'stash pop', 'push'], + par: 6, startingState: { commits: { @@ -91,8 +91,20 @@ export const level3_03: Scenario = { targetState: { branches: ['main'], + remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: true, + requiredCommands: ['stash', 'merge'], + descriptions: { + branches: { main: 'merged feature/maps + stashed changes committed' }, + remoteBranches: { main: 'pushed to remote' }, + head: 'on main', + workingTree: 'stash popped and committed', + requiredCommands: { + stash: 'stash uncommitted work before merging', + merge: 'merge the feature branch', + }, + }, }, slackThread: [ @@ -101,6 +113,12 @@ export const level3_03: Scenario = { text: "I need you to merge feature/maps into main. But you've got uncommitted changes in your working tree — stash them first so the merge goes cleanly.", trigger: { type: 'level_start' }, }, + { + from: 'sarah', + text: "you've got uncommitted changes in the working tree — merging now could get messy. stash your work first with git stash.", + trigger: { type: 'after_command_without', command: 'merge', without: 'stash' }, + variant: 'warning', + }, { from: 'alex', text: 'nice, merge is clean! now pop your stash and commit everything together 👍', @@ -108,9 +126,14 @@ export const level3_03: Scenario = { }, { from: 'marcus', - text: 'good.', + text: 'committed. push it.', trigger: { type: 'after_commit' }, }, + { + from: 'alex', + text: 'pushed! maps are live 🗺️', + trigger: { type: 'after_command', command: 'push' }, + }, ], hints: [ @@ -119,5 +142,6 @@ export const level3_03: Scenario = { 'Restore your stashed changes with "git stash pop"', 'Stage everything with "git add ." or "git add styles.css"', 'Commit with "git commit -m \\"merge maps and add styles\\""', + 'Push with "git push"', ], }; diff --git a/src/levels/tier3/level-3-04.ts b/src/levels/tier3/level-3-04.ts index d7c46bc..92c4329 100644 --- a/src/levels/tier3/level-3-04.ts +++ b/src/levels/tier3/level-3-04.ts @@ -16,7 +16,7 @@ export const level3_04: Scenario = { description: 'Undo the last commit with reset --soft, add a missing file, and recommit.', concepts: ['reset --soft'], - par: 3, + par: 4, startingState: { commits: { @@ -102,6 +102,14 @@ export const level3_04: Scenario = { remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: true, + requiredCommands: ['reset'], + descriptions: { + branches: { main: 'recommitted with validate.test.js included' }, + remoteBranches: { main: 'pushed to remote' }, + head: 'on main', + workingTree: 'all files committed', + requiredCommands: { reset: 'undo the last commit with reset --soft' }, + }, }, slackThread: [ @@ -110,6 +118,12 @@ export const level3_04: Scenario = { text: "you committed validate.js but forgot to include validate.test.js in the same commit. use reset --soft to undo the commit, add the test file, and recommit both together. then push.", trigger: { type: 'level_start' }, }, + { + from: 'alex', + text: "adding more files won't fix the old commit. use git reset --soft HEAD~1 to undo it first, then recommit with everything included.", + trigger: { type: 'after_command_without', command: 'add', without: 'reset' }, + variant: 'warning', + }, { from: 'alex', text: 'reset done! now add the test file and recommit 💪', diff --git a/src/levels/tier3/level-3-05.ts b/src/levels/tier3/level-3-05.ts index ecc65fe..0c65029 100644 --- a/src/levels/tier3/level-3-05.ts +++ b/src/levels/tier3/level-3-05.ts @@ -14,7 +14,7 @@ export const level3_05: Scenario = { description: 'Unstage all files, then selectively re-stage only the ones that belong in this commit.', concepts: ['reset', 'add', 'selective staging'], - par: 4, + par: 5, startingState: { commits: { @@ -85,6 +85,12 @@ export const level3_05: Scenario = { remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: false, + descriptions: { + branches: { main: 'commit with stats.js and stats.test.js only' }, + remoteBranches: { main: 'pushed to remote' }, + head: 'on main', + workingTree: 'debug.log and temp-notes.txt left out', + }, }, slackThread: [ diff --git a/src/levels/tier4/level-4-01.ts b/src/levels/tier4/level-4-01.ts index 7fae298..7656b1c 100644 --- a/src/levels/tier4/level-4-01.ts +++ b/src/levels/tier4/level-4-01.ts @@ -116,6 +116,13 @@ export const level4_01: Scenario = { branches: ['feature/user-profile'], head: { type: 'branch', name: 'feature/user-profile' }, workingTreeClean: true, + requiredCommands: ['rebase'], + descriptions: { + branches: { 'feature/user-profile': 'WIP commits squashed into one clean commit' }, + head: 'on the feature branch', + workingTree: 'no uncommitted changes', + requiredCommands: { rebase: 'squash commits with interactive rebase' }, + }, }, slackThread: [ @@ -124,6 +131,12 @@ export const level4_01: Scenario = { text: 'before you open the PR, squash those commits on feature/user-profile. 4 commits for one feature is noise. make it one clean commit.', trigger: { type: 'level_start' }, }, + { + from: 'marcus', + text: "more commits? that's the opposite of cleanup. use git rebase -i to squash them down.", + trigger: { type: 'after_command_without', command: 'commit', without: 'rebase' }, + variant: 'warning', + }, { from: 'marcus', text: 'better.', diff --git a/src/levels/tier4/level-4-02.ts b/src/levels/tier4/level-4-02.ts index 5ecece3..518b3aa 100644 --- a/src/levels/tier4/level-4-02.ts +++ b/src/levels/tier4/level-4-02.ts @@ -18,7 +18,7 @@ export const level4_02: Scenario = { description: 'Cherry-pick a critical bug fix from a feature branch onto main without merging the whole branch.', concepts: ['cherry-pick'], - par: 3, + par: 2, startingState: { commits: { @@ -126,6 +126,16 @@ export const level4_02: Scenario = { remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: true, + requiredCommands: ['cherry-pick'], + forbiddenCommands: ['merge'], + descriptions: { + branches: { main: 'contains cherry-picked "fix crash on null coords"' }, + remoteBranches: { main: 'pushed with the fix applied' }, + head: 'on main', + workingTree: 'no uncommitted changes', + requiredCommands: { 'cherry-pick': 'apply a single commit from another branch' }, + forbiddenCommands: { merge: 'do NOT merge the whole feature branch' }, + }, }, slackThread: [ @@ -134,6 +144,12 @@ export const level4_02: Scenario = { text: "there's a null pointer crash in production from gps.js. the fix is in feature/gps — the commit \"fix crash on null coords\". cherry-pick just that fix onto main and push. do NOT merge the whole feature branch.", trigger: { type: 'level_start' }, }, + { + from: 'sarah', + text: "hold on — you merged the whole feature branch. we only needed one commit. undo the merge and use cherry-pick to grab just the fix.", + trigger: { type: 'after_command', command: 'merge' }, + variant: 'warning', + }, { from: 'marcus', text: 'cherry-pick applied. push it.', diff --git a/src/levels/tier4/level-4-03.ts b/src/levels/tier4/level-4-03.ts index 93d1de7..3020894 100644 --- a/src/levels/tier4/level-4-03.ts +++ b/src/levels/tier4/level-4-03.ts @@ -92,6 +92,15 @@ export const level4_03: Scenario = { branches: ['feature/leaderboard'], head: { type: 'branch', name: 'feature/leaderboard' }, workingTreeClean: true, + requiredCommands: ['rebase'], + forbiddenCommands: ['merge'], + descriptions: { + branches: { 'feature/leaderboard': 'rebased onto main, conflict resolved' }, + head: 'on the feature branch', + workingTree: 'rebase completed cleanly', + requiredCommands: { rebase: 'rebase onto main, not merge' }, + forbiddenCommands: { merge: 'rebase produces linear history, not merge commits' }, + }, }, slackThread: [ @@ -100,6 +109,12 @@ export const level4_03: Scenario = { text: 'main has moved forward with an API endpoint update. rebase feature/leaderboard onto main before the PR. there will be a conflict in api.js — keep the v3 endpoint but preserve your leaderboard function.', trigger: { type: 'level_start' }, }, + { + from: 'marcus', + text: "merge creates merge commits. we need linear history here. undo that and use rebase instead.", + trigger: { type: 'after_command', command: 'merge' }, + variant: 'warning', + }, { from: 'sarah', text: 'conflict in api.js as expected. the correct resolution: accept the incoming (main) changes for the BASE_URL line, but make sure your fetchLeaderboard function is preserved.', diff --git a/src/levels/tier4/level-4-04.ts b/src/levels/tier4/level-4-04.ts index 256f829..ecb4572 100644 --- a/src/levels/tier4/level-4-04.ts +++ b/src/levels/tier4/level-4-04.ts @@ -116,6 +116,11 @@ export const level4_04: Scenario = { branches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: true, + descriptions: { + branches: { main: 'restored to "add streak counter" commit' }, + head: 'on main', + workingTree: 'lost commits recovered', + }, }, slackThread: [ diff --git a/src/levels/tier4/level-4-05.ts b/src/levels/tier4/level-4-05.ts index 8c20348..f7699e8 100644 --- a/src/levels/tier4/level-4-05.ts +++ b/src/levels/tier4/level-4-05.ts @@ -91,6 +91,17 @@ export const level4_05: Scenario = { remoteBranches: ['main'], head: { type: 'branch', name: 'main' }, workingTreeClean: true, + requiredCommands: ['rebase', 'merge'], + descriptions: { + branches: { main: 'feature branch merged after squash' }, + remoteBranches: { main: 'pushed to remote' }, + head: 'on main', + workingTree: 'all files committed', + requiredCommands: { + rebase: 'squash commits with interactive rebase', + merge: 'merge feature branch into main', + }, + }, }, slackThread: [ @@ -104,6 +115,12 @@ export const level4_05: Scenario = { text: 'start by creating a feature branch off main. something like feature/notifications.', trigger: { type: 'level_start' }, }, + { + from: 'marcus', + text: "don't push yet — squash your commits with git rebase -i first, then merge to main.", + trigger: { type: 'after_command_without', command: 'push', without: 'rebase' }, + variant: 'warning', + }, { from: 'alex', text: 'two commits on the branch, nice! now squash them with git rebase -i HEAD~2 before merging 💪', diff --git a/src/levels/winCondition.ts b/src/levels/winCondition.ts index f3036c7..e31ebfd 100644 --- a/src/levels/winCondition.ts +++ b/src/levels/winCondition.ts @@ -13,18 +13,22 @@ import type { TargetStateSpec } from './schema'; * - All target remote branches must exist (if specified) and have advanced * - HEAD position is matched by type + branch name (or hash for detached) * - Working tree cleanliness checks for empty index, no modified/untracked/conflicted files + * - Required commands must all appear in the player's command history + * - Forbidden commands must not appear in the player's command history * - Commit messages are NOT checked */ export function checkWinCondition( state: RepoState, target: TargetStateSpec, startingState: RepoState, + executedCommands: string[] = [], ): boolean { return ( checkBranches(state, target, startingState) && checkRemoteBranches(state, target, startingState) && checkHead(state, target) && - checkWorkingTree(state, target) + checkWorkingTree(state, target) && + checkCommandConstraints(target, executedCommands) ); } @@ -98,3 +102,28 @@ function checkWorkingTree( return true; } + +/** + * Check command constraints: required commands must all appear in history, + * and forbidden commands must not appear in history. + */ +function checkCommandConstraints( + target: TargetStateSpec, + executedCommands: string[], +): boolean { + // Check required commands — every one must appear at least once + if (target.requiredCommands) { + for (const cmd of target.requiredCommands) { + if (!executedCommands.includes(cmd)) return false; + } + } + + // Check forbidden commands — none may appear + if (target.forbiddenCommands) { + for (const cmd of target.forbiddenCommands) { + if (executedCommands.includes(cmd)) return false; + } + } + + return true; +} diff --git a/tests/levels/useLevel.test.ts b/tests/levels/useLevel.test.ts new file mode 100644 index 0000000..a4e96a1 --- /dev/null +++ b/tests/levels/useLevel.test.ts @@ -0,0 +1,281 @@ +// tests/levels/useLevel.test.ts — Tests for getVisibleMessages and computeScore + +import { describe, expect, it } from 'vitest'; + +import type { SlackMessage } from '@/levels/schema'; +import { computeScore, getVisibleMessages } from '@/hooks/useLevel'; + +// ─── Helper ──────────────────────────────────────────────────────────── + +function mkMsg( + overrides: Partial & Pick, +): SlackMessage { + return { + from: 'alex', + text: 'test message', + ...overrides, + }; +} + +// ─── computeScore ────────────────────────────────────────────────────── + +describe('computeScore', () => { + it('returns 3 stars at par', () => { + expect(computeScore(3, 3)).toBe(3); + }); + + it('returns 3 stars under par', () => { + expect(computeScore(2, 3)).toBe(3); + }); + + it('returns 2 stars at par+1', () => { + expect(computeScore(4, 3)).toBe(2); + }); + + it('returns 2 stars at par+2', () => { + expect(computeScore(5, 3)).toBe(2); + }); + + it('returns 1 star at par+3', () => { + expect(computeScore(6, 3)).toBe(1); + }); + + it('returns 1 star well over par', () => { + expect(computeScore(20, 3)).toBe(1); + }); +}); + +// ─── getVisibleMessages ──────────────────────────────────────────────── + +describe('getVisibleMessages', () => { + // ── level_start ──────────────────────────────────────────────────── + + it('always shows level_start messages', () => { + const msgs = [mkMsg({ trigger: { type: 'level_start' } })]; + const result = getVisibleMessages(msgs, [], new Set(), 0, false); + expect(result).toEqual(msgs); + }); + + // ── after_command ────────────────────────────────────────────────── + + it('shows after_command when command has been executed', () => { + const msgs = [ + mkMsg({ trigger: { type: 'after_command', command: 'push' } }), + ]; + const result = getVisibleMessages(msgs, ['push'], new Set(), 0, false); + expect(result).toEqual(msgs); + }); + + it('hides after_command when command has not been executed', () => { + const msgs = [ + mkMsg({ trigger: { type: 'after_command', command: 'push' } }), + ]; + const result = getVisibleMessages(msgs, ['add'], new Set(), 0, false); + expect(result).toEqual([]); + }); + + // ── after_command_without ────────────────────────────────────────── + + it('shows after_command_without when command used but without-command not used', () => { + const msgs = [ + mkMsg({ + trigger: { + type: 'after_command_without', + command: 'add', + without: 'stash', + }, + variant: 'warning', + }), + ]; + const result = getVisibleMessages(msgs, ['add'], new Set(), 0, false); + expect(result).toEqual(msgs); + }); + + it('hides after_command_without when without-command has been used', () => { + const msgs = [ + mkMsg({ + trigger: { + type: 'after_command_without', + command: 'add', + without: 'stash', + }, + variant: 'warning', + }), + ]; + const result = getVisibleMessages( + msgs, + ['stash', 'add'], + new Set(), + 0, + false, + ); + expect(result).toEqual([]); + }); + + it('hides after_command_without when the command itself has not been used', () => { + const msgs = [ + mkMsg({ + trigger: { + type: 'after_command_without', + command: 'merge', + without: 'stash', + }, + variant: 'warning', + }), + ]; + const result = getVisibleMessages( + msgs, + ['commit'], + new Set(), + 0, + false, + ); + expect(result).toEqual([]); + }); + + it('hides after_command_without when neither command has been used', () => { + const msgs = [ + mkMsg({ + trigger: { + type: 'after_command_without', + command: 'push', + without: 'rebase', + }, + variant: 'warning', + }), + ]; + const result = getVisibleMessages(msgs, [], new Set(), 0, false); + expect(result).toEqual([]); + }); + + // ── after_branch_created ─────────────────────────────────────────── + + it('shows after_branch_created when branch exists', () => { + const msgs = [ + mkMsg({ + trigger: { type: 'after_branch_created', name: 'feature/x' }, + }), + ]; + const result = getVisibleMessages( + msgs, + [], + new Set(['feature/x']), + 0, + false, + ); + expect(result).toEqual(msgs); + }); + + it('hides after_branch_created when branch does not exist', () => { + const msgs = [ + mkMsg({ + trigger: { type: 'after_branch_created', name: 'feature/x' }, + }), + ]; + const result = getVisibleMessages(msgs, [], new Set(), 0, false); + expect(result).toEqual([]); + }); + + // ── after_commit ─────────────────────────────────────────────────── + + it('shows after_commit when commitCount > 0', () => { + const msgs = [mkMsg({ trigger: { type: 'after_commit' } })]; + const result = getVisibleMessages(msgs, [], new Set(), 1, false); + expect(result).toEqual(msgs); + }); + + it('hides after_commit when commitCount is 0', () => { + const msgs = [mkMsg({ trigger: { type: 'after_commit' } })]; + const result = getVisibleMessages(msgs, [], new Set(), 0, false); + expect(result).toEqual([]); + }); + + // ── conflict_triggered ───────────────────────────────────────────── + + it('shows conflict_triggered when hasConflicts is true', () => { + const msgs = [mkMsg({ trigger: { type: 'conflict_triggered' } })]; + const result = getVisibleMessages(msgs, [], new Set(), 0, true); + expect(result).toEqual(msgs); + }); + + it('hides conflict_triggered when hasConflicts is false', () => { + const msgs = [mkMsg({ trigger: { type: 'conflict_triggered' } })]; + const result = getVisibleMessages(msgs, [], new Set(), 0, false); + expect(result).toEqual([]); + }); + + // ── Mixed thread scenario ────────────────────────────────────────── + + it('filters a realistic thread correctly', () => { + const thread: SlackMessage[] = [ + mkMsg({ + from: 'sarah', + text: 'stash first', + trigger: { type: 'level_start' }, + }), + mkMsg({ + from: 'sarah', + text: 'you should stash first!', + trigger: { + type: 'after_command_without', + command: 'merge', + without: 'stash', + }, + variant: 'warning', + }), + mkMsg({ + from: 'alex', + text: 'merge done!', + trigger: { type: 'after_command', command: 'merge' }, + }), + mkMsg({ + from: 'marcus', + text: 'push it', + trigger: { type: 'after_command', command: 'push' }, + }), + ]; + + // Player merged without stashing: should see level_start, warning, and merge msg + const result1 = getVisibleMessages( + thread, + ['merge'], + new Set(), + 0, + false, + ); + expect(result1).toHaveLength(3); + expect(result1.map((m) => m.text)).toEqual([ + 'stash first', + 'you should stash first!', + 'merge done!', + ]); + expect(result1.find((m) => m.variant === 'warning')?.text).toBe( + 'you should stash first!', + ); + + // Player stashed then merged: warning should disappear + const result2 = getVisibleMessages( + thread, + ['stash', 'merge'], + new Set(), + 0, + false, + ); + expect(result2).toHaveLength(2); + expect(result2.map((m) => m.text)).toEqual([ + 'stash first', + 'merge done!', + ]); + + // Player stashed, merged, and pushed: all non-warning messages visible + const result3 = getVisibleMessages( + thread, + ['stash', 'merge', 'push'], + new Set(), + 0, + false, + ); + expect(result3).toHaveLength(3); + expect(result3.every((m) => m.variant !== 'warning')).toBe(true); + }); +}); diff --git a/tests/levels/winCondition.test.ts b/tests/levels/winCondition.test.ts index ac7c666..1d9de87 100644 --- a/tests/levels/winCondition.test.ts +++ b/tests/levels/winCondition.test.ts @@ -420,6 +420,129 @@ describe('checkWinCondition', () => { }); }); + describe('command constraints', () => { + it('should pass when no command constraints are specified (backward compatible)', () => { + const state = makeState(); + const startingState = makeStartingState(); + const target: TargetStateSpec = { + branches: ['main'], + head: { type: 'branch', name: 'main' }, + workingTreeClean: true, + }; + expect(checkWinCondition(state, target, startingState, [])).toBe(true); + }); + + it('should pass when required command appears in executed commands', () => { + const state = makeState(); + const startingState = makeStartingState(); + const target: TargetStateSpec = { + branches: ['main'], + head: { type: 'branch', name: 'main' }, + workingTreeClean: true, + requiredCommands: ['cherry-pick'], + }; + expect( + checkWinCondition(state, target, startingState, ['checkout', 'cherry-pick', 'push']), + ).toBe(true); + }); + + it('should fail when required command is missing from executed commands', () => { + const state = makeState(); + const startingState = makeStartingState(); + const target: TargetStateSpec = { + branches: ['main'], + head: { type: 'branch', name: 'main' }, + workingTreeClean: true, + requiredCommands: ['cherry-pick'], + }; + expect( + checkWinCondition(state, target, startingState, ['merge', 'push']), + ).toBe(false); + }); + + it('should fail when forbidden command appears in executed commands', () => { + const state = makeState(); + const startingState = makeStartingState(); + const target: TargetStateSpec = { + branches: ['main'], + head: { type: 'branch', name: 'main' }, + workingTreeClean: true, + forbiddenCommands: ['merge'], + }; + expect( + checkWinCondition(state, target, startingState, ['merge', 'push']), + ).toBe(false); + }); + + it('should pass when forbidden command is absent from executed commands', () => { + const state = makeState(); + const startingState = makeStartingState(); + const target: TargetStateSpec = { + branches: ['main'], + head: { type: 'branch', name: 'main' }, + workingTreeClean: true, + forbiddenCommands: ['merge'], + }; + expect( + checkWinCondition(state, target, startingState, ['cherry-pick', 'push']), + ).toBe(true); + }); + + it('should enforce both required and forbidden constraints together', () => { + const state = makeState(); + const startingState = makeStartingState(); + const target: TargetStateSpec = { + branches: ['main'], + head: { type: 'branch', name: 'main' }, + workingTreeClean: true, + requiredCommands: ['cherry-pick'], + forbiddenCommands: ['merge'], + }; + // Has required, no forbidden — pass + expect( + checkWinCondition(state, target, startingState, ['cherry-pick', 'push']), + ).toBe(true); + // Missing required — fail + expect( + checkWinCondition(state, target, startingState, ['push']), + ).toBe(false); + // Has both required and forbidden — fail + expect( + checkWinCondition(state, target, startingState, ['cherry-pick', 'merge', 'push']), + ).toBe(false); + }); + + it('should fail when only some required commands are present', () => { + const state = makeState(); + const startingState = makeStartingState(); + const target: TargetStateSpec = { + branches: ['main'], + head: { type: 'branch', name: 'main' }, + workingTreeClean: true, + requiredCommands: ['rebase', 'merge'], + }; + // Only rebase present, missing merge + expect( + checkWinCondition(state, target, startingState, ['rebase', 'push']), + ).toBe(false); + // Both present + expect( + checkWinCondition(state, target, startingState, ['rebase', 'merge', 'push']), + ).toBe(true); + }); + + it('should pass with empty executedCommands when no constraints are set', () => { + const state = makeState(); + const startingState = makeStartingState(); + const target: TargetStateSpec = { + branches: ['main'], + head: { type: 'branch', name: 'main' }, + workingTreeClean: true, + }; + expect(checkWinCondition(state, target, startingState, [])).toBe(true); + }); + }); + describe('combined scenarios', () => { it('should pass for level 1-01 after player commits and pushes', () => { const commit1 = makeCommit('a1b2c3f', 'initial project setup');