Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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: [
Expand All @@ -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: [
Expand All @@ -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
Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- TODO: Add screenshot/gif of gameplay here -->
**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

Expand All @@ -18,7 +30,7 @@ Read Slack-style conversations from fictional coworkers, observe an interactive
## Quick Start

```bash
git clone https://github.com/<your-org>/git-quest.git
git clone https://github.com/MassimoGennaro/git-quest.git
cd git-quest
npm install
npm run dev # http://localhost:5173
Expand Down Expand Up @@ -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

```
Expand Down
26 changes: 19 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand All @@ -292,6 +297,7 @@ export interface SlackMessage {
from: 'alex' | 'sarah' | 'marcus';
text: string;
trigger: SlackTrigger;
variant?: 'normal' | 'warning'; // warning renders with amber styling
}
```

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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.**

---

Expand Down
116 changes: 116 additions & 0 deletions docs/LEVELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,123 @@ 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)
- Commit messages are **not** checked by the win condition — players have freedom in their messages
- `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.

---

Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down
Loading