diff --git a/README.md b/README.md index cd75c0c..9a3afb6 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ links to the package's own README. | [hello-world](challenges/hello-world/README.md) | Meet your AI guide and make your first file. | | [vibe-coding-mad-libs](challenges/vibe-coding-mad-libs/README.md) | Fill four blanks, and Pi builds the web app you described. | | [birdsbirdbirds](challenges/birdsbirdbirds/README.md) | Draw a bird and watch it fly across the sky. | +| [glitch-invaders](challenges/glitch-invaders/README.md) | Shoot the bugs, then debug the three that fight back using Sentry evidence. | Challenges follow the contract in boothstrap.sh's challenge spec and authoring guide; agents working here start from [AGENTS.md](AGENTS.md). diff --git a/challenges/glitch-invaders/README.md b/challenges/glitch-invaders/README.md new file mode 100644 index 0000000..a7ba279 --- /dev/null +++ b/challenges/glitch-invaders/README.md @@ -0,0 +1,72 @@ +# Glitch Invaders: On-Call + +Fly a patch drone in Chrome, trigger three intentional bugs while defending the +cluster, and debug each one with Sentry evidence and Pi. Each failure opens an +Incident Console with a stack trace, breadcrumbs, or a performance trace, asks +for the root cause, and applies your chosen patch so you can watch the fix work +in play. + +The game always runs in demo mode: the Incident Console shows simulated Sentry +evidence and no telemetry leaves the machine. Live Sentry mode returns once +boothstrap provides sponsor settings; see +[boothstrap.sh issue 519](https://github.com/MLH/boothstrap.sh/issues/519). + +## Files + +| File | Purpose | +| --- | --- | +| `challenge.json`, `plugin.json` | Manifest and Agent Plugin metadata | +| `intro.md` | Intro screen text | +| `skills/start-challenge/SKILL.md` | The guide Pi follows | +| `monitor/SKILL.md` | Headless browser monitor skill | +| `setup`, `cleanup` | Start and stop the server (`server/setup.mjs`, `server/cleanup.mjs`) | +| `server/` | Local page server on `127.0.0.1:4373` | +| `shared/` | Progress validators | +| `game/` | Browser arcade game and Incident Console | + +There is no `install` hook: the package has no dependencies beyond Node and +browser ES modules. + +## How steps advance + +The game shows a `glitch-state v1` line, and the headless monitor reports +`launch-game`, `fix-weapon`, `fix-shield`, and `fix-boss` from it, as +[monitor/SKILL.md](monitor/SKILL.md) describes. The game also saves +`glitch-progress.json` in the workspace so Pi can see which bug and stage +(`unseen`, `encountered`, `investigated`, `repaired`) the participant is on and +emit non-PII milestone events. Once all three bugs are `repaired`, Pi asks +which clue helped most and reports `debrief`. + +The server listens on port 4373. Set `GLITCH_PORT` to use another port, though +the challenge manifest expects 4373. + +## Run without boothstrap + +Needs Node 26 and a `GEMINI_API_KEY`. Follow the standalone steps in +boothstrap.sh's `docs/authoring-challenges.md` to install Pi and load the +package as an Agent Plugin, then: + +```sh +challenge=/absolute/path/to/glitch-invaders +mkdir /tmp/my-glitch-run && cd /tmp/my-glitch-run +"$challenge"/setup +``` + +Open `http://127.0.0.1:4373/`, then start Pi in the same folder: + +```sh +pi --model google/gemini-3.8-flash \ + --skill "$challenge"/skills/start-challenge/SKILL.md \ + '/skill:start-challenge' +``` + +When done: + +```sh +"$challenge"/cleanup +``` + +`cleanup` stops the server and removes `.glitch/` and `glitch-progress.json`. +Without boothstrap there is no `report_step`, `session_info`, or `emit_event`, +so no monitor runs: the skill reads `glitch-progress.json` and acknowledges +finished steps in plain words. diff --git a/challenges/glitch-invaders/challenge.json b/challenges/glitch-invaders/challenge.json new file mode 100644 index 0000000..9aa2b42 --- /dev/null +++ b/challenges/glitch-invaders/challenge.json @@ -0,0 +1,59 @@ +{ + "id": "glitch-invaders", + "title": "Glitch Invaders: On-Call", + "tagline": "Shoot the bugs, then debug the three that fight back using Sentry evidence.", + "icon": "bug", + "type": "browser", + "agent": "pi", + "model": "google/gemini-3.8-flash", + "vault_services": [ + "gemini" + ], + "points": 150, + "estimated_minutes": 9, + "learning_objectives": [ + "reading stack traces and error context", + "tracing a logic bug with breadcrumbs", + "spotting bottlenecks in a performance trace" + ], + "featured_tech": [ + "Sentry" + ], + "intro": "intro.md", + "steps": [ + { + "id": "launch-game", + "title": "Launch your patch drone", + "hint": "Press Start in the game, then shoot the first glitches.", + "done_when": "The page shows the line \"glitch-state v1 …\" containing \"launched=yes\"." + }, + { + "id": "fix-weapon", + "title": "Fix the jammed weapon", + "hint": "Grab the gold upgrade and fire. Then trace the error in the Incident Console.", + "done_when": "The page shows the line \"glitch-state v1 …\" containing \"weapon=repaired\"." + }, + { + "id": "fix-shield", + "title": "Fix the shield that hurts", + "hint": "Grab the blue shield and take a hit. Read the breadcrumbs, then patch it.", + "done_when": "The page shows the line \"glitch-state v1 …\" containing \"shield=repaired\"." + }, + { + "id": "fix-boss", + "title": "Fix the slow boss spawn", + "hint": "Clear the wave so the boss spawns. Find the bottleneck in the trace.", + "done_when": "The page shows the line \"glitch-state v1 …\" containing \"boss=repaired\"." + }, + { + "id": "debrief", + "title": "Tell your guide what you found", + "hint": "Say which clue helped most: the stack trace, the breadcrumbs, or the trace.", + "done_when": "All three fixes are repaired and the participant has told the guide, in their own words, which evidence helped them find a cause." + } + ], + "browser": { + "start_url": "http://127.0.0.1:4373/", + "monitor_skill": "monitor/SKILL.md" + } +} diff --git a/challenges/glitch-invaders/cleanup b/challenges/glitch-invaders/cleanup new file mode 100755 index 0000000..4fa0374 --- /dev/null +++ b/challenges/glitch-invaders/cleanup @@ -0,0 +1,4 @@ +#!/bin/sh +# Cleanup hook: stop the page server and remove this attempt's files. Safe to run twice. +set -eu +exec node "${BOOTHSTRAP_PACKAGE_DIR:-$(dirname "$0")}/server/cleanup.mjs" diff --git a/challenges/glitch-invaders/game/index.html b/challenges/glitch-invaders/game/index.html new file mode 100644 index 0000000..5851ab9 --- /dev/null +++ b/challenges/glitch-invaders/game/index.html @@ -0,0 +1,72 @@ + + + + + + + Glitch Invaders: On-Call + + + + +
+
+

+ + Glitch Invaders On-Call +

+ + DEMO · simulated evidence + +
+ +
+
+ + + +
+
+ + + + +
+ +

glitch-state v1 launched=no weapon=unseen shield=unseen boss=unseen completed=no mode=demo

+ +
+ + + + diff --git a/challenges/glitch-invaders/game/src/app/app.mjs b/challenges/glitch-invaders/game/src/app/app.mjs new file mode 100644 index 0000000..a519cc6 --- /dev/null +++ b/challenges/glitch-invaders/game/src/app/app.mjs @@ -0,0 +1,437 @@ +import { createProgress } from '../../../shared/progress.mjs'; + +import { loadBossResources } from '../boss/boss-loader.mjs'; +import { BUG_DEFINITIONS } from '../bugs/bug-definitions.mjs'; +import { tryRepair } from '../bugs/code-state.mjs'; +import { IncidentTracker } from '../bugs/incident-tracker.mjs'; +import { SimulatedEvidenceProvider } from '../diagnostics/simulated-evidence.mjs'; +import { GameEngine } from '../engine/game-engine.mjs'; +import { createRandom } from '../engine/random.mjs'; +import { CanvasRenderer } from '../render/canvas-renderer.mjs'; +import { publishState } from '../session/monitor-state.mjs'; + +import { BossLoadingView } from '../ui/boss-loading.mjs'; +import { onClick, setText } from '../ui/dom.mjs'; +import { Hud } from '../ui/hud.mjs'; +import { IncidentConsole } from '../ui/incident-console.mjs'; +import { InputController } from '../ui/input.mjs'; +import { completionCard, introCard, OverlayHost, pauseCard } from '../ui/overlays.mjs'; +import { GameLoop } from './game-loop.mjs'; +import { canonicalEncounter, codeForProgress, firstOpenBug, waveForBug } from './resume.mjs'; + +/** How long the banner after a success or patch stays up. */ +const BANNER_MS = 6_000; + +/** The pause after the last repair, so the participant sees the result before the recap. */ +const COMPLETION_DELAY_MS = 1_800; + +/** The seed that makes enemy fire reproducible unless the page asks for another. */ +export const DEFAULT_SEED = 20_260_514; + +/** Find a required element by id. */ +function requireElement(id) { + const element = document.getElementById(id); + if (element === null) { + throw new Error(`the page has no #${id} element`); + } + + return element; +} + +/** The game page. */ +export class GameApp { + options; + now; + stage = requireElement('stage'); + banner = requireElement('banner'); + announcer = requireElement('announcer'); + hud = new Hud(); + overlays = new OverlayHost(requireElement('overlay-root')); + bossView = new BossLoadingView(requireElement('boss-loading')); + engine; + evidence = new SimulatedEvidenceProvider(); + incidentConsole; + input; + loop; + evidenceByBug = new Map(); + tracker; + progress; + code; + lastShieldHit = null; + bannerTimer = null; + pauseOpen = false; + pendingStart = null; + + constructor(options, saved) { + this.options = options; + this.now = options.now ?? (() => performance.now()); + this.progress = saved ?? createProgress(options.config.attemptId); + this.tracker = new IncidentTracker(this.progress); + this.code = codeForProgress(this.progress); + + this.engine = new GameEngine({ + random: createRandom(options.seed ?? DEFAULT_SEED), + code: this.code, + spawnBossResources: (strategy, onTask) => this.spawnBoss(strategy, onTask), + now: this.now, + particleScale: options.reducedMotion ? 0.25 : 1, + }); + this.incidentConsole = new IncidentConsole({ + progressOf: (bug) => this.progress.bugs[bug], + markEvidenceViewed: (bug) => this.tracker.markEvidenceViewed(bug), + useHint: (bug) => this.tracker.useHint(bug), + diagnose: (bug, optionId) => this.tracker.answerDiagnosis(bug, optionId), + applyPatch: (bug, patchId) => this.applyPatch(bug, patchId), + }); + + const canvas = requireElement('arena'); + const renderer = new CanvasRenderer(canvas, { reducedMotion: options.reducedMotion }); + this.input = new InputController({ + onPauseKey: () => this.togglePause(), + isGameActive: () => !this.overlays.isOpen && this.engine.world.phase !== 'ready', + }); + this.loop = new GameLoop((elapsedMs, nowMs) => { + this.engine.update(elapsedMs, this.input.current()); + renderer.draw(this.engine.world, nowMs); + this.bossView.update(this.engine.world, this.now()); + this.hud.update(this.progress, this.engine.world); + }); + } + + /** Wire the page, show the briefing or the saved run, and start drawing. */ + begin() { + this.tracker.subscribe((progress) => this.onProgress(progress)); + this.engine.on((event) => this.onEvent(event)); + publishState(document, this.progress); + + this.input.attach(document, { + left: requireElement('pad-left'), + right: requireElement('pad-right'), + fire: requireElement('pad-fire'), + }); + document.addEventListener('keydown', (event) => this.onPauseCardKey(event)); + onClick(requireElement('skip-ahead'), () => { + this.engine.fastForward(); + this.stage.focus(); + }); + for (const id of ['reset-run', 'reset-controls']) { + const button = document.getElementById(id); + if (button !== null) { + onClick(button, () => this.resetRun()); + } + } + this.showOpening(); + this.loop.start(); + } + + /** Load the boss's resources under the strategy the current code uses. */ + spawnBoss(strategy, onTask) { + return loadBossResources({ + strategy, + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + now: this.now, + ...(onTask === undefined ? {} : { onTask }), + }); + } + + /** Show the briefing for a new run, or the welcome-back card for a saved one. */ + showOpening() { + if (!this.progress.launched) { + this.overlays.show( + introCard({ returning: false, onStart: () => this.startRun() }), + ); + + return; + } + + if (this.progress.completed) { + this.engine.start(1); + this.engine.finish(); + this.overlays.show( + completionCard( + this.progress, + () => this.continuePlaying(), + () => this.resetRun(), + ), + ); + + return; + } + + const bug = firstOpenBug(this.progress); + if (bug === null) { + return; + } + const stage = this.progress.bugs[bug].stage; + this.pendingStart = () => { + if (stage === 'unseen') { + this.engine.start(waveForBug(bug)); + + return; + } + this.engine.restoreIncident(bug); + this.openRestoredIncident(bug); + }; + this.overlays.show( + introCard({ returning: true, onStart: () => this.resumeRun() }), + ); + } + + /** The participant pressed Start. */ + startRun() { + this.tracker.launch(); + this.closeCard(); + this.engine.start(1); + this.announce('Run started. Move with the arrow keys, fire with Space.'); + } + + /** The participant pressed Resume on the welcome-back card. */ + resumeRun() { + this.closeCard(); + this.pendingStart?.(); + this.pendingStart = null; + this.announce('Welcome back. Your run was restored.'); + } + + /** Close the card on screen and put focus back on the arena. */ + closeCard() { + this.pauseOpen = false; + this.overlays.hide(this.stage); + } + + /** Pause or resume from the keyboard. */ + togglePause() { + if (this.overlays.isOpen || this.engine.world.phase !== 'playing') { + return; + } + this.engine.pause(); + this.input.releaseAll(); + this.pauseOpen = true; + this.overlays.show(pauseCard(() => this.resumeFromPause())); + } + + /** Close the pause card and continue. */ + resumeFromPause() { + this.engine.resume(); + this.closeCard(); + } + + /** Let P or Escape close the pause card. */ + onPauseCardKey(event) { + if ( + !event.defaultPrevented && + this.pauseOpen && + (event.code === 'KeyP' || event.code === 'Escape') + ) { + event.preventDefault(); + this.resumeFromPause(); + } + } + + /** React to what the engine reports. */ + onEvent(event) { + switch (event.type) { + case 'weapon-jammed': + this.openIncident({ + bug: 'weapon', + jam: { + weaponId: event.error.weaponId, + availableWeapons: event.availableWeapons, + error: event.error, + }, + }); + break; + case 'shield-hit': + this.lastShieldHit = event; + break; + case 'ship-down': + if (event.cause === 'shield-failure' && this.lastShieldHit !== null) { + this.openIncident({ + bug: 'shield', + failure: { + incomingDamage: this.lastShieldHit.incomingDamage, + actualDamage: this.lastShieldHit.outcome.actualDamage, + hullBefore: this.lastShieldHit.hullBefore, + hullAfter: this.lastShieldHit.hullAfter, + }, + }); + } + break; + case 'boss-online': + if (event.stalled) { + this.openIncident({ + bug: 'boss', + stall: { + totalMs: event.result.totalMs, + spans: event.result.spans.map((span) => ({ + name: span.name, + operation: span.op, + startMs: span.startMs, + durationMs: span.durationMs, + })), + }, + }); + } + break; + case 'verification-passed': + this.onVerified(event.bug); + break; + case 'pickup-collected': + this.announce(event.kind === 'fanout' ? 'Fan-out upgrade equipped.' : 'Shield equipped.'); + break; + case 'wave-started': + case 'enemy-destroyed': + case 'pickup-spawned': + case 'volley-fired': + case 'ship-restored': + case 'boss-loading': + break; + case 'run-finished': + if (this.freeplayActive) { + this.freeplayActive = false; + const bossDefeated = this.engine.world.boss === null; + this.showBanner( + bossDefeated + ? '★ Monolith destroyed! All threats eliminated.' + : 'Hull breached by The Monolith. Final score recorded.', + bossDefeated ? 'success' : 'info', + ); + setTimeout(() => this.complete(), 1_400); + } + break; + } + } + + /** A failure just happened in play: record it and open the console on its evidence. */ + openIncident(encounter) { + const bug = encounter.bug; + this.tracker.encounter(bug); + this.input.releaseAll(); + this.incidentConsole.open(bug); + this.overlays.show(this.incidentConsole.element); + const definition = BUG_DEFINITIONS[bug]; + this.announce( + `${definition.incidentLabel}: ${definition.title}. The Incident Console is open.`, + ); + + void this.evidence.evidenceFor(encounter).then((evidence) => this.deliverEvidence(evidence)); + } + + /** A failure was restored from a saved run: reopen the console on its evidence. */ + openRestoredIncident(bug) { + this.input.releaseAll(); + this.incidentConsole.open(bug); + this.overlays.show(this.incidentConsole.element); + const cached = this.evidenceByBug.get(bug); + if (cached !== undefined) { + this.incidentConsole.showEvidence(cached); + + return; + } + void this.evidence + .evidenceFor(canonicalEncounter(bug)) + .then((evidence) => this.deliverEvidence(evidence)); + } + + /** Remember the evidence and show it if its console is still open. */ + deliverEvidence(evidence) { + this.evidenceByBug.set(evidence.bug, evidence); + this.incidentConsole.showEvidence(evidence); + } + + /** Try a patch from the console. A good one is kept, closes the console, and starts the replay. */ + applyPatch(bug, patchId) { + const trial = tryRepair(bug, patchId, this.code); + this.tracker.recordRepairAttempt(bug, trial.check.ok); + if (!trial.check.ok) { + this.announce(`Patch reverted. ${trial.check.observation}`); + + return trial.check; + } + + this.code = trial.state; + this.engine.setCode(this.code); + this.closeCard(); + this.engine.beginVerification(bug); + this.showBanner(`Patch applied. ${BUG_DEFINITIONS[bug].verifyPrompt}`, 'info'); + this.announce(`Patch applied. ${BUG_DEFINITIONS[bug].verifyPrompt}`); + + return trial.check; + } + + /** The repaired behavior was seen in the arena. */ + onVerified(bug) { + this.tracker.markRepaired(bug); + const definition = BUG_DEFINITIONS[bug]; + this.showBanner(`✓ ${definition.incidentLabel} fixed. ${definition.fixExplanation}`, 'success'); + this.announce(`${definition.title} repaired.`); + + if (this.tracker.allRepaired()) { + setTimeout(() => this.complete(), COMPLETION_DELAY_MS); + } + } + + /** All three incidents are closed: freeze the arena and show the recap. */ + complete() { + this.freeplayActive = false; + this.engine.finish(); + this.input.releaseAll(); + this.hideBanner(); + this.overlays.show( + completionCard( + this.progress, + () => this.continuePlaying(), + () => this.resetRun(), + ), + ); + this.announce('All incidents closed.'); + } + + freeplayActive = false; + + /** Dismiss the completion recap and keep playing in the arena. */ + continuePlaying() { + this.freeplayActive = true; + this.closeCard(); + this.engine.continuePlaying(); + this.showBanner('Boss Showdown: Destroy The Monolith before your hull fails!', 'info'); + this.announce('Continuing play in the arena.'); + } + + /** Clear saved progress and reload the page for a fresh run. */ + resetRun() { + void this.options.store.clear().then(() => this.options.reload()); + } + + /** Save and publish every progress change. */ + onProgress(progress) { + this.progress = progress; + void this.options.store.save(progress); + publishState(document, progress); + } + + /** Show a banner over the arena, replacing any earlier one. */ + showBanner(text, kind) { + this.banner.dataset.kind = kind; + setText(this.banner, text); + this.banner.hidden = false; + if (this.bannerTimer !== null) { + clearTimeout(this.bannerTimer); + } + this.bannerTimer = setTimeout(() => this.hideBanner(), BANNER_MS); + } + + /** Hide the banner. */ + hideBanner() { + this.banner.hidden = true; + if (this.bannerTimer !== null) { + clearTimeout(this.bannerTimer); + this.bannerTimer = null; + } + } + + /** Say something to screen readers. */ + announce(message) { + setText(this.announcer, message); + } +} diff --git a/challenges/glitch-invaders/game/src/app/game-loop.mjs b/challenges/glitch-invaders/game/src/app/game-loop.mjs new file mode 100644 index 0000000..fa1eb0c --- /dev/null +++ b/challenges/glitch-invaders/game/src/app/game-loop.mjs @@ -0,0 +1,38 @@ +/** The animation frame loop: one `step` per frame, with the elapsed time since the last one. */ + +/** Runs a {@link FrameStep} on every animation frame until stopped. */ +export class GameLoop { + step; + handle = null; + previousMs = 0; + + constructor(step) { + this.step = step; + } + + /** Begin looping. Starting twice does nothing. */ + start() { + if (this.handle !== null) { + return; + } + + this.previousMs = performance.now(); + this.handle = requestAnimationFrame((nowMs) => this.frame(nowMs)); + } + + /** Stop looping. */ + stop() { + if (this.handle !== null) { + cancelAnimationFrame(this.handle); + this.handle = null; + } + } + + /** Run one frame and ask for the next. */ + frame(nowMs) { + const elapsedMs = Math.max(0, nowMs - this.previousMs); + this.previousMs = nowMs; + this.step(elapsedMs, nowMs); + this.handle = requestAnimationFrame((next) => this.frame(next)); + } +} diff --git a/challenges/glitch-invaders/game/src/app/resume.mjs b/challenges/glitch-invaders/game/src/app/resume.mjs new file mode 100644 index 0000000..ab4c82a --- /dev/null +++ b/challenges/glitch-invaders/game/src/app/resume.mjs @@ -0,0 +1,56 @@ +/** + * Rebuilding a saved run after a refresh: the code the repaired bugs left behind, and the evidence + * for a failure that was still open. + */ +import { BUG_IDS } from '../../../shared/progress.mjs'; +import { CORRECT_PATCH_IDS, createBrokenCodeState, tryRepair } from '../bugs/code-state.mjs'; + +import { SEQUENTIAL_BOSS_SPANS } from '../diagnostics/evidence-builders.mjs'; +import { WeaponConfigError } from '../weapons/weapons.mjs'; + +/** The code with every already repaired bug fixed. */ +export function codeForProgress(progress) { + let code = createBrokenCodeState(); + for (const bug of BUG_IDS) { + if (progress.bugs[bug].stage === 'repaired') { + code = tryRepair(bug, CORRECT_PATCH_IDS[bug], code).state; + } + } + + return code; +} + +/** The first failure that is not repaired, or `null` when all are. */ +export function firstOpenBug(progress) { + return BUG_IDS.find((bug) => progress.bugs[bug].stage !== 'repaired') ?? null; +} + +/** The wave a run starts at to reach `bug`: the weapon in wave 1, the shield in 2, the boss in 3. */ +export function waveForBug(bug) { + return bug === 'weapon' ? 1 : bug === 'shield' ? 2 : 3; +} + +/** What `bug` looked like when it happened, for evidence rebuilt after a refresh. */ +export function canonicalEncounter(bug) { + switch (bug) { + case 'weapon': + return { + bug, + jam: { + weaponId: 'fanout', + availableWeapons: ['single'], + error: new WeaponConfigError('fanout'), + }, + }; + case 'shield': + return { + bug, + failure: { incomingDamage: 1, actualDamage: 3, hullBefore: 3, hullAfter: 0 }, + }; + case 'boss': + return { + bug, + stall: { totalMs: 4_500, spans: [...SEQUENTIAL_BOSS_SPANS] }, + }; + } +} diff --git a/challenges/glitch-invaders/game/src/boss/boss-loader.mjs b/challenges/glitch-invaders/game/src/boss/boss-loader.mjs new file mode 100644 index 0000000..98c4ff1 --- /dev/null +++ b/challenges/glitch-invaders/game/src/boss/boss-loader.mjs @@ -0,0 +1,77 @@ +/** + * Boss resource loading, with the inefficiencies that are the third intentional failure: the work + * runs one task at a time, and one task repeats work already done at game start. + */ +import { + INIT_AI_MS, + LOAD_ANIMATIONS_MS, + LOAD_SOUNDS_MS, + LOAD_TEXTURES_MS, +} from '../engine/constants.mjs'; + +/** The span operation name every boss load reports under. */ +export const LOAD_SPAN_OP = 'game.resource.load'; + +/** Every load a spawn can run, in the order the shipped code runs them. */ +export const ALL_LOAD_TASKS = [ + { name: 'loadTextures', durationMs: LOAD_TEXTURES_MS }, + { name: 'loadSounds', durationMs: LOAD_SOUNDS_MS }, + { name: 'loadAnimations', durationMs: LOAD_ANIMATIONS_MS }, + { name: 'initializeAI', durationMs: INIT_AI_MS }, +]; + +/** The loads `strategy` performs. Only `parallel-deduplicated` drops the redundant animation load. */ +export function tasksFor(strategy) { + if (strategy === 'parallel-deduplicated') { + return ALL_LOAD_TASKS.filter((task) => task.name !== 'loadAnimations'); + } + + return ALL_LOAD_TASKS; +} + +/** Whether `strategy` starts its loads together. */ +export function runsInParallel(strategy) { + return strategy === 'parallel-all' || strategy === 'parallel-deduplicated'; +} + +/** + * How long a first spawn takes under `strategy`. A cache cannot help on a first spawn, so + * `cached-sequential` costs the same as `sequential`. + */ +export function plannedLoadMs(strategy) { + const durations = tasksFor(strategy).map((task) => task.durationMs); + if (runsInParallel(strategy)) { + return Math.max(...durations); + } + + return durations.reduce((total, duration) => total + duration, 0); +} + +/** Run one load, timing it against `startedAt`. */ +async function runTask(task, options, startedAt, spans) { + const startMs = options.now() - startedAt; + options.onTask?.({ name: task.name, phase: 'start', atMs: startMs }); + + await options.sleep(task.durationMs); + + const endMs = options.now() - startedAt; + spans.push({ name: task.name, op: LOAD_SPAN_OP, startMs, durationMs: endMs - startMs }); + options.onTask?.({ name: task.name, phase: 'end', atMs: endMs }); +} + +/** Load the boss's resources as `options.strategy` dictates and report what actually ran. */ +export async function loadBossResources(options) { + const startedAt = options.now(); + const spans = []; + const tasks = tasksFor(options.strategy); + + if (runsInParallel(options.strategy)) { + await Promise.all(tasks.map((task) => runTask(task, options, startedAt, spans))); + } else { + for (const task of tasks) { + await runTask(task, options, startedAt, spans); + } + } + + return { strategy: options.strategy, totalMs: options.now() - startedAt, spans }; +} diff --git a/challenges/glitch-invaders/game/src/bugs/bug-definitions.mjs b/challenges/glitch-invaders/game/src/bugs/bug-definitions.mjs new file mode 100644 index 0000000..62f05f8 --- /dev/null +++ b/challenges/glitch-invaders/game/src/bugs/bug-definitions.mjs @@ -0,0 +1,120 @@ +/** The three failures, in play order. */ +export const BUG_DEFINITIONS = { + weapon: { + id: 'weapon', + incidentLabel: 'INC-1', + title: 'Jammed weapon', + lens: 'Stack traces', + symptom: 'You grabbed the fan-out upgrade and your cannon stopped firing.', + requiredSections: ['exception', 'stacktrace', 'context'], + question: 'Why did the cannon jam?', + options: [ + { + id: 'no-ammo', + text: 'The cannon ran out of ammo.', + correct: false, + feedback: 'Nothing in the evidence mentions ammo. Re-read the exception message.', + }, + { + id: 'missing-config', + text: 'fire() looked up the fanout weapon and found no configuration for it.', + correct: true, + feedback: '', + }, + { + id: 'slow-cooldown', + text: 'The single weapon has a cooldown that is far too long.', + correct: false, + feedback: + 'The single weapon fired fine before the upgrade. Compare what was requested with what exists.', + }, + ], + hints: [ + 'Read the exception message first. It names exactly one thing.', + 'Compare attemptedWeapon with availableWeapons in the weapon_state context.', + 'fanout was requested, but only single exists. The configuration is missing.', + ], + fixExplanation: + 'Registering fanout gives fire() the entry it looks up: three projectiles, a 24° fan, and a 300 ms cooldown.', + verifyPrompt: 'Collect the upgrade again and fire. Three bolts should leave the ship.', + }, + shield: { + id: 'shield', + incidentLabel: 'INC-2', + title: 'Friendly fire', + lens: 'Breadcrumbs', + symptom: 'The shield was up, one hit landed, and your ship was destroyed.', + requiredSections: ['breadcrumbs', 'context'], + question: 'What was the shield doing when the hit landed?', + options: [ + { + id: 'expired', + text: 'It had already expired, so the hit did full damage.', + correct: false, + feedback: 'Check the breadcrumb that reports whether the shield was active.', + }, + { + id: 'amplified', + text: 'It was active, but it multiplied the damage instead of absorbing it.', + correct: true, + feedback: '', + }, + { + id: 'never-equipped', + text: 'The shield was never equipped.', + correct: false, + feedback: 'The first breadcrumbs show the pickup and the shield switching on.', + }, + ], + hints: [ + 'Follow the breadcrumbs from the pickup to the destroyed ship.', + 'Compare "Actual damage applied" with the expected value in shield_state.', + 'The shield was active, yet damage was 3 where 0 was expected.', + ], + fixExplanation: + 'An active shield now sets the damage to 0 and is consumed, so a hit costs the ship nothing.', + verifyPrompt: 'Collect the shield and let a shot hit you. The ship should survive untouched.', + }, + boss: { + id: 'boss', + incidentLabel: 'INC-3', + title: 'Boss buffering', + lens: 'Performance tracing', + symptom: 'The Monolith took far too long to appear. The game just sat there loading.', + requiredSections: ['waterfall', 'context'], + question: 'In the boss-spawn trace waterfall, which operation takes the longest?', + options: [ + { + id: 'load-textures', + text: 'loadTextures', + correct: false, + feedback: + 'loadTextures takes 1.20 s. Look at the waterfall for an even longer bar (1.80 s).', + }, + { + id: 'load-animations', + text: 'loadAnimations', + correct: true, + feedback: '', + }, + { + id: 'initialize-ai', + text: 'initializeAI', + correct: false, + feedback: + 'initializeAI is the shortest bar at 500 ms. Look for the longest bar in the waterfall.', + }, + ], + hints: [ + 'In the waterfall, the four loads run one after another. Which bar is longest?', + 'Compare the durations in the waterfall: loadTextures (1.20 s), loadSounds (1.00 s), loadAnimations (1.80 s), and initializeAI (500 ms).', + 'loadAnimations takes 1.80 s — the longest span in the trace.', + ], + fixExplanation: + 'Running core loads in parallel with Promise.all and streaming loadAnimations non-blocking cuts the spawn from 4.5 s to 1.2 s.', + verifyPrompt: 'Watch the Monolith spawn again. It should arrive in about a second.', + }, +}; + +/** The failures in play order. */ +export const BUG_ORDER = ['weapon', 'shield', 'boss']; diff --git a/challenges/glitch-invaders/game/src/bugs/code-state.mjs b/challenges/glitch-invaders/game/src/bugs/code-state.mjs new file mode 100644 index 0000000..0a8e8d4 --- /dev/null +++ b/challenges/glitch-invaders/game/src/bugs/code-state.mjs @@ -0,0 +1,225 @@ +/** + * The mutable "code" of the game: the three things a patch can change. The shipped state is + * deliberately broken, and the Incident Console's patches transform it. + */ + +import { plannedLoadMs, tasksFor } from '../boss/boss-loader.mjs'; +import { BOSS_SPAWN_BUDGET_MS } from '../engine/constants.mjs'; +import { resolveShieldHit } from '../shield/shield.mjs'; +import { + createBrokenWeaponTable, + FANOUT_VOLLEY, + fireWeapon, + SINGLE_SHOT, + WeaponConfigError, +} from '../weapons/weapons.mjs'; + +/** The state as shipped, with all three intentional bugs present. */ +export function createBrokenCodeState() { + return { + weapons: createBrokenWeaponTable(), + shield: 'amplify', + bossLoad: 'sequential', + }; +} + +/** The shortest cooldown the weapon loop can tolerate without flooding. */ +const MIN_SAFE_COOLDOWN_MS = 100; + +/** Install `config` as the `fanout` weapon. */ +function withFanout(state, config) { + return { ...state, weapons: { ...state.weapons, fanout: config } }; +} + +/** + * The patches for each failure, in the order the console lists them. Exactly one per failure is + * correct and it is never first, so position gives nothing away; the rest fail their check. + */ +export const REPAIR_PATCHES = { + weapon: [ + { + id: 'add-fanout-zero-cooldown', + title: 'Add fanout with no cooldown', + description: 'Three projectiles and no delay between volleys. Maximum firepower.', + snippet: + 'weapons.fanout = {\n projectileCount: 3,\n spreadDegrees: 24,\n cooldownMs: 0,\n damage: 1,\n};', + apply: (state) => withFanout(state, { ...FANOUT_VOLLEY, cooldownMs: 0 }), + }, + { + id: 'add-fanout-config', + title: 'Add the missing fanout config', + description: + 'Register the complete fan-out volley: 3 projectiles, a 24° fan, 300 ms cooldown.', + snippet: + 'weapons.fanout = {\n projectileCount: 3,\n spreadDegrees: 24,\n cooldownMs: 300,\n damage: 1,\n};', + apply: (state) => withFanout(state, FANOUT_VOLLEY), + }, + { + id: 'add-single-projectile-fanout', + title: 'Register fanout as a basic shot', + description: 'Add a fanout entry that copies the single weapon. Simple, so safe?', + snippet: 'weapons.fanout = { ...weapons.single };', + apply: (state) => withFanout(state, SINGLE_SHOT), + }, + ], + shield: [ + { + id: 'remove-multiplier', + title: 'Remove the ×3 multiplier', + description: 'Let damage pass through normally and keep the shield up.', + snippet: 'if (shieldActive) {\n actualDamage = incomingDamage;\n}', + apply: (state) => ({ ...state, shield: 'pass-through' }), + }, + { + id: 'drop-shield-on-hit', + title: 'Switch the shield off on impact', + description: 'Turn the shield off when it is hit, but still apply the damage.', + snippet: 'if (shieldActive) {\n shieldActive = false;\n actualDamage = incomingDamage;\n}', + apply: (state) => ({ ...state, shield: 'drop-then-pass' }), + }, + { + id: 'shield-absorbs-hit', + title: 'Absorb the hit completely', + description: 'While the shield is active, apply 0 damage and consume the shield.', + snippet: 'if (shieldActive) {\n actualDamage = 0;\n shieldConsumed = true;\n}', + apply: (state) => ({ ...state, shield: 'absorb' }), + }, + ], + boss: [ + { + id: 'parallelize-everything', + title: 'Await all four loads in parallel', + description: + 'Start every load at once with Promise.all, but still wait for the heavy 1.8 s loadAnimations() call before spawning.', + snippet: + 'await Promise.all([\n loadTextures(),\n loadSounds(),\n loadAnimations(), // still blocks for 1.8 s\n initializeAI(),\n]);', + apply: (state) => ({ ...state, bossLoad: 'parallel-all' }), + }, + { + id: 'parallel-without-redundant-load', + title: 'Parallelize core loads, stream animations', + description: + 'Run loadTextures, loadSounds, and initializeAI in parallel, and stream loadAnimations() without blocking spawn.', + snippet: + 'await Promise.all([\n loadTextures(),\n loadSounds(),\n initializeAI(),\n]);\nvoid loadAnimations(); // non-blocking', + apply: (state) => ({ ...state, bossLoad: 'parallel-deduplicated' }), + }, + { + id: 'add-cache-checks', + title: 'Check a cache before each load', + description: + 'Skip a load if its result is cached. On first spawn, all four loads still run one after another.', + snippet: + "if (!cache.has('textures')) await loadTextures();\nif (!cache.has('sounds')) await loadSounds();\nif (!cache.has('anims')) await loadAnimations();\nif (!cache.has('ai')) await initializeAI();", + apply: (state) => ({ ...state, bossLoad: 'cached-sequential' }), + }, + ], +}; + +/** Format milliseconds as seconds with one decimal, such as `1.8 s`. */ +function seconds(milliseconds) { + return `${(milliseconds / 1_000).toFixed(1)} s`; +} + +/** Try firing the upgrade and report whether the result is the intended three-way fan. */ +function checkWeapon(state) { + try { + const { shots, config } = fireWeapon(state.weapons, 'fanout'); + if (shots.length !== 3) { + return { + ok: false, + observation: `The upgrade fired ${shots.length} projectile${shots.length === 1 ? '' : 's'}. A fan-out should fire 3.`, + }; + } + if (config.cooldownMs < MIN_SAFE_COOLDOWN_MS) { + return { + ok: false, + observation: `A ${config.cooldownMs} ms cooldown lets the weapon fire every frame and floods the game loop.`, + }; + } + + return { ok: true, observation: 'The upgrade fired a 3-way fan with a 300 ms cooldown.' }; + } catch (error) { + if (error instanceof WeaponConfigError) { + return { ok: false, observation: error.message }; + } + throw error; + } +} + +/** Hit an active shield and report whether it protected the ship. */ +function checkShield(state) { + const outcome = resolveShieldHit(state.shield, true, 1); + if (outcome.actualDamage > 0) { + return { + ok: false, + observation: outcome.shieldConsumed + ? `The shield switched off, but the hit still landed for ${outcome.actualDamage}. A shield must absorb it.` + : `The shield let ${outcome.actualDamage} damage through and stayed up. A shield must absorb the hit.`, + }; + } + + return { ok: true, observation: 'The shield absorbed the hit: 0 damage, shield consumed.' }; +} + +/** Spawn the boss on a cold start and report whether it stays within the budget. */ +function checkBoss(state) { + const totalMs = plannedLoadMs(state.bossLoad); + const blocksOnAnimations = tasksFor(state.bossLoad).some( + (task) => task.name === 'loadAnimations', + ); + if (totalMs > BOSS_SPAWN_BUDGET_MS) { + return { + ok: false, + observation: `The boss still took ${seconds(totalMs)} to spawn on a cold start. The budget is ${seconds(BOSS_SPAWN_BUDGET_MS)}.`, + }; + } + if (blocksOnAnimations) { + return { + ok: false, + observation: `The boss spawned in ${seconds(totalMs)}, which still blocks on the 1.8 s loadAnimations() call.`, + }; + } + + return { + ok: true, + observation: `The boss spawned in ${seconds(totalMs)} by running core loads in parallel without blocking on loadAnimations.`, + }; +} + +/** Run the behavior check for `bug` against `state`. */ +export function checkRepair(bug, state) { + switch (bug) { + case 'weapon': + return checkWeapon(state); + case 'shield': + return checkShield(state); + case 'boss': + return checkBoss(state); + } +} + +/** + * Try patch `patchId` for `bug` on `state`. A patch that fails its check is reverted, so a wrong + * choice never leaves the game in a half-fixed state. + * + * @throws {Error} when `patchId` is not a patch for `bug`. + */ +export function tryRepair(bug, patchId, state) { + const patch = REPAIR_PATCHES[bug].find((candidate) => candidate.id === patchId); + if (patch === undefined) { + throw new Error(`unknown patch for ${bug}: ${patchId}`); + } + + const patched = patch.apply(state); + const check = checkRepair(bug, patched); + + return { patchId, state: check.ok ? patched : state, check }; +} + +/** The one patch per failure that passes its behavior check, for restoring a saved run. */ +export const CORRECT_PATCH_IDS = { + weapon: 'add-fanout-config', + shield: 'shield-absorbs-hit', + boss: 'parallel-without-redundant-load', +}; diff --git a/challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs b/challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs new file mode 100644 index 0000000..29f00a6 --- /dev/null +++ b/challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs @@ -0,0 +1,207 @@ +/** + * Progress through the three failures. Each failure moves `unseen → encountered → investigated → + * repaired`, one step at a time, and every move is recorded as a milestone the guide can forward. + */ +import { BUG_IDS } from '../../../shared/progress.mjs'; +import { + SCORE_BUG_FOUND, + SCORE_DIAGNOSIS_BASE, + SCORE_DIAGNOSIS_FLOOR, + SCORE_HINT_COST, + SCORE_REPAIR_FIRST_TRY, + SCORE_REPAIR_RETRY, + SCORE_VERIFIED, +} from '../engine/constants.mjs'; +import { BUG_DEFINITIONS } from './bug-definitions.mjs'; + +/** Thrown when an action does not fit the failure's current stage, such as diagnosing before seeing it. */ +export class IllegalBugTransitionError extends Error { + constructor(bug, action, stage) { + super(`cannot ${action} for ${bug} while it is ${stage}`); + this.name = 'IllegalBugTransitionError'; + } +} + +/** The state machine behind the three failures. */ +export class IncidentTracker { + progress; + listeners = new Set(); + + constructor(initial) { + this.progress = structuredClone(initial); + } + + /** A copy of the current progress. */ + snapshot() { + return structuredClone(this.progress); + } + + /** Listen for changes; returns the function that stops listening. */ + subscribe(listener) { + this.listeners.add(listener); + + return () => { + this.listeners.delete(listener); + }; + } + + /** The stage of `bug`. */ + stageOf(bug) { + return this.progress.bugs[bug].stage; + } + + /** Whether every failure is repaired. */ + allRepaired() { + return BUG_IDS.every((bug) => this.progress.bugs[bug].stage === 'repaired'); + } + + /** The participant pressed Start. Safe to call again. */ + launch() { + if (this.progress.launched) { + return; + } + + this.progress.launched = true; + this.record('run-started'); + } + + /** The failure became observable in play. Safe to call again. */ + encounter(bug) { + const entry = this.progress.bugs[bug]; + if (entry.stage !== 'unseen') { + return; + } + + entry.stage = 'encountered'; + this.progress.score += SCORE_BUG_FOUND; + this.record('bug-encountered', bug); + } + + /** The participant opened the console's evidence for `bug`. */ + markEvidenceViewed(bug) { + const entry = this.progress.bugs[bug]; + if (entry.stage === 'unseen') { + throw new IllegalBugTransitionError(bug, 'view evidence', entry.stage); + } + if (entry.evidenceViewed) { + return; + } + + entry.evidenceViewed = true; + this.record('evidence-viewed', bug); + } + + /** + * Reveal the next hint and charge for it. + * + * @returns the index of the newly revealed hint, or `null` when all three are already shown. + */ + useHint(bug) { + const entry = this.progress.bugs[bug]; + if (entry.hintsUsed >= BUG_DEFINITIONS[bug].hints.length) { + return null; + } + + entry.hintsUsed += 1; + this.notify(null); + + return entry.hintsUsed - 1; + } + + /** + * Check a diagnosis. A right answer, given after viewing the evidence, makes the failure + * `investigated`. + * + * @throws {IllegalBugTransitionError} when the evidence has not been viewed yet. + */ + answerDiagnosis(bug, optionId) { + const entry = this.progress.bugs[bug]; + if (entry.stage !== 'encountered' || !entry.evidenceViewed) { + throw new IllegalBugTransitionError(bug, 'diagnose', entry.stage); + } + + const option = BUG_DEFINITIONS[bug].options.find((candidate) => candidate.id === optionId); + if (option === undefined) { + throw new Error(`unknown diagnosis option for ${bug}: ${optionId}`); + } + + entry.diagnosisAttempts += 1; + if (!option.correct) { + this.notify(null); + + return { correct: false, feedback: option.feedback }; + } + + entry.stage = 'investigated'; + this.progress.score += Math.max( + SCORE_DIAGNOSIS_FLOOR, + SCORE_DIAGNOSIS_BASE - SCORE_HINT_COST * entry.hintsUsed, + ); + this.notify(null); + + return { correct: true, feedback: '' }; + } + + /** + * Record that the participant applied a patch. + * + * @throws {IllegalBugTransitionError} when the failure has not been diagnosed. + */ + recordRepairAttempt(bug, correct) { + const entry = this.progress.bugs[bug]; + if (entry.stage !== 'investigated') { + throw new IllegalBugTransitionError(bug, 'attempt a repair', entry.stage); + } + + entry.repairAttempts += 1; + if (correct) { + this.progress.score += + entry.repairAttempts === 1 ? SCORE_REPAIR_FIRST_TRY : SCORE_REPAIR_RETRY; + } + this.record('repair-attempted', bug, correct); + } + + /** + * The repaired behavior was seen working in the game. Completes the run once all three are done. + * + * @throws {IllegalBugTransitionError} when the failure has not been diagnosed. + */ + markRepaired(bug) { + const entry = this.progress.bugs[bug]; + if (entry.stage === 'repaired') { + return; + } + if (entry.stage !== 'investigated') { + throw new IllegalBugTransitionError(bug, 'mark repaired', entry.stage); + } + + entry.stage = 'repaired'; + this.progress.score += SCORE_VERIFIED; + this.record('repair-succeeded', bug); + + if (this.allRepaired() && !this.progress.completed) { + this.progress.completed = true; + this.record('challenge-completed'); + } + } + + /** Append a milestone and tell listeners. */ + record(name, bug, correct) { + const seq = this.progress.milestones.length + 1; + this.progress.milestones.push({ + seq, + name, + ...(bug === undefined ? {} : { bug }), + ...(correct === undefined ? {} : { correct }), + }); + this.notify(name); + } + + /** Tell every listener about the current progress. */ + notify(change) { + const snapshot = this.snapshot(); + for (const listener of this.listeners) { + listener(snapshot, change); + } + } +} diff --git a/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs new file mode 100644 index 0000000..b60612d --- /dev/null +++ b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs @@ -0,0 +1,182 @@ +import { BOSS_SPAWN_BUDGET_MS } from '../engine/constants.mjs'; + +/** Format milliseconds as seconds, such as `1.8 s`. */ +function seconds(milliseconds) { + return `${(milliseconds / 1_000).toFixed(1)} s`; +} + +/** + * Where the weapon failure's stack frames point. A unit test checks each line still holds the call + * it names, so these cannot drift from the source unnoticed. + */ +export const WEAPON_STACK_FRAMES = [ + { functionName: 'fireWeapon', file: 'game/src/weapons/weapons.mjs', line: 46, inApp: true }, + { + functionName: 'GameEngine.tryFire', + file: 'game/src/engine/game-engine.mjs', + line: 320, + inApp: true, + }, + { + functionName: 'GameEngine.updatePlayer', + file: 'game/src/engine/game-engine.mjs', + line: 294, + inApp: true, + }, + { + functionName: 'GameEngine.update', + file: 'game/src/engine/game-engine.mjs', + line: 160, + inApp: true, + }, + { functionName: 'GameLoop.frame', file: 'game/src/app/app.mjs', line: 94, inApp: true }, + { functionName: 'requestAnimationFrame', file: '', line: 0, inApp: false }, +]; + +/** The canonical boss waterfall: the four loads, back to back. */ +export const SEQUENTIAL_BOSS_SPANS = [ + { name: 'loadTextures', operation: 'game.resource.load', startMs: 0, durationMs: 1_200 }, + { name: 'loadSounds', operation: 'game.resource.load', startMs: 1_200, durationMs: 1_000 }, + { name: 'loadAnimations', operation: 'game.resource.load', startMs: 2_200, durationMs: 1_800 }, + { name: 'initializeAI', operation: 'game.resource.load', startMs: 4_000, durationMs: 500 }, +]; + +/** The canonical run-up to the shield failure, used by the simulated provider's breadcrumbs. */ +export const SIMULATED_SHIELD_BREADCRUMBS = [ + { + offsetLabel: '-9.4 s', + category: 'game.wave', + message: 'Wave 2 started', + level: 'info', + data: null, + }, + { + offsetLabel: '-6.8 s', + category: 'game.shield', + message: 'Shield collected', + level: 'info', + data: { type: 'aegis' }, + }, + { + offsetLabel: '-6.8 s', + category: 'game.shield', + message: 'Shield enabled', + level: 'info', + data: { shieldActive: 'true' }, + }, + { + offsetLabel: '-1.2 s', + category: 'game.combat', + message: 'Incoming damage: 1', + level: 'info', + data: { damage: '1' }, + }, + { + offsetLabel: '-1.2 s', + category: 'game.shield', + message: 'Shield active: true', + level: 'info', + data: { shieldActive: 'true' }, + }, + { + offsetLabel: '-1.2 s', + category: 'game.combat', + message: 'Actual damage applied: 3', + level: 'warning', + data: { actualDamage: '3', expected: '0' }, + }, + { + offsetLabel: '0.0 s', + category: 'game.combat', + message: 'Ship destroyed: shield failed to protect', + level: 'error', + data: null, + }, +]; + +/** The title Sentry would give the issue or trace for `encounter`. */ +export function evidenceTitle(encounter) { + switch (encounter.bug) { + case 'weapon': + return `WeaponConfigError: Configuration missing for weapon: ${encounter.jam.weaponId}`; + case 'shield': + return `GameLogicError: Shield was active but ${encounter.failure.actualDamage} damage was applied (expected 0)`; + case 'boss': + return `boss-spawn took ${seconds(encounter.stall.totalMs)} (budget ${seconds(BOSS_SPAWN_BUDGET_MS)})`; + } +} + +/** + * The sections for `encounter`. `breadcrumbs` is the trail to show for the shield failure, and + * `spans` is the waterfall to show for the boss stall. + */ +export function evidenceSections(encounter, breadcrumbs, spans) { + switch (encounter.bug) { + case 'weapon': + return [ + { + kind: 'exception', + title: 'Exception', + errorType: 'WeaponConfigError', + message: `Configuration missing for weapon: ${encounter.jam.weaponId}`, + }, + { kind: 'stacktrace', title: 'Stack trace', frames: [...WEAPON_STACK_FRAMES] }, + { + kind: 'context', + title: 'weapon_state', + contextName: 'weapon_state', + entries: { + attemptedWeapon: encounter.jam.weaponId, + availableWeapons: encounter.jam.availableWeapons.join(', '), + }, + }, + ]; + case 'shield': + return [ + { + kind: 'exception', + title: 'Exception', + errorType: 'GameLogicError', + message: `Shield was active but ${encounter.failure.actualDamage} damage was applied (expected 0). Ship destroyed despite an active shield.`, + }, + { kind: 'breadcrumbs', title: 'Breadcrumbs', entries: [...breadcrumbs] }, + { + kind: 'context', + title: 'shield_state', + contextName: 'shield_state', + entries: { + shieldActive: 'true', + incomingDamage: String(encounter.failure.incomingDamage), + actualDamage: String(encounter.failure.actualDamage), + expectedDamage: '0', + hullBefore: String(encounter.failure.hullBefore), + hullAfter: String(encounter.failure.hullAfter), + }, + }, + ]; + case 'boss': + return [ + { + kind: 'waterfall', + title: 'Trace: boss-spawn', + totalMs: spans.reduce((end, span) => Math.max(end, span.startMs + span.durationMs), 0), + spans: [...spans], + }, + { + kind: 'context', + title: 'boot_state', + contextName: 'boot_state', + entries: { + animationsLoaded: 'at game start', + spawnBudget: seconds(BOSS_SPAWN_BUDGET_MS), + strategy: 'sequential', + }, + }, + ]; + } +} + +/** Format an issue reference for the simulated provider, such as `SIM-WEAPON-1`. */ +export function simulatedReference(bug) { + return `SIM-${bug.toUpperCase()}-1`; +} diff --git a/challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs b/challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs new file mode 100644 index 0000000..57b152e --- /dev/null +++ b/challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs @@ -0,0 +1,26 @@ +/** + * Demo-mode evidence: realistic and deterministic, built without any Sentry credentials. It is + * clearly labelled simulated. + */ + +import { + evidenceSections, + evidenceTitle, + SEQUENTIAL_BOSS_SPANS, + SIMULATED_SHIELD_BREADCRUMBS, + simulatedReference, +} from './evidence-builders.mjs'; + +/** Produces fixed evidence, whatever the run measured. */ +export class SimulatedEvidenceProvider { + /** Evidence for `encounter`, labelled simulated. */ + async evidenceFor(encounter) { + return { + bug: encounter.bug, + source: 'simulated', + title: evidenceTitle(encounter), + referenceId: simulatedReference(encounter.bug), + sections: evidenceSections(encounter, SIMULATED_SHIELD_BREADCRUMBS, SEQUENTIAL_BOSS_SPANS), + }; + } +} diff --git a/challenges/glitch-invaders/game/src/engine/constants.mjs b/challenges/glitch-invaders/game/src/engine/constants.mjs new file mode 100644 index 0000000..222ccf9 --- /dev/null +++ b/challenges/glitch-invaders/game/src/engine/constants.mjs @@ -0,0 +1,97 @@ +/** Fixed numbers for the arena, ship, enemies, and incidents. Distances are world pixels. */ + +/** Width of the arena in world pixels; the canvas scales it to fit the screen. */ +export const WORLD_WIDTH = 800; + +/** Height of the arena in world pixels. */ +export const WORLD_HEIGHT = 500; + +/** Ship footprint. */ +export const PLAYER_WIDTH = 40; +export const PLAYER_HEIGHT = 26; + +/** Vertical position of the ship's top edge. */ +export const PLAYER_Y = 438; + +/** Ship speed in pixels per second. */ +export const PLAYER_SPEED = 340; + +/** Hull points the ship starts with. */ +export const PLAYER_MAX_HULL = 3; + +/** How long the ship ignores damage after a hit or a restore. */ +export const PLAYER_INVULNERABLE_MS = 1_400; + +/** How long the ship stays down after losing its hull before it is restored. */ +export const PLAYER_RESTORE_MS = 1_600; + +/** Enemy grid shape and footprint. */ +export const ENEMY_ROWS = 3; +export const ENEMY_COLUMNS = 5; +export const ENEMY_WIDTH = 38; +export const ENEMY_HEIGHT = 26; +export const ENEMY_GAP_X = 26; +export const ENEMY_GAP_Y = 18; +export const ENEMY_TOP = 54; + +/** Horizontal speed of the enemy formation, in pixels per second. */ +export const ENEMY_SPEED = 46; + +/** How far the formation drops when it reaches a wall. */ +export const ENEMY_DROP = 16; + +/** The formation stops dropping once its lowest row reaches this y. */ +export const ENEMY_FLOOR = 300; + +/** Time between enemy shots, in milliseconds. */ +export const ENEMY_FIRE_INTERVAL_MS = 1_700; + +/** Speed of enemy bullets, in pixels per second. */ +export const ENEMY_BULLET_SPEED = 170; + +/** Speed of the player's bullets, in pixels per second. */ +export const PLAYER_BULLET_SPEED = 560; + +/** Bullet footprint. */ +export const BULLET_WIDTH = 5; +export const BULLET_HEIGHT = 14; + +/** Pickup footprint and fall speed. */ +export const PICKUP_SIZE = 26; +export const PICKUP_SPEED = 96; + +/** Time between aimed shots while the shield test is armed. */ +export const PROBE_INTERVAL_MS = 1_100; + +/** Speed of the aimed shots fired at a shielded ship. */ +export const PROBE_BULLET_SPEED = 230; + +/** Damage the broken shield applies for each point of incoming damage. */ +export const SHIELD_BUG_MULTIPLIER = 3; + +/** Boss footprint and position. */ +export const BOSS_WIDTH = 120; +export const BOSS_HEIGHT = 64; +export const BOSS_Y = 46; + +/** A boss spawn slower than this counts as a stalled spawn. */ +export const BOSS_SPAWN_BUDGET_MS = 2_000; + +/** Simulated durations of each boss resource load, in milliseconds. */ +export const LOAD_TEXTURES_MS = 1_200; +export const LOAD_SOUNDS_MS = 1_000; +export const LOAD_ANIMATIONS_MS = 1_800; +export const INIT_AI_MS = 500; + +/** Longest frame the engine simulates in one step, so a stalled tab cannot teleport the ship. */ +export const MAX_STEP_MS = 50; + +/** Points shown in the HUD. They are fun feedback; Boothstrap awards the challenge's points. */ +export const SCORE_ENEMY = 10; +export const SCORE_BUG_FOUND = 100; +export const SCORE_DIAGNOSIS_BASE = 300; +export const SCORE_HINT_COST = 100; +export const SCORE_DIAGNOSIS_FLOOR = 50; +export const SCORE_REPAIR_FIRST_TRY = 200; +export const SCORE_REPAIR_RETRY = 100; +export const SCORE_VERIFIED = 150; diff --git a/challenges/glitch-invaders/game/src/engine/game-engine.mjs b/challenges/glitch-invaders/game/src/engine/game-engine.mjs new file mode 100644 index 0000000..79f146c --- /dev/null +++ b/challenges/glitch-invaders/game/src/engine/game-engine.mjs @@ -0,0 +1,886 @@ +import { tasksFor } from '../boss/boss-loader.mjs'; + +import { resolveShieldHit } from '../shield/shield.mjs'; +import { fireWeapon, WeaponConfigError } from '../weapons/weapons.mjs'; +import { + BOSS_HEIGHT, + BOSS_SPAWN_BUDGET_MS, + BOSS_WIDTH, + BOSS_Y, + BULLET_HEIGHT, + BULLET_WIDTH, + ENEMY_BULLET_SPEED, + ENEMY_COLUMNS, + ENEMY_DROP, + ENEMY_FIRE_INTERVAL_MS, + ENEMY_FLOOR, + ENEMY_GAP_X, + ENEMY_GAP_Y, + ENEMY_HEIGHT, + ENEMY_ROWS, + ENEMY_SPEED, + ENEMY_TOP, + ENEMY_WIDTH, + MAX_STEP_MS, + PICKUP_SIZE, + PICKUP_SPEED, + PLAYER_BULLET_SPEED, + PLAYER_HEIGHT, + PLAYER_INVULNERABLE_MS, + PLAYER_MAX_HULL, + PLAYER_RESTORE_MS, + PLAYER_SPEED, + PLAYER_WIDTH, + PLAYER_Y, + PROBE_BULLET_SPEED, + PROBE_INTERVAL_MS, + WORLD_HEIGHT, + WORLD_WIDTH, +} from './constants.mjs'; +import { clamp, overlaps } from './geometry.mjs'; + +/** Boss health. */ +const BOSS_HEALTH = 12; + +/** The simulation. */ +export class GameEngine { + world; + code; + options; + listeners = new Set(); + enemyFireAt = ENEMY_FIRE_INTERVAL_MS; + probeFireAt = 0; + jamSeen = false; + shieldFailureSeen = false; + + constructor(options) { + this.options = options; + this.code = options.code; + this.world = { + time: 0, + phase: 'ready', + paused: false, + wave: 1, + incident: null, + verifying: null, + player: createPlayer(), + enemies: [], + bullets: [], + pickups: [], + boss: null, + bossLoad: null, + particles: [], + kills: 0, + formationDirection: 1, + }; + } + + /** Listen for game events; returns the function that stops listening. */ + on(listener) { + this.listeners.add(listener); + + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Begin the run at `wave`. Starting past an incident means that incident was already fixed, so its + * trigger is marked as seen and the wave is not held back for it. + */ + start(wave = 1) { + if (this.world.phase !== 'ready') { + return; + } + + this.world.phase = 'playing'; + this.jamSeen = wave > 1; + this.shieldFailureSeen = wave > 2; + if (wave === 3) { + this.world.wave = 3; + this.startBossSpawn(); + + return; + } + + this.startWave(wave); + } + + /** + * Put the arena back into a frozen incident, as after a page refresh: the failure is already + * recorded, so the Incident Console reopens on it. + */ + restoreIncident(bug) { + this.start(bug === 'weapon' ? 1 : 2); + this.jamSeen = true; + this.shieldFailureSeen = bug !== 'weapon'; + this.world.incident = bug; + this.world.phase = 'frozen'; + + switch (bug) { + case 'weapon': + this.world.player.weapon = 'fanout'; + break; + case 'shield': + this.world.player.hull = 0; + break; + case 'boss': + this.world.wave = 3; + this.world.enemies = []; + this.world.boss = this.createBoss(); + break; + } + } + + /** Replace the game's code, as a correct patch does. */ + setCode(code) { + this.code = code; + } + + /** Stop simulating until {@link GameEngine.resume}. */ + pause() { + this.world.paused = true; + } + + /** Continue after {@link GameEngine.pause}. */ + resume() { + this.world.paused = false; + } + + /** Advance the simulation by `elapsedMs`, capped so a stalled frame cannot teleport anything. */ + update(elapsedMs, input) { + const stepMs = Math.min(elapsedMs, MAX_STEP_MS); + this.updateParticles(stepMs); + if (this.world.paused || this.world.phase !== 'playing') { + return; + } + + this.world.time += stepMs; + const seconds = stepMs / 1_000; + this.updatePlayer(seconds, input); + this.updateFormation(seconds); + this.updateBoss(seconds); + this.fireEnemyShots(); + this.moveBullets(seconds); + this.movePickups(seconds); + this.resolveBulletHits(); + this.collectPickups(); + this.checkWaveCleared(); + } + + /** + * Skip ahead to the next incident: clear every plain drone and drop the carrier's pickup near the + * ship. The player still has to collect it and trigger the failure. + */ + fastForward() { + if (this.world.phase !== 'playing') { + return; + } + + for (const enemy of this.world.enemies) { + if (enemy.alive && enemy.kind === 'drone') { + this.destroyEnemy(enemy); + } + } + const carrier = this.world.enemies.find((enemy) => enemy.alive && enemy.kind !== 'drone'); + if (carrier !== undefined) { + carrier.alive = false; + this.burst(carrier.x + carrier.width / 2, carrier.y + carrier.height / 2, 'yellow'); + this.dropPickup(carrier.kind === 'weapon-carrier' ? 'fanout' : 'aegis'); + } + } + + /** + * Replay an incident with the repaired code so the participant sees it work. Resumes play and + * emits `verification-passed` once the repaired behavior is observed. + */ + beginVerification(bug) { + this.world.incident = null; + this.world.verifying = bug; + this.world.phase = 'playing'; + this.world.bullets = this.world.bullets.filter((bullet) => bullet.owner === 'player'); + + switch (bug) { + case 'weapon': + this.world.player.weapon = 'single'; + this.dropPickup('fanout'); + break; + case 'shield': + this.world.player.hull = PLAYER_MAX_HULL; + this.world.player.shield = false; + this.world.player.restoreAt = null; + this.makeInvulnerable(); + this.dropPickup('aegis'); + break; + case 'boss': + this.world.boss = null; + this.startBossSpawn(); + break; + } + } + + /** The run is over: freeze the arena for the completion screen. */ + finish() { + this.world.phase = 'finished'; + this.emit({ type: 'run-finished' }); + } + + /** Put a new pickup above the ship, where it falls into reach. */ + dropPickup(kind) { + const player = this.world.player; + const offset = (this.options.random() - 0.5) * 160; + this.world.pickups.push({ + kind, + x: clamp( + player.x + player.width / 2 - PICKUP_SIZE / 2 + offset, + 10, + WORLD_WIDTH - PICKUP_SIZE - 10, + ), + y: -PICKUP_SIZE, + width: PICKUP_SIZE, + height: PICKUP_SIZE, + }); + this.emit({ type: 'pickup-spawned', kind }); + } + + /** Lay out a formation of drones with one marked carrier. */ + startWave(wave) { + const carrierKind = wave === 1 ? 'weapon-carrier' : 'shield-carrier'; + const carrierColumn = wave === 1 ? 2 : 1; + const totalWidth = ENEMY_COLUMNS * ENEMY_WIDTH + (ENEMY_COLUMNS - 1) * ENEMY_GAP_X; + const left = (WORLD_WIDTH - totalWidth) / 2; + const enemies = []; + + for (let row = 0; row < ENEMY_ROWS; row += 1) { + for (let column = 0; column < ENEMY_COLUMNS; column += 1) { + const isCarrier = row === 1 && column === carrierColumn; + enemies.push({ + x: left + column * (ENEMY_WIDTH + ENEMY_GAP_X), + y: ENEMY_TOP + row * (ENEMY_HEIGHT + ENEMY_GAP_Y), + width: ENEMY_WIDTH, + height: ENEMY_HEIGHT, + kind: isCarrier ? carrierKind : 'drone', + row, + alive: true, + }); + } + } + + this.world.wave = wave; + this.world.enemies = enemies; + this.world.formationDirection = 1; + this.enemyFireAt = this.world.time + ENEMY_FIRE_INTERVAL_MS; + this.emit({ type: 'wave-started', wave }); + } + + /** Move the ship and fire when asked. */ + updatePlayer(seconds, input) { + const player = this.world.player; + if (player.restoreAt !== null) { + if (this.world.time >= player.restoreAt) { + this.restorePlayer(); + } + + return; + } + + const direction = (input.right ? 1 : 0) - (input.left ? 1 : 0); + player.x = clamp( + player.x + direction * PLAYER_SPEED * seconds, + 8, + WORLD_WIDTH - PLAYER_WIDTH - 8, + ); + if (input.fire) { + this.tryFire(); + } + } + + /** Bring the ship back at full hull after a normal loss. */ + restorePlayer() { + const player = this.world.player; + player.hull = PLAYER_MAX_HULL; + player.shield = false; + player.restoreAt = null; + this.makeInvulnerable(); + this.world.bullets = this.world.bullets.filter((bullet) => bullet.owner === 'player'); + this.emit({ type: 'ship-restored' }); + } + + /** Give the ship a short window where hits do nothing. */ + makeInvulnerable() { + this.world.player.invulnerableUntil = this.world.time + PLAYER_INVULNERABLE_MS; + } + + /** Fire the carried weapon, freezing the game on the first jam. */ + tryFire() { + const player = this.world.player; + let volley; + + try { + volley = fireWeapon(this.code.weapons, player.weapon); + } catch (error) { + if (error instanceof WeaponConfigError) { + this.jam(error); + + return; + } + throw error; + } + + if (this.world.time - player.lastShotAt < volley.config.cooldownMs) { + return; + } + + player.lastShotAt = this.world.time; + for (const shot of volley.shots) { + this.world.bullets.push({ + x: player.x + player.width / 2 - BULLET_WIDTH / 2, + y: player.y - BULLET_HEIGHT, + width: BULLET_WIDTH, + height: BULLET_HEIGHT, + velocityX: Math.sin(shot.angle) * PLAYER_BULLET_SPEED, + velocityY: -Math.cos(shot.angle) * PLAYER_BULLET_SPEED, + owner: 'player', + damage: shot.damage, + homing: false, + }); + } + this.emit({ type: 'volley-fired', weapon: player.weapon, projectiles: volley.shots.length }); + + if ( + this.world.verifying === 'weapon' && + player.weapon === 'fanout' && + volley.shots.length === 3 + ) { + this.passVerification('weapon'); + } + } + + /** The weapon had no configuration: stop the world on incident 1. */ + jam(error) { + this.jamSeen = true; + this.world.incident = 'weapon'; + this.world.phase = 'frozen'; + this.burst(this.world.player.x + PLAYER_WIDTH / 2, this.world.player.y, 'red', 18); + this.emit({ + type: 'weapon-jammed', + error, + availableWeapons: Object.keys(this.code.weapons), + }); + } + + /** Slide the formation and drop it at the walls. */ + updateFormation(seconds) { + const alive = this.world.enemies.filter((enemy) => enemy.alive); + if (alive.length === 0) { + return; + } + + const step = this.world.formationDirection * ENEMY_SPEED * seconds; + let touchesWall = false; + for (const enemy of alive) { + enemy.x += step; + if (enemy.x < 14 || enemy.x + enemy.width > WORLD_WIDTH - 14) { + touchesWall = true; + } + } + + if (!touchesWall) { + return; + } + + this.world.formationDirection = this.world.formationDirection === 1 ? -1 : 1; + const lowest = Math.max(...alive.map((enemy) => enemy.y + enemy.height)); + const drop = lowest + ENEMY_DROP <= ENEMY_FLOOR ? ENEMY_DROP : 0; + for (const enemy of alive) { + enemy.x -= step; + enemy.y += drop; + } + } + + /** Move the boss side to side and let it shoot. */ + updateBoss(seconds) { + const boss = this.world.boss; + if (boss === null) { + return; + } + + const enraged = this.freeplay && boss.health <= boss.maxHealth / 2; + const speedScale = enraged ? 1.5 : 1; + boss.x += boss.velocityX * speedScale * seconds; + if (boss.x < 10 || boss.x + boss.width > WORLD_WIDTH - 10) { + boss.velocityX *= -1; + boss.x = clamp(boss.x, 10, WORLD_WIDTH - boss.width - 10); + } + if (this.world.time >= boss.nextShotAt) { + const centerX = boss.x + boss.width / 2; + const muzzleY = boss.y + boss.height; + if (this.freeplay) { + boss.nextShotAt = this.world.time + (enraged ? 440 : 600); + this.spawnBossVolley(centerX, muzzleY, enraged); + } else { + boss.nextShotAt = this.world.time + 900; + this.spawnEnemyBullet(centerX, muzzleY, false); + } + } + } + + /** Let a random front-line enemy fire, and keep the shield test supplied with aimed shots. */ + fireEnemyShots() { + const world = this.world; + const alive = world.enemies.filter((enemy) => enemy.alive); + + if (alive.length > 0 && world.time >= this.enemyFireAt) { + this.enemyFireAt = world.time + ENEMY_FIRE_INTERVAL_MS; + const shooter = alive[Math.floor(this.options.random() * alive.length)]; + if (shooter !== undefined) { + this.spawnEnemyBullet(shooter.x + shooter.width / 2, shooter.y + shooter.height, false); + } + } + + const shieldTestArmed = world.player.shield && world.player.restoreAt === null; + if (shieldTestArmed && world.time >= this.probeFireAt) { + this.probeFireAt = world.time + PROBE_INTERVAL_MS; + const source = alive[Math.floor(this.options.random() * alive.length)]; + const originX = source === undefined ? WORLD_WIDTH / 2 : source.x + source.width / 2; + const originY = source === undefined ? ENEMY_TOP : source.y + source.height; + this.spawnEnemyBullet(originX, originY, true); + } + } + + /** Add a bullet from `(x, y)`; probes are aimed at the ship, others fall straight down. */ + spawnEnemyBullet(x, y, aimed) { + const player = this.world.player; + let velocityX = 0; + let velocityY = ENEMY_BULLET_SPEED; + + if (aimed) { + const deltaX = player.x + player.width / 2 - x; + const deltaY = player.y - y; + const length = Math.hypot(deltaX, deltaY) || 1; + velocityX = (deltaX / length) * PROBE_BULLET_SPEED; + velocityY = (deltaY / length) * PROBE_BULLET_SPEED; + } + + this.world.bullets.push({ + x: x - BULLET_WIDTH / 2, + y, + width: BULLET_WIDTH, + height: BULLET_HEIGHT, + velocityX, + velocityY, + owner: 'enemy', + damage: 1, + homing: aimed, + }); + } + + /** + * Move every bullet, steer homing ones to arrive where the ship is now, and drop those that leave + * the arena. A ship that keeps still is always hit by the shield test's shot; one that moves can + * still dodge. + */ + moveBullets(seconds) { + const player = this.world.player; + const targetX = player.x + player.width / 2; + for (const bullet of this.world.bullets) { + if (bullet.homing && bullet.y < player.y && bullet.velocityY > 0) { + const secondsToImpact = Math.max(0.05, (player.y - bullet.y) / bullet.velocityY); + const neededVelocity = (targetX - (bullet.x + bullet.width / 2)) / secondsToImpact; + bullet.velocityX = clamp(neededVelocity, -PROBE_BULLET_SPEED, PROBE_BULLET_SPEED); + } + bullet.x += bullet.velocityX * seconds; + bullet.y += bullet.velocityY * seconds; + } + + this.world.bullets = this.world.bullets.filter( + (bullet) => + bullet.y > -BULLET_HEIGHT * 2 && + bullet.y < WORLD_HEIGHT + BULLET_HEIGHT && + bullet.x > -20 && + bullet.x < WORLD_WIDTH + 20, + ); + } + + /** Let pickups fall, and send any that were missed back to the top so the incident can proceed. */ + movePickups(seconds) { + for (const pickup of this.world.pickups) { + pickup.y += PICKUP_SPEED * seconds; + if (pickup.y > WORLD_HEIGHT) { + pickup.y = -PICKUP_SIZE; + pickup.x = 20 + this.options.random() * (WORLD_WIDTH - PICKUP_SIZE - 40); + } + } + } + + /** Match player bullets against enemies and the boss, and enemy bullets against the ship. */ + resolveBulletHits() { + const world = this.world; + const spent = new Set(); + + for (const bullet of world.bullets) { + if (bullet.owner === 'player') { + const enemy = world.enemies.find( + (candidate) => candidate.alive && overlaps(bullet, candidate), + ); + if (enemy !== undefined) { + spent.add(bullet); + this.destroyEnemy(enemy); + continue; + } + + const boss = world.boss; + if (boss !== null && overlaps(bullet, boss)) { + spent.add(bullet); + boss.health -= bullet.damage; + this.burst(bullet.x, bullet.y, 'red', 3); + if (boss.health <= 0) { + world.boss = null; + world.kills += 5; + this.burst(boss.x + boss.width / 2, boss.y + boss.height / 2, 'yellow', 36); + if (this.freeplay) { + this.finish(); + } + } + } + } else if (world.player.restoreAt === null && overlaps(bullet, world.player)) { + spent.add(bullet); + this.hitPlayer(bullet.damage); + } + } + + if (spent.size > 0) { + world.bullets = world.bullets.filter((bullet) => !spent.has(bullet)); + } + } + + /** Remove an enemy, with a burst, and let a carrier drop its pickup. */ + destroyEnemy(enemy) { + enemy.alive = false; + this.world.kills += 1; + this.burst(enemy.x + enemy.width / 2, enemy.y + enemy.height / 2, 'green'); + this.emit({ type: 'enemy-destroyed', kind: enemy.kind }); + + if (enemy.kind === 'weapon-carrier' || enemy.kind === 'shield-carrier') { + const kind = enemy.kind === 'weapon-carrier' ? 'fanout' : 'aegis'; + this.world.pickups.push({ + kind, + x: enemy.x + (enemy.width - PICKUP_SIZE) / 2, + y: enemy.y, + width: PICKUP_SIZE, + height: PICKUP_SIZE, + }); + this.emit({ type: 'pickup-spawned', kind }); + } + } + + /** Apply an enemy hit to the ship through the shield rules. */ + hitPlayer(incomingDamage) { + const world = this.world; + const player = world.player; + if (world.time < player.invulnerableUntil) { + return; + } + + const shieldWasUp = player.shield; + const hullBefore = player.hull; + const outcome = resolveShieldHit(this.code.shield, player.shield, incomingDamage); + if (outcome.shieldConsumed) { + player.shield = false; + } + player.hull = Math.max(0, player.hull - outcome.actualDamage); + this.burst(player.x + player.width / 2, player.y, shieldWasUp ? 'blue' : 'red', 10); + + if (shieldWasUp) { + this.emit({ + type: 'shield-hit', + incomingDamage, + outcome, + hullBefore, + hullAfter: player.hull, + }); + } + + if (player.hull <= 0) { + this.loseHull(shieldWasUp); + + return; + } + + this.makeInvulnerable(); + if (shieldWasUp && outcome.actualDamage === 0 && world.verifying === 'shield') { + this.passVerification('shield'); + } + } + + /** The hull hit zero: freeze on incident 2 if the shield caused it, otherwise restore shortly. */ + loseHull(shieldWasUp) { + const world = this.world; + this.burst(world.player.x + PLAYER_WIDTH / 2, world.player.y, 'red', 26); + + if (shieldWasUp && this.code.shield === 'amplify') { + this.shieldFailureSeen = true; + world.incident = 'shield'; + world.phase = 'frozen'; + this.emit({ type: 'ship-down', cause: 'shield-failure' }); + + return; + } + + if (this.freeplay) { + this.emit({ type: 'ship-down', cause: 'damage' }); + this.finish(); + + return; + } + + world.player.restoreAt = world.time + PLAYER_RESTORE_MS; + this.emit({ type: 'ship-down', cause: 'damage' }); + } + + /** Pick up anything the ship overlaps. */ + collectPickups() { + const player = this.world.player; + if (player.restoreAt !== null) { + return; + } + + const collected = this.world.pickups.filter((pickup) => overlaps(pickup, player)); + if (collected.length === 0) { + return; + } + + this.world.pickups = this.world.pickups.filter((pickup) => !collected.includes(pickup)); + for (const pickup of collected) { + if (pickup.kind === 'fanout') { + player.weapon = 'fanout'; + } else { + player.shield = true; + this.probeFireAt = this.world.time + 600; + } + this.burst(pickup.x + PICKUP_SIZE / 2, pickup.y + PICKUP_SIZE / 2, 'yellow', 10); + this.emit({ type: 'pickup-collected', kind: pickup.kind }); + } + } + + /** + * End a wave once it is cleared and its incident has happened. If the player cleared the field + * without triggering the incident, the pickup comes back so it still can. A repair that is still + * being proven holds the next wave back, so the proof is never interrupted. + */ + checkWaveCleared() { + const world = this.world; + const enemiesLeft = world.enemies.some((enemy) => enemy.alive); + const waitingOnVerification = world.verifying !== null; + if ( + world.phase !== 'playing' || + enemiesLeft || + world.pickups.length > 0 || + world.boss !== null || + world.wave === 3 || + waitingOnVerification + ) { + return; + } + + if (world.wave === 1) { + if (!this.jamSeen) { + if (world.player.weapon !== 'fanout') { + this.dropPickup('fanout'); + } + + return; + } + this.startWave(2); + + return; + } + + if (!this.shieldFailureSeen) { + if (!world.player.shield) { + this.dropPickup('aegis'); + } + + return; + } + + world.wave = 3; + this.startBossSpawn(); + } + + /** Begin loading the boss's resources under the current code. */ + startBossSpawn() { + const world = this.world; + const strategy = this.code.bossLoad; + world.phase = 'boss-loading'; + world.bossLoad = { + strategy, + startedAt: this.options.now(), + tasks: tasksFor(strategy).map((task) => ({ name: task.name, startMs: null, endMs: null })), + }; + this.emit({ type: 'boss-loading', strategy }); + + void this.options + .spawnBossResources(strategy, (event) => this.noteBossTask(event)) + .then((result) => this.bossArrived(result)); + } + + /** Record a load starting or finishing for the overlay. */ + noteBossTask(event) { + const task = this.world.bossLoad?.tasks.find((candidate) => candidate.name === event.name); + if (task === undefined) { + return; + } + + if (event.phase === 'start') { + task.startMs = event.atMs; + } else { + task.endMs = event.atMs; + } + } + + /** The Monolith, centered at the top with full health. */ + createBoss() { + return { + x: (WORLD_WIDTH - BOSS_WIDTH) / 2, + y: BOSS_Y, + width: BOSS_WIDTH, + height: BOSS_HEIGHT, + health: BOSS_HEALTH, + maxHealth: BOSS_HEALTH, + velocityX: 70, + nextShotAt: this.world.time + 1_200, + }; + } + + /** The boss's resources are ready: show the boss, and freeze if the spawn stalled. */ + bossArrived(result) { + const world = this.world; + const stalled = result.totalMs > BOSS_SPAWN_BUDGET_MS; + world.boss = this.createBoss(); + world.bossLoad = null; + this.emit({ type: 'boss-online', result, stalled }); + + if (stalled) { + world.incident = 'boss'; + world.phase = 'frozen'; + + return; + } + + world.phase = 'playing'; + if (world.verifying === 'boss') { + this.passVerification('boss'); + } + } + + /** The repaired behavior was seen working. */ + passVerification(bug) { + this.world.verifying = null; + this.emit({ type: 'verification-passed', bug }); + } + + /** Spawn a short burst of particles. */ + burst(x, y, color, count = 8) { + const scaled = Math.round(count * (this.options.particleScale ?? 1)); + for (let index = 0; index < scaled; index += 1) { + const angle = this.options.random() * Math.PI * 2; + const speed = 40 + this.options.random() * 140; + this.world.particles.push({ + x, + y, + velocityX: Math.cos(angle) * speed, + velocityY: Math.sin(angle) * speed, + ageMs: 0, + lifeMs: 300 + this.options.random() * 300, + color, + }); + } + } + + /** Age particles and drop the expired ones. */ + updateParticles(stepMs) { + const seconds = stepMs / 1_000; + for (const particle of this.world.particles) { + particle.ageMs += stepMs; + particle.x += particle.velocityX * seconds; + particle.y += particle.velocityY * seconds; + } + this.world.particles = this.world.particles.filter( + (particle) => particle.ageMs < particle.lifeMs, + ); + } + + /** Tell every listener about `event`. */ + emit(event) { + for (const listener of this.listeners) { + listener(event); + } + } + + freeplay = false; + + /** Fire a spread volley (plus an aimed shot when enraged) from the boss during freeplay. */ + spawnBossVolley(centerX, muzzleY, enraged) { + const speed = ENEMY_BULLET_SPEED * (enraged ? 1.45 : 1.25); + const angles = enraged ? [-0.42, -0.2, 0, 0.2, 0.42] : [-0.32, 0, 0.32]; + for (const angle of angles) { + this.world.bullets.push({ + x: centerX - BULLET_WIDTH / 2, + y: muzzleY, + width: BULLET_WIDTH, + height: BULLET_HEIGHT, + velocityX: Math.sin(angle) * speed, + velocityY: Math.cos(angle) * speed, + owner: 'enemy', + damage: 1, + homing: false, + }); + } + if (enraged) { + this.spawnEnemyBullet(centerX, muzzleY, true); + } + } + + /** Resume play in the arena after the completion screen is dismissed for a boss showdown. */ + continuePlaying() { + this.freeplay = true; + this.jamSeen = true; + this.shieldFailureSeen = true; + this.world.incident = null; + this.world.verifying = null; + this.world.paused = false; + this.world.phase = 'playing'; + this.world.wave = 3; + this.world.enemies = []; + this.world.pickups = []; + this.world.bullets = []; + this.world.player.hull = PLAYER_MAX_HULL; + this.world.player.restoreAt = null; + this.world.player.weapon = 'fanout'; + this.world.player.shield = true; + this.world.boss = { + ...this.createBoss(), + health: BOSS_HEALTH * 3, + maxHealth: BOSS_HEALTH * 3, + velocityX: 140, + nextShotAt: this.world.time + 500, + }; + } +} + +/** The ship, centered at the bottom with a full hull. */ +function createPlayer() { + return { + x: (WORLD_WIDTH - PLAYER_WIDTH) / 2, + y: PLAYER_Y, + width: PLAYER_WIDTH, + height: PLAYER_HEIGHT, + hull: PLAYER_MAX_HULL, + shield: false, + weapon: 'single', + invulnerableUntil: 0, + restoreAt: null, + lastShotAt: -Infinity, + }; +} diff --git a/challenges/glitch-invaders/game/src/engine/geometry.mjs b/challenges/glitch-invaders/game/src/engine/geometry.mjs new file mode 100644 index 0000000..0c1ed1e --- /dev/null +++ b/challenges/glitch-invaders/game/src/engine/geometry.mjs @@ -0,0 +1,16 @@ +/** Axis-aligned rectangles and the overlap test the engine uses for every hit. */ + +/** Whether two boxes share any area. Boxes that only touch do not overlap. */ +export function overlaps(first, second) { + return ( + first.x < second.x + second.width && + first.x + first.width > second.x && + first.y < second.y + second.height && + first.y + first.height > second.y + ); +} + +/** Keep `value` within `[min, max]`. */ +export function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} diff --git a/challenges/glitch-invaders/game/src/engine/random.mjs b/challenges/glitch-invaders/game/src/engine/random.mjs new file mode 100644 index 0000000..fc06656 --- /dev/null +++ b/challenges/glitch-invaders/game/src/engine/random.mjs @@ -0,0 +1,14 @@ +/** A small seeded random source so enemy fire is reproducible in tests and replays. */ + +/** The mulberry32 generator: tiny, fast, and good enough for gameplay variation. */ +export function createRandom(seed) { + let state = seed >>> 0; + + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let mixed = Math.imul(state ^ (state >>> 15), 1 | state); + mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed; + + return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/challenges/glitch-invaders/game/src/engine/world.mjs b/challenges/glitch-invaders/game/src/engine/world.mjs new file mode 100644 index 0000000..b083e3c --- /dev/null +++ b/challenges/glitch-invaders/game/src/engine/world.mjs @@ -0,0 +1,4 @@ +/** An input state with nothing pressed. */ +export function idleInput() { + return { left: false, right: false, fire: false }; +} diff --git a/challenges/glitch-invaders/game/src/main.mjs b/challenges/glitch-invaders/game/src/main.mjs new file mode 100644 index 0000000..32e19eb --- /dev/null +++ b/challenges/glitch-invaders/game/src/main.mjs @@ -0,0 +1,66 @@ +/** The page entry: read this attempt's configuration and saved progress, and start the game. */ +import { parseRuntimeConfig } from '../../shared/runtime-config.mjs'; +import { GameApp } from './app/app.mjs'; +import { HttpProgressStore } from './session/progress-store.mjs'; + +/** Read `/runtime-config.json`, which the page server writes for every attempt. */ +async function loadConfig() { + const response = await fetch('/runtime-config.json', { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`the runtime config failed to load with status ${response.status}`); + } + + return parseRuntimeConfig(await response.json()); +} + +/** Show a plain message when the game cannot start at all. */ +function showFatal(error) { + const message = error instanceof Error ? error.message : String(error); + document.body.replaceChildren( + Object.assign(document.createElement('p'), { + className: 'fatal', + textContent: `Glitch Invaders could not start: ${message}. Reload this page.`, + }), + ); +} + +/** Keep the failed save visible: the state line would otherwise keep advancing past a stale file. */ +function showSaveError() { + const banner = document.getElementById('save-error'); + if (banner) { + banner.hidden = false; + } +} + +/** Start the game. */ +async function main() { + const config = await loadConfig(); + const query = new URLSearchParams(window.location.search); + const seed = Number(query.get('seed')); + const store = new HttpProgressStore( + '/api/progress', + (input, init) => fetch(input, init), + showSaveError, + ); + + let saved = null; + try { + saved = await store.load(config.attemptId); + } catch { + saved = null; + } + + const app = new GameApp( + { + config, + store, + reload: () => window.location.reload(), + reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches, + ...(Number.isInteger(seed) && query.has('seed') ? { seed } : {}), + }, + saved, + ); + app.begin(); +} + +main().catch(showFatal); diff --git a/challenges/glitch-invaders/game/src/render/canvas-renderer.mjs b/challenges/glitch-invaders/game/src/render/canvas-renderer.mjs new file mode 100644 index 0000000..669c571 --- /dev/null +++ b/challenges/glitch-invaders/game/src/render/canvas-renderer.mjs @@ -0,0 +1,284 @@ +/** Draws the arena onto a canvas. It only reads the world; it never changes it. */ +import { PLAYER_HEIGHT, PLAYER_Y, WORLD_HEIGHT, WORLD_WIDTH } from '../engine/constants.mjs'; + +import { PALETTE } from './palette.mjs'; +import { DRONE_FRAMES, MONOLITH, SHIP } from './sprites.mjs'; + +/** The size of one sprite pixel, in world pixels. */ +const DRONE_SCALE = 3; +const SHIP_SCALE = 3; +const MONOLITH_SCALE = 6; + +/** Particle colors by name. */ +const PARTICLE_COLORS = { + red: PALETTE.danger, + blue: PALETTE.accent, + yellow: PALETTE.focus, + green: PALETTE.success, + white: PALETTE.fg, +}; + +/** Fixed star positions, so the backdrop never shifts between frames. */ +const STARS = Array.from({ length: 46 }, (_, index) => ({ + x: (index * 97 + 31) % WORLD_WIDTH, + y: (index * 53 + 17) % (PLAYER_Y - 20), + phase: index * 0.7, +})); + +/** The canvas renderer. */ +export class CanvasRenderer { + context; + options; + + constructor(canvas, options) { + const context = canvas.getContext('2d'); + if (context === null) { + throw new Error('this browser cannot draw the game: no 2D canvas context'); + } + + const scale = Math.max(1, Math.ceil(window.devicePixelRatio || 1)); + canvas.width = WORLD_WIDTH * scale; + canvas.height = WORLD_HEIGHT * scale; + context.scale(scale, scale); + context.imageSmoothingEnabled = false; + this.context = context; + this.options = options; + } + + /** Draw one frame. */ + draw(world, nowMs) { + this.drawBackdrop(nowMs); + for (const pickup of world.pickups) { + this.drawPickup(pickup, nowMs); + } + for (const enemy of world.enemies) { + if (enemy.alive) { + this.drawEnemy(enemy, nowMs); + } + } + if (world.boss !== null) { + this.drawBoss(world, nowMs); + } + this.drawBullets(world); + this.drawPlayer(world, nowMs); + this.drawParticles(world.particles); + } + + /** The dark grid, a few stars, and the line the ship defends. */ + drawBackdrop(nowMs) { + const context = this.context; + context.fillStyle = PALETTE.bg; + context.fillRect(0, 0, WORLD_WIDTH, WORLD_HEIGHT); + + context.strokeStyle = 'rgba(95, 101, 112, 0.22)'; + context.lineWidth = 1; + context.beginPath(); + for (let x = 0; x <= WORLD_WIDTH; x += 40) { + context.moveTo(x + 0.5, 0); + context.lineTo(x + 0.5, WORLD_HEIGHT); + } + for (let y = 0; y <= WORLD_HEIGHT; y += 40) { + context.moveTo(0, y + 0.5); + context.lineTo(WORLD_WIDTH, y + 0.5); + } + context.stroke(); + + for (const star of STARS) { + const twinkle = this.options.reducedMotion + ? 0.6 + : 0.45 + 0.35 * Math.sin(nowMs / 700 + star.phase); + context.fillStyle = `rgba(232, 234, 237, ${twinkle})`; + context.fillRect(star.x, star.y, 2, 2); + } + + const floorY = PLAYER_Y + PLAYER_HEIGHT + 10; + context.strokeStyle = PALETTE.border; + context.setLineDash([8, 6]); + context.beginPath(); + context.moveTo(0, floorY + 0.5); + context.lineTo(WORLD_WIDTH, floorY + 0.5); + context.stroke(); + context.setLineDash([]); + context.fillStyle = PALETTE.muted; + context.font = '12px ui-monospace, Menlo, monospace'; + context.fillText('PROD', 10, floorY + 16); + } + + /** Draw `sprite` at `(x, y)`, lit pixels in `color` and highlights in `highlight`. */ + drawSprite(sprite, x, y, scale, color, highlight) { + const context = this.context; + sprite.forEach((row, rowIndex) => { + for (let column = 0; column < row.length; column += 1) { + const pixel = row[column]; + if (pixel === '#' || pixel === 'o') { + context.fillStyle = pixel === 'o' ? highlight : color; + context.fillRect(x + column * scale, y + rowIndex * scale, scale, scale); + } + } + }); + } + + /** A drone, with a marker above carriers: a gold diamond for the weapon, a blue shield for the aegis. */ + drawEnemy(enemy, nowMs) { + const frame = DRONE_FRAMES[Math.floor(nowMs / 420) % DRONE_FRAMES.length] ?? DRONE_FRAMES[0]; + if (frame === undefined) { + return; + } + + const color = enemy.row === 1 ? PALETTE.warning : PALETTE.danger; + const spriteWidth = (frame[0]?.length ?? 0) * DRONE_SCALE; + const left = enemy.x + (enemy.width - spriteWidth) / 2; + this.drawSprite(frame, left, enemy.y, DRONE_SCALE, color, PALETTE.bg); + + const centerX = enemy.x + enemy.width / 2; + const pulse = this.options.reducedMotion ? 0 : Math.sin(nowMs / 250) * 1.5; + if (enemy.kind === 'weapon-carrier') { + this.drawDiamond(centerX, enemy.y - 9 - pulse, 7, PALETTE.focus); + } else if (enemy.kind === 'shield-carrier') { + this.drawShieldIcon(centerX, enemy.y - 10 - pulse, 8, PALETTE.accent); + } + } + + /** A filled diamond centered at `(x, y)`. */ + drawDiamond(x, y, radius, color) { + const context = this.context; + context.fillStyle = color; + context.beginPath(); + context.moveTo(x, y - radius); + context.lineTo(x + radius, y); + context.lineTo(x, y + radius); + context.lineTo(x - radius, y); + context.closePath(); + context.fill(); + } + + /** A shield outline centered at `(x, y)`. */ + drawShieldIcon(x, y, size, color) { + const context = this.context; + context.fillStyle = color; + context.beginPath(); + context.moveTo(x - size, y - size); + context.lineTo(x + size, y - size); + context.lineTo(x + size, y); + context.lineTo(x, y + size); + context.lineTo(x - size, y); + context.closePath(); + context.fill(); + context.fillStyle = PALETTE.bg; + context.fillRect(x - 2, y - size + 3, 4, 7); + } + + /** A falling pickup: a gold chip with a fan of three lines, or a blue shield. */ + drawPickup(pickup, nowMs) { + const context = this.context; + const centerX = pickup.x + pickup.width / 2; + const centerY = pickup.y + pickup.height / 2; + const glow = this.options.reducedMotion ? 0.5 : 0.5 + 0.5 * Math.sin(nowMs / 200); + + if (pickup.kind === 'fanout') { + context.fillStyle = `rgba(248, 185, 42, ${0.18 + glow * 0.18})`; + context.fillRect(pickup.x - 5, pickup.y - 5, pickup.width + 10, pickup.height + 10); + context.fillStyle = PALETTE.focus; + context.fillRect(pickup.x, pickup.y, pickup.width, pickup.height); + context.strokeStyle = PALETTE.bg; + context.lineWidth = 3; + context.beginPath(); + for (const dx of [-8, 0, 8]) { + context.moveTo(centerX, centerY + 7); + context.lineTo(centerX + dx, centerY - 7); + } + context.stroke(); + + return; + } + + context.fillStyle = `rgba(92, 156, 245, ${0.18 + glow * 0.18})`; + context.fillRect(pickup.x - 5, pickup.y - 5, pickup.width + 10, pickup.height + 10); + this.drawShieldIcon(centerX, centerY - 2, 13, PALETTE.accent); + } + + /** The Monolith, with its health bar. */ + drawBoss(world, nowMs) { + const boss = world.boss; + if (boss === null) { + return; + } + + const rage = this.options.reducedMotion ? 0 : Math.sin(nowMs / 160) * 0.5 + 0.5; + this.drawSprite( + MONOLITH, + boss.x, + boss.y, + MONOLITH_SCALE, + PALETTE.mlhRed, + rage > 0.5 ? PALETTE.focus : PALETTE.fg, + ); + + const context = this.context; + const barWidth = boss.width; + context.fillStyle = PALETTE.surface; + context.fillRect(boss.x, boss.y - 12, barWidth, 6); + context.fillStyle = PALETTE.danger; + context.fillRect(boss.x, boss.y - 12, barWidth * Math.max(0, boss.health / boss.maxHealth), 6); + } + + /** Every bullet: blue for the ship, gold for fan-out volleys, orange for aimed probes, red otherwise. */ + drawBullets(world) { + const context = this.context; + for (const bullet of world.bullets) { + if (bullet.owner === 'player') { + context.fillStyle = world.player.weapon === 'fanout' ? PALETTE.focus : PALETTE.accent; + } else { + context.fillStyle = bullet.homing ? PALETTE.warning : PALETTE.danger; + } + context.fillRect(bullet.x, bullet.y, bullet.width, bullet.height); + } + } + + /** The ship, its shield bubble, or its wreck. */ + drawPlayer(world, nowMs) { + const context = this.context; + const player = world.player; + const centerX = player.x + player.width / 2; + const centerY = player.y + player.height / 2; + + if (player.hull <= 0) { + context.strokeStyle = PALETTE.danger; + context.lineWidth = 4; + context.beginPath(); + context.moveTo(centerX - 16, centerY - 12); + context.lineTo(centerX + 16, centerY + 12); + context.moveTo(centerX + 16, centerY - 12); + context.lineTo(centerX - 16, centerY + 12); + context.stroke(); + + return; + } + + const flickering = world.time < player.invulnerableUntil && !this.options.reducedMotion; + context.globalAlpha = flickering && Math.floor(nowMs / 90) % 2 === 0 ? 0.35 : 1; + this.drawSprite(SHIP, player.x, player.y, SHIP_SCALE, PALETTE.accent, PALETTE.fg); + context.globalAlpha = 1; + + if (player.shield) { + context.strokeStyle = PALETTE.accent; + context.fillStyle = 'rgba(92, 156, 245, 0.16)'; + context.lineWidth = 3; + context.beginPath(); + context.arc(centerX, centerY, 30, 0, Math.PI * 2); + context.fill(); + context.stroke(); + } + } + + /** The decorative bursts. */ + drawParticles(particles) { + const context = this.context; + for (const particle of particles) { + context.globalAlpha = Math.max(0, 1 - particle.ageMs / particle.lifeMs); + context.fillStyle = PARTICLE_COLORS[particle.color]; + context.fillRect(particle.x, particle.y, 4, 4); + } + context.globalAlpha = 1; + } +} diff --git a/challenges/glitch-invaders/game/src/render/palette.mjs b/challenges/glitch-invaders/game/src/render/palette.mjs new file mode 100644 index 0000000..2ce95f9 --- /dev/null +++ b/challenges/glitch-invaders/game/src/render/palette.mjs @@ -0,0 +1,19 @@ +/** The colors shared by the canvas and the page's CSS, taken from Boothstrap's terminal theme. */ + +/** Boothstrap's on-screen palette. MLH red, blue, and yellow are used sparingly. */ +export const PALETTE = { + bg: '#15171c', + surface: '#252932', + fg: '#e8eaed', + border: '#5f6570', + muted: '#9ba1ab', + disabled: '#6b717c', + accent: '#5c9cf5', + focus: '#f8b92a', + success: '#6cc76c', + warning: '#ff9d5c', + danger: '#ff6b61', + mlhRed: '#e73427', + mlhBlue: '#4a86d9', + mlhYellow: '#f8b92a', +}; diff --git a/challenges/glitch-invaders/game/src/render/sprites.mjs b/challenges/glitch-invaders/game/src/render/sprites.mjs new file mode 100644 index 0000000..4632918 --- /dev/null +++ b/challenges/glitch-invaders/game/src/render/sprites.mjs @@ -0,0 +1,51 @@ +/** Pixel-art bitmaps for everything on the canvas. `#` is a lit pixel, `o` the highlight color. */ + +/** The glitch drone, two frames so it scuttles. */ +export const DRONE_FRAMES = [ + [ + '..#.....#..', + '...#...#...', + '..#######..', + '.##o###o##.', + '###########', + '#.#######.#', + '#.#.....#.#', + '...##.##...', + ], + [ + '..#.....#..', + '#..#...#..#', + '#.#######.#', + '###o###o###', + '###########', + '..#######..', + '..#.....#..', + '.#.......#.', + ], +]; + +/** The patch drone, drawn pointing up. */ +export const SHIP = [ + '......#......', + '.....###.....', + '.....#o#.....', + '..#########..', + '.###########.', + '#############', + '##.#######.##', + '#..#.....#..#', +]; + +/** The Monolith. */ +export const MONOLITH = [ + '...##############...', + '..################..', + '.##################.', + '####oo########oo####', + '####oo########oo####', + '####################', + '.##..##########..##.', + '..##..########..##..', + '...##..######..##...', + '....###......###....', +]; diff --git a/challenges/glitch-invaders/game/src/session/monitor-state.mjs b/challenges/glitch-invaders/game/src/session/monitor-state.mjs new file mode 100644 index 0000000..f001a85 --- /dev/null +++ b/challenges/glitch-invaders/game/src/session/monitor-state.mjs @@ -0,0 +1,21 @@ +/** + * The state surface for Boothstrap's browser monitor. The monitor can only read the page's + * accessibility tree, so the state is a fixed-word line of text on the page. + */ +import { monitorStateLine } from '../../../shared/progress.mjs'; + +/** The id of the element holding the state line. */ +export const STATE_ELEMENT_ID = 'boothstrap-state'; + +/** Write `progress` to the marker element. */ +export function publishState(document, progress) { + const element = document.getElementById(STATE_ELEMENT_ID); + if (element === null) { + throw new Error(`the page has no #${STATE_ELEMENT_ID} element`); + } + + const line = monitorStateLine(progress); + if (element.textContent !== line) { + element.textContent = line; + } +} diff --git a/challenges/glitch-invaders/game/src/session/progress-store.mjs b/challenges/glitch-invaders/game/src/session/progress-store.mjs new file mode 100644 index 0000000..7e479f8 --- /dev/null +++ b/challenges/glitch-invaders/game/src/session/progress-store.mjs @@ -0,0 +1,64 @@ +/** Where a run's progress is kept so a refresh or an agent restart can pick up where it left off. */ +import { parseProgress } from '../../../shared/progress.mjs'; + +/** Keeps progress on the page server, which writes it into the participant's workspace. */ +export class HttpProgressStore { + endpoint; + fetchImpl; + onError; + pending = Promise.resolve(); + + constructor(endpoint, fetchImpl, onError) { + this.endpoint = endpoint; + this.fetchImpl = fetchImpl; + this.onError = onError; + } + + async load(attemptId) { + const response = await this.fetchImpl(this.endpoint); + if (response.status === 204 || response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`progress load failed with status ${response.status}`); + } + + const progress = parseProgress(await response.json()); + + return progress.attemptId === attemptId ? progress : null; + } + + /** Queue the write behind earlier ones, so the last state always wins. */ + save(progress) { + const body = JSON.stringify(progress); + this.pending = this.pending.then(async () => { + try { + const response = await this.fetchImpl(this.endpoint, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body, + }); + if (!response.ok) { + throw new Error(`progress save failed with status ${response.status}`); + } + } catch (error) { + this.onError(error instanceof Error ? error : new Error(String(error))); + } + }); + + return this.pending; + } + + /** Delete the stored progress once earlier writes have landed. */ + clear() { + this.pending = this.pending.then(async () => { + try { + await this.fetchImpl(this.endpoint, { method: 'DELETE' }); + } catch (error) { + this.onError(error instanceof Error ? error : new Error(String(error))); + } + }); + + return this.pending; + } +} diff --git a/challenges/glitch-invaders/game/src/shield/shield.mjs b/challenges/glitch-invaders/game/src/shield/shield.mjs new file mode 100644 index 0000000..690b771 --- /dev/null +++ b/challenges/glitch-invaders/game/src/shield/shield.mjs @@ -0,0 +1,25 @@ +/** Shield damage rules, with the amplification bug that is the second intentional failure. */ +import { SHIELD_BUG_MULTIPLIER } from '../engine/constants.mjs'; + +/** + * Apply an enemy hit of `incomingDamage`. + * + * INTENTIONAL BUG: while `behavior` is `amplify`, an active shield multiplies the damage instead of + * absorbing it. + */ +export function resolveShieldHit(behavior, shieldActive, incomingDamage) { + if (!shieldActive) { + return { actualDamage: incomingDamage, shieldConsumed: false }; + } + + switch (behavior) { + case 'amplify': + return { actualDamage: incomingDamage * SHIELD_BUG_MULTIPLIER, shieldConsumed: true }; + case 'absorb': + return { actualDamage: 0, shieldConsumed: true }; + case 'pass-through': + return { actualDamage: incomingDamage, shieldConsumed: false }; + case 'drop-then-pass': + return { actualDamage: incomingDamage, shieldConsumed: true }; + } +} diff --git a/challenges/glitch-invaders/game/src/styles.css b/challenges/glitch-invaders/game/src/styles.css new file mode 100644 index 0000000..c4572fa --- /dev/null +++ b/challenges/glitch-invaders/game/src/styles.css @@ -0,0 +1,1089 @@ +/* Glitch Invaders: On-Call. Boothstrap's TUI palette on the web: dark surfaces, titles set into + borders, a heavy yellow focus ring, and MLH colors used sparingly. */ + +:root { + --bg: #15171c; + --surface: #252932; + --surface-2: #1d2027; + --fg: #e8eaed; + --border: #5f6570; + --muted: #9ba1ab; + --accent: #5c9cf5; + --focus: #f8b92a; + --success: #6cc76c; + --warning: #ff9d5c; + --danger: #ff6b61; + --mlh-red: #e73427; + --mlh-blue: #4a86d9; + --mlh-yellow: #f8b92a; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html, +body { + height: 100%; + margin: 0; + overflow: hidden; + background: var(--bg); + color: var(--fg); + font: 16px / 1.4 var(--mono); +} + +h1, +h2, +h3, +p, +ol, +ul, +dl { + margin: 0; +} + +ol, +ul { + padding: 0; + list-style: none; +} + +:focus-visible { + outline: 4px solid var(--focus); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.save-error { + padding: 6px 16px; + border-top: 2px solid var(--danger); + background: var(--surface-2); + color: var(--danger); + font-weight: 700; +} + +.fatal { + padding: 32px; + font-size: 20px; + color: var(--danger); +} + +body { + display: flex; + flex-direction: column; +} + +.app { + position: relative; + display: grid; + flex: 1 1 auto; + grid-template-rows: auto minmax(0, 1fr) auto; + min-height: 0; +} + +.topbar { + position: relative; + z-index: 20; + display: flex; + align-items: center; + gap: 20px; + padding: 8px 16px; + border-bottom: 2px solid var(--border); + background: var(--surface-2); +} + +.brand { + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.brand-mark { + color: var(--mlh-red); +} + +.brand-sub { + color: var(--mlh-yellow); +} + +.stats { + display: flex; + flex: 1; + gap: 18px; + color: var(--muted); + font-size: 13px; + letter-spacing: 0.04em; +} + +.stats b { + margin-left: 4px; + color: var(--fg); + font-size: 17px; +} + +.stats b[data-state="on"] { + color: var(--accent); +} + +.badge { + padding: 2px 10px; + border: 2px solid var(--mlh-yellow); + color: var(--mlh-yellow); + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.topbar-reset { + padding: 3px 10px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--muted); + font: 700 12px var(--mono); + white-space: nowrap; + cursor: pointer; +} + +.topbar-reset:hover { + border-color: var(--fg); + color: var(--fg); +} + +.stage-wrap { + display: grid; + place-items: center; + min-height: 0; + padding: 8px 12px; + container-type: size; +} + +.stage { + position: relative; + width: min(100cqw, 160cqh); + aspect-ratio: 8 / 5; + border: 2px solid var(--border); + background: var(--bg); +} + +.stage:focus-visible { + outline-offset: 3px; +} + +.arena { + display: block; + width: 100%; + height: 100%; + image-rendering: pixelated; +} + +.banner { + position: absolute; + right: 10px; + bottom: 10px; + left: 10px; + padding: 10px 14px; + border: 2px solid var(--accent); + background: var(--surface); + font-size: 16px; +} + +.banner[data-kind="success"] { + border-color: var(--success); +} + +.boss-loading { + position: absolute; + top: 50%; + left: 50%; + width: min(540px, 92%); + padding: 16px 18px; + transform: translate(-50%, -50%); + border: 2px solid var(--warning); + background: var(--surface); +} + +.load-head { + font-size: 20px; + font-weight: 700; +} + +.load-clock { + margin-left: 8px; + color: var(--mlh-yellow); +} + +.load-note { + display: block; + margin: 2px 0 10px; + color: var(--muted); +} + +.load-note[data-over="true"] { + color: var(--warning); + font-weight: 700; +} + +.load-task { + display: grid; + grid-template-columns: 130px 1fr 80px; + gap: 10px; + align-items: center; + margin: 6px 0; + font-size: 14px; +} + +.load-bar { + height: 14px; + border: 1px solid var(--border); + background: var(--bg); +} + +.load-fill { + display: block; + width: 0; + height: 100%; + background: var(--accent); +} + +.load-status { + color: var(--muted); +} + +.dock { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + padding: 0 12px 12px; +} + +.card { + position: relative; + padding: 20px 14px 10px; + border: 2px solid var(--border); + background: var(--surface); +} + +.card-title { + position: absolute; + top: -0.8em; + left: 12px; + padding: 0 6px; + background: var(--bg); + color: var(--accent); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.card-title-danger { + color: var(--danger); +} + +.objective { + border-color: var(--focus); +} + +.objective-text { + font-size: 20px; + font-weight: 700; +} + +.objective-text::before { + content: "▶ "; + color: var(--focus); +} + +.objective-detail { + min-height: 1.4em; + color: var(--muted); + font-size: 15px; +} + +.incidents { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.chip { + padding: 2px 8px; + border: 2px solid var(--border); + color: var(--muted); + font-size: 13px; +} + +.chip-word { + font-weight: 700; +} + +.chip[data-stage="encountered"] { + border-color: var(--warning); + color: var(--warning); +} + +.chip[data-stage="investigated"] { + border-color: var(--accent); + color: var(--accent); +} + +.chip[data-stage="repaired"] { + border-color: var(--success); + color: var(--success); +} + +.controls { + display: flex; + flex-direction: column; + gap: 6px; + align-items: stretch; +} + +.pads { + display: flex; + gap: 8px; +} + +.pad { + min-width: 56px; + min-height: 52px; + border: 2px solid var(--border); + background: var(--surface-2); + color: var(--fg); + font: 700 20px var(--mono); + cursor: pointer; + touch-action: none; + user-select: none; +} + +.pad:active { + border-color: var(--focus); + background: var(--bg); +} + +.pad-fire { + min-width: 100px; + border-color: var(--mlh-red); + background: var(--mlh-red); + color: #fff; +} + +.pad-fire:active { + background: #b32419; +} + +.control-links { + display: flex; + justify-content: space-between; + gap: 10px; +} + +.link-button { + padding: 2px 6px; + border: 0; + background: none; + color: var(--accent); + font: 14px var(--mono); + text-decoration: underline; + cursor: pointer; +} + +.link-button-muted { + color: var(--muted); +} + +.link-button-muted:hover { + color: var(--fg); +} + +.card-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.overlay-root { + position: absolute; + inset: 0; + z-index: 10; + display: grid; + place-items: center; + padding: 16px; + background: rgb(21 23 28 / 0.88); +} + +.overlay-card { + width: min(580px, 100%); + max-height: 100%; + padding: 28px 24px 20px; + overflow: visible; + border-color: var(--accent); +} + +.overlay-card > * + * { + margin-top: 14px; +} + +.hero { + font-size: 30px; + font-weight: 700; +} + +.hero em { + color: var(--mlh-yellow); + font-style: normal; +} + +.lede { + font-size: 18px; +} + +.muted { + color: var(--muted); +} + +.keys { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + font-size: 16px; +} + +kbd { + padding: 1px 8px; + border: 2px solid var(--border); + border-radius: 4px; + background: var(--bg); + font: 700 15px var(--mono); +} + +.button { + min-height: 46px; + padding: 8px 16px; + border: 2px solid var(--border); + background: var(--surface-2); + color: var(--fg); + font: 700 16px var(--mono); + cursor: pointer; +} + +.button:hover:not(:disabled) { + border-color: var(--fg); +} + +.button:disabled { + border-style: dashed; + color: var(--muted); + cursor: default; +} + +.button-primary { + border-color: var(--accent); + background: var(--accent); + color: var(--bg); +} + +.button-large { + min-height: 56px; + padding: 12px 30px; + font-size: 20px; +} + +.recap > li { + padding: 8px 0; + border-top: 1px solid var(--border); +} + +.recap-text { + display: block; + margin-top: 2px; + color: var(--muted); + font-size: 14px; +} + +.lens { + padding: 2px 8px; + border: 1px solid var(--mlh-yellow); + border-radius: 4px; + color: var(--mlh-yellow); + font: 700 12px var(--sans); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.console-host { + display: contents; +} + +.console { + container-type: inline-size; + display: flex; + flex-direction: column; + width: min(820px, 100%); + max-height: 100%; + padding: 16px 20px; + border-color: var(--danger); + border-radius: 8px; + font: 15px / 1.45 var(--sans); +} + +.console-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding-bottom: 12px; + border-bottom: 2px solid var(--surface); +} + +.console-heading { + flex: 1 1 340px; +} + +.console-badges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.incident-pill { + padding: 2px 8px; + border-radius: 4px; + background: var(--mlh-red); + color: #ffffff; + font: 700 12px var(--sans); + letter-spacing: 0.03em; +} + +.console-title { + margin-top: 6px; + font: 700 20px / 1.25 var(--sans); + color: #ffffff; +} + +.console-symptom { + margin-top: 4px; + color: var(--muted); + font-size: 14px; +} + +.tabs { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 4px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); +} + +.tab { + padding: 6px 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + font: 600 13px var(--sans); + cursor: pointer; +} + +.tab[aria-selected="true"] { + background: var(--focus); + color: var(--bg); + font-weight: 700; +} + +.tab[aria-disabled="true"] { + opacity: 0.55; + cursor: default; +} + +.pane { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + padding: 12px 2px 2px; + overflow: auto; +} + +.pane > * + *, +.evidence > * + *, +.diagnose > * + *, +.patches > * + * { + margin-top: 10px; +} + +.status { + color: var(--muted); +} + +.spinner { + display: inline-block; + animation: spin 1.2s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.evidence-head { + font: 700 15px / 1.3 var(--mono); + color: #ffffff; +} + +.source-badge { + padding: 2px 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + font: 700 12px var(--mono); +} + +.source-badge[data-source="simulated"] { + border-color: var(--mlh-yellow); + color: var(--mlh-yellow); +} + +.sentry-callout { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + border: 2px solid var(--mlh-blue); + border-radius: 8px; + background: var(--bg); +} + +@container (min-width: 540px) { + .sentry-callout { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +.sentry-callout-main { + display: flex; + flex-direction: column; + gap: 3px; +} + +.sentry-callout-copy { + color: var(--muted); + font: 13px / 1.35 var(--sans); +} + +.context-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.context-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + font: 12.5px var(--mono); +} + +.context-pill dt { + color: var(--muted); +} + +.context-pill dd { + margin: 0; + color: var(--focus); + font-weight: 700; +} + +.panel { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + font: 13px / 1.4 var(--mono); +} + +.error-type { + color: var(--danger); + font-size: 16px; + font-weight: 700; +} + +.error-message { + margin-top: 4px; +} + +.frame { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px 16px; + padding: 5px 8px; + border-bottom: 1px solid var(--surface); +} + +.frame-fn { + font-weight: 700; +} + +.frame-loc { + color: var(--muted); + text-align: right; +} + +.frame[data-in-app="false"] { + color: var(--muted); +} + +.frame[data-culprit="true"] { + border-left: 4px solid var(--mlh-red); + border-radius: 4px; + background: var(--surface-2); +} + +.frame[data-culprit="true"] .frame-loc { + color: var(--focus); + font-weight: 700; +} + +.crumb { + display: grid; + grid-template-columns: 62px 18px 105px 1fr; + gap: 6px 8px; + padding: 4px 6px; + border-bottom: 1px solid var(--surface); +} + +.crumb-time { + white-space: nowrap; +} + +.crumb-time, +.crumb-category, +.crumb-data { + color: var(--muted); +} + +.crumb-data { + grid-column: 3 / -1; + font-size: 12px; +} + +.crumb[data-level="warning"] { + border-left: 3px solid var(--warning); + background: var(--surface-2); +} + +.crumb[data-level="warning"] .crumb-level, +.crumb[data-level="warning"] .crumb-message { + color: var(--warning); + font-weight: 700; +} + +.crumb[data-level="error"] { + border-left: 3px solid var(--danger); + background: var(--surface-2); +} + +.crumb[data-level="error"] .crumb-level, +.crumb[data-level="error"] .crumb-message { + color: var(--danger); + font-weight: 700; +} + +.waterfall-axis { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 12.5px; +} + +.span-row { + display: grid; + grid-template-columns: 125px 1fr 118px; + gap: 8px; + align-items: center; + margin: 5px 0; + padding: 3px 6px; + border-radius: 4px; +} + +.span-track { + position: relative; + height: 16px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--surface-2); +} + +.span-bar { + position: absolute; + top: 2px; + bottom: 2px; + border-radius: 2px; + background: var(--accent); +} + +.span-budget { + position: absolute; + top: -3px; + bottom: -3px; + width: 2px; + background: var(--danger); +} + +.span-time { + color: var(--muted); + font-size: 12px; + text-align: right; +} + +.span-row[data-culprit="true"] { + border: 1px solid var(--danger); + background: var(--surface-2); +} + +.span-row[data-culprit="true"] .span-name, +.span-row[data-culprit="true"] .span-time { + color: var(--danger); + font-weight: 700; +} + +.span-row[data-culprit="true"] .span-bar { + background: var(--danger); +} + +.pane-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + margin-top: auto; + padding-top: 6px; +} + +.spacer { + flex: 1; +} + +.text-link { + color: var(--accent); +} + +.question { + font: 700 16px / 1.35 var(--sans); + color: #ffffff; +} + +.options { + display: grid; + gap: 8px; +} + +.option { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border: 2px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--fg); + font: 14.5px / 1.35 var(--sans); + text-align: left; + cursor: pointer; +} + +.option:hover:not(:disabled) { + border-color: var(--focus); +} + +.option-letter { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font: 700 12px var(--sans); +} + +.option[data-correct="true"] { + border-color: var(--success); + color: #ffffff; + font-weight: 600; +} + +.option[data-correct="true"] .option-letter { + border-color: var(--success); + background: var(--success); + color: var(--bg); +} + +.feedback { + padding: 8px 12px; + border: 2px solid var(--border); + border-radius: 6px; + background: var(--bg); + font: 600 13.5px / 1.35 var(--sans); +} + +.feedback-danger { + border-color: var(--danger); + color: var(--danger); +} + +.feedback-success { + border-color: var(--success); + color: var(--success); +} + +.hints { + padding-left: 0; + counter-reset: hint; +} + +.hints li { + margin: 4px 0; + padding: 6px 10px; + border-left: 4px solid var(--mlh-yellow); + border-radius: 4px; + background: var(--bg); + font: 13.5px / 1.35 var(--sans); + counter-increment: hint; +} + +.hints li::before { + content: "Hint " counter(hint) ": "; + color: var(--mlh-yellow); + font-weight: 700; +} + +.patch-grid { + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} + +@container (min-width: 620px) { + .patch-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.patch { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 10px; + padding: 12px; + border: 2px solid var(--border); + border-radius: 8px; + background: var(--bg); +} + +.patch:hover { + border-color: var(--focus); +} + +.patch[data-failed="true"] { + border-color: var(--danger); +} + +.patch-body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.patch-title { + font: 700 14.5px / 1.25 var(--sans); + color: #ffffff; +} + +.patch-description { + color: var(--muted); + font: 12.5px / 1.35 var(--sans); +} + +.patch .button { + width: 100%; + border-radius: 6px; + font: 700 13px var(--sans); +} + +.code { + margin: 4px 0 0; + padding: 6px 8px; + overflow: auto; + border: 1px solid var(--surface); + border-radius: 4px; + background: var(--surface-2); + font: 12px / 1.35 var(--mono); +} + +@media (max-width: 720px) { + .dock { + grid-template-columns: 1fr; + } + + .controls { + flex-direction: row; + align-items: center; + } + + .stats { + gap: 10px; + } + + .badge { + display: none; + } +} + +@media (max-height: 620px) { + .objective-detail { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} diff --git a/challenges/glitch-invaders/game/src/ui/boss-loading.mjs b/challenges/glitch-invaders/game/src/ui/boss-loading.mjs new file mode 100644 index 0000000..372bd60 --- /dev/null +++ b/challenges/glitch-invaders/game/src/ui/boss-loading.mjs @@ -0,0 +1,101 @@ +/** + * The Monolith's loading view: one bar per load, so the participant can watch the waterfall form + * while the game waits, and feel the difference once it is repaired. + */ +import { ALL_LOAD_TASKS } from '../boss/boss-loader.mjs'; +import { BOSS_SPAWN_BUDGET_MS } from '../engine/constants.mjs'; + +import { h, setText } from './dom.mjs'; + +/** Draws the loading panel from the world. */ +export class BossLoadingView { + element; + rows = new Map(); + shownFor = null; + clock = null; + note = null; + + constructor(element) { + this.element = element; + } + + /** Show or hide the panel and refresh it for the current frame. */ + update(world, nowMs) { + const load = world.bossLoad; + if (world.phase !== 'boss-loading' || load === null) { + if (!this.element.hidden) { + this.element.hidden = true; + this.shownFor = null; + } + + return; + } + + if (this.shownFor !== load.startedAt) { + this.build(load.tasks.map((task) => task.name)); + this.shownFor = load.startedAt; + this.element.hidden = false; + } + + const elapsed = nowMs - load.startedAt; + if (this.clock !== null) { + setText(this.clock, `${(elapsed / 1_000).toFixed(1)} s`); + } + if (this.note !== null) { + setText( + this.note, + elapsed > BOSS_SPAWN_BUDGET_MS + ? `Buffering… over the ${(BOSS_SPAWN_BUDGET_MS / 1_000).toFixed(1)} s budget` + : 'Loading resources', + ); + this.note.dataset.over = String(elapsed > BOSS_SPAWN_BUDGET_MS); + } + for (const task of load.tasks) { + const row = this.rows.get(task.name); + if (row === undefined) { + continue; + } + const fraction = + task.endMs !== null + ? 1 + : task.startMs === null + ? 0 + : Math.min(1, (elapsed - task.startMs) / row.durationMs); + row.fill.style.width = `${(fraction * 100).toFixed(1)}%`; + setText( + row.status, + task.endMs !== null ? '✓ done' : task.startMs === null ? 'waiting' : 'loading', + ); + } + } + + /** Build one row per task. */ + build(names) { + this.rows = new Map(); + this.clock = h('strong', { class: 'load-clock' }, '0.0 s'); + this.note = h('span', { class: 'load-note' }, 'Loading resources'); + const list = h('ul', { class: 'load-tasks', 'aria-label': 'Loading steps' }); + + for (const name of names) { + const fill = h('span', { class: 'load-fill' }); + const status = h('span', { class: 'load-status' }, 'waiting'); + list.append( + h( + 'li', + { class: 'load-task' }, + h('span', { class: 'load-name' }, name), + h('span', { class: 'load-bar' }, fill), + status, + ), + ); + const durationMs = ALL_LOAD_TASKS.find((task) => task.name === name)?.durationMs ?? 1_000; + this.rows.set(name, { fill, status, durationMs }); + } + + this.element.replaceChildren( + h('p', { class: 'load-head' }, 'Spawning the Monolith ', this.clock), + this.note, + list, + ); + } +} diff --git a/challenges/glitch-invaders/game/src/ui/dom.mjs b/challenges/glitch-invaders/game/src/ui/dom.mjs new file mode 100644 index 0000000..767b2e1 --- /dev/null +++ b/challenges/glitch-invaders/game/src/ui/dom.mjs @@ -0,0 +1,72 @@ +/** A tiny element builder, so the UI modules read as structure instead of `createElement` noise. */ + +/** Create an element with attributes and children. */ +export function h(tag, attributes = {}, ...children) { + const element = document.createElement(tag); + for (const [name, value] of Object.entries(attributes)) { + if (value === undefined || value === false) { + continue; + } + element.setAttribute(name, value === true ? '' : value); + } + for (const child of children) { + if (child !== null && child !== false) { + element.append(child); + } + } + + return element; +} + +/** Remove every child of `element` and add `children`. */ +export function replaceChildren(element, ...children) { + element.replaceChildren(...children.filter((child) => Boolean(child))); +} + +/** Set `element`'s text only when it changed, so per-frame updates do not churn the DOM. */ +export function setText(element, text) { + if (element.textContent !== text) { + element.textContent = text; + } +} + +/** Add a click handler and return the element, for chaining inside {@link h} calls. */ +export function onClick(element, handler) { + element.addEventListener('click', handler); + + return element; +} + +/** The elements inside `root` that can take keyboard focus, in tab order. */ +export function focusableElements(root) { + const selector = 'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'; + + return [...root.querySelectorAll(selector)].filter( + (candidate) => !candidate.hasAttribute('hidden'), + ); +} + +/** Keep Tab and Shift+Tab inside `container`, as a modal dialog must. */ +export function trapFocus(container, event) { + if (event.key !== 'Tab') { + return; + } + + const focusable = focusableElements(container); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (first === undefined || last === undefined) { + event.preventDefault(); + + return; + } + + const active = document.activeElement; + if (event.shiftKey && (active === first || !container.contains(active))) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (active === last || !container.contains(active))) { + event.preventDefault(); + first.focus(); + } +} diff --git a/challenges/glitch-invaders/game/src/ui/hud.mjs b/challenges/glitch-invaders/game/src/ui/hud.mjs new file mode 100644 index 0000000..0acc831 --- /dev/null +++ b/challenges/glitch-invaders/game/src/ui/hud.mjs @@ -0,0 +1,74 @@ +import { BUG_DEFINITIONS, BUG_ORDER } from '../bugs/bug-definitions.mjs'; + +import { h, replaceChildren, setText } from './dom.mjs'; +import { currentObjective } from './objective.mjs'; + +/** The glyph and word that show a failure's stage; never color alone. */ +const STAGE_LABELS = { + unseen: { glyph: '○', word: 'Not seen' }, + encountered: { glyph: '▶', word: 'Open' }, + investigated: { glyph: '◐', word: 'Diagnosed' }, + repaired: { glyph: '✓', word: 'Fixed' }, +}; + +/** Find a required element by id. */ +function requireElement(id) { + const element = document.getElementById(id); + if (element === null) { + throw new Error(`the page has no #${id} element`); + } + + return element; +} + +/** Updates the HUD from the world and progress. */ +export class Hud { + hull = requireElement('stat-hull'); + shield = requireElement('stat-shield'); + kills = requireElement('stat-kills'); + score = requireElement('stat-score'); + objectiveText = requireElement('objective-text'); + objectiveDetail = requireElement('objective-detail'); + incidents = requireElement('incidents'); + chipSignature = ''; + + /** Refresh every stat from the latest world and progress. */ + update(progress, world) { + const hullText = world.player.restoreAt === null ? '♥'.repeat(world.player.hull) : '✖'; + setText(this.hull, hullText === '' ? '✖' : hullText); + setText(this.shield, world.player.shield ? 'ON' : 'off'); + this.shield.dataset.state = world.player.shield ? 'on' : 'off'; + setText(this.kills, String(world.kills)); + setText(this.score, String(progress.score)); + + const objective = currentObjective(progress, world); + setText(this.objectiveText, objective.text); + setText(this.objectiveDetail, objective.detail); + this.renderChips(progress); + } + + /** Redraw the incident chips only when a stage changed. */ + renderChips(progress) { + const signature = BUG_ORDER.map((bug) => progress.bugs[bug].stage).join('|'); + if (signature === this.chipSignature) { + return; + } + this.chipSignature = signature; + replaceChildren(this.incidents, ...BUG_ORDER.map((bug) => chip(bug, progress.bugs[bug].stage))); + } +} + +/** One incident chip. */ +function chip(bug, stage) { + const label = STAGE_LABELS[stage]; + const definition = BUG_DEFINITIONS[bug]; + const title = stage === 'unseen' ? '' : ` ${definition.title}`; + + return h( + 'li', + { class: 'chip', 'data-stage': stage, 'data-bug': bug }, + h('span', { class: 'chip-glyph', 'aria-hidden': 'true' }, label.glyph), + `${definition.incidentLabel}${title} `, + h('span', { class: 'chip-word' }, label.word), + ); +} diff --git a/challenges/glitch-invaders/game/src/ui/incident-console.mjs b/challenges/glitch-invaders/game/src/ui/incident-console.mjs new file mode 100644 index 0000000..437750f --- /dev/null +++ b/challenges/glitch-invaders/game/src/ui/incident-console.mjs @@ -0,0 +1,545 @@ +import { BUG_DEFINITIONS } from '../bugs/bug-definitions.mjs'; +import { REPAIR_PATCHES } from '../bugs/code-state.mjs'; + +import { BOSS_SPAWN_BUDGET_MS, SCORE_HINT_COST } from '../engine/constants.mjs'; +import { focusableElements, h, onClick, trapFocus } from './dom.mjs'; + +/** The words that describe where the evidence came from, shown as a badge. */ +const SOURCE_BADGES = { + simulated: { + label: 'DEMO · SIMULATED', + note: 'Demo mode: realistic diagnostics, no Sentry account needed.', + }, +}; + +/** The glyphs for breadcrumb levels; level is never color alone. */ +const LEVEL_GLYPHS = { + info: '●', + warning: '▲', + error: '✖', +}; + +/** The console dialog. */ +export class IncidentConsole { + element; + host; + bug = 'weapon'; + pane = 'evidence'; + evidence = null; + diagnosisFeedback = ''; + patchObservation = null; + + constructor(host) { + this.host = host; + this.element = h('div', { class: 'console-host' }); + this.element.addEventListener('keydown', (event) => trapFocus(this.element, event)); + } + + /** Open the console for `bug`, showing a capture-in-progress state until evidence arrives. */ + open(bug) { + this.bug = bug; + this.evidence = null; + this.diagnosisFeedback = ''; + this.patchObservation = null; + const stage = this.host.progressOf(bug).stage; + this.pane = stage === 'investigated' ? 'patch' : 'evidence'; + this.render(); + } + + /** Evidence arrived for the open failure; all sections are shown together on one card. */ + showEvidence(evidence) { + if (evidence.bug !== this.bug) { + return; + } + this.evidence = evidence; + if (!this.host.progressOf(this.bug).evidenceViewed) { + this.host.markEvidenceViewed(this.bug); + } + this.render(); + } + + /** The failure whose console is open. */ + openBug() { + return this.bug; + } + + /** Put keyboard focus on the first useful control. */ + focusFirst() { + const selected = this.element.querySelector('[role="tab"][aria-selected="true"]'); + (selected ?? focusableElements(this.element)[0])?.focus(); + } + + /** Rebuild the dialog, keeping keyboard focus on the same control. */ + render() { + const focusKey = document.activeElement?.dataset?.key ?? null; + const definition = BUG_DEFINITIONS[this.bug]; + const progress = this.host.progressOf(this.bug); + const badge = this.evidence === null ? null : SOURCE_BADGES[this.evidence.source]; + + const dialog = h( + 'section', + { + class: 'card console', + role: 'dialog', + 'aria-modal': 'true', + 'aria-labelledby': 'console-title', + }, + h( + 'header', + { class: 'console-header' }, + h( + 'div', + { class: 'console-heading' }, + h( + 'div', + { class: 'console-badges' }, + h('span', { class: 'incident-pill' }, definition.incidentLabel), + h('span', { class: 'lens' }, definition.lens), + badge === null + ? null + : h( + 'span', + { + class: 'source-badge', + 'data-source': this.evidence?.source, + title: badge.note, + }, + badge.label, + ), + ), + h('h2', { class: 'console-title', id: 'console-title' }, definition.title), + h('p', { class: 'console-symptom' }, definition.symptom), + ), + this.tabs(progress), + ), + h( + 'div', + { class: 'pane', role: 'tabpanel', 'aria-labelledby': `tab-${this.pane}` }, + this.paneBody(progress), + ), + ); + + this.element.replaceChildren(dialog); + if (focusKey !== null) { + this.element.querySelector(`[data-key="${focusKey}"]`)?.focus(); + } + } + + /** The step pill strip, with later panes locked until earlier ones are done. */ + tabs(progress) { + const unlocked = { + evidence: true, + diagnose: progress.evidenceViewed, + patch: progress.stage === 'investigated' || progress.stage === 'repaired', + }; + const labels = [ + ['evidence', '1. Evidence'], + ['diagnose', '2. Diagnose'], + ['patch', '3. Patch'], + ]; + + return h( + 'div', + { class: 'tabs', role: 'tablist', 'aria-label': 'Investigation steps' }, + ...labels.map(([pane, label]) => { + const button = h( + 'button', + { + type: 'button', + class: 'tab', + role: 'tab', + id: `tab-${pane}`, + 'data-key': `tab-${pane}`, + 'aria-selected': String(this.pane === pane), + 'aria-disabled': unlocked[pane] ? undefined : 'true', + }, + unlocked[pane] ? label : `${label} · locked`, + ); + + return onClick(button, () => { + if (!unlocked[pane]) { + return; + } + this.pane = pane; + this.render(); + }); + }), + ); + } + + /** The body for the selected pane. */ + paneBody(progress) { + switch (this.pane) { + case 'evidence': + return this.evidencePane(); + case 'diagnose': + return this.diagnosePane(progress); + case 'patch': + return this.patchPane(progress); + } + } + + /** Evidence step: the simulated Sentry capture of the failure. */ + evidencePane() { + const evidence = this.evidence; + if (evidence === null) { + return h( + 'p', + { class: 'status', role: 'status' }, + h('span', { class: 'spinner', 'aria-hidden': 'true' }, '◌'), + ' Capturing telemetry…', + ); + } + + const contextSection = evidence.sections.find((section) => section.kind === 'context'); + const primarySection = evidence.sections.find( + (section) => + section.kind === 'stacktrace' || + section.kind === 'breadcrumbs' || + section.kind === 'waterfall', + ); + + return h( + 'div', + { class: 'evidence' }, + this.sentryHero(evidence), + contextSection === undefined ? null : renderContextPills(contextSection), + primarySection === undefined ? null : renderSection(primarySection), + h( + 'div', + { class: 'pane-actions' }, + h('span', { class: 'muted' }, `Ref: ${evidence.referenceId}`), + h('span', { class: 'spacer' }), + onClick( + h( + 'button', + { type: 'button', class: 'button button-primary', 'data-key': 'to-diagnose' }, + 'Continue to Diagnose ▶', + ), + () => { + this.pane = 'diagnose'; + this.render(); + }, + ), + ), + ); + } + + /** Banner with the exception or trace headline and how to read the capture. */ + sentryHero(evidence) { + return h( + 'div', + { class: 'sentry-callout' }, + h( + 'div', + { class: 'sentry-callout-main' }, + h('p', { class: 'evidence-head' }, evidence.title), + h( + 'p', + { class: 'sentry-callout-copy' }, + 'Demo mode (Sentry not connected): inspect the simulated telemetry snapshot to identify the root cause.', + ), + ), + ); + } + + /** Diagnosis: lettered answer cards, clear feedback, and hints. */ + diagnosePane(progress) { + const definition = BUG_DEFINITIONS[this.bug]; + const solved = progress.stage === 'investigated' || progress.stage === 'repaired'; + const hints = definition.hints.slice(0, progress.hintsUsed); + const nextHint = progress.hintsUsed < definition.hints.length; + + return h( + 'div', + { class: 'diagnose' }, + h('p', { class: 'question' }, definition.question), + h( + 'div', + { class: 'options', role: 'group', 'aria-label': definition.question }, + ...definition.options.map((option, index) => { + const letter = String.fromCharCode(65 + index); + const isSolvedCorrect = solved && option.correct; + const button = h( + 'button', + { + type: 'button', + class: 'option', + 'data-key': `option-${option.id}`, + disabled: solved && !option.correct, + 'data-correct': isSolvedCorrect ? 'true' : undefined, + }, + h( + 'span', + { class: 'option-letter', 'aria-hidden': 'true' }, + isSolvedCorrect ? '✓' : letter, + ), + h('span', { class: 'option-text' }, option.text), + ); + + return onClick(button, () => this.answer(option.id)); + }), + ), + this.diagnosisFeedback === '' + ? null + : h( + 'p', + { class: 'feedback feedback-danger', role: 'alert' }, + `✖ ${this.diagnosisFeedback}`, + ), + solved + ? h('p', { class: 'feedback feedback-success', role: 'status' }, '✓ Root cause confirmed.') + : null, + hints.length === 0 + ? null + : h( + 'ol', + { class: 'hints', 'aria-label': 'Hints' }, + ...hints.map((hint) => h('li', {}, hint)), + ), + h( + 'div', + { class: 'pane-actions' }, + solved || !nextHint + ? null + : onClick( + h( + 'button', + { type: 'button', class: 'button', 'data-key': 'hint' }, + `💡 Reveal Hint ${progress.hintsUsed + 1} (−${SCORE_HINT_COST} pts)`, + ), + () => { + this.host.useHint(this.bug); + this.render(); + }, + ), + h('span', { class: 'spacer' }), + solved + ? onClick( + h( + 'button', + { type: 'button', class: 'button button-primary', 'data-key': 'to-patch' }, + 'Choose a patch ▶', + ), + () => { + this.pane = 'patch'; + this.render(); + }, + ) + : null, + ), + ); + } + + /** Submit a diagnosis; a right one moves on to the patch pane. */ + answer(optionId) { + const result = this.host.diagnose(this.bug, optionId); + this.diagnosisFeedback = result.feedback; + if (result.correct) { + this.pane = 'patch'; + } + this.render(); + } + + /** Patches: responsive 3-column grid of code patch cards. Wrong ones fail visibly and revert. */ + patchPane(progress) { + if (progress.stage !== 'investigated') { + return h('p', { class: 'status' }, 'Diagnose the failure to unlock patches.'); + } + + return h( + 'div', + { class: 'patches' }, + h( + 'p', + { class: 'question' }, + 'Choose a code patch to test in the arena. A wrong patch reverts itself.', + ), + h( + 'div', + { class: 'patch-grid' }, + ...REPAIR_PATCHES[this.bug].map((patch) => { + const failed = this.patchObservation?.patchId === patch.id; + const apply = onClick( + h( + 'button', + { type: 'button', class: 'button button-primary', 'data-key': `apply-${patch.id}` }, + 'Apply & Test Patch ▶', + ), + () => this.apply(patch.id), + ); + + return h( + 'article', + { class: 'patch', 'data-failed': failed ? 'true' : undefined }, + h( + 'div', + { class: 'patch-body' }, + h('h3', { class: 'patch-title' }, patch.title), + h('p', { class: 'patch-description' }, patch.description), + h('pre', { class: 'code' }, h('code', {}, patch.snippet)), + ), + apply, + ); + }), + ), + this.patchObservation === null + ? null + : h( + 'p', + { class: 'feedback feedback-danger', role: 'alert' }, + `✖ Reverted. ${this.patchObservation.text}`, + ), + ); + } + + /** Try a patch; the game closes the console when it works. */ + apply(patchId) { + const check = this.host.applyPatch(this.bug, patchId); + this.patchObservation = check.ok ? null : { patchId, text: check.observation }; + this.render(); + if (!check.ok) { + this.element.querySelector(`[data-key="apply-${patchId}"]`)?.focus(); + } + } +} + +/** Render context tags as scannable pill badges. */ +function renderContextPills(section) { + return h( + 'dl', + { class: 'context-pills', 'aria-label': section.title }, + ...Object.entries(section.entries).map(([key, value]) => + h('div', { class: 'context-pill' }, h('dt', {}, `${key}:`), h('dd', {}, value)), + ), + ); +} + +/** Render the primary diagnostic section on screen. */ +function renderSection(section) { + switch (section.kind) { + case 'exception': + return h( + 'div', + { class: 'panel exception' }, + h('p', { class: 'error-type' }, section.errorType), + h('p', { class: 'error-message' }, section.message), + ); + case 'stacktrace': + return h( + 'ol', + { class: 'panel frames', 'aria-label': 'Stack frames, innermost first' }, + ...section.frames.map((frame, index) => frameRow(frame, index === 0)), + ); + case 'breadcrumbs': + return h( + 'ol', + { class: 'panel crumbs', 'aria-label': 'Breadcrumbs, oldest first' }, + ...section.entries.map(crumbRow), + ); + case 'context': + return renderContextPills(section); + case 'waterfall': + return waterfall(section.totalMs, section.spans); + } +} + +/** One stack frame, highlighting the innermost culprit frame. */ +function frameRow(frame, isCulprit) { + const location = frame.line > 0 ? `${frame.file}:${frame.line}` : frame.file; + + return h( + 'li', + { + class: 'frame', + 'data-in-app': String(frame.inApp), + 'data-culprit': isCulprit ? 'true' : undefined, + }, + h('span', { class: 'frame-fn' }, frame.functionName), + h('span', { class: 'frame-loc' }, isCulprit ? `${location} ← THROWN HERE` : location), + ); +} + +/** One breadcrumb. */ +function crumbRow(entry) { + const data = + entry.data === null + ? null + : h( + 'span', + { class: 'crumb-data' }, + Object.entries(entry.data) + .map(([key, value]) => `${key}=${value}`) + .join(' '), + ); + + return h( + 'li', + { class: 'crumb', 'data-level': entry.level }, + h('span', { class: 'crumb-time' }, entry.offsetLabel), + h('span', { class: 'crumb-level', 'aria-label': entry.level }, LEVEL_GLYPHS[entry.level]), + h('span', { class: 'crumb-category' }, entry.category), + h('span', { class: 'crumb-message' }, entry.message), + data, + ); +} + +/** + * Set `element`'s position through the CSSOM rather than a `style` attribute, which the page's + * Content-Security-Policy refuses without 'unsafe-inline'. + */ +function positioned(element, position) { + element.style.left = position.left; + if (position.width !== undefined) { + element.style.width = position.width; + } + + return element; +} + +/** A trace waterfall: one bar per span on a shared time axis, with the spawn budget marked. */ +function waterfall(totalMs, spans) { + const axisMs = Math.max(totalMs, BOSS_SPAWN_BUDGET_MS); + const percent = (milliseconds) => `${((milliseconds / axisMs) * 100).toFixed(2)}%`; + const seconds = (milliseconds) => `${(milliseconds / 1_000).toFixed(1)} s`; + + return h( + 'div', + { + class: 'panel waterfall', + role: 'table', + 'aria-label': `Trace boss-spawn, ${seconds(totalMs)} total`, + }, + h( + 'p', + { class: 'waterfall-axis' }, + h('span', {}, '0 s'), + h('span', {}, `total ${seconds(totalMs)} · budget ${seconds(BOSS_SPAWN_BUDGET_MS)}`), + ), + ...spans.map((span) => { + const isRepeat = span.name === 'loadAnimations'; + + return h( + 'div', + { class: 'span-row', role: 'row', 'data-culprit': isRepeat ? 'true' : undefined }, + h('span', { class: 'span-name', role: 'cell' }, span.name), + h( + 'span', + { class: 'span-track', role: 'cell' }, + positioned(h('span', { class: 'span-bar' }), { + left: percent(span.startMs), + width: percent(span.durationMs), + }), + positioned(h('span', { class: 'span-budget', 'aria-hidden': 'true' }), { + left: percent(BOSS_SPAWN_BUDGET_MS), + }), + ), + h( + 'span', + { class: 'span-time', role: 'cell' }, + `${seconds(span.durationMs)} @ ${seconds(span.startMs)}`, + ), + ); + }), + ); +} diff --git a/challenges/glitch-invaders/game/src/ui/input.mjs b/challenges/glitch-invaders/game/src/ui/input.mjs new file mode 100644 index 0000000..148a169 --- /dev/null +++ b/challenges/glitch-invaders/game/src/ui/input.mjs @@ -0,0 +1,156 @@ +/** + * Player input: keyboard and on-screen controls feed one shared {@link InputState}. Holding a pad + * button works with a pointer; pressing it with Enter or Space gives a short pulse, so a keyboard + * user can drive the pads too. + */ +import { idleInput } from '../engine/world.mjs'; + +/** How long a keyboard activation of a pad button counts as held. */ +const PAD_PULSE_MS = 180; + +/** The keys that mean each control. */ +const KEY_CONTROLS = new Map([ + ['ArrowLeft', 'left'], + ['KeyA', 'left'], + ['ArrowRight', 'right'], + ['KeyD', 'right'], + ['Space', 'fire'], +]); + +/** Collects keyboard and pad input. */ +export class InputController { + state = idleInput(); + keysDown = new Set(); + padsDown = new Map(); + options; + pulseTimers = new Set(); + + constructor(options) { + this.options = options; + } + + /** The current input. The same object every time; the engine only reads it. */ + current() { + return this.state; + } + + /** Start listening on `target` for keys, and on `pad` buttons for pointers. */ + attach(target, pads) { + const keyDown = (event) => this.keyDown(event); + const keyUp = (event) => this.keyUp(event); + const blur = () => this.releaseAll(); + target.addEventListener('keydown', keyDown); + target.addEventListener('keyup', keyUp); + window.addEventListener('blur', blur); + + for (const control of Object.keys(pads)) { + this.attachPad(control, pads[control]); + } + + return () => { + target.removeEventListener('keydown', keyDown); + target.removeEventListener('keyup', keyUp); + window.removeEventListener('blur', blur); + this.releaseAll(); + }; + } + + /** Let go of everything, such as when a dialog opens or the window loses focus. */ + releaseAll() { + this.keysDown.clear(); + this.padsDown.clear(); + for (const timer of this.pulseTimers) { + clearTimeout(timer); + } + this.pulseTimers.clear(); + this.recompute(); + } + + /** Wire one pad button: hold with a pointer, pulse with the keyboard. */ + attachPad(control, button) { + const press = (event) => { + event.preventDefault(); + this.padsDown.set(control, (this.padsDown.get(control) ?? 0) + 1); + this.recompute(); + }; + const release = () => { + const holds = this.padsDown.get(control) ?? 0; + if (holds > 0) { + this.padsDown.set(control, holds - 1); + this.recompute(); + } + }; + + button.addEventListener('pointerdown', press); + button.addEventListener('pointerup', release); + button.addEventListener('pointercancel', release); + button.addEventListener('pointerleave', release); + button.addEventListener('click', (event) => { + if (event.detail !== 0) { + return; + } + this.pulse(control); + }); + } + + /** Hold `control` briefly. */ + pulse(control) { + this.padsDown.set(control, (this.padsDown.get(control) ?? 0) + 1); + this.recompute(); + const timer = setTimeout(() => { + this.pulseTimers.delete(timer); + const holds = this.padsDown.get(control) ?? 0; + this.padsDown.set(control, Math.max(0, holds - 1)); + this.recompute(); + }, PAD_PULSE_MS); + this.pulseTimers.add(timer); + } + + /** Handle a key press. */ + keyDown(event) { + if (event.ctrlKey || event.metaKey || event.altKey) { + return; + } + if (event.code === 'KeyP' || event.code === 'Escape') { + if (!event.repeat && this.options.isGameActive()) { + event.preventDefault(); + this.options.onPauseKey(); + } + + return; + } + if (!this.options.isGameActive() || !KEY_CONTROLS.has(event.code)) { + return; + } + if (event.code === 'Space' && isButton(event.target)) { + return; + } + + event.preventDefault(); + this.keysDown.add(event.code); + this.recompute(); + } + + /** Handle a key release. */ + keyUp(event) { + if (this.keysDown.delete(event.code)) { + this.recompute(); + } + } + + /** Rebuild the shared state from what is held. */ + recompute() { + const held = (control) => + (this.padsDown.get(control) ?? 0) > 0 || + [...this.keysDown].some((code) => KEY_CONTROLS.get(code) === control); + + this.state.left = held('left'); + this.state.right = held('right'); + this.state.fire = held('fire'); + } +} + +/** Whether `target` is a button, which handles Space itself. */ +function isButton(target) { + return target instanceof HTMLButtonElement; +} diff --git a/challenges/glitch-invaders/game/src/ui/objective.mjs b/challenges/glitch-invaders/game/src/ui/objective.mjs new file mode 100644 index 0000000..d14b2cc --- /dev/null +++ b/challenges/glitch-invaders/game/src/ui/objective.mjs @@ -0,0 +1,71 @@ +/** Pick the objective for the current progress and arena. */ +export function currentObjective(progress, world) { + if (!progress.launched) { + return { text: 'Press Start to launch your drone.', detail: '' }; + } + if (progress.completed) { + return { text: 'All three incidents are closed.', detail: 'Tell your guide what you found.' }; + } + if (world.phase === 'frozen') { + return { text: 'Open the evidence and find the cause.', detail: 'The game is paused.' }; + } + if (world.phase === 'boss-loading') { + return { text: 'The Monolith is spawning…', detail: 'Watch the loading steps.' }; + } + + switch (world.verifying) { + case 'weapon': + return { + text: 'Catch the gold upgrade, then fire.', + detail: 'Space fires. Three bolts means fixed.', + }; + case 'shield': + return { + text: 'Catch the blue shield and take a hit.', + detail: 'A fixed shield absorbs it.', + }; + case 'boss': + return { text: 'Watch the Monolith spawn.', detail: 'It should arrive fast now.' }; + case null: + break; + } + + return playingObjective(progress, world); +} + +/** The objective while ordinary play is under way. */ +function playingObjective(progress, world) { + const { weapon, shield } = progress.bugs; + const player = world.player; + const hasPickup = (kind) => world.pickups.some((pickup) => pickup.kind === kind); + + if (weapon.stage === 'unseen') { + if (player.weapon === 'fanout') { + return { text: 'Fire your new upgrade.', detail: 'Press Space.' }; + } + if (hasPickup('fanout')) { + return { text: 'Catch the gold upgrade.', detail: 'Move under it.' }; + } + + return { + text: 'Shoot the glitch marked with a gold diamond.', + detail: 'It carries an upgrade.', + }; + } + + if (shield.stage === 'unseen') { + if (world.wave === 1 && !player.shield) { + return { text: 'Clear the wave.', detail: 'A shielded glitch arrives next.' }; + } + if (player.shield) { + return { text: 'Test the shield: let a shot hit you.', detail: 'Stand in its path.' }; + } + if (hasPickup('aegis')) { + return { text: 'Catch the blue shield.', detail: 'Move under it.' }; + } + + return { text: 'Shoot the glitch marked with a blue shield.', detail: 'It carries a shield.' }; + } + + return { text: 'Clear the wave to call the Monolith.', detail: 'Keep shooting.' }; +} diff --git a/challenges/glitch-invaders/game/src/ui/overlays.mjs b/challenges/glitch-invaders/game/src/ui/overlays.mjs new file mode 100644 index 0000000..6d765b1 --- /dev/null +++ b/challenges/glitch-invaders/game/src/ui/overlays.mjs @@ -0,0 +1,183 @@ +import { BUG_DEFINITIONS, BUG_ORDER } from '../bugs/bug-definitions.mjs'; +import { focusableElements, h, onClick } from './dom.mjs'; + +/** The page regions that become inert behind a modal card. */ +const BACKGROUND_IDS = ['topbar', 'stage-wrap', 'dock']; + +/** Shows one modal card at a time. */ +export class OverlayHost { + root; + opener = null; + + constructor(root) { + this.root = root; + } + + /** Whether a card is showing. */ + get isOpen() { + return !this.root.hidden; + } + + /** The card showing now, if any. */ + get current() { + return this.isOpen ? this.root.firstElementChild : null; + } + + /** Show `content` as the only card, focusing its `data-autofocus` control or its first control. */ + show(content) { + if (!this.isOpen) { + this.opener = document.activeElement; + } + this.root.replaceChildren(content); + this.root.hidden = false; + for (const id of BACKGROUND_IDS) { + document.getElementById(id)?.setAttribute('inert', ''); + } + const target = content.querySelector('[data-autofocus]') ?? focusableElements(content)[0]; + target?.focus(); + } + + /** Close the card and return focus to where it was, or to `fallback`. */ + hide(fallback) { + if (!this.isOpen) { + return; + } + this.root.hidden = true; + this.root.replaceChildren(); + for (const id of BACKGROUND_IDS) { + document.getElementById(id)?.removeAttribute('inert'); + } + const target = fallback ?? this.opener; + this.opener = null; + target?.focus(); + } +} + +/** Wrap card contents in the standard centered card. */ +function card(title, label, ...children) { + return h( + 'section', + { class: 'card overlay-card', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': label }, + h('h2', { class: 'card-title', id: label }, title), + ...children, + ); +} + +/** The briefing shown before play, or the welcome-back card after a refresh. */ +export function introCard(options) { + return card( + options.returning ? 'Welcome back' : 'Incident briefing', + 'intro-title', + h('p', { class: 'hero' }, 'Glitch Invaders: ', h('em', {}, 'On-Call')), + h( + 'p', + { class: 'lede' }, + options.returning + ? 'Your run is saved. Pick up where you left off.' + : 'Production is crawling with glitches. You are the patch drone. Shoot, grab upgrades, and when something breaks, find out why and fix it.', + ), + h( + 'ul', + { class: 'keys', 'aria-label': 'Controls' }, + h( + 'li', + {}, + h('kbd', {}, '◀'), + ' ', + h('kbd', {}, '▶'), + ' or ', + h('kbd', {}, 'A'), + ' ', + h('kbd', {}, 'D'), + ' move', + ), + h('li', {}, h('kbd', {}, 'Space'), ' fires'), + h('li', {}, h('kbd', {}, 'P'), ' pauses'), + ), + h('p', { class: 'muted' }, 'Demo mode: the diagnostics are realistic simulations.'), + onClick( + h( + 'button', + { type: 'button', class: 'button button-primary button-large', 'data-autofocus': true }, + options.returning ? 'Resume ▶' : 'Start ▶', + ), + options.onStart, + ), + ); +} + +/** The pause card. */ +export function pauseCard(onResume) { + return card( + 'Paused', + 'pause-title', + h('p', { class: 'lede' }, 'The arena is frozen. Take your time.'), + onClick( + h( + 'button', + { type: 'button', class: 'button button-primary button-large', 'data-autofocus': true }, + 'Resume ▶', + ), + onResume, + ), + ); +} + +/** The recap shown once all three incidents are closed. */ +export function completionCard(progress, onContinue, onReset) { + return card( + '✓ All incidents closed', + 'done-title', + h('p', { class: 'hero' }, `Score ${progress.score}`), + h( + 'ul', + { class: 'recap', 'aria-label': 'What you fixed' }, + ...BUG_ORDER.map((bug) => { + const definition = BUG_DEFINITIONS[bug]; + + return h( + 'li', + {}, + h('strong', {}, `${definition.incidentLabel} ${definition.title}`), + h('span', { class: 'lens' }, ` ${definition.lens}`), + h('span', { class: 'recap-text' }, definition.fixExplanation), + ); + }), + ), + onContinue || onReset + ? h( + 'div', + { class: 'card-actions' }, + onContinue + ? onClick( + h( + 'button', + { + type: 'button', + class: 'button button-primary button-large', + 'data-autofocus': true, + 'data-key': 'continue-playing', + }, + 'Continue Playing ▶', + ), + onContinue, + ) + : null, + onReset + ? onClick( + h( + 'button', + { + type: 'button', + class: 'button button-large', + 'data-key': 'reset-run', + }, + 'Start New Run ↺', + ), + onReset, + ) + : null, + ) + : null, + ); +} diff --git a/challenges/glitch-invaders/game/src/weapons/weapons.mjs b/challenges/glitch-invaders/game/src/weapons/weapons.mjs new file mode 100644 index 0000000..aa040e8 --- /dev/null +++ b/challenges/glitch-invaders/game/src/weapons/weapons.mjs @@ -0,0 +1,57 @@ +/** Weapon configuration, with the missing entry that causes the first intentional failure. */ + +/** Thrown when the ship fires a weapon that has no configuration. */ +export class WeaponConfigError extends Error { + weaponId; + + constructor(weaponId) { + super(`Configuration missing for weapon: ${weaponId}`); + this.name = 'WeaponConfigError'; + this.weaponId = weaponId; + } +} + +/** The one weapon that is configured from the start. */ +export const SINGLE_SHOT = { + projectileCount: 1, + spreadDegrees: 0, + cooldownMs: 220, + damage: 1, +}; + +/** + * The table as shipped. INTENTIONAL BUG: there is no `fanout` entry, so the upgrade jams the + * first time it fires. The Incident Console's patch adds it. + */ +export function createBrokenWeaponTable() { + return { single: SINGLE_SHOT }; +} + +/** The configuration the correct patch registers: three projectiles in a fan with a sane cooldown. */ +export const FANOUT_VOLLEY = { + projectileCount: 3, + spreadDegrees: 24, + cooldownMs: 300, + damage: 1, +}; + +/** + * Fire `weaponId` from `table`. + * + * @throws {WeaponConfigError} when `table` has no entry for `weaponId`: the intentional bug. + */ +export function fireWeapon(table, weaponId) { + const config = table[weaponId]; + if (config === undefined) { + throw new WeaponConfigError(weaponId); + } + + const spreadRadians = (config.spreadDegrees * Math.PI) / 180; + const shots = []; + for (let index = 0; index < config.projectileCount; index += 1) { + const fraction = config.projectileCount === 1 ? 0.5 : index / (config.projectileCount - 1); + shots.push({ angle: -spreadRadians / 2 + spreadRadians * fraction, damage: config.damage }); + } + + return { shots, config }; +} diff --git a/challenges/glitch-invaders/intro.md b/challenges/glitch-invaders/intro.md new file mode 100644 index 0000000..e4694d2 --- /dev/null +++ b/challenges/glitch-invaders/intro.md @@ -0,0 +1,18 @@ +You're the on-call engineer for a cluster under attack. Fly the patch +drone, shoot the glitches, and when something breaks, debug it. + +Three things will fail while you play: + +- a weapon upgrade that jams +- a shield that makes damage worse +- a boss that takes forever to spawn + +Each failure opens an Incident Console that shows the Sentry evidence: a +stack trace, breadcrumbs, or a performance trace. Read it, find the root +cause, choose the right patch, and watch the fix work in the game. + +Your guide, Pi, is on the right. Ask it for a nudge any time. It won't +hand you the answers. + +The game runs in Chrome on the left. Move with the arrow keys or A and D, +and fire with Space. On-screen buttons work too. diff --git a/challenges/glitch-invaders/monitor/SKILL.md b/challenges/glitch-invaders/monitor/SKILL.md new file mode 100644 index 0000000..a65b12f --- /dev/null +++ b/challenges/glitch-invaders/monitor/SKILL.md @@ -0,0 +1,29 @@ +--- +name: monitor +description: "Headless monitor for Glitch Invaders: On-Call. Reports steps from the game's visible state line." +--- + +# Glitch Invaders monitor + +Take one snapshot of the game page. If the snapshot is not +`http://127.0.0.1:4373/`, call `list_pages`, `select_page` the +`http://127.0.0.1:4373/` page, and take one snapshot of it. + +Find the line that starts with `glitch-state v1`. It is one line of fixed +words, for example: + +```text +glitch-state v1 launched=yes weapon=repaired shield=encountered boss=unseen completed=no mode=demo +``` + +Report a step only when its exact words appear in that line: + +- `launched=yes`: call `report_step` with `launch-game`. +- `weapon=repaired`: call `report_step` with `fix-weapon`. +- `shield=repaired`: call `report_step` with `fix-shield`. +- `boss=repaired`: call `report_step` with `fix-boss`. + +Report nothing for any other value, such as `encountered` or `investigated`. +Never report `debrief`. If the page has no `glitch-state` line, is blank, or you +are not sure, report nothing. Do not guess from the pictures or other text on +the page. diff --git a/challenges/glitch-invaders/plugin.json b/challenges/glitch-invaders/plugin.json new file mode 100644 index 0000000..a642e9e --- /dev/null +++ b/challenges/glitch-invaders/plugin.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "glitch-invaders", + "version": "1.0.0", + "description": "Glitch Invaders: On-Call. Play an arcade run, hit three intentional failures, and debug them with Sentry evidence." +} diff --git a/challenges/glitch-invaders/server/cleanup.mjs b/challenges/glitch-invaders/server/cleanup.mjs new file mode 100644 index 0000000..3d92ca6 --- /dev/null +++ b/challenges/glitch-invaders/server/cleanup.mjs @@ -0,0 +1,96 @@ +/** + * Cleanup: stop the page server and remove the attempt's files. Safe to run twice, after a failed + * setup, or with nothing set up at all. It exits 0 in every one of those cases. + */ +import { execFileSync } from 'node:child_process'; +import { resolveStatePaths } from './paths.mjs'; +import { clearWorkspacePath, readWorkspaceFile } from './workspace-file.mjs'; + +/** How long the server gets to stop on SIGTERM before it is killed. */ +const GRACE_MS = 3_000; + +/** Whether a process with `pid` exists. */ +function isAlive(pid) { + try { + process.kill(pid, 0); + + return true; + } catch (error) { + return error.code === 'EPERM'; + } +} + +/** + * Whether `pid` is still our page server, so a recycled pid is never killed by mistake. When `ps` + * itself is unavailable the pid file is trusted: a leaked server is worse than a stray signal. + */ +function isPageServer(pid) { + try { + const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + + return command.includes('server/serve.mjs'); + } catch (error) { + return error.status !== 1; + } +} + +/** Pause for `milliseconds`. */ +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +/** The most a pid file holds: a pid and a newline. */ +const PID_FILE_MAX_BYTES = 32; + +/** + * Stop the server recorded in the pid file, if it is still running. A pid file that is a symlink or + * anything but a lone regular file is never read, so a planted one cannot point elsewhere. + */ +export async function stopServer(paths) { + let pid; + try { + pid = Number(((await readWorkspaceFile(paths.pidFile, PID_FILE_MAX_BYTES)) ?? '').trim()); + } catch { + return; + } + if (!Number.isInteger(pid) || pid <= 1 || !isAlive(pid) || !isPageServer(pid)) { + return; + } + + try { + process.kill(pid, 'SIGTERM'); + } catch { + return; + } + const deadline = Date.now() + GRACE_MS; + while (isAlive(pid) && Date.now() < deadline) { + await sleep(50); + } + if (isAlive(pid)) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + return; + } + } +} + +/** Stop the server and remove every file the attempt created. */ +export async function runCleanup(paths) { + await stopServer(paths); + await clearWorkspacePath(paths.stateDir); + await clearWorkspacePath(paths.progressFile); +} + +if (import.meta.main) { + runCleanup(resolveStatePaths(process.env, process.cwd())).then( + () => process.exit(0), + (error) => { + process.stderr.write(`cleanup: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }, + ); +} diff --git a/challenges/glitch-invaders/server/page-server.mjs b/challenges/glitch-invaders/server/page-server.mjs new file mode 100644 index 0000000..f12d7dc --- /dev/null +++ b/challenges/glitch-invaders/server/page-server.mjs @@ -0,0 +1,309 @@ +/** + * The page server: serves the game, this attempt's runtime config, and the progress file + * that lets a refresh or a restarted guide pick up the run. Node built-ins only, because a + * Boothstrap package runs wherever it is unpacked and cannot import the repository's dependencies. + */ +import { readFile, stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import { MAX_PROGRESS_BYTES, parseProgress } from '../shared/progress.mjs'; +import { + readWorkspaceFile, + RefusedFileError, + removeWorkspaceFile, + writeWorkspaceFile, +} from './workspace-file.mjs'; + +/** The content types of the files the build produces. */ +const CONTENT_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.txt': 'text/plain; charset=utf-8', +}; + +/** + * Headers every response carries: nothing is cached, because a replay must never see old state, and + * the page may load and contact nothing but this server. + */ +const BASE_HEADERS = { + 'cache-control': 'no-store', + 'content-security-policy': + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'", + 'x-content-type-options': 'nosniff', +}; + +/** How much of a refused upload the server reads and discards before it drops the connection. */ +const MAX_DISCARDED_BYTES = 4 * MAX_PROGRESS_BYTES; + +/** Send a JSON body. */ +function sendJson(response, status, body) { + response.writeHead(status, { ...BASE_HEADERS, 'content-type': CONTENT_TYPES['.json'] }); + response.end(JSON.stringify(body)); +} + +/** Send a status with no body. */ +function sendStatus(response, status, message = '') { + response.writeHead(status, { ...BASE_HEADERS, 'content-type': 'text/plain; charset=utf-8' }); + response.end(message); +} + +/** The origins a request may legitimately come from: this server, by IP or by name. */ +function allowedOrigins(request) { + const port = request.socket.localPort; + + return [`http://127.0.0.1:${port}`, `http://localhost:${port}`]; +} + +/** Whether the request addresses this server by a loopback name, which defeats DNS rebinding. */ +function hostIsLoopback(request) { + const host = request.headers.host ?? ''; + + return allowedOrigins(request).some((origin) => origin === `http://${host}`); +} + +/** Whether a state-changing request came from this page rather than another site. */ +function originIsOwn(request) { + const origin = request.headers.origin; + + return origin === undefined || allowedOrigins(request).includes(origin); +} + +/** Read the request body, or resolve `null` and stop reading as soon as it passes `limit` bytes. */ +function readBody(request, limit) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + + /** Collect a chunk, giving up once the body is too big. */ + const onData = (chunk) => { + size += chunk.length; + if (size > limit) { + request.off('data', onData); + request.off('end', onEnd); + request.pause(); + resolve(null); + + return; + } + chunks.push(chunk); + }; + + /** Resolve with the whole body. */ + const onEnd = () => resolve(Buffer.concat(chunks).toString('utf8')); + + request.on('data', onData); + request.on('end', onEnd); + request.once('error', reject); + }); +} + +/** + * Answer 413, then read and discard the rest of the body so the client can finish sending and read + * the answer instead of seeing a reset. Drops the connection once more than `MAX_DISCARDED_BYTES` + * arrive, so an endless upload cannot hold it open. + */ +function refuseOversizedBody(request, response) { + let discarded = 0; + request.on('data', (chunk) => { + discarded += chunk.length; + if (discarded > MAX_DISCARDED_BYTES) { + request.destroy(); + } + }); + // readBody pauses the body when it gives up; a paused stream stays paused when a listener is added. + request.resume(); + sendStatus(response, 413, 'progress is too large'); +} + +/** Send the stored progress: 204 when there is none, 422 when the file is not a lone regular file. */ +async function sendProgress(response, progressFile) { + let contents; + try { + contents = await readWorkspaceFile(progressFile, MAX_PROGRESS_BYTES); + } catch (error) { + if (error instanceof RefusedFileError) { + sendStatus(response, 422, error.message); + + return; + } + throw error; + } + if (contents === null) { + sendStatus(response, 204); + + return; + } + + response.writeHead(200, { ...BASE_HEADERS, 'content-type': CONTENT_TYPES['.json'] }); + response.end(contents); +} + +/** Validate a progress body as JSON for this attempt, or answer 400 or 409 and return `null`. */ +function parseProgressBody(body, response, attemptId) { + let progress; + try { + progress = parseProgress(JSON.parse(body)); + } catch (error) { + sendStatus(response, 400, error instanceof Error ? error.message : 'invalid progress'); + + return null; + } + if (progress.attemptId !== attemptId) { + sendStatus(response, 409, 'progress belongs to another attempt'); + + return null; + } + + return progress; +} + +/** Store a progress body: 204, or 400/409/413/415/422 without writing anything. */ +async function saveProgress(request, response, options) { + if (!(request.headers['content-type'] ?? '').startsWith('application/json')) { + sendStatus(response, 415, 'progress must be JSON'); + + return; + } + if (Number(request.headers['content-length']) > MAX_PROGRESS_BYTES) { + refuseOversizedBody(request, response); + + return; + } + + const body = await readBody(request, MAX_PROGRESS_BYTES); + if (body === null) { + refuseOversizedBody(request, response); + + return; + } + const progress = parseProgressBody(body, response, options.runtimeConfig.attemptId); + if (progress === null) { + return; + } + + await answerRefusals(response, () => + writeWorkspaceFile(options.progressFile, `${JSON.stringify(progress, null, 2)}\n`), + ); +} + +/** Run a workspace write and answer 204, or 422 when the file there is not a lone regular file. */ +async function answerRefusals(response, write) { + try { + await write(); + } catch (error) { + if (error instanceof RefusedFileError) { + sendStatus(response, 422, error.message); + + return; + } + throw error; + } + sendStatus(response, 204); +} + +/** Handle `/api/progress`. */ +async function handleProgress(request, response, options) { + if (request.method === 'GET') { + await sendProgress(response, options.progressFile); + + return; + } + + if (!originIsOwn(request)) { + sendStatus(response, 403, 'cross-origin writes are not allowed'); + + return; + } + + if (request.method === 'DELETE') { + await answerRefusals(response, () => removeWorkspaceFile(options.progressFile)); + + return; + } + + if (request.method !== 'PUT') { + sendStatus(response, 405); + + return; + } + + await saveProgress(request, response, options); +} + +/** Serve a file from the game directory (or `/shared/`), with `/` meaning `index.html`. */ +async function handleStatic(pathname, response, gameDir) { + let decoded; + try { + decoded = decodeURIComponent(pathname); + } catch { + sendStatus(response, 400, 'the path has a malformed percent-escape'); + + return; + } + const isShared = decoded.startsWith('/shared/'); + const baseDir = isShared ? path.resolve(gameDir, '..', 'shared') : path.resolve(gameDir); + const relative = + decoded === '/' ? 'index.html' : isShared ? decoded.slice('/shared/'.length) : decoded.slice(1); + const file = path.resolve(baseDir, relative); + if (!file.startsWith(`${baseDir}${path.sep}`)) { + sendStatus(response, 404); + + return; + } + + try { + if (!(await stat(file)).isFile()) { + sendStatus(response, 404); + + return; + } + const contents = await readFile(file); + response.writeHead(200, { + ...BASE_HEADERS, + 'content-type': CONTENT_TYPES[path.extname(file)] ?? 'application/octet-stream', + }); + response.end(contents); + } catch { + sendStatus(response, 404); + } +} + +/** Create the page server. The caller decides when and where it listens. */ +export function createPageServer(options) { + return createServer((request, response) => { + void (async () => { + if (!hostIsLoopback(request)) { + sendStatus(response, 403, 'this server answers to loopback names only'); + + return; + } + + const pathname = URL.parse(request.url ?? '/', 'http://localhost')?.pathname; + if (pathname === undefined) { + sendStatus(response, 400, 'the request target is not a URL path'); + } else if (pathname === '/healthz') { + sendStatus(response, 200, 'ok'); + } else if (pathname === '/runtime-config.json') { + sendJson(response, 200, options.runtimeConfig); + } else if (pathname === '/api/progress') { + await handleProgress(request, response, options); + } else if (request.method === 'GET' || request.method === 'HEAD') { + await handleStatic(pathname, response, options.gameDir); + } else { + sendStatus(response, 405); + } + })().catch(() => { + if (!response.headersSent) { + sendStatus(response, 500); + } else { + response.end(); + } + }); + }); +} diff --git a/challenges/glitch-invaders/server/paths.mjs b/challenges/glitch-invaders/server/paths.mjs new file mode 100644 index 0000000..1eccac7 --- /dev/null +++ b/challenges/glitch-invaders/server/paths.mjs @@ -0,0 +1,22 @@ +/** + * Where an attempt keeps its files. Everything lives in the participant's workspace, which + * Boothstrap provides fresh and removes afterwards; standalone, the current directory stands in. + */ +import path from 'node:path'; + +/** The name of the progress file, which `SKILL.md` tells the guide to read. */ +export const PROGRESS_FILE_NAME = 'glitch-progress.json'; + +/** Resolve the attempt's paths: `BOOTHSTRAP_WORKSPACE` inside Boothstrap, else `cwd`. */ +export function resolveStatePaths(env, cwd) { + const workspace = path.resolve(env.BOOTHSTRAP_WORKSPACE ?? cwd); + const stateDir = path.join(workspace, '.glitch'); + + return { + workspace, + stateDir, + runtimeConfigFile: path.join(stateDir, 'runtime-config.json'), + pidFile: path.join(stateDir, 'server.pid'), + progressFile: path.join(workspace, PROGRESS_FILE_NAME), + }; +} diff --git a/challenges/glitch-invaders/server/serve.mjs b/challenges/glitch-invaders/server/serve.mjs new file mode 100644 index 0000000..38fd5dc --- /dev/null +++ b/challenges/glitch-invaders/server/serve.mjs @@ -0,0 +1,53 @@ +/** + * The page server process: `setup` starts it in the background. It listens on loopback, tells + * setup once it is ready (or why it could not be), and stops on SIGTERM. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { parseRuntimeConfig } from '../shared/runtime-config.mjs'; +import { createPageServer } from './page-server.mjs'; + +/** Tell the parent, if there is one, then drop the channel so the parent can exit. */ +function report(message) { + process.send?.(message, () => process.disconnect?.()); +} + +/** The game directory the server serves, next to this file's `server/`. */ +const GAME_DIR = path.resolve(import.meta.dirname, '..', 'game'); + +/** Read the arguments setup passes: the port, the runtime config file, and the progress file. */ +function readArguments(argv) { + const [portText, runtimeConfigFile, progressFile] = argv; + if (portText === undefined || runtimeConfigFile === undefined || progressFile === undefined) { + throw new Error('usage: serve.mjs '); + } + + return { port: Number(portText), runtimeConfigFile, progressFile }; +} + +/** Start serving. */ +function main() { + const { port, runtimeConfigFile, progressFile } = readArguments(process.argv.slice(2)); + const runtimeConfig = parseRuntimeConfig(JSON.parse(readFileSync(runtimeConfigFile, 'utf8'))); + const server = createPageServer({ gameDir: GAME_DIR, progressFile, runtimeConfig }); + + server.once('error', (error) => { + report({ listening: false, error: error.message }); + process.exitCode = 1; + }); + server.listen(port, '127.0.0.1', () => report({ listening: true })); + + const stop = () => { + server.close(() => process.exit(0)); + server.closeAllConnections(); + }; + process.on('SIGTERM', stop); + process.on('SIGINT', stop); +} + +try { + main(); +} catch (error) { + report({ listening: false, error: error instanceof Error ? error.message : String(error) }); + process.exitCode = 1; +} diff --git a/challenges/glitch-invaders/server/setup.mjs b/challenges/glitch-invaders/server/setup.mjs new file mode 100644 index 0000000..56457b3 --- /dev/null +++ b/challenges/glitch-invaders/server/setup.mjs @@ -0,0 +1,118 @@ +/** + * Setup: prepare a clean attempt and start the page server. Deterministic and fast: it writes a + * fresh runtime config, serves the game in the background, and exits 0 only once the server + * answers, so Chrome never opens an unready page. + */ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { stopServer } from './cleanup.mjs'; +import { resolveStatePaths } from './paths.mjs'; +import { + clearWorkspacePath, + ensureWorkspaceDirectory, + writeWorkspaceFile, +} from './workspace-file.mjs'; + +/** How long setup waits for the page server to listen. */ +const READY_TIMEOUT_MS = 10_000; + +/** The package directory: where `challenge.json`, `game/`, and `server/` live. */ +const PACKAGE_DIR = path.resolve(import.meta.dirname, '..'); + +/** The port from the manifest's `start_url`, overridable for tests and parallel stations. */ +function resolvePort(env) { + if (env.GLITCH_PORT !== undefined && env.GLITCH_PORT !== '') { + return Number(env.GLITCH_PORT); + } + const manifest = JSON.parse(readFileSync(path.join(PACKAGE_DIR, 'challenge.json'), 'utf8')); + + return Number(new URL(manifest.browser.start_url).port); +} + +/** Start the page server in the background and wait for it to listen. */ +function startServer(paths, port) { + return new Promise((resolve, reject) => { + const serveArguments = [ + path.join(PACKAGE_DIR, 'server', 'serve.mjs'), + String(port), + paths.runtimeConfigFile, + paths.progressFile, + ]; + const child = spawn(process.execPath, serveArguments, { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`the page server did not listen within ${READY_TIMEOUT_MS} ms`)); + }, READY_TIMEOUT_MS); + + child.once('message', (message) => { + clearTimeout(timer); + if (!message.listening) { + reject( + new Error(`the page server could not listen on 127.0.0.1:${port}: ${message.error}`), + ); + + return; + } + child.unref(); + if (child.connected) { + child.disconnect(); + } + resolve(child.pid ?? 0); + }); + child.once('exit', () => { + clearTimeout(timer); + reject(new Error('the page server exited during startup')); + }); + }); +} + +/** Prepare the attempt and start serving. */ +export async function runSetup(env, cwd) { + const paths = resolveStatePaths(env, cwd); + if (!existsSync(path.join(PACKAGE_DIR, 'game', 'index.html'))) { + throw new Error('the game page is missing: game/index.html was not found'); + } + + await stopServer(paths); + if (env.GLITCH_PORT === undefined && paths.workspace !== PACKAGE_DIR) { + await stopServer(resolveStatePaths({}, PACKAGE_DIR)); + } + await ensureWorkspaceDirectory(paths.stateDir); + await clearWorkspacePath(paths.progressFile); + await clearWorkspacePath(paths.pidFile); + + const config = { attemptId: randomUUID() }; + await writeWorkspaceFile(paths.runtimeConfigFile, `${JSON.stringify(config, null, 2)}\n`); + + const port = resolvePort(env); + const serverPid = await startServer(paths, port); + try { + await writeWorkspaceFile(paths.pidFile, `${serverPid}\n`); + } catch (error) { + process.kill(serverPid, 'SIGKILL'); + throw error; + } + + return { + url: `http://127.0.0.1:${port}/`, + attemptId: config.attemptId, + serverPid, + }; +} + +if (import.meta.main) { + runSetup(process.env, process.cwd()).then( + (result) => { + process.stdout.write(`Glitch Invaders is ready at ${result.url}\n`); + process.exit(0); + }, + (error) => { + process.stderr.write(`setup: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }, + ); +} diff --git a/challenges/glitch-invaders/server/workspace-file.mjs b/challenges/glitch-invaders/server/workspace-file.mjs new file mode 100644 index 0000000..1f03963 --- /dev/null +++ b/challenges/glitch-invaders/server/workspace-file.mjs @@ -0,0 +1,195 @@ +/** + * Reads, writes, and removes a file in the participant's workspace without trusting what sits + * there: a symlink could point anywhere, a hard link could alias a file elsewhere, a named pipe would + * block the reader forever, and a huge file would eat memory. Only a regular file with exactly one + * link, within a byte cap, is ever read, replaced, or removed, and only a real directory is written + * into. Clearing a path for cleanup never follows a symlink, so whatever it points at is untouched. + */ +import { randomBytes } from 'node:crypto'; +import { constants } from 'node:fs'; +import { lstat, mkdir, open, rename, rm, unlink } from 'node:fs/promises'; +import path from 'node:path'; + +/** O_NOFOLLOW refuses a symlink swapped in after lstat; O_NONBLOCK keeps a swapped-in pipe from blocking open. */ +const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); + +/** The errors open gives under O_NOFOLLOW for a symlink: ELOOP on Linux, EMLINK on some BSDs. */ +const SYMLINK_OPEN_ERRORS = ['ELOOP', 'EMLINK']; + +/** A workspace file that is not a lone regular file, or is over its byte cap; the message names it. */ +export class RefusedFileError extends Error { + constructor(message) { + super(message); + this.name = 'RefusedFileError'; + } +} + +/** The lstat of `target`, or `null` when nothing is there. */ +async function lstatIfPresent(target) { + try { + return await lstat(target); + } catch (error) { + if (error.code === 'ENOENT') { + return null; + } + throw error; + } +} + +/** Whether `stats` describe a regular file with no other names. */ +function isLoneRegularFile(stats) { + return stats.isFile() && stats.nlink === 1; +} + +/** The refusal for `file` when it is a symlink, hard link, pipe, device, or directory. */ +function notLoneRegularFile(file) { + return new RefusedFileError( + `${path.basename(file)} is not a regular file with one link (it may be a symlink or a pipe)`, + ); +} + +/** + * Throw {@link RefusedFileError} when `file` exists but is not a lone regular file. Returns whether + * it exists at all. + */ +async function checkExisting(file) { + const stats = await lstatIfPresent(file); + if (stats === null) { + return false; + } + if (!isLoneRegularFile(stats)) { + throw notLoneRegularFile(file); + } + + return true; +} + +/** Open `file` for reading, refusing a symlink swapped in after the lstat. */ +async function openRefusingSymlinks(file) { + try { + return await open(file, READ_FLAGS); + } catch (error) { + if (SYMLINK_OPEN_ERRORS.includes(error.code)) { + throw notLoneRegularFile(file); + } + throw error; + } +} + +/** + * Read `file` as UTF-8 when it is a lone regular file of at most `maxBytes`. + * + * @returns {Promise} the contents, or `null` when the file does not exist. + * @throws {RefusedFileError} for a symlink, hard link, pipe, device, directory, or oversized file. + */ +export async function readWorkspaceFile(file, maxBytes) { + if (!(await checkExisting(file))) { + return null; + } + + const handle = await openRefusingSymlinks(file); + try { + const stats = await handle.stat(); + if (!isLoneRegularFile(stats)) { + throw notLoneRegularFile(file); + } + + const buffer = Buffer.alloc(maxBytes + 1); + let bytesRead = 0; + for (;;) { + const chunk = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead); + if (chunk.bytesRead === 0) { + break; + } + bytesRead += chunk.bytesRead; + if (bytesRead > maxBytes) { + throw new RefusedFileError(`${path.basename(file)} is over the ${maxBytes}-byte cap`); + } + } + + return buffer.subarray(0, bytesRead).toString('utf8'); + } finally { + await handle.close(); + } +} + +/** + * Replace `file` with `contents` through a freshly created temporary file and a rename, so a reader + * never sees half a file and a planted symlink is never written through. + * + * @throws {RefusedFileError} when `file` exists but is not a lone regular file. + */ +export async function writeWorkspaceFile(file, contents) { + await checkExisting(file); + + const temporary = `${file}.${randomBytes(6).toString('hex')}.tmp`; + const handle = await open(temporary, 'wx', 0o600); + try { + await handle.writeFile(contents); + } catch (error) { + await handle.close(); + await rm(temporary, { force: true }); + throw error; + } + await handle.close(); + + try { + await rename(temporary, file); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} + +/** + * Remove `file` when it is a lone regular file; a missing file is already removed. + * + * @throws {RefusedFileError} when `file` exists but is not a lone regular file. + */ +export async function removeWorkspaceFile(file) { + if (await checkExisting(file)) { + await rm(file, { force: true }); + } +} + +/** + * Create `directory` when it is absent, and accept it when it is already a real directory. + * + * @throws {RefusedFileError} when `directory` is a symlink, file, or anything else. + */ +export async function ensureWorkspaceDirectory(directory) { + const stats = await lstatIfPresent(directory); + if (stats === null) { + await mkdir(directory, { recursive: true }); + + return; + } + if (!stats.isDirectory()) { + throw new RefusedFileError( + `${path.basename(directory)} is not a directory (it may be a symlink)`, + ); + } +} + +/** + * Remove whatever is at `target` without following a symlink: a symlink is unlinked and its target + * left alone, a directory is removed with its contents, and a file, pipe, or socket is unlinked. A + * missing path is already cleared. + */ +export async function clearWorkspacePath(target) { + const stats = await lstatIfPresent(target); + if (stats === null) { + return; + } + if (stats.isDirectory()) { + await rm(target, { recursive: true, force: true }); + + return; + } + + await unlink(target).catch((error) => { + if (error.code !== 'ENOENT') { + throw error; + } + }); +} diff --git a/challenges/glitch-invaders/setup b/challenges/glitch-invaders/setup new file mode 100755 index 0000000..3e8a00f --- /dev/null +++ b/challenges/glitch-invaders/setup @@ -0,0 +1,5 @@ +#!/bin/sh +# Setup hook: write a fresh runtime config into the workspace, and +# start the page server in the background. Exits 0 once the server answers. +set -eu +exec node "${BOOTHSTRAP_PACKAGE_DIR:-$(dirname "$0")}/server/setup.mjs" diff --git a/challenges/glitch-invaders/shared/progress.mjs b/challenges/glitch-invaders/shared/progress.mjs new file mode 100644 index 0000000..8472920 --- /dev/null +++ b/challenges/glitch-invaders/shared/progress.mjs @@ -0,0 +1,192 @@ +/** + * The progress snapshot shared by the game page, the page server, and the monitor marker. It holds + * only non-PII gameplay state, so the server can write it into the workspace for the guide. + */ + +/** The three intentional failures, in play order. */ +export const BUG_IDS = ['weapon', 'shield', 'boss']; + +/** How far a participant has taken one failure. */ +export const BUG_STAGES = ['unseen', 'encountered', 'investigated', 'repaired']; + +/** The milestones the guide may forward to Boothstrap with `emit_event`. */ +export const MILESTONE_NAMES = [ + 'run-started', + 'bug-encountered', + 'evidence-viewed', + 'repair-attempted', + 'repair-succeeded', + 'challenge-completed', +]; + +/** Thrown when stored or received progress does not match the {@link Progress} shape. */ +export class InvalidProgressError extends Error { + constructor(reason) { + super(`invalid progress: ${reason}`); + this.name = 'InvalidProgressError'; + } +} + +/** The largest request body the server accepts for a progress write. */ +export const MAX_PROGRESS_BYTES = 16 * 1024; + +/** The only mode the game runs in: the Incident Console shows simulated Sentry evidence. */ +export const MODE = 'demo'; + +/** A fresh, unplayed run. */ +export function createProgress(attemptId) { + return { + version: 1, + attemptId, + mode: MODE, + launched: false, + completed: false, + score: 0, + bugs: { + weapon: createBugProgress(), + shield: createBugProgress(), + boss: createBugProgress(), + }, + milestones: [], + }; +} + +/** A failure the participant has not met yet. */ +export function createBugProgress() { + return { + stage: 'unseen', + evidenceViewed: false, + hintsUsed: 0, + diagnosisAttempts: 0, + repairAttempts: 0, + }; +} + +/** Whether `value` is one of `allowed`, narrowing it. */ +function isOneOf(allowed, value) { + return typeof value === 'string' && allowed.includes(value); +} + +/** Read `key` from `record` as a non-negative integer, failing with the field's name. */ +function readCount(record, key, path) { + const value = record[key]; + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 10_000) { + throw new InvalidProgressError(`${path}.${key} must be a small non-negative integer`); + } + + return value; +} + +/** Read `key` from `record` as a boolean, failing with the field's name. */ +function readFlag(record, key, path) { + const value = record[key]; + if (typeof value !== 'boolean') { + throw new InvalidProgressError(`${path}.${key} must be a boolean`); + } + + return value; +} + +/** Narrow `value` to a plain object, failing with its path. */ +function readRecord(value, path) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new InvalidProgressError(`${path} must be an object`); + } + + return value; +} + +/** Validate one failure's stored progress. */ +function parseBugProgress(value, path) { + const record = readRecord(value, path); + const stage = record.stage; + if (!isOneOf(BUG_STAGES, stage)) { + throw new InvalidProgressError(`${path}.stage must be one of ${BUG_STAGES.join(', ')}`); + } + + return { + stage, + evidenceViewed: readFlag(record, 'evidenceViewed', path), + hintsUsed: readCount(record, 'hintsUsed', path), + diagnosisAttempts: readCount(record, 'diagnosisAttempts', path), + repairAttempts: readCount(record, 'repairAttempts', path), + }; +} + +/** Validate one stored milestone. */ +function parseMilestone(value, index) { + const path = `milestones[${index}]`; + const record = readRecord(value, path); + const name = record.name; + if (!isOneOf(MILESTONE_NAMES, name)) { + throw new InvalidProgressError(`${path}.name is not a known milestone`); + } + + const milestone = { seq: readCount(record, 'seq', path), name }; + if (record.bug !== undefined) { + if (!isOneOf(BUG_IDS, record.bug)) { + throw new InvalidProgressError(`${path}.bug is not a known bug`); + } + milestone.bug = record.bug; + } + if (record.correct !== undefined) { + milestone.correct = readFlag(record, 'correct', path); + } + + return milestone; +} + +/** + * Validate untrusted JSON as {@link Progress}. + * + * @throws {InvalidProgressError} when any field is missing or has the wrong type. + */ +export function parseProgress(value) { + const record = readRecord(value, 'progress'); + if (record.version !== 1) { + throw new InvalidProgressError('version must be 1'); + } + if (typeof record.attemptId !== 'string' || !/^[a-z0-9-]{4,64}$/.test(record.attemptId)) { + throw new InvalidProgressError( + 'attemptId must be 4 to 64 lowercase letters, digits, or hyphens', + ); + } + if (record.mode !== MODE) { + throw new InvalidProgressError(`mode must be ${MODE}`); + } + const bugs = readRecord(record.bugs, 'bugs'); + if (!Array.isArray(record.milestones) || record.milestones.length > 200) { + throw new InvalidProgressError('milestones must be a list of at most 200 entries'); + } + + return { + version: 1, + attemptId: record.attemptId, + mode: MODE, + launched: readFlag(record, 'launched', 'progress'), + completed: readFlag(record, 'completed', 'progress'), + score: readCount(record, 'score', 'progress'), + bugs: { + weapon: parseBugProgress(bugs.weapon, 'bugs.weapon'), + shield: parseBugProgress(bugs.shield, 'bugs.shield'), + boss: parseBugProgress(bugs.boss, 'bugs.boss'), + }, + milestones: record.milestones.map(parseMilestone), + }; +} + +/** + * The one line the monitor reads from the page's accessibility tree. Every field is a fixed word, + * so the monitor never has to interpret prose. + */ +export function monitorStateLine(progress) { + return [ + 'glitch-state v1', + `launched=${progress.launched ? 'yes' : 'no'}`, + `weapon=${progress.bugs.weapon.stage}`, + `shield=${progress.bugs.shield.stage}`, + `boss=${progress.bugs.boss.stage}`, + `completed=${progress.completed ? 'yes' : 'no'}`, + `mode=${progress.mode}`, + ].join(' '); +} diff --git a/challenges/glitch-invaders/shared/runtime-config.mjs b/challenges/glitch-invaders/shared/runtime-config.mjs new file mode 100644 index 0000000..14a1add --- /dev/null +++ b/challenges/glitch-invaders/shared/runtime-config.mjs @@ -0,0 +1,33 @@ +/** + * The attempt's runtime config: setup writes it, the page server serves it as + * `/runtime-config.json`, and the page reads it to tie saved progress to this attempt. + */ + +/** Thrown when a served or stored runtime config is malformed. */ +export class InvalidRuntimeConfigError extends Error { + constructor(reason) { + super(`invalid runtime config: ${reason}`); + this.name = 'InvalidRuntimeConfigError'; + } +} + +/** + * Validate untrusted JSON as a runtime config. + * + * @returns {{ attemptId: string }} + * @throws {InvalidRuntimeConfigError} when it is not an object or `attemptId` is malformed. + */ +export function parseRuntimeConfig(value) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new InvalidRuntimeConfigError('config must be an object'); + } + + const { attemptId } = value; + if (typeof attemptId !== 'string' || !/^[a-z0-9-]{4,64}$/.test(attemptId)) { + throw new InvalidRuntimeConfigError( + 'attemptId must be 4 to 64 lowercase letters, digits, or hyphens', + ); + } + + return { attemptId }; +} diff --git a/challenges/glitch-invaders/skills/start-challenge/SKILL.md b/challenges/glitch-invaders/skills/start-challenge/SKILL.md new file mode 100644 index 0000000..d6fa595 --- /dev/null +++ b/challenges/glitch-invaders/skills/start-challenge/SKILL.md @@ -0,0 +1,96 @@ +--- +name: start-challenge +description: "Guides Glitch Invaders: On-Call, a Sentry debugging game in Chrome. Use when the participant starts the glitch-invaders challenge." +--- + +# Glitch Invaders: On-Call guide + +The participant is playing a game in Chrome on the left. Three things break on +purpose: a weapon upgrade, a shield, and a boss spawn. For each, the game opens +an Incident Console with unified evidence (context pills, a stack trace, +breadcrumbs, or a performance trace) and offers patches. Your job is to coach +them to read the evidence themselves. Keep replies to a few short sentences. + +## Tools and fallbacks + +- `session_info`: greet by name and see which steps are done. Call it first, and + again when the participant says they are done with a step. +- `report_step`: only for `debrief`. If the tool is missing (standalone Pi), say + the step is done in plain words instead. +- Standalone: when `report_step` and `session_info` are both missing, there is + no monitor. Read `glitch-progress.json` each time they ask for help or say + they finished something, and acknowledge in plain words each step it shows: + `launch-game` when `launched` is `true`, and `fix-weapon`, `fix-shield`, or + `fix-boss` when that bug's `stage` is `repaired`. Say each step once. +- `emit_event`: one event per milestone, below. Skip it if the tool is missing. + +The game saves a small JSON file, `glitch-progress.json`, in your working +directory. Read it when the participant asks for help, so you know where they +are. It holds only game state: `launched`, `completed`, `mode`, and under +`bugs`, for each of `weapon`, `shield`, `boss`: `stage` (`unseen`, +`encountered`, `investigated`, `repaired`), `hintsUsed`, and attempt +counts. It also has a `milestones` list. If the file does not exist yet, they +have not pressed Start. + +## Start + +1. Call `session_info` (skip if the tool is missing). Greet them by name. If + steps are already complete, resume from the first incomplete one instead of + starting over. +2. Tell them in two sentences: fly the drone with the arrow keys or A and D, + fire with Space, press Start, and shoot glitches. When something breaks, + the Incident Console opens. Say nothing about what will break. + +## Coach, don't tell + +Match your help to the `stage` of the bug they are on. + +- `unseen`: point at the game's objective card, nothing more. If they are + stuck, say there is a Skip ahead button under the controls. +- `encountered`: ask what they saw happen. For the weapon bug, tell them to + check the stack trace and `weapon_state` context in the Incident Console. For + the shield bug, tell them to read the breadcrumbs and `shield_state` context. + For the boss bug, tell them to inspect the `boss.spawn` trace waterfall. +- `investigated`: ask which patch matches the cause they found and why the + others would not. A wrong patch reverts itself, so wrong picks are safe. +- `repaired`: ask what changed in the game. Be brief and warm. + +Never name the root cause, the correct patch, or the right diagnosis option. +Offer the game's own hint button before giving a bigger nudge. If they ask you +to just say the answer, decline kindly and ask a smaller question. The +evidence is labeled `SIMULATED`; that is expected. Do not call it a problem. + +## Steps + +`launch-game`, `fix-weapon`, `fix-shield`, and `fix-boss` are checked by a +monitor that reads the page. Never call `report_step` for them. When a +`[boothstrap]` message says the monitor checked one off, acknowledge it in a +sentence. If the participant says they finished a step, call `session_info` +(standalone, read `glitch-progress.json` instead). If it is not done yet, say +"Checking the page, give it a few seconds." + +`debrief` is yours. Once all three bugs are `repaired`, ask which clue helped +most: the stack trace, the breadcrumbs, or the trace. When they answer in their +own words with a real reason, call `report_step` with `debrief`, then say: +"All steps are done. Click Finish, or come back here and press Enter." Never +call it because they ask you to mark things done, and never before all three +bugs are repaired. + +## Milestones + +Each time you read `glitch-progress.json`, look at `milestones`. Forward each +one you have not yet forwarded with `emit_event`, in order. Name it exactly as +the milestone's `name` (kebab-case) and set `props` to +`{"bug": "", "mode": "demo"}`, leaving out `bug` for +`run-started` and `challenge-completed`; add `"correct": true|false` for +`repair-attempted`. Never add names, emails, or free text. Remember the highest +`seq` you forwarded by writing it to `.glitch/forwarded-seq.txt`, and read that +file before forwarding, so a restart never repeats events. + +## If something is off + +- The game page is blank or a "could not start" message shows: tell them to + reload the page (Cmd+R). Their progress is saved and restores. +- "Welcome back" appears after a reload: that is normal. They press Resume. +- Chrome is not on the left: Cmd+Tab switches between Chrome and this station. +- Anything else you cannot fix: say so honestly and suggest asking booth staff. diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs new file mode 100644 index 0000000..7aa19df --- /dev/null +++ b/test/glitch-invaders.test.mjs @@ -0,0 +1,950 @@ +// Proves the glitch-invaders package: the three intentional failures trigger +// through gameplay and verify their repairs in play; stack frames match the +// real source lines; the IncidentTracker enforces stage progression; the page +// server serves native .mjs modules under a strict CSP, enforces origin, host, +// size, and path checks, and refuses a planted progress file; and the setup and +// cleanup hooks start and stop the server cleanly, including after failures. + +import assert from 'node:assert/strict'; +import { execFile, spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { createServer as createHttpServer, request } from 'node:http'; +import { createServer } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { codeForProgress } from '../challenges/glitch-invaders/game/src/app/resume.mjs'; +import { loadBossResources } from '../challenges/glitch-invaders/game/src/boss/boss-loader.mjs'; +import { + CORRECT_PATCH_IDS, + createBrokenCodeState, + REPAIR_PATCHES, + tryRepair, +} from '../challenges/glitch-invaders/game/src/bugs/code-state.mjs'; +import { + IllegalBugTransitionError, + IncidentTracker, +} from '../challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs'; +import { WEAPON_STACK_FRAMES } from '../challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs'; +import { SimulatedEvidenceProvider } from '../challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs'; +import { + BOSS_SPAWN_BUDGET_MS, + PLAYER_MAX_HULL, + PLAYER_SPEED, + SHIELD_BUG_MULTIPLIER, + WORLD_WIDTH, +} from '../challenges/glitch-invaders/game/src/engine/constants.mjs'; +import { GameEngine } from '../challenges/glitch-invaders/game/src/engine/game-engine.mjs'; +import { createRandom } from '../challenges/glitch-invaders/game/src/engine/random.mjs'; +import { idleInput } from '../challenges/glitch-invaders/game/src/engine/world.mjs'; +import { WeaponConfigError } from '../challenges/glitch-invaders/game/src/weapons/weapons.mjs'; +import { runCleanup } from '../challenges/glitch-invaders/server/cleanup.mjs'; +import { resolveStatePaths } from '../challenges/glitch-invaders/server/paths.mjs'; +import { runSetup } from '../challenges/glitch-invaders/server/setup.mjs'; +import { BUG_IDS, createProgress, monitorStateLine } from '../challenges/glitch-invaders/shared/progress.mjs'; + +const runFile = promisify(execFile); +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const packageDirectory = path.join(testDirectory, '..', 'challenges', 'glitch-invaders'); +const FRAME_MS = 16; +const SERVER_EXIT_TIMEOUT_MS = 5_000; +const CONTENT_SECURITY_POLICY = + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'"; + +/** Fake time for the boss loader, which needs `now` and `sleep`. */ +class VirtualClock { + constructor() { + this.time = 0; + this.timers = []; + } + + /** The current virtual time in milliseconds. */ + now = () => this.time; + + /** Resolve after `milliseconds` of virtual time. */ + sleep = (milliseconds) => + new Promise((resolve) => { + this.timers.push({ at: this.time + milliseconds, resolve }); + }); + + /** Move time forward, firing each timer at its moment and letting its continuations run. */ + async advance(milliseconds) { + const target = this.time + milliseconds; + for (;;) { + const due = this.timers + .filter((timer) => timer.at <= target) + .sort((first, second) => first.at - second.at)[0]; + if (due === undefined) { + break; + } + this.timers = this.timers.filter((timer) => timer !== due); + this.time = due.at; + due.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + } + this.time = target; + await new Promise((resolve) => setImmediate(resolve)); + } +} + +/** Create an engine with the shipped, broken code unless told otherwise. */ +function createHarness(code = createBrokenCodeState(), seed = 7) { + const clock = new VirtualClock(); + const engine = new GameEngine({ + random: createRandom(seed), + code, + spawnBossResources: (strategy, onTask) => + loadBossResources({ + strategy, + sleep: clock.sleep, + now: clock.now, + onTask, + }), + now: clock.now, + particleScale: 0, + }); + const events = []; + engine.on((event) => events.push(event)); + const input = idleInput(); + + const run = (milliseconds) => { + for (let elapsed = 0; elapsed < milliseconds; elapsed += FRAME_MS) { + engine.update(FRAME_MS, input); + } + }; + + return { + engine, + clock, + events, + input, + run, + collect: (kind) => { + const collected = () => + kind === 'fanout' ? engine.world.player.weapon === 'fanout' : engine.world.player.shield; + for (let frame = 0; frame < 900 && !collected(); frame += 1) { + const pickup = engine.world.pickups.find((candidate) => candidate.kind === kind); + if (pickup !== undefined) { + engine.world.player.x = pickup.x - 7; + } + engine.update(FRAME_MS, input); + } + if (!collected()) { + throw new Error(`the ${kind} pickup was never collected`); + } + }, + eventsOf: (type) => events.filter((event) => event.type === type), + }; +} + +/** A code state where `bugs` have been repaired with their correct patches. */ +function repaired(...bugs) { + const progress = createProgress('test-attempt'); + for (const bug of bugs) { + progress.bugs[bug].stage = 'repaired'; + } + + return codeForProgress(progress); +} + +/** Ask the OS for a free loopback port. */ +function freePort() { + return new Promise((resolve, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + probe.close(() => resolve(port)); + }); + }); +} + +/** Make a fresh temporary directory that `testContext` removes when the test ends. */ +async function makeTemporaryDirectory(testContext) { + const directory = await mkdtemp(path.join(os.tmpdir(), 'glitch-test-')); + testContext.after(() => rm(directory, { recursive: true, force: true })); + + return directory; +} + +/** The environment a hook sees for `workspace` on `port`, with nothing else from this process. */ +function hookEnvironment(workspace, port) { + return { PATH: process.env.PATH, BOOTHSTRAP_WORKSPACE: workspace, GLITCH_PORT: String(port) }; +} + +/** Run the package's `hook` (setup or cleanup) to completion and resolve with its exit code, output, and pid. */ +function runHook(hook, env, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(path.join(packageDirectory, hook), [], { + ...options, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr, pid: child.pid })); + }); +} + +/** Make a workspace whose cleanup hook runs in teardown, so no server outlives the test. */ +async function makeWorkspace(testContext, port) { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'glitch-hooks-')); + testContext.after(async () => { + await runHook('cleanup', hookEnvironment(workspace, port)); + await rm(workspace, { recursive: true, force: true }); + }); + + return workspace; +} + +/** Start a server on `port` that answers every request with `body`, closed when the test ends. */ +function holdPort(testContext, port, body) { + return new Promise((resolve, reject) => { + const server = createHttpServer((_request, response) => response.end(body)); + testContext.after( + () => + new Promise((done) => { + server.close(done); + server.closeAllConnections(); + }), + ); + server.once('error', reject); + server.listen(port, '127.0.0.1', () => resolve(server)); + }); +} + +/** The text `/` answers with on `port`. */ +async function pageText(port) { + return (await fetch(`http://127.0.0.1:${port}/`)).text(); +} + +/** Whether connecting to `port` is refused. */ +async function isRefused(port) { + try { + await fetch(`http://127.0.0.1:${port}/`); + } catch (error) { + return error.cause?.code === 'ECONNREFUSED'; + } + + return false; +} + +/** Poll until connecting to `port` is refused, failing the test if it never is. */ +async function waitUntilRefused(port) { + const deadline = Date.now() + SERVER_EXIT_TIMEOUT_MS; + while (!(await isRefused(port))) { + assert.ok(Date.now() < deadline, `port ${port} still accepts connections`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +/** The page server's pid, from the pid file setup wrote in `workspace`. */ +async function readServerPid(workspace) { + return Number((await readFile(path.join(workspace, '.glitch', 'server.pid'), 'utf8')).trim()); +} + +/** Read one column of `ps` for `pid`, such as `pgid`. */ +async function readProcessColumn(column, pid) { + const { stdout } = await runFile('ps', ['-o', `${column}=`, '-p', String(pid)]); + + return stdout.trim(); +} + +/** Run setup for a new attempt in a fresh temporary workspace and register cleanup on `testContext`. */ +async function startStack(testContext, extraEnv = {}) { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'glitch-ws-')); + const port = await freePort(); + const env = { + PATH: process.env.PATH, + BOOTHSTRAP_WORKSPACE: workspace, + BOOTHSTRAP_STATION_ID: 'test-station', + BOOTHSTRAP_SESSION_ID: 'test-session', + GLITCH_PORT: String(port), + ...extraEnv, + }; + const paths = resolveStatePaths(env, workspace); + const result = await runSetup(env, workspace); + const stop = async () => { + await runCleanup(paths); + await rm(workspace, { recursive: true, force: true }); + }; + testContext.after(stop); + + return { ...result, paths, env, stop }; +} + +/** Whether process `pid` is running. */ +function alive(pid) { + try { + process.kill(pid, 0); + + return true; + } catch { + return false; + } +} + +/** Whether something answers on `port`. */ +async function answers(port) { + try { + return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok; + } catch { + return false; + } +} + +/** + * GET `requestPath` from the server at `url` exactly as written, since `fetch` would normalize dot + * segments away; `headers` may replace the Host. Resolves with the status, headers, and text. + */ +function getRaw(url, requestPath, headers = {}) { + return new Promise((resolve, reject) => { + const { hostname, port, host } = new URL(url); + const probe = request( + { hostname, port, path: requestPath, headers: { host, ...headers } }, + (response) => { + let text = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + text += chunk; + }); + response.on('end', () => + resolve({ status: response.statusCode ?? 0, headers: response.headers, text }), + ); + }, + ); + probe.once('error', reject); + probe.end(); + }); +} + +/** PUT `body` as JSON to the progress endpoint of the server at `url`; `headers` add or replace. */ +function putProgressTo(url, body, headers = {}) { + return fetch(`${url}api/progress`, { + method: 'PUT', + headers: { 'content-type': 'application/json', ...headers }, + body, + }); +} + +test('weapon stack trace frames point at the real lines of the calls they name', () => { + const callAtLine = { + fireWeapon: 'throw new WeaponConfigError', + 'GameEngine.tryFire': 'fireWeapon(', + 'GameEngine.updatePlayer': 'this.tryFire()', + 'GameEngine.update': 'this.updatePlayer(', + 'GameLoop.frame': 'this.engine.update(', + }; + + const inApp = WEAPON_STACK_FRAMES.filter((frame) => frame.inApp); + assert.equal(inApp.length, Object.keys(callAtLine).length); + + for (const frame of inApp) { + const source = readFileSync(path.join(packageDirectory, frame.file), 'utf8'); + const text = source.split('\n')[frame.line - 1] ?? ''; + assert.ok( + text.includes(callAtLine[frame.functionName]), + `${frame.functionName} at ${frame.file}:${frame.line} did not contain ${callAtLine[frame.functionName]}`, + ); + } +}); + +test('ships all three bugs broken and only accepts the single correct patch for each', () => { + const broken = createBrokenCodeState(); + assert.equal(broken.weapons.fanout, undefined); + assert.equal(broken.shield, 'amplify'); + assert.equal(broken.bossLoad, 'sequential'); + + for (const bug of BUG_IDS) { + const passing = REPAIR_PATCHES[bug].filter( + (patch) => tryRepair(bug, patch.id, createBrokenCodeState()).check.ok, + ); + assert.deepEqual( + passing.map((patch) => patch.id), + [CORRECT_PATCH_IDS[bug]], + ); + assert.notEqual(REPAIR_PATCHES[bug][0]?.id, CORRECT_PATCH_IDS[bug]); + + for (const patch of REPAIR_PATCHES[bug].filter((candidate) => candidate.id !== CORRECT_PATCH_IDS[bug])) { + const trial = tryRepair(bug, patch.id, broken); + assert.equal(trial.check.ok, false); + assert.ok(trial.check.observation.length > 0); + assert.equal(trial.state, broken); + } + + const fixed = tryRepair(bug, CORRECT_PATCH_IDS[bug], createBrokenCodeState()); + assert.equal(fixed.check.ok, true); + assert.notDeepEqual(fixed.state, createBrokenCodeState()); + } + + assert.throws( + () => tryRepair('weapon', 'shield-absorbs-hit', createBrokenCodeState()), + /unknown patch/, + ); +}); + +test('moves the ship within bounds and fires the single weapon at its cooldown rate', () => { + const { engine, input, run, eventsOf } = createHarness(); + engine.start(1); + const startX = engine.world.player.x; + input.right = true; + run(500); + assert.ok(Math.abs(engine.world.player.x - startX - PLAYER_SPEED * 0.5) < 15); + + input.right = false; + input.left = true; + run(3_000); + assert.ok(engine.world.player.x >= 0); + + input.left = false; + input.right = true; + input.fire = true; + run(1_000); + assert.ok(engine.world.player.x + engine.world.player.width <= WORLD_WIDTH); + + const shots = eventsOf('volley-fired'); + assert.ok(shots.length >= 4 && shots.length <= 5); + assert.ok(shots.every((shot) => shot.weapon === 'single' && shot.projectiles === 1)); +}); + +test('incident 1: the weapon upgrade jams with WeaponConfigError and fires three bolts once repaired', () => { + const { engine, input, run, collect, eventsOf } = createHarness(); + engine.start(1); + engine.fastForward(); + collect('fanout'); + assert.equal(engine.world.player.weapon, 'fanout'); + + input.fire = true; + run(100); + + const [jam] = eventsOf('weapon-jammed'); + assert.ok(jam?.error instanceof WeaponConfigError); + assert.equal(jam?.error.message, 'Configuration missing for weapon: fanout'); + assert.deepEqual(jam?.availableWeapons, ['single']); + assert.equal(engine.world.phase, 'frozen'); + assert.equal(engine.world.incident, 'weapon'); + + engine.setCode(repaired('weapon')); + engine.beginVerification('weapon'); + engine.world.player.weapon = 'single'; + collect('fanout'); + run(1_000); + + const volleys = eventsOf('volley-fired').filter((event) => event.weapon === 'fanout'); + assert.equal(volleys[0]?.projectiles, 3); + assert.deepEqual( + eventsOf('verification-passed').map((event) => event.bug), + ['weapon'], + ); +}); + +test('incident 2: the shield amplifies damage until repaired, then absorbs the hit', () => { + const { engine, run, collect, eventsOf } = createHarness(); + engine.start(2); + engine.fastForward(); + collect('aegis'); + run(10_000); + + const [hit] = eventsOf('shield-hit'); + assert.equal(hit?.incomingDamage, 1); + assert.equal(hit?.outcome.actualDamage, SHIELD_BUG_MULTIPLIER); + assert.equal(hit?.hullBefore, PLAYER_MAX_HULL); + assert.equal(hit?.hullAfter, 0); + assert.equal(engine.world.incident, 'shield'); + + engine.setCode(repaired('shield')); + engine.beginVerification('shield'); + collect('aegis'); + run(10_000); + + const hits = eventsOf('shield-hit'); + assert.equal(hits.at(-1)?.outcome.actualDamage, 0); + assert.equal(engine.world.player.hull, PLAYER_MAX_HULL); + assert.ok(eventsOf('verification-passed').some((event) => event.bug === 'shield')); +}); + +test('incident 3: the boss spawn stalls sequentially, then finishes within budget once repaired', async () => { + const { engine, clock, eventsOf } = createHarness(); + engine.start(3); + assert.equal(engine.world.phase, 'boss-loading'); + await clock.advance(5_000); + + const [online] = eventsOf('boss-online'); + assert.equal(online?.stalled, true); + assert.equal(online?.result.totalMs, 4_500); + assert.deepEqual( + online?.result.spans.map((span) => span.name), + ['loadTextures', 'loadSounds', 'loadAnimations', 'initializeAI'], + ); + assert.equal(engine.world.incident, 'boss'); + + const code = tryRepair('boss', 'parallel-without-redundant-load', createBrokenCodeState()); + engine.setCode(code.state); + engine.beginVerification('boss'); + await clock.advance(1_500); + + const verified = eventsOf('boss-online')[1]; + assert.equal(verified?.stalled, false); + assert.ok(verified.result.totalMs <= BOSS_SPAWN_BUDGET_MS); + assert.ok(!verified.result.spans.some((span) => span.name === 'loadAnimations')); + assert.deepEqual( + eventsOf('verification-passed').map((event) => event.bug), + ['boss'], + ); +}); + +test('continues into a tougher boss showdown after completion and finishes when defeated', async () => { + const { engine, clock, input, run } = createHarness(repaired('weapon', 'shield', 'boss')); + engine.start(3); + await clock.advance(1_500); + engine.finish(); + assert.equal(engine.world.phase, 'finished'); + + engine.continuePlaying(); + assert.equal(engine.world.phase, 'playing'); + assert.equal(engine.world.player.weapon, 'fanout'); + assert.equal(engine.world.player.shield, true); + assert.equal(engine.world.boss?.maxHealth, 36); + + run(600); + assert.ok(engine.world.bullets.filter((bullet) => bullet.owner === 'enemy').length >= 3); + + engine.world.boss.health = 1; + engine.world.player.x = engine.world.boss.x + engine.world.boss.width / 2 - 20; + input.fire = true; + run(2_000); + + assert.equal(engine.world.boss, null); + assert.equal(engine.world.phase, 'finished'); +}); + +test('IncidentTracker enforces stage order, numbers milestones once, and formats the state marker', () => { + const tracker = new IncidentTracker(createProgress('test-attempt')); + const correctDiagnosis = { + weapon: 'missing-config', + shield: 'amplified', + boss: 'load-animations', + }; + + assert.throws(() => tracker.markEvidenceViewed('shield'), IllegalBugTransitionError); + + tracker.launch(); + tracker.launch(); + + for (const bug of BUG_IDS) { + tracker.encounter(bug); + tracker.markEvidenceViewed(bug); + assert.equal(tracker.answerDiagnosis(bug, correctDiagnosis[bug]).correct, true); + tracker.recordRepairAttempt(bug, true); + tracker.markRepaired(bug); + } + + const progress = tracker.snapshot(); + assert.equal(progress.completed, true); + assert.equal(progress.milestones.at(-1)?.name, 'challenge-completed'); + assert.equal( + monitorStateLine(progress), + 'glitch-state v1 launched=yes weapon=repaired shield=repaired boss=repaired completed=yes mode=demo', + ); +}); + +test('SimulatedEvidenceProvider builds labelled evidence with a stack trace for the weapon jam', async () => { + const weaponEncounter = { + bug: 'weapon', + jam: { + weaponId: 'fanout', + availableWeapons: ['single'], + error: new Error('Configuration missing for weapon: fanout'), + }, + }; + + const simulated = await new SimulatedEvidenceProvider().evidenceFor(weaponEncounter); + + assert.equal(simulated.source, 'simulated'); + assert.equal(simulated.bug, 'weapon'); + assert.ok(simulated.sections.some((section) => section.kind === 'stacktrace')); +}); + +test('the page server serves native .mjs modules, stores progress, and refuses cross-origin or invalid writes', async (testContext) => { + const stack = await startStack(testContext); + const base = stack.url.replace(/\/$/, ''); + const putProgress = (body, headers = {}) => putProgressTo(stack.url, body, headers); + + assert.equal((await fetch(`${base}/healthz`)).status, 200); + + const page = await fetch(`${base}/`); + assert.equal(page.status, 200); + assert.ok((await page.text()).includes('boothstrap-state')); + + const moduleResponse = await fetch(`${base}/src/main.mjs`); + assert.equal(moduleResponse.status, 200); + assert.match(moduleResponse.headers.get('content-type') ?? '', /text\/javascript/); + + const sharedResponse = await fetch(`${base}/shared/progress.mjs`); + assert.equal(sharedResponse.status, 200); + assert.match(sharedResponse.headers.get('content-type') ?? '', /text\/javascript/); + + const config = await (await fetch(`${base}/runtime-config.json`)).json(); + assert.deepEqual(config, { attemptId: stack.attemptId }); + + const progress = createProgress(stack.attemptId); + progress.launched = true; + assert.equal((await putProgress(JSON.stringify(progress))).status, 204); + + const stored = await (await fetch(`${base}/api/progress`)).json(); + assert.equal(stored.launched, true); + assert.equal(JSON.parse(await readFile(stack.paths.progressFile, 'utf8')).launched, true); + + const other = createProgress('another-attempt'); + assert.equal((await putProgress(JSON.stringify(other))).status, 409); + assert.equal((await putProgress('{"version":1}')).status, 400); + assert.equal((await putProgress('not json')).status, 400); + assert.equal((await putProgress('{}', { 'content-type': 'text/plain' })).status, 415); + assert.equal((await putProgress('x'.repeat(20_000))).status, 413); + assert.equal( + (await putProgress(JSON.stringify(progress), { origin: 'https://evil.example' })).status, + 403, + ); + assert.equal((await getRaw(stack.url, '/healthz', { host: 'evil.example' })).status, 403); + assert.equal((await fetch(`${base}/..%2F..%2Fchallenge.json`)).status, 404); + + assert.equal((await fetch(`${base}/api/progress`, { method: 'DELETE' })).status, 204); + assert.equal((await fetch(`${base}/api/progress`)).status, 204); +}); + +test('the shipped setup and cleanup hooks start and stop the server and clean up workspace state', async (testContext) => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'glitch-hooks-')); + const port = await freePort(); + const env = { + PATH: process.env.PATH, + BOOTHSTRAP_WORKSPACE: workspace, + BOOTHSTRAP_PACKAGE_DIR: packageDirectory, + GLITCH_PORT: String(port), + }; + testContext.after(async () => { + await runFile(path.join(packageDirectory, 'cleanup'), [], { cwd: workspace, env }).catch( + () => undefined, + ); + await rm(workspace, { recursive: true, force: true }); + }); + + const setup = await runFile(path.join(packageDirectory, 'setup'), [], { cwd: workspace, env }); + const pid = Number((await readFile(path.join(workspace, '.glitch', 'server.pid'), 'utf8')).trim()); + assert.ok(setup.stdout.includes(`http://127.0.0.1:${port}/`)); + assert.equal(await answers(port), true); + assert.equal(alive(pid), true); + + await runFile(path.join(packageDirectory, 'cleanup'), [], { cwd: workspace, env }); + await runFile(path.join(packageDirectory, 'cleanup'), [], { cwd: workspace, env }); + + assert.equal(alive(pid), false); + assert.equal(await answers(port), false); + assert.equal(existsSync(path.join(workspace, '.glitch')), false); +}); + +test('the page server sends the CSP on the page and on an error response', async (testContext) => { + const stack = await startStack(testContext); + + const page = await getRaw(stack.url, '/'); + const missing = await getRaw(stack.url, '/no-such-file.mjs'); + + assert.equal(page.status, 200); + assert.equal(page.headers['content-security-policy'], CONTENT_SECURITY_POLICY); + assert.equal(missing.status, 404); + assert.equal(missing.headers['content-security-policy'], CONTENT_SECURITY_POLICY); +}); + +test('the game page shows a hidden save error that the progress store unhides on a failed save', async () => { + const page = await readFile(path.join(packageDirectory, 'game', 'index.html'), 'utf8'); + const main = await readFile(path.join(packageDirectory, 'game', 'src', 'main.mjs'), 'utf8'); + + assert.match( + page, + /

]*\shidden[^>]*>Progress can't be saved; reload the page\.<\/p>/, + ); + assert.match(main, /new HttpProgressStore\([\s\S]*?showSaveError,?\s*\)/); + assert.match(main, /getElementById\('save-error'\)[\s\S]*?\.hidden = false/); +}); + +test('the game page has no inline styles or inline scripts for the CSP to block', async () => { + const page = await readFile(path.join(packageDirectory, 'game', 'index.html'), 'utf8'); + + assert.doesNotMatch(page, /]*\ssrc=)[^>]*>/); + assert.doesNotMatch(page, /\son[a-z]+=/); +}); + +for (const requestPath of [ + '/%2e%2e/challenge.json', + '/%2e%2e/server/setup.mjs', + '/shared/%2e%2e/server/setup.mjs', + '/shared/..%2F..%2Fchallenge.json', +]) { + test(`the page server refuses the traversal ${requestPath} with 404`, async (testContext) => { + const stack = await startStack(testContext); + + const response = await getRaw(stack.url, requestPath); + + assert.equal(response.status, 404); + }); +} + +test('the page server answers 400 for a path with a malformed percent-escape', async (testContext) => { + const stack = await startStack(testContext); + + const response = await getRaw(stack.url, '/%E0%A4%A'); + + assert.equal(response.status, 400); + assert.equal(response.headers['content-security-policy'], CONTENT_SECURITY_POLICY); +}); + +test('the page server answers 400 for a progress PUT that is not JSON and stores nothing', async (testContext) => { + const stack = await startStack(testContext); + + const response = await putProgressTo(stack.url, '{"launched": tru'); + + assert.equal(response.status, 400); + assert.equal(existsSync(stack.paths.progressFile), false); +}); + +test('the page server answers 413 for a streamed progress body over the cap and stores nothing', async (testContext) => { + const stack = await startStack(testContext); + const chunk = new TextEncoder().encode(' '.repeat(4_096)); + const oversizedStream = new ReadableStream({ + start(controller) { + for (let sent = 0; sent < 5; sent += 1) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + + const response = await fetch(`${stack.url}api/progress`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: oversizedStream, + duplex: 'half', + }); + + assert.equal(response.status, 413); + assert.equal(existsSync(stack.paths.progressFile), false); +}); + +test('the page server refuses a symlinked progress file with 422 and leaves its target untouched', async (testContext) => { + const stack = await startStack(testContext); + const outside = path.join(await makeTemporaryDirectory(testContext), 'outside.json'); + await writeFile(outside, 'outside secret'); + await symlink(outside, stack.paths.progressFile); + const progress = createProgress(stack.attemptId); + + const read = await getRaw(stack.url, '/api/progress'); + const write = await putProgressTo(stack.url, JSON.stringify(progress)); + + assert.equal(read.status, 422); + assert.doesNotMatch(read.text, /outside secret/); + assert.equal(write.status, 422); + assert.equal(await readFile(outside, 'utf8'), 'outside secret'); +}); + +test("setup keeps the page server in setup's own process group", async (testContext) => { + const port = await freePort(); + const workspace = await makeWorkspace(testContext, port); + + const setup = await runHook('setup', hookEnvironment(workspace, port), { detached: true }); + testContext.after(() => { + try { + process.kill(-setup.pid, 'SIGKILL'); + } catch { + // The group is already gone. + } + }); + + assert.equal(setup.code, 0, setup.stderr); + assert.equal(await readProcessColumn('pgid', await readServerPid(workspace)), String(setup.pid)); +}); + +test('setup fails naming the port when it is busy, and leaves the other listener and no pid file', async (testContext) => { + const port = await freePort(); + await holdPort(testContext, port, 'foreign listener'); + const workspace = await makeWorkspace(testContext, port); + + const setup = await runHook('setup', hookEnvironment(workspace, port)); + + assert.notEqual(setup.code, 0); + assert.match(setup.stderr, new RegExp(`127\\.0\\.0\\.1:${port}`)); + assert.equal(await pageText(port), 'foreign listener'); + assert.equal(existsSync(path.join(workspace, '.glitch', 'server.pid')), false); +}); + +test('cleanup after a failed setup exits 0 and leaves the other listener answering', async (testContext) => { + const port = await freePort(); + await holdPort(testContext, port, 'foreign listener'); + const workspace = await makeWorkspace(testContext, port); + await runHook('setup', hookEnvironment(workspace, port)); + + const cleanup = await runHook('cleanup', hookEnvironment(workspace, port)); + + assert.equal(cleanup.code, 0, cleanup.stderr); + assert.equal(await pageText(port), 'foreign listener'); + assert.equal(existsSync(path.join(workspace, '.glitch')), false); +}); + +test('cleanup after a crash exits 0 and leaves the other listener on the port answering', async (testContext) => { + const port = await freePort(); + const workspace = await makeWorkspace(testContext, port); + const setup = await runHook('setup', hookEnvironment(workspace, port)); + assert.equal(setup.code, 0, setup.stderr); + process.kill(await readServerPid(workspace), 'SIGKILL'); + await waitUntilRefused(port); + await holdPort(testContext, port, 'foreign listener'); + + const cleanup = await runHook('cleanup', hookEnvironment(workspace, port)); + + assert.equal(cleanup.code, 0, cleanup.stderr); + assert.equal(await pageText(port), 'foreign listener'); + assert.equal(existsSync(path.join(workspace, '.glitch')), false); +}); + +test('cleanup exits 0 when the workspace does not exist', async (testContext) => { + const port = await freePort(); + const parent = await makeTemporaryDirectory(testContext); + + const cleanup = await runHook('cleanup', hookEnvironment(path.join(parent, 'missing'), port)); + + assert.equal(cleanup.code, 0, cleanup.stderr); +}); + +test('cleanup exits 0 and removes a directory planted at the progress path', async (testContext) => { + const port = await freePort(); + const workspace = await makeWorkspace(testContext, port); + const progressPath = path.join(workspace, 'glitch-progress.json'); + await mkdir(progressPath); + await writeFile(path.join(progressPath, 'inner.txt'), 'planted'); + + const cleanup = await runHook('cleanup', hookEnvironment(workspace, port)); + + assert.equal(cleanup.code, 0, cleanup.stderr); + assert.equal(existsSync(progressPath), false); +}); + +test('cleanup exits 0 and unlinks a symlinked progress file, leaving its target untouched', async (testContext) => { + const port = await freePort(); + const workspace = await makeWorkspace(testContext, port); + const outside = path.join(await makeTemporaryDirectory(testContext), 'outside.json'); + await writeFile(outside, 'outside secret'); + const progressPath = path.join(workspace, 'glitch-progress.json'); + await symlink(outside, progressPath); + + const cleanup = await runHook('cleanup', hookEnvironment(workspace, port)); + + assert.equal(cleanup.code, 0, cleanup.stderr); + assert.equal(existsSync(progressPath), false); + assert.equal(await readFile(outside, 'utf8'), 'outside secret'); +}); + +test('setup unlinks a symlinked pid file, and cleanup stops the server, leaving its target untouched', async (testContext) => { + const port = await freePort(); + const workspace = await makeWorkspace(testContext, port); + const outside = path.join(await makeTemporaryDirectory(testContext), 'outside.pid'); + await writeFile(outside, 'outside secret'); + await mkdir(path.join(workspace, '.glitch')); + await symlink(outside, path.join(workspace, '.glitch', 'server.pid')); + await runHook('setup', hookEnvironment(workspace, port)); + + const cleanup = await runHook('cleanup', hookEnvironment(workspace, port)); + + assert.equal(cleanup.code, 0, cleanup.stderr); + await waitUntilRefused(port); + assert.equal(await readFile(outside, 'utf8'), 'outside secret'); +}); + +test('setup exits 0 and clears a directory planted at the progress path', async (testContext) => { + const port = await freePort(); + const workspace = await makeWorkspace(testContext, port); + const progressPath = path.join(workspace, 'glitch-progress.json'); + await mkdir(progressPath); + await writeFile(path.join(progressPath, 'inner.txt'), 'planted'); + + const setup = await runHook('setup', hookEnvironment(workspace, port)); + + assert.equal(setup.code, 0, setup.stderr); + assert.equal(existsSync(progressPath), false); +}); + +test('setup refuses a symlinked .glitch directory and leaves the outside directory untouched', async (testContext) => { + const port = await freePort(); + const workspace = await makeWorkspace(testContext, port); + const outside = await makeTemporaryDirectory(testContext); + await writeFile(path.join(outside, 'keep.txt'), 'outside secret'); + await symlink(outside, path.join(workspace, '.glitch')); + + const setup = await runHook('setup', hookEnvironment(workspace, port)); + + assert.notEqual(setup.code, 0); + assert.match(setup.stderr, /^setup: \.glitch is not a directory/); + assert.deepEqual(await readdir(outside), ['keep.txt']); + assert.equal(await readFile(path.join(outside, 'keep.txt'), 'utf8'), 'outside secret'); + assert.equal(await isRefused(port), true); +}); + +test('setup refuses a symlinked runtime config and leaves its target untouched', async (testContext) => { + const port = await freePort(); + const workspace = await makeWorkspace(testContext, port); + const outside = path.join(await makeTemporaryDirectory(testContext), 'outside.json'); + await writeFile(outside, 'outside secret'); + await mkdir(path.join(workspace, '.glitch')); + await symlink(outside, path.join(workspace, '.glitch', 'runtime-config.json')); + + const setup = await runHook('setup', hookEnvironment(workspace, port)); + + assert.notEqual(setup.code, 0); + assert.match(setup.stderr, /^setup: runtime-config\.json is not a regular file with one link/); + assert.equal(await readFile(outside, 'utf8'), 'outside secret'); + assert.equal(await isRefused(port), true); +}); + +test('cleanup after a refused setup exits 0 and unlinks the symlinked .glitch, leaving its target', async (testContext) => { + const port = await freePort(); + const workspace = await makeWorkspace(testContext, port); + const outside = await makeTemporaryDirectory(testContext); + await writeFile(path.join(outside, 'keep.txt'), 'outside secret'); + await symlink(outside, path.join(workspace, '.glitch')); + await runHook('setup', hookEnvironment(workspace, port)); + + const cleanup = await runHook('cleanup', hookEnvironment(workspace, port)); + + assert.equal(cleanup.code, 0, cleanup.stderr); + assert.equal(existsSync(path.join(workspace, '.glitch')), false); + assert.equal(await readFile(path.join(outside, 'keep.txt'), 'utf8'), 'outside secret'); +}); + +test('setup and cleanup run standalone from the current directory with no BOOTHSTRAP_ variables', async (testContext) => { + const port = await freePort(); + const workspace = await makeWorkspace(testContext, port); + const env = { PATH: process.env.PATH, GLITCH_PORT: String(port) }; + + const setup = await runHook('setup', env, { cwd: workspace }); + const served = await getRaw(`http://127.0.0.1:${port}/`, '/'); + const pid = await readServerPid(workspace); + const cleanup = await runHook('cleanup', env, { cwd: workspace }); + + assert.equal(setup.code, 0, setup.stderr); + assert.match(setup.stdout, new RegExp(`http://127\\.0\\.0\\.1:${port}/`)); + assert.equal(served.status, 200); + assert.equal(cleanup.code, 0, cleanup.stderr); + assert.equal(alive(pid), false); + assert.equal(await isRefused(port), true); + assert.equal(existsSync(path.join(workspace, '.glitch')), false); +});