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
2 changes: 1 addition & 1 deletion docs/GAME_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ 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`.
Authoring voice, anti-spoiler rules, and transfer guidance: [MISSION_AUTHORING.md](./MISSION_AUTHORING.md). Automated checks live in `src/missions/learningDesign.test.ts`. Challenge/boss Impact copy uses `learning.impact` (not `ticketDetails[0]`), so Standard pace does not leak Easy ticket recipes.

---

Expand Down
21 changes: 11 additions & 10 deletions docs/MISSION_AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,26 @@ Short checklist for writing or revising campaign stages. Authoritative campaign

## 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 |
| Mode | Player-facing brief | Impact line | Ticket details | Visible objectives |
|---|---|---|---|---|
| Guided | Exact values and steps | n/a | Optional; may repeat the recipe | Exact checklist |
| Practice | Outcome + limited ticket facts | n/a | Exact values OK | Broad objectives |
| Challenge | Symptoms only | Service impact (`learning.impact`) | Exact values (Easy opens these) | Outcome only |
| Boss | Incident story | Service impact (`learning.impact`) | 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.
**Campaign pace:** Easy may open ticket details and show coach tips. Standard must feel like real diagnosis — briefs, Impact, and objectives stay symptom/outcome-first. Never reuse `ticketDetails[0]` as the Impact line.

## 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`.
1. Brief, constraints, and `learning.impact` describe **symptoms and service impact**, not the fix.
2. Do **not** put goal port IDs, human port labels (`Gi1/0/1`, `A-01`, `OUT5`), CIDRs, ACL actions (`permit` / `deny`), next hops, or target host IPs in brief/constraints/impact/visible objectives 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”).
5. Challenge/boss stages must set a spoiler-free `learning.impact`.

Automated coverage: `src/missions/learningDesign.test.ts` fails if a challenge/boss brief/constraints surface contains goal literals.
Automated coverage: `src/missions/learningDesign.test.ts` fails if a challenge/boss player-facing surface contains goal literals or human recipe labels.

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

Expand Down
2 changes: 2 additions & 0 deletions e2e/patchlab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,9 @@ test.describe('logic / security missions', () => {
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).toContainText(/Impact:/i);
await expect(incident).not.toContainText(/198\.51\.100\.0\/24/);
await expect(incident).not.toContainText(/203\.0\.113\.2/);
await expect(
page.locator('details').filter({ hasText: /Ticket details/i }),
).not.toHaveAttribute('open', '');
Expand Down
4 changes: 2 additions & 2 deletions src/lib/transferVariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,13 @@ export const TRANSFER_DEFS: TransferVariantDef[] = [
parentId: 'm18-deny-host',
titleSuffix: 'Transfer',
brief:
'Policy review: one approved host must keep WAN access while the other is cut off. Apply a selective deny without breaking the remaining service.',
'Policy review: one approved host must keep WAN access while the other is cut off. Apply a selective host block without breaking the remaining service.',
apply: (mission) => ({
...mission,
id: 'm18-deny-host-t1',
title: `${mission.title} · Transfer`,
brief:
'Policy review: one approved host must keep WAN access while the other is cut off. Apply a selective deny without breaking the remaining service.',
'Policy review: one approved host must keep WAN access while the other is cut off. Apply a selective host block without breaking the remaining service.',
goals: [
{
type: 'firewall_rule',
Expand Down
48 changes: 44 additions & 4 deletions src/missions/learningDesign.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from './learningDesign';
import { missions } from './index';

/** Goal literals that must not appear in challenge/boss briefs or constraints. */
/** Goal literals that must not appear in challenge/boss player-facing surfaces. */
function goalSpoilers(goals: Goal[]): string[] {
const spoilers = new Set<string>();
for (const goal of goals) {
Expand Down Expand Up @@ -63,6 +63,25 @@ function goalSpoilers(goals: Goal[]): string[] {
return [...spoilers].filter((value) => value.length > 0);
}

/** Human-facing port / address recipes that must not leak into Standard-visible copy. */
const HUMAN_RECIPE_PATTERNS: RegExp[] = [
/\b(?:Gi|Te)\d\/\d\/\d+\b/i,
/\bA-\d{2}\b/,
/\bF-\d{2}\b/,
/\bOUT\d+\b/i,
/\b\d{1,3}(?:\.\d{1,3}){3}(?:\/\d{1,2})?\b/,
];

function playerFacingSurface(mission: (typeof missions)[number]): string {
const design = mission.learning;
return [
mission.brief,
...(mission.constraints ?? []),
design.impact ?? '',
...design.visibleObjectives,
].join('\n');
}

describe('learningDesign', () => {
it('recognizes campaign mission ids', () => {
expect(isCampaignMissionId('m1-first-lights')).toBe(true);
Expand Down Expand Up @@ -138,19 +157,40 @@ describe('learningDesign', () => {
}
});

it('keeps challenge/boss briefs free of goal literals', () => {
it('requires spoiler-free impact copy on challenge/boss stages', () => {
for (const mission of missions) {
if (
mission.learning.mode !== 'challenge' &&
mission.learning.mode !== 'boss'
) {
continue;
}
const surface = [mission.brief, ...(mission.constraints ?? [])].join('\n');
expect(
mission.learning.impact?.trim().length,
`${mission.id} missing learning.impact`,
).toBeGreaterThan(0);
}
});

it('keeps challenge/boss player surfaces free of goal literals and human recipes', () => {
for (const mission of missions) {
if (
mission.learning.mode !== 'challenge' &&
mission.learning.mode !== 'boss'
) {
continue;
}
const surface = playerFacingSurface(mission);
for (const literal of goalSpoilers(mission.goals)) {
expect(
surface.includes(literal),
`${mission.id} ${mission.learning.mode} surface spoils goal literal "${literal}"`,
`${mission.id} surface spoils goal literal "${literal}"`,
).toBe(false);
}
for (const pattern of HUMAN_RECIPE_PATTERNS) {
expect(
pattern.test(surface),
`${mission.id} surface matches human recipe ${pattern}`,
).toBe(false);
}
}
Expand Down
16 changes: 16 additions & 0 deletions src/missions/learningDesign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const LEARNING_DESIGN_BY_ID = {
conceptsPracticed: ['Physical endpoints and link-state evidence', 'Cable removal and clean final state'],
enabledTools: ['patch'],
visibleObjectives: ['Migrate the documented panel circuit to its assigned path.', 'Keep SERVER-01 attached and finish with no abandoned cross-connects.'],
impact: "The panel circuit must move without dropping SERVER-01 or leaving abandoned copper.",
ticketDetails: ['The destination circuit is A-08 through Gi1/0/8.', 'The old A-01 and Gi1/0/1 endpoints must be empty after the change.'],
debrief: {
outcome: 'The service has moved to the new documented cross-connect with the old path fully retired.',
Expand Down Expand Up @@ -153,6 +154,7 @@ export const LEARNING_DESIGN_BY_ID = {
deviceUnlocks: ['PDU', 'C13 power cord'],
enabledTools: ['power', 'patch'],
visibleObjectives: ['Restore power to the switch and server.', 'Rebuild and verify the documented data path.'],
impact: "Active gear stays dark until power returns, so the data path cannot light.",
ticketDetails: ['Power ToR-SW-A and SERVER-01 from PDU-A.', 'The data circuit uses A-01, Gi1/0/1, Gi1/0/5, and eth0.'],
debrief: {
outcome: 'The active devices are powered and SERVER-01 has a working data path.',
Expand Down Expand Up @@ -216,6 +218,7 @@ export const LEARNING_DESIGN_BY_ID = {
conceptsPracticed: ['Administrative-down link symptoms', 'Administrative interface recovery', 'OM4 fiber media and LC endpoints'],
enabledTools: ['switchport'],
visibleObjectives: ['Recover the documented fiber circuit in place.', 'Leave the unauthorized spare fiber endpoints unused.'],
impact: "The documented optical path stays dark despite a correct-looking patch.",
ticketDetails: ['F-03 is correctly patched to Te1/0/3.', 'The Te1/0/3 SFP cage is administratively down.'],
debrief: {
outcome: 'The intended F-03 fiber path is lit without an undocumented repatch.',
Expand Down Expand Up @@ -281,6 +284,7 @@ export const LEARNING_DESIGN_BY_ID = {
conceptsPracticed: ['Same-subnet IPv4 reachability', 'IPv4 interface addressing', 'Ping-based fault isolation'],
enabledTools: ['ip', 'ping'],
visibleObjectives: ['Diagnose why a healthy local link cannot reach the firewall.', 'Repair only the server addressing fault and verify service.'],
impact: "Local ping to the firewall fails even though copper and power look healthy.",
ticketDetails: [
'The physical path is already working; only host addressing is wrong.',
'Set SERVER-01 eth0 to 10.10.10.10/24 with gateway 10.10.10.1.',
Expand All @@ -305,6 +309,7 @@ export const LEARNING_DESIGN_BY_ID = {
conceptsPracticed: ['Same-subnet IPv4 reachability', 'Ping-based fault isolation'],
enabledTools: ['ip', 'ping'],
visibleObjectives: ['Find the addressing detail that disagrees with the local network.', 'Restore and verify firewall reachability without repatching.'],
impact: "Familiar-looking addressing still cannot reach the firewall on the LAN.",
ticketDetails: [
'The host address text looks familiar, but same-subnet reachability still fails.',
'Keep 10.10.10.10 and set the prefix to /24 with gateway 10.10.10.1.',
Expand All @@ -329,6 +334,7 @@ export const LEARNING_DESIGN_BY_ID = {
conceptsPracticed: ['Power as a prerequisite for data links', 'Same-subnet IPv4 reachability', 'Ping-based fault isolation'],
enabledTools: ['power', 'ping'],
visibleObjectives: ['Restore firewall and SERVER-07 power on the reserved outlets.', 'Prove the staged LAN service works after power recovery.'],
impact: "Firewall and SERVER-07 stay dark after outlet reassignment until spare power is restored.",
ticketDetails: ['OUT5 is reserved for FW-EDGE and OUT6 for SERVER-07.', 'OUT1 through OUT4 support other staged equipment.'],
debrief: {
outcome: 'The firewall and SERVER-07 are powered from the spare outlets and local service is verified.',
Expand Down Expand Up @@ -376,6 +382,7 @@ export const LEARNING_DESIGN_BY_ID = {
],
enabledTools: ['acl', 'ping'],
visibleObjectives: ['Block only the unauthorized server from the WAN.', 'Preserve approved WAN reachability for the other server.'],
impact: "One unauthorized host must lose WAN access while approved service continues.",
ticketDetails: [
'One LAN host must lose WAN access while the approved server keeps it.',
'Deny 10.10.10.20/32 to 203.0.113.0/30 above the broad permit.',
Expand Down Expand Up @@ -446,6 +453,7 @@ export const LEARNING_DESIGN_BY_ID = {
conceptsPracticed: ['Access-port VLAN assignment', 'Same-subnet IPv4 reachability', 'Negative probe evidence'],
enabledTools: ['patch', 'ip', 'ping'],
visibleObjectives: ['Bring both tenant servers online in their assigned segments.', 'Prove that the tenant boundary blocks direct communication.'],
impact: "Both tenants must stay linked while cross-tenant traffic remains blocked.",
ticketDetails: [
'Both servers must stay linked while cross-tenant ping fails.',
'SERVER-01: 10.10.10.10/24 on VLAN 10 (Gi1/0/5); SERVER-07: 10.10.10.20/24 on VLAN 20 (Gi1/0/7).',
Expand All @@ -470,6 +478,7 @@ export const LEARNING_DESIGN_BY_ID = {
conceptsPracticed: ['Access-port VLAN assignment', 'VLANs as Layer-2 isolation boundaries', 'Physical endpoints and link-state evidence', 'Clean rack documentation'],
enabledTools: ['patch'],
visibleObjectives: ['Bring both tenant servers online simultaneously.', 'Use the documented panel circuits and matching VLAN access paths.'],
impact: "Both tenant servers must come online cleanly without crossing documented paths.",
ticketDetails: ['A-01 documents SERVER-01 in VLAN 10.', 'A-02 documents SERVER-07 in VLAN 20.'],
debrief: {
outcome: 'Both tenant servers have clean, simultaneous access links on their assigned VLAN paths.',
Expand Down Expand Up @@ -517,6 +526,7 @@ export const LEARNING_DESIGN_BY_ID = {
],
enabledTools: ['ip', 'ping'],
visibleObjectives: ['Diagnose why local configuration cannot reach the WAN.', 'Repair only the incorrect forwarding setting and verify service.'],
impact: "LAN fabric works, but every off-subnet attempt toward the WAN fails.",
ticketDetails: [
'Address, prefix, links, and firewall policy are already valid; only off-subnet forwarding fails.',
'Keep 10.10.10.10/24 and set the default gateway to 10.10.10.1.',
Expand Down Expand Up @@ -569,6 +579,7 @@ export const LEARNING_DESIGN_BY_ID = {
],
enabledTools: ['patch', 'ip', 'ping'],
visibleObjectives: ['Bring both tenant networks to their firewall interfaces.', 'Configure each server for its local gateway and prove routed service.'],
impact: "Separate tenant VLANs cannot reach each other until a Layer-3 path exists.",
ticketDetails: [
'Each tenant needs a firewall interface on its own LAN; trunks alone do not route.',
'SERVER-01: 10.10.10.10/24 gw 10.10.10.1 on VLAN 10; SERVER-07: 10.10.20.10/24 gw 10.10.20.1 on VLAN 20.',
Expand Down Expand Up @@ -621,6 +632,7 @@ export const LEARNING_DESIGN_BY_ID = {
],
enabledTools: ['pat', 'ping'],
visibleObjectives: ['Restore outbound translation for the private LAN.', 'Verify that a LAN host can use the shared WAN address.'],
impact: "Private LAN hosts still cannot leave for the WAN despite healthy route and ACL.",
ticketDetails: [
'Private LAN hosts need shared egress through the firewall outside address.',
'Overload 10.10.10.0/24 to FW-EDGE WAN 203.0.113.1.',
Expand Down Expand Up @@ -672,6 +684,7 @@ export const LEARNING_DESIGN_BY_ID = {
conceptsPracticed: ['Static routes to remote prefixes', 'Ping-based fault isolation'],
enabledTools: ['route', 'ping'],
visibleObjectives: ['Identify why one branch host ignores the working summary path.', 'Restore that destination without changing the WAN or ACL.'],
impact: "BRANCH is unreachable for one destination while broader routing looks intact.",
ticketDetails: [
'A working /24 branch summary exists, but one host still black-holes.',
'Set 198.51.100.10/32 next hop to 203.0.113.2 (ISP-PEER).',
Expand Down Expand Up @@ -700,6 +713,7 @@ export const LEARNING_DESIGN_BY_ID = {
],
enabledTools: ['route', 'ping'],
visibleObjectives: ['Preserve the configured primary route while adding branch resilience.', 'Recover branch service through the valid backup path.'],
impact: "Preferred BRANCH forwarding is withdrawn; service needs a backup path.",
ticketDetails: [
'The preferred primary is withdrawn by failed reachability tracking; branch service needs a backup path.',
'Keep 198.51.100.0/24 via 10.10.10.10 at AD1; add 198.51.100.0/24 via 203.0.113.2 at AD10.',
Expand Down Expand Up @@ -748,6 +762,7 @@ export const LEARNING_DESIGN_BY_ID = {
conceptsPracticed: ['First-match ACL order and host-specific /32 rules', 'Out-of-band console access and management addressing', 'Negative probe evidence'],
enabledTools: ['console', 'acl', 'ping'],
visibleObjectives: ['Use the recovery path to make a least-privilege policy change.', 'Restore one approved branch flow without opening access for other hosts.'],
impact: "BRANCH is locked down for the LAN; one approved host needs a narrow exception.",
ticketDetails: [
'BRANCH is locked for the LAN; only one approved host-to-host flow may return.',
'Console in, then permit 10.10.10.10/32 to 198.51.100.10/32 above the broad deny.',
Expand Down Expand Up @@ -781,6 +796,7 @@ export const LEARNING_DESIGN_BY_ID = {
'Restore BRANCH path.',
'Prove the hop path with traceroute.',
],
impact: "BRANCH stays dark from SERVER-01 until hop evidence proves the repaired path.",
ticketDetails: [
'Firewall policy toward BRANCH is already open — focus on forwarding.',
'Add route 198.51.100.0/24 via 203.0.113.2, then traceroute SERVER-01 → BRANCH-01.',
Expand Down
40 changes: 29 additions & 11 deletions src/missions/m28-fiber-no-shutdown.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
"title": "Fiber No-Shut",
"order": 28,
"track": "fiber",
"lesson": "Fiber admin-down looks exactly like a dark strand. Docs already land on Te1/0/3 enable the SFP instead of jumping to a spare tray port.",
"brief": "F-03 is already patched to Te1/0/3, but the cage is admin down. Select Te1/0/3 and Toggle admin (no shutdown). Do not move the circuit to F-04 / Te1/0/4.",
"lesson": "Fiber admin-down looks exactly like a dark strand. Docs already land on Te1/0/3 \u2014 enable the SFP instead of jumping to a spare tray port.",
"brief": "The documented fiber circuit is already patched, but the path stays dark. Recover the intended strand in place \u2014 do not jump to a spare tray or SFP.",
"constraints": [
"Keep F-03 ↔ Te1/0/3",
"Do not leave cables on F-04 or Te1/0/4",
"Use Toggle admin on the SFP"
"Keep the existing documented fiber patch",
"Leave unauthorized spare fiber endpoints unused",
"Treat admin state as a possible cause of a dark optical path"
],
"parTimeSec": 75,
"hintAfterWrongAttempts": 1,
Expand All @@ -27,23 +27,41 @@
"color": "aqua",
"lengthM": 2,
"ends": [
{ "deviceId": "fiber-tray", "portId": "f-3" },
{ "deviceId": "tor-sfp", "portId": "sfp-3" }
{
"deviceId": "fiber-tray",
"portId": "f-3"
},
{
"deviceId": "tor-sfp",
"portId": "sfp-3"
}
]
}
]
},
"goals": [
{
"type": "link_up",
"a": { "deviceId": "fiber-tray", "portId": "f-3" },
"b": { "deviceId": "tor-sfp", "portId": "sfp-3" }
"a": {
"deviceId": "fiber-tray",
"portId": "f-3"
},
"b": {
"deviceId": "tor-sfp",
"portId": "sfp-3"
}
},
{
"type": "no_cables_on",
"ports": [
{ "deviceId": "fiber-tray", "portId": "f-4" },
{ "deviceId": "tor-sfp", "portId": "sfp-4" }
{
"deviceId": "fiber-tray",
"portId": "f-4"
},
{
"deviceId": "tor-sfp",
"portId": "sfp-4"
}
]
}
]
Expand Down
Loading
Loading