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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ See [docs/ci/README.md](docs/ci/README.md). Manual fallback: `npm run deploy:pag
|---|---|
| [docs/PRODUCT_BRIEF.md](docs/PRODUCT_BRIEF.md) | Vision / MVP roots |
| [docs/GAME_DESIGN.md](docs/GAME_DESIGN.md) | Game loop, learning progression, campaign arcs, and implementation plan |
| [docs/MISSION_AUTHORING.md](docs/MISSION_AUTHORING.md) | Mode voice, anti-spoiler checklist, and mission authoring rules |
| [docs/SCREEN_MAP.md](docs/SCREEN_MAP.md) | Screens & flows |
| [docs/DATA_MODEL.md](docs/DATA_MODEL.md) | Ports, cables, intents |
| [docs/MVP_MISSIONS.md](docs/MVP_MISSIONS.md) | Mission catalog |
| [docs/MVP_MISSIONS.md](docs/MVP_MISSIONS.md) | Historical MVP mission notes (see learningDesign for current catalog) |
| [docs/TECH_STACK.md](docs/TECH_STACK.md) | Stack & phases |
2 changes: 2 additions & 0 deletions docs/GAME_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,8 @@ Catalog validation should enforce:
- every introduced concept is revisited later,
- difficulty changes by no more than two points between adjacent stages.

Authoring voice, anti-spoiler rules, and transfer guidance: [MISSION_AUTHORING.md](./MISSION_AUTHORING.md). Automated checks live in `src/missions/learningDesign.test.ts`.

---

## 7. Target stage loop
Expand Down
53 changes: 53 additions & 0 deletions docs/MISSION_AUTHORING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# PatchLab — Mission Authoring

Short checklist for writing or revising campaign stages. Authoritative campaign order lives in `src/missions/learningDesign.ts` (`CAMPAIGN_MISSION_IDS`), not in each JSON file’s `order` field.

## Mode voice

| Mode | Player-facing brief | Ticket details | Visible objectives |
|---|---|---|---|
| Guided | Exact values and steps | Optional; may repeat the recipe | Exact checklist |
| Practice | Outcome + limited ticket facts | Exact values OK | Broad objectives |
| Challenge | Symptoms + service impact only | Exact values (Easy opens these) | Outcome only |
| Boss | Incident story + service targets | Exact repair values only here / hints | Service outcomes |

**Campaign pace:** Easy may open ticket details and soften challenge/boss support. Standard must feel like real diagnosis — briefs stay symptom-first.

## Anti-spoiler checklist

For **practice / challenge / boss** (especially challenge and boss):

1. Brief and constraints describe **symptoms and impact**, not the fix.
2. Do **not** put goal port IDs, CIDRs, ACL actions (`permit` / `deny`), next hops, or target host IPs in the brief/constraints when those values appear in `goals`.
3. Put exact repair values in `learning.ticketDetails` and the hint ladder (`prompt` → `evidence` → `action` → `solution`).
4. Visible objectives name outcomes (“restore WAN reachability”), not recipes (“set gateway 10.10.10.1”).

Automated coverage: `src/missions/learningDesign.test.ts` fails if a challenge/boss brief/constraints surface contains goal literals.

## Cadence (teach → practice → challenge → boss)

- Each stage introduces **at most one** concept (`conceptsIntroduced` length ≤ 1).
- The arc’s **first** stage owns the introduction; later challenge/boss stages should mostly use `conceptsPracticed`.
- Every introduced concept (except the campaign finale) must appear later in `conceptsPracticed`.
- Prefer ~6+ challenge stages with faultful initial state so Standard pace practices diagnosis.
- Bosses combine known skills; they should not be the first place a primary novelty appears when that can be avoided.

## Initial faults vs goals

- Prefer a **broken initial state** the player can observe (wrong gateway, poisoned route, ACL deny, admin-down port).
- Engine `goals` are hidden truth used for completion — not the player checklist.
- Pre-seed healthy layers when teaching layered diagnosis (e.g. ACL already open, route missing) so the brief does not disclose both faults.

## Transfers

- Aim for **one changed-value transfer** per arc after the intro/practice parent clears.
- Transfer briefs stay symptom-first; values live in Easy ticket details / hints.
- Definitions live in `src/lib/transferVariants.ts`.

## Files to touch

1. `src/missions/<id>.json` — topology, inventory, brief, constraints, goals, initial faults
2. `src/missions/learningDesign.ts` — mode, concepts, tools, objectives, ticket details, debrief, hints, campaign order
3. Tests — `learningDesign.test.ts`, engine fixtures, Playwright when flow/UI text changes

See also [GAME_DESIGN.md](./GAME_DESIGN.md) for arcs and the player loop.
9 changes: 6 additions & 3 deletions docs/MVP_MISSIONS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# PatchLab — Mission Catalog (historical MVP notes)

> **Current product:** 32 stages across 15 chapters (`src/missions/`, `src/lib/chapters.ts`),
> plus sandbox free-play unlocked after Stage 5. This document retains early M1–M5 design
> notes for context; authoritative mission JSON lives under `src/missions/`.
> **Current product:** 32 stages across 10 operational arcs
> (`src/missions/learningDesign.ts` campaign order + `src/lib/chapters.ts` chapter titles),
> plus sandbox free-play unlocked after the first boss and optional transfer variants
> (`src/lib/transferVariants.ts`). This document retains early M1–M5 design notes for
> context; authoritative mission JSON lives under `src/missions/`. For authoring rules
> (symptom briefs, mode voice), see [MISSION_AUTHORING.md](./MISSION_AUTHORING.md).

---

Expand Down
13 changes: 11 additions & 2 deletions e2e/patchlab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,11 +650,20 @@ test.describe('logic / security missions', () => {

test('Mission 32 traceroute path', async ({ page }) => {
await clearApp(page);
await page.getByRole('button', { name: /Pace: Easy/i }).click();
await unlockThrough(page, 32);
await startMission(page, /Traceroute Path/i);
await page.getByRole('button', { name: /Traceroute Path/i }).click();
await expect(page.getByRole('button', { name: /Start stage/i })).toBeVisible();
const incident = page.locator('.incident-report');
await expect(incident).toContainText(/BRANCH is dark/i);
await expect(incident).not.toContainText(/198\.51\.100\.0\/24/);
await expect(
page.locator('details').filter({ hasText: /Ticket details/i }),
).not.toHaveAttribute('open', '');
await page.getByRole('button', { name: /Start stage/i }).click();
await expect(page.locator('svg.rack-svg')).toBeVisible();
await focusDevice(page, 'FW-EDGE');
await page.getByRole('button', { name: 'Apply route' }).click();
await insertCustomAcl(page, 'permit', '10.10.10.0/24', '198.51.100.0/24');
await focusDevice(page, 'SERVER-01');
const diagnostics = page.locator('.config-block', {
has: page.getByRole('heading', { name: /Traceroute/ }),
Expand Down
17 changes: 5 additions & 12 deletions src/engine/reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1113,29 +1113,22 @@ describe('NetPractice-inspired routing lessons', () => {
expect(state.snapshot.complete).toBe(true);
});

it('M32: traceroute succeeds after route + permit', () => {
it('M32: traceroute succeeds after route when ACL is already open', () => {
const mission = getMission('m32-traceroute')!;
let state = createEngineState(mission, baseRack);
expect(mission.brief).toMatch(/traceroute/i);
expect(mission.brief).not.toMatch(/198\.51\.100\.0\/24/);
expect(mission.brief).not.toMatch(/203\.0\.113\.2/);
expect(
evaluateTraceroute(state.snapshot.rack, 'server-01', 'branch-01').ok,
).toBe(false);
// ACL toward BRANCH is pre-seeded; only the route is missing.
state = reduce(state, {
type: 'SET_ROUTE',
deviceId: 'fw-1',
destCidr: '198.51.100.0/24',
nextHop: '203.0.113.2',
});
state = reduce(state, {
type: 'UPSERT_FIREWALL_RULE',
deviceId: 'fw-1',
rule: {
id: 'permit-branch',
action: 'permit',
srcCidr: '10.10.10.0/24',
dstCidr: '198.51.100.0/24',
enabled: true,
},
});
expect(state.snapshot.complete).toBe(false);
state = reduce(state, {
type: 'TRACEROUTE',
Expand Down
42 changes: 39 additions & 3 deletions src/lib/transferVariants.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import { describe, expect, it } from 'vitest';
import { missions } from '../missions';
import {
TRANSFER_DEFS,
allTransferMissions,
getTransferMission,
transfersForParent,
} from './transferVariants';

const ARC_TRANSFER_PARENTS = [
'm1-first-lights',
'm11-subnet-ping',
'm13-access-vlan',
'm19-broken-address',
'm18-deny-host',
'm24-wrong-gateway',
'm25-host-route',
'm31-pat-overload',
'm30-floating-static',
'm32-traceroute',
] as const;

describe('transferVariants', () => {
it('builds transfer missions from cleared parents', () => {
const transfers = transfersForParent('m1-first-lights', missions);
Expand All @@ -17,11 +31,33 @@ describe('transferVariants', () => {
});
});

it('covers one transfer parent per late arc plus early arcs', () => {
for (const parentId of ARC_TRANSFER_PARENTS) {
expect(
TRANSFER_DEFS.some((def) => def.parentId === parentId),
`missing transfer for ${parentId}`,
).toBe(true);
}
expect(allTransferMissions(missions).length).toBeGreaterThanOrEqual(
ARC_TRANSFER_PARENTS.length,
);
});

it('keeps transfer briefs symptom-first with values in ticket details', () => {
const transfer = getTransferMission('m11-subnet-ping-t1', missions);
expect(transfer).toBeTruthy();
expect(transfer!.brief).not.toMatch(/10\.10\.10\.40/);
expect(transfer!.learning.ticketDetails?.join(' ')).toMatch(/10\.10\.10\.40/);

const deny = getTransferMission('m18-deny-host-t1', missions);
expect(deny!.brief).not.toMatch(/10\.10\.10\.10\/32/);
expect(deny!.learning.ticketDetails?.join(' ')).toMatch(/10\.10\.10\.10\/32/);
});

it('resolves transfer missions by id', () => {
expect(getTransferMission('missing', missions)).toBeUndefined();
expect(getTransferMission('m11-subnet-ping-t1', missions)?.brief).toMatch(
/10\.10\.10\.40/,
expect(getTransferMission('m32-traceroute-t1', missions)?.id).toBe(
'm32-traceroute-t1',
);
expect(allTransferMissions(missions).length).toBeGreaterThanOrEqual(4);
});
});
Loading
Loading