From 51bd37a87e2355d2c74580a3ec6eec64f8598a09 Mon Sep 17 00:00:00 2001 From: mp siebert Date: Fri, 16 Oct 2026 12:33:53 -0400 Subject: [PATCH 1/8] feat: add the Glitch Invaders challenge --- README.md | 1 + challenges/glitch-invaders/README.md | 83 ++ challenges/glitch-invaders/challenge.json | 59 + challenges/glitch-invaders/cleanup | 4 + challenges/glitch-invaders/game/index.html | 71 + .../glitch-invaders/game/src/app/app.mjs | 565 ++++++++ .../game/src/app/game-loop.mjs | 38 + .../glitch-invaders/game/src/app/resume.mjs | 57 + .../game/src/boss/boss-loader.mjs | 81 ++ .../game/src/bugs/bug-definitions.mjs | 120 ++ .../game/src/bugs/code-state.mjs | 225 ++++ .../game/src/bugs/incident-tracker.mjs | 207 +++ .../game/src/diagnostics/breadcrumbs.mjs | 150 +++ .../src/diagnostics/evidence-builders.mjs | 183 +++ .../game/src/diagnostics/evidence.mjs | 0 .../game/src/diagnostics/live-evidence.mjs | 63 + .../src/diagnostics/simulated-evidence.mjs | 27 + .../game/src/engine/constants.mjs | 97 ++ .../game/src/engine/game-engine.mjs | 886 +++++++++++++ .../game/src/engine/geometry.mjs | 16 + .../game/src/engine/random.mjs | 14 + .../glitch-invaders/game/src/engine/world.mjs | 4 + challenges/glitch-invaders/game/src/main.mjs | 94 ++ .../game/src/render/canvas-renderer.mjs | 284 ++++ .../game/src/render/palette.mjs | 19 + .../game/src/render/sprites.mjs | 51 + .../glitch-invaders/game/src/sentry/links.mjs | 19 + .../game/src/sentry/offline-reporter.mjs | 18 + .../game/src/sentry/reporter.mjs | 0 .../game/src/sentry/sentry-reporter.mjs | 364 ++++++ .../game/src/session/inactivity.mjs | 60 + .../game/src/session/monitor-state.mjs | 31 + .../game/src/session/progress-store.mjs | 81 ++ .../game/src/shield/shield.mjs | 25 + .../glitch-invaders/game/src/styles.css | 1149 +++++++++++++++++ .../game/src/ui/boss-loading.mjs | 101 ++ .../glitch-invaders/game/src/ui/dom.mjs | 72 ++ .../glitch-invaders/game/src/ui/hud.mjs | 87 ++ .../game/src/ui/incident-console.mjs | 658 ++++++++++ .../glitch-invaders/game/src/ui/input.mjs | 162 +++ .../glitch-invaders/game/src/ui/objective.mjs | 71 + .../glitch-invaders/game/src/ui/overlays.mjs | 217 ++++ .../game/src/weapons/weapons.mjs | 57 + challenges/glitch-invaders/intro.md | 18 + challenges/glitch-invaders/monitor/SKILL.md | 25 + challenges/glitch-invaders/plugin.json | 6 + challenges/glitch-invaders/server/cleanup.mjs | 90 ++ .../glitch-invaders/server/live-config.mjs | 130 ++ .../glitch-invaders/server/page-server.mjs | 208 +++ challenges/glitch-invaders/server/paths.mjs | 22 + challenges/glitch-invaders/server/serve.mjs | 56 + challenges/glitch-invaders/server/setup.mjs | 147 +++ challenges/glitch-invaders/setup | 5 + .../glitch-invaders/shared/progress.mjs | 189 +++ .../glitch-invaders/shared/runtime-config.mjs | 180 +++ .../skills/start-challenge/SKILL.md | 94 ++ test/glitch-invaders.test.mjs | 629 +++++++++ 57 files changed, 8370 insertions(+) create mode 100644 challenges/glitch-invaders/README.md create mode 100644 challenges/glitch-invaders/challenge.json create mode 100755 challenges/glitch-invaders/cleanup create mode 100644 challenges/glitch-invaders/game/index.html create mode 100644 challenges/glitch-invaders/game/src/app/app.mjs create mode 100644 challenges/glitch-invaders/game/src/app/game-loop.mjs create mode 100644 challenges/glitch-invaders/game/src/app/resume.mjs create mode 100644 challenges/glitch-invaders/game/src/boss/boss-loader.mjs create mode 100644 challenges/glitch-invaders/game/src/bugs/bug-definitions.mjs create mode 100644 challenges/glitch-invaders/game/src/bugs/code-state.mjs create mode 100644 challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs create mode 100644 challenges/glitch-invaders/game/src/diagnostics/breadcrumbs.mjs create mode 100644 challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs create mode 100644 challenges/glitch-invaders/game/src/diagnostics/evidence.mjs create mode 100644 challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs create mode 100644 challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs create mode 100644 challenges/glitch-invaders/game/src/engine/constants.mjs create mode 100644 challenges/glitch-invaders/game/src/engine/game-engine.mjs create mode 100644 challenges/glitch-invaders/game/src/engine/geometry.mjs create mode 100644 challenges/glitch-invaders/game/src/engine/random.mjs create mode 100644 challenges/glitch-invaders/game/src/engine/world.mjs create mode 100644 challenges/glitch-invaders/game/src/main.mjs create mode 100644 challenges/glitch-invaders/game/src/render/canvas-renderer.mjs create mode 100644 challenges/glitch-invaders/game/src/render/palette.mjs create mode 100644 challenges/glitch-invaders/game/src/render/sprites.mjs create mode 100644 challenges/glitch-invaders/game/src/sentry/links.mjs create mode 100644 challenges/glitch-invaders/game/src/sentry/offline-reporter.mjs create mode 100644 challenges/glitch-invaders/game/src/sentry/reporter.mjs create mode 100644 challenges/glitch-invaders/game/src/sentry/sentry-reporter.mjs create mode 100644 challenges/glitch-invaders/game/src/session/inactivity.mjs create mode 100644 challenges/glitch-invaders/game/src/session/monitor-state.mjs create mode 100644 challenges/glitch-invaders/game/src/session/progress-store.mjs create mode 100644 challenges/glitch-invaders/game/src/shield/shield.mjs create mode 100644 challenges/glitch-invaders/game/src/styles.css create mode 100644 challenges/glitch-invaders/game/src/ui/boss-loading.mjs create mode 100644 challenges/glitch-invaders/game/src/ui/dom.mjs create mode 100644 challenges/glitch-invaders/game/src/ui/hud.mjs create mode 100644 challenges/glitch-invaders/game/src/ui/incident-console.mjs create mode 100644 challenges/glitch-invaders/game/src/ui/input.mjs create mode 100644 challenges/glitch-invaders/game/src/ui/objective.mjs create mode 100644 challenges/glitch-invaders/game/src/ui/overlays.mjs create mode 100644 challenges/glitch-invaders/game/src/weapons/weapons.mjs create mode 100644 challenges/glitch-invaders/intro.md create mode 100644 challenges/glitch-invaders/monitor/SKILL.md create mode 100644 challenges/glitch-invaders/plugin.json create mode 100644 challenges/glitch-invaders/server/cleanup.mjs create mode 100644 challenges/glitch-invaders/server/live-config.mjs create mode 100644 challenges/glitch-invaders/server/page-server.mjs create mode 100644 challenges/glitch-invaders/server/paths.mjs create mode 100644 challenges/glitch-invaders/server/serve.mjs create mode 100644 challenges/glitch-invaders/server/setup.mjs create mode 100755 challenges/glitch-invaders/setup create mode 100644 challenges/glitch-invaders/shared/progress.mjs create mode 100644 challenges/glitch-invaders/shared/runtime-config.mjs create mode 100644 challenges/glitch-invaders/skills/start-challenge/SKILL.md create mode 100644 test/glitch-invaders.test.mjs 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..ab59c82 --- /dev/null +++ b/challenges/glitch-invaders/README.md @@ -0,0 +1,83 @@ +# Glitch Invaders: On-Call + +Fly a patch drone in Chrome, trigger three intentional bugs while defending the +cluster, and debug each one with Sentry telemetry 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. + +## 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` and live-config resolver | +| `shared/` | Progress and runtime configuration validators | +| `game/` | Browser arcade game, Incident Console, and Sentry envelope reporter | + +There is no `install` hook: the package has no dependencies beyond Node and browser +ES modules. + +## How steps advance + +The browser game writes `#boothstrap-state` with a single-line marker such as: + +```text +glitch-state v1 launched=yes weapon=repaired shield=unseen boss=unseen completed=no mode=demo +``` + +The headless monitor reads that marker from the page's accessibility tree and +reports `launch-game`, `fix-weapon`, `fix-shield`, and `fix-boss` as each step +reaches `yes` or `repaired`. 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 telemetry 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. + +## Modes + +- **Demo** (default): needs no Sentry credentials and renders deterministic + simulated evidence in the Incident Console. +- **Live**: sends real error events (`WeaponConfigError`, `GameLogicError`) and + a `boss-spawn` performance trace to Sentry over HTTP envelopes, and links + directly to the issue or trace in Sentry. Set `GLITCH_SENTRY_DSN`, + `GLITCH_SENTRY_ORG`, and `GLITCH_SENTRY_PROJECT` (plus optional + `GLITCH_SENTRY_ENVIRONMENT`) in the environment, `.env`, or + `live.config.json`. Set `GLITCH_MODE=demo` to force demo mode. + +## 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` or `emit_event`, so the skill +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..96c01f7 --- /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's #boothstrap-state text includes \"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's #boothstrap-state text includes \"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's #boothstrap-state text includes \"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's #boothstrap-state text includes \"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..d7b6987 --- /dev/null +++ b/challenges/glitch-invaders/game/index.html @@ -0,0 +1,71 @@ + + + + + + + Glitch Invaders: On-Call + + + + +
+
+

+ + Glitch Invaders On-Call +

+
    +
  • HULL 3
  • +
  • SHIELD off
  • +
  • SQUASHED 0
  • +
  • SCORE 0
  • +
+ + +
+ +
+
+ + + +
+
+ +
+
+

Objective

+

+

+
    +
    +
    +

    Controls

    +
    + + + +
    + +
    +
    + + +
    + +

    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..2470fa1 --- /dev/null +++ b/challenges/glitch-invaders/game/src/app/app.mjs @@ -0,0 +1,565 @@ +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 { attachBreadcrumbs, BreadcrumbRecorder } from '../diagnostics/breadcrumbs.mjs'; + +import { LiveEvidenceProvider } from '../diagnostics/live-evidence.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 { InactivityMonitor } from '../session/inactivity.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, + inactivityCard, + 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')); + recorder; + engine; + provider; + simulated = new SimulatedEvidenceProvider(); + incidentConsole; + input; + loop; + evidenceByBug = new Map(); + tracker; + progress; + code; + lastShieldHit = null; + lastBossTraceId = null; + bannerTimer = null; + inactivity = null; + pauseOpen = false; + pendingStart = null; + + constructor(options, saved) { + this.options = options; + this.now = options.now ?? (() => performance.now()); + this.progress = + saved === null + ? createProgress(options.config.attemptId, options.config.mode) + : { ...saved, mode: options.config.mode }; + this.tracker = new IncidentTracker(this.progress); + this.code = codeForProgress(this.progress); + + this.recorder = new BreadcrumbRecorder(() => this.engine.world.time); + 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, + }); + attachBreadcrumbs(this.engine, { + add: (breadcrumb) => { + this.recorder.add(breadcrumb); + options.reporter.add(breadcrumb); + }, + }); + this.provider = this.createProvider(); + 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({ + onActivity: () => this.inactivity?.noteActivity(), + 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)); + this.hud.showMode(this.options.config.mode, false); + 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.inactivity?.noteActivity(); + 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.exposeDiagnostics(); + this.startInactivityMonitor(); + this.showOpening(); + this.loop.start(); + } + + /** Choose the evidence provider for the mode. */ + createProvider() { + const { config, reporter } = this.options; + if (config.mode === 'live' && config.sentry !== null) { + return new LiveEvidenceProvider({ + reporter, + settings: config.sentry, + attemptId: config.attemptId, + recorder: this.recorder, + clock: () => this.engine.world.time, + }); + } + + return this.simulated; + } + + /** Run the boss spawn through the reporter, keeping the trace ID for the evidence link. */ + async spawnBoss(strategy, onTask) { + const traced = await this.options.reporter.traceBossSpawn((runSpan) => + loadBossResources({ + strategy, + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + now: this.now, + runSpan, + ...(onTask === undefined ? {} : { onTask }), + }), + ); + this.lastBossTraceId = traced.traceId; + + return traced.result; + } + + /** 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({ mode: this.progress.mode, 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({ mode: this.progress.mode, 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, + })), + traceId: this.lastBossTraceId, + }, + }); + } + 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.provider.evidenceFor(encounter).then((evidence) => this.deliverEvidence(evidence)); + } + + /** A failure was restored from a saved run: reopen the console on simulated 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.simulated + .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); + } + + /** Warn after a quiet stretch, then reset the run so nobody inherits it. */ + startInactivityMonitor() { + const { config } = this.options; + let prior = null; + let pausedBefore = false; + const card = inactivityCard(() => this.inactivity?.noteActivity()); + + this.inactivity = new InactivityMonitor({ + warningAfterMs: config.inactivityWarningMs, + resetAfterWarningMs: config.inactivityResetMs, + now: this.now, + onWarning: () => { + prior = this.overlays.current; + pausedBefore = this.engine.world.paused; + this.engine.pause(); + this.input.releaseAll(); + this.overlays.show(card.element); + }, + onResume: () => { + if (prior === null) { + this.overlays.hide(this.stage); + } else { + this.overlays.show(prior); + } + if (!pausedBefore) { + this.engine.resume(); + } + }, + onReset: () => { + void this.options.store.clear().then(() => this.options.reload()); + }, + }); + + setInterval(() => { + if (!this.progress.launched) { + this.inactivity?.noteActivity(); + + return; + } + this.inactivity?.tick(); + if (this.inactivity?.state === 'warning') { + card.setSeconds(this.inactivity.secondsLeft()); + } + }, 500); + } + + /** A read-only view of the arena for tests and for diagnosing a stuck booth. */ + exposeDiagnostics() { + Object.defineProperty(window, '__GLITCH_WORLD__', { + configurable: true, + value: () => { + const world = this.engine.world; + + return { + phase: world.phase, + wave: world.wave, + paused: world.paused, + kills: world.kills, + player: { + x: world.player.x, + width: world.player.width, + weapon: world.player.weapon, + shield: world.player.shield, + hull: world.player.hull, + }, + pickups: world.pickups.map((pickup) => ({ kind: pickup.kind, x: pickup.x, y: pickup.y })), + bossAlive: world.boss !== null, + enemiesAlive: world.enemies.filter((enemy) => enemy.alive).length, + enemyBullets: world.bullets.filter((bullet) => bullet.owner === 'enemy').length, + }; + }, + }); + } +} 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..ae03f78 --- /dev/null +++ b/challenges/glitch-invaders/game/src/app/resume.mjs @@ -0,0 +1,57 @@ +/** + * Rebuilding a saved run after a refresh: the code the repaired bugs left behind, and the evidence + * for a failure that was still open. Evidence rebuilt this way is always the simulated fixture, + * because the live capture is gone and a second Sentry report would be a duplicate. + */ +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], traceId: null }, + }; + } +} 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..adac919 --- /dev/null +++ b/challenges/glitch-invaders/game/src/boss/boss-loader.mjs @@ -0,0 +1,81 @@ +/** + * 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); +} + +/** Runs each load directly, recording nothing. */ +export const runWithoutSpans = (_name, _operation, work) => work(); + +/** Run one load under the span runner, timing it against `startedAt`. */ +async function runTask(task, options, startedAt, spans) { + const runSpan = options.runSpan ?? runWithoutSpans; + const startMs = options.now() - startedAt; + options.onTask?.({ name: task.name, phase: 'start', atMs: startMs }); + + await runSpan(task.name, LOAD_SPAN_OP, () => 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/breadcrumbs.mjs b/challenges/glitch-invaders/game/src/diagnostics/breadcrumbs.mjs new file mode 100644 index 0000000..5221c62 --- /dev/null +++ b/challenges/glitch-invaders/game/src/diagnostics/breadcrumbs.mjs @@ -0,0 +1,150 @@ +/** The most breadcrumbs the recorder keeps. */ +const MAX_BREADCRUMBS = 40; + +/** Keeps the latest breadcrumbs with game timestamps. */ +export class BreadcrumbRecorder { + entries = []; + clock; + + constructor(clock) { + this.clock = clock; + } + + /** Record `breadcrumb` at the current game time. */ + add(breadcrumb) { + this.entries.push({ ...breadcrumb, atMs: this.clock(), level: breadcrumb.level ?? 'info' }); + if (this.entries.length > MAX_BREADCRUMBS) { + this.entries.shift(); + } + } + + /** The recorded trail, oldest first. */ + trail() { + return this.entries; + } +} + +/** Format how long before `failureAtMs` something happened, such as `-1.2 s`. */ +export function formatOffset(failureAtMs, atMs) { + const seconds = Math.max(0, failureAtMs - atMs) / 1_000; + + return seconds < 0.05 ? '0.0 s' : `-${seconds.toFixed(1)} s`; +} + +/** Turn recorded breadcrumbs into console entries, with offsets measured from the failure. */ +export function toBreadcrumbEntries(trail, failureAtMs, limit) { + return trail.slice(-limit).map((crumb) => ({ + offsetLabel: formatOffset(failureAtMs, crumb.atMs), + category: crumb.category, + message: crumb.message, + level: crumb.level, + data: crumb.data ?? null, + })); +} + +/** Format a number of seconds for breadcrumb text, such as `4.5 s`. */ +function secondsText(milliseconds) { + return `${(milliseconds / 1_000).toFixed(1)} s`; +} + +/** Subscribe `sink` to the engine's events, turning each into the breadcrumb a real service would log. */ +export function attachBreadcrumbs(engine, sink) { + return engine.on((event) => { + switch (event.type) { + case 'wave-started': + sink.add({ category: 'game.wave', message: `Wave ${event.wave} started` }); + break; + case 'pickup-collected': + if (event.kind === 'fanout') { + sink.add({ + category: 'game.weapon', + message: 'Weapon selected: fanout', + data: { weaponId: 'fanout' }, + }); + } else { + sink.add({ + category: 'game.shield', + message: 'Shield collected', + data: { type: 'aegis' }, + }); + sink.add({ + category: 'game.shield', + message: 'Shield enabled', + data: { shieldActive: 'true' }, + }); + } + break; + case 'volley-fired': + if (event.weapon !== 'single') { + sink.add({ + category: 'game.weapon', + message: `Fired ${event.weapon}`, + data: { projectiles: String(event.projectiles) }, + }); + } + break; + case 'weapon-jammed': + sink.add({ + category: 'game.weapon', + message: `Firing weapon: ${event.error.weaponId}`, + data: { weaponId: event.error.weaponId }, + }); + sink.add({ + category: 'game.weapon', + message: 'Weapon fire failed: configuration missing', + level: 'error', + data: { availableWeapons: event.availableWeapons.join(', ') }, + }); + break; + case 'shield-hit': + sink.add({ + category: 'game.combat', + message: `Incoming damage: ${event.incomingDamage}`, + data: { damage: String(event.incomingDamage) }, + }); + sink.add({ + category: 'game.shield', + message: 'Shield active: true', + data: { shieldActive: 'true' }, + }); + sink.add({ + category: 'game.combat', + message: `Actual damage applied: ${event.outcome.actualDamage}`, + level: event.outcome.actualDamage === 0 ? 'info' : 'warning', + data: { actualDamage: String(event.outcome.actualDamage), expected: '0' }, + }); + break; + case 'ship-down': + if (event.cause === 'shield-failure') { + sink.add({ + category: 'game.combat', + message: 'Ship destroyed: shield failed to protect', + level: 'error', + }); + } + break; + case 'boss-loading': + sink.add({ + category: 'game.boss', + message: 'Boss spawn initiated', + data: { strategy: event.strategy }, + }); + break; + case 'boss-online': + sink.add({ + category: 'game.boss', + message: `Boss ready after ${secondsText(event.result.totalMs)}`, + level: event.stalled ? 'warning' : 'info', + }); + break; + case 'verification-passed': + sink.add({ category: 'game.repair', message: `Repair verified: ${event.bug}` }); + break; + case 'enemy-destroyed': + case 'pickup-spawned': + case 'ship-restored': + case 'run-finished': + break; + } + }); +} 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..a7dd949 --- /dev/null +++ b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs @@ -0,0 +1,183 @@ +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: 121, 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; the + * simulated provider passes its fixture and the live provider passes the recorded trail. + * `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/evidence.mjs b/challenges/glitch-invaders/game/src/diagnostics/evidence.mjs new file mode 100644 index 0000000..e69de29 diff --git a/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs b/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs new file mode 100644 index 0000000..8184d51 --- /dev/null +++ b/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs @@ -0,0 +1,63 @@ +import { issueSearchUrl, traceUrl } from '../sentry/links.mjs'; + +import { toBreadcrumbEntries } from './breadcrumbs.mjs'; + +import { evidenceSections, evidenceTitle } from './evidence-builders.mjs'; + +/** How many of the most recent breadcrumbs the console shows. */ +const SHOWN_BREADCRUMBS = 8; + +/** Reports each encounter to Sentry and shows the local capture of what was sent. */ +export class LiveEvidenceProvider { + options; + + constructor(options) { + this.options = options; + } + + /** Report `encounter`, then build evidence from the local capture. Never rejects. */ + async evidenceFor(encounter) { + const { recorder, clock } = this.options; + const entries = toBreadcrumbEntries(recorder.trail(), clock(), SHOWN_BREADCRUMBS); + const spans = encounter.bug === 'boss' ? encounter.stall.spans : []; + const outcome = await this.report(encounter); + + return { + bug: encounter.bug, + source: outcome.delivery === 'confirmed' ? 'live' : 'live-unconfirmed', + title: evidenceTitle(encounter), + referenceId: outcome.referenceId, + sections: evidenceSections(encounter, entries, spans), + link: this.linkFor(encounter, outcome), + }; + } + + /** Report to Sentry, degrading to an unconfirmed outcome if reporting itself fails. */ + async report(encounter) { + try { + return await this.options.reporter.report(encounter); + } catch { + return { referenceId: 'not-sent', delivery: 'unconfirmed' }; + } + } + + /** The Sentry page for this failure, when there is something to link to. */ + linkFor(encounter, outcome) { + const { settings, attemptId } = this.options; + if (encounter.bug === 'boss') { + const traceId = encounter.stall.traceId; + + return traceId === null + ? null + : { label: 'Investigate trace in Sentry', url: traceUrl(settings, traceId) }; + } + if (outcome.referenceId === 'not-sent') { + return null; + } + + return { + label: 'Investigate in Sentry', + url: issueSearchUrl(settings, attemptId, encounter.bug), + }; + } +} 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..cf1935a --- /dev/null +++ b/challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs @@ -0,0 +1,27 @@ +/** + * Demo-mode evidence: realistic and deterministic, built without any Sentry credentials. It is + * clearly labelled simulated, and carries the same decisions as the live evidence. + */ + +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 and with no Sentry link. */ + 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), + link: null, + }; + } +} 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..be8cb33 --- /dev/null +++ b/challenges/glitch-invaders/game/src/main.mjs @@ -0,0 +1,94 @@ +/** The page entry: read this attempt's configuration, choose live or demo, and start the game. */ +import { createDemoRuntimeConfig, parseRuntimeConfig } from '../../shared/runtime-config.mjs'; +import { GameApp } from './app/app.mjs'; + +import { OfflineReporter } from './sentry/offline-reporter.mjs'; + +import { SentryReporter } from './sentry/sentry-reporter.mjs'; +import { HttpProgressStore, MemoryProgressStore } from './session/progress-store.mjs'; + +/** The release name live events carry, so Sentry groups them with this challenge. */ +const RELEASE = 'glitch-invaders-on-call@1'; + +/** Read `/runtime-config.json`, falling back to demo mode when there is no page server. */ +async function loadConfig() { + try { + const response = await fetch('/runtime-config.json', { cache: 'no-store' }); + if (response.ok) { + return { config: parseRuntimeConfig(await response.json()), served: true }; + } + } catch { + // No page server, such as under `vite dev`: demo mode below. + } + + return { config: createDemoRuntimeConfig('local-dev'), served: false }; +} + +/** Build the live reporter, or fall back to demo mode if Sentry cannot start. */ +function chooseReporter(config) { + if (config.mode === 'live' && config.sentry !== null) { + try { + return { + config, + reporter: new SentryReporter(config.sentry, { + attemptId: config.attemptId, + stationId: config.stationId, + sessionId: config.sessionId, + release: RELEASE, + }), + }; + } catch { + return { config: { ...config, mode: 'demo', sentry: null }, reporter: new OfflineReporter() }; + } + } + + return { config, reporter: new OfflineReporter() }; +} + +/** 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.`, + }), + ); +} + +/** Start the game. */ +async function main() { + const loaded = await loadConfig(); + const { config, reporter } = chooseReporter(loaded.config); + const query = new URLSearchParams(window.location.search); + const seed = Number(query.get('seed')); + const store = loaded.served + ? new HttpProgressStore( + '/api/progress', + (input, init) => fetch(input, init), + () => {}, + ) + : new MemoryProgressStore(); + + let saved = null; + try { + saved = await store.load(config.attemptId); + } catch { + saved = null; + } + + const app = new GameApp( + { + config, + store, + reporter, + 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/sentry/links.mjs b/challenges/glitch-invaders/game/src/sentry/links.mjs new file mode 100644 index 0000000..8a203b0 --- /dev/null +++ b/challenges/glitch-invaders/game/src/sentry/links.mjs @@ -0,0 +1,19 @@ +/** The Sentry web address of the organization. */ +function organizationUrl(settings) { + return `https://${encodeURIComponent(settings.org)}.sentry.io`; +} + +/** + * A search for the issue about `bug`. Searching by the stable `bug` tag rather than a fresh + * per-run UUID matches the issue in Sentry immediately before single-event indexing finishes. + */ +export function issueSearchUrl(settings, _attemptId, bug) { + const query = encodeURIComponent(`bug:${bug}`); + + return `${organizationUrl(settings)}/issues/?query=${query}&statsPeriod=14d`; +} + +/** The trace view for `traceId`. */ +export function traceUrl(settings, traceId) { + return `${organizationUrl(settings)}/explore/traces/trace/${encodeURIComponent(traceId)}/`; +} diff --git a/challenges/glitch-invaders/game/src/sentry/offline-reporter.mjs b/challenges/glitch-invaders/game/src/sentry/offline-reporter.mjs new file mode 100644 index 0000000..d9c2c89 --- /dev/null +++ b/challenges/glitch-invaders/game/src/sentry/offline-reporter.mjs @@ -0,0 +1,18 @@ +/** The reporter demo mode uses: it sends nothing anywhere. */ +import { runWithoutSpans } from '../boss/boss-loader.mjs'; + +/** Reports to no one, so demo mode needs no credentials and makes no network requests. */ +export class OfflineReporter { + /** Breadcrumbs are kept by the local recorder alone. */ + add() {} + + /** Run `work` with no tracing. */ + async traceBossSpawn(work) { + return { result: await work(runWithoutSpans), traceId: null }; + } + + /** There is nothing to deliver, so nothing is confirmed. */ + async report() { + return { referenceId: 'offline', delivery: 'unconfirmed' }; + } +} diff --git a/challenges/glitch-invaders/game/src/sentry/reporter.mjs b/challenges/glitch-invaders/game/src/sentry/reporter.mjs new file mode 100644 index 0000000..e69de29 diff --git a/challenges/glitch-invaders/game/src/sentry/sentry-reporter.mjs b/challenges/glitch-invaders/game/src/sentry/sentry-reporter.mjs new file mode 100644 index 0000000..7228347 --- /dev/null +++ b/challenges/glitch-invaders/game/src/sentry/sentry-reporter.mjs @@ -0,0 +1,364 @@ +/** + * Live reporting through Sentry's v7 Envelope HTTP endpoint: one real exception for the weapon jam, + * one for the shield failure, and one real span v2 trace for the boss spawn, each fingerprinted and + * tagged so the participant's issue and trace links open immediately in Sentry. + */ +import { ALL_LOAD_TASKS, LOAD_SPAN_OP, runWithoutSpans } from '../boss/boss-loader.mjs'; +import { WEAPON_STACK_FRAMES } from '../diagnostics/evidence-builders.mjs'; + +/** How long to wait for Sentry to confirm a report before showing the local capture. */ +export const DELIVERY_TIMEOUT_MS = 2_500; + +/** Generate a lowercase hex string of `length` characters using Web Crypto. */ +function randomHex(length) { + const bytes = new Uint8Array(length / 2); + crypto.getRandomValues(bytes); + + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +/** Parse a Sentry DSN into its ingestion endpoint and public key. */ +export function parseDsn(dsn) { + const url = new URL(dsn); + const publicKey = url.username; + const projectId = url.pathname.replace(/^\/+/, ''); + const orgMatch = /^o(\d+)\./.exec(url.hostname); + const params = new URLSearchParams({ + sentry_version: '7', + sentry_key: publicKey, + sentry_client: 'sentry.javascript.browser/11.7.0', + }); + + return { + publicKey, + projectId, + orgId: orgMatch?.[1] ?? undefined, + envelopeUrl: `${url.protocol}//${url.host}/api/${projectId}/envelope/?${params.toString()}`, + }; +} + +/** Build a typed span attribute entry for Sentry's v2 span envelope item. */ +function spanAttribute(value, type = 'string') { + return { value, type }; +} + +/** Convert curated stack frames into Sentry's oldest-to-newest stacktrace frame array. */ +function toSentryFrames(frames) { + return [...frames].reverse().map((frame) => ({ + filename: frame.file, + function: frame.functionName, + lineno: frame.line, + in_app: frame.inApp, + })); +} + +/** Sends the game's intentional failures to Sentry over HTTP envelopes. */ +export class SentryReporter { + constructor(settings, identity, fetchImpl = globalThis.fetch) { + this.settings = settings; + this.identity = identity; + this.fetchImpl = fetchImpl; + this.parsedDsn = parseDsn(settings.dsn); + this.traceId = randomHex(32); + this.breadcrumbs = []; + this.outcomes = new Map(); + this.prewarmedBossTraceId = null; + this.prewarmBossTrace(); + } + + /** Record a breadcrumb so the next captured event carries the trail. */ + add(breadcrumb) { + this.breadcrumbs.push({ + timestamp: Date.now() / 1_000, + category: breadcrumb.category, + message: breadcrumb.message, + level: breadcrumb.level ?? 'info', + ...(breadcrumb.data === undefined ? {} : { data: breadcrumb.data }), + }); + } + + /** Run the boss spawn, returning the prewarmed trace ID on the first unpatched stall. */ + async traceBossSpawn(work) { + if (this.prewarmedBossTraceId !== null) { + const traceId = this.prewarmedBossTraceId; + this.prewarmedBossTraceId = null; + + return { + traceId, + result: await work(runWithoutSpans), + }; + } + + const traceId = randomHex(32); + const rootSpanId = randomHex(16); + const childItems = []; + const startSeconds = Date.now() / 1_000; + const runSpan = async (name, operation, load) => { + const spanStart = Date.now() / 1_000; + const value = await load(); + const spanEnd = Date.now() / 1_000; + childItems.push( + this.buildChildSpanItem(traceId, rootSpanId, name, operation, spanStart, spanEnd), + ); + + return value; + }; + + const result = await work(runSpan); + const endSeconds = Date.now() / 1_000; + const envelope = this.buildSpanEnvelope(traceId, rootSpanId, startSeconds, endSeconds, childItems); + void this.postEnvelope(envelope); + + return { traceId, result }; + } + + /** + * Pre-send the stalled `boss-spawn` trace at startup so Sentry's trace ingestion pipeline has + * already indexed it by the time the participant reaches Wave 3. + */ + prewarmBossTrace() { + const traceId = randomHex(32); + const rootSpanId = randomHex(16); + const totalSeconds = ALL_LOAD_TASKS.reduce((sum, task) => sum + task.durationMs, 0) / 1_000; + const baseSeconds = Date.now() / 1_000 - totalSeconds; + let cursorSeconds = baseSeconds; + const childItems = []; + + for (const task of ALL_LOAD_TASKS) { + const startSeconds = cursorSeconds; + cursorSeconds += task.durationMs / 1_000; + childItems.push( + this.buildChildSpanItem( + traceId, + rootSpanId, + task.name, + LOAD_SPAN_OP, + startSeconds, + cursorSeconds, + ), + ); + } + + const envelope = this.buildSpanEnvelope( + traceId, + rootSpanId, + baseSeconds, + cursorSeconds, + childItems, + ); + this.prewarmedBossTraceId = traceId; + const outcome = this.postEnvelope(envelope).then((confirmed) => ({ + referenceId: traceId, + delivery: confirmed ? 'confirmed' : 'unconfirmed', + })); + this.outcomes.set('boss', outcome); + } + + /** Report `encounter` once, caching the outcome so repeats send nothing. */ + report(encounter) { + const existing = this.outcomes.get(encounter.bug); + if (existing !== undefined) { + return existing; + } + + const outcome = this.sendEncounter(encounter); + this.outcomes.set(encounter.bug, outcome); + + return outcome; + } + + /** Build and send the Sentry event envelope for `encounter`. */ + async sendEncounter(encounter) { + if (encounter.bug === 'boss') { + return { + referenceId: encounter.stall.traceId ?? 'unavailable', + delivery: 'unconfirmed', + }; + } + + const eventId = randomHex(32); + const envelope = + encounter.bug === 'weapon' + ? this.buildEventEnvelope( + eventId, + 'weapon', + 'WeaponConfigError', + encounter.jam.error.message, + toSentryFrames(WEAPON_STACK_FRAMES), + { + weapon_state: { + attemptedWeapon: encounter.jam.weaponId, + availableWeapons: encounter.jam.availableWeapons, + }, + }, + ) + : this.buildEventEnvelope( + eventId, + 'shield', + 'GameLogicError', + `Shield was active but ${encounter.failure.actualDamage} damage was applied (expected 0). Ship destroyed despite an active shield.`, + [ + { + filename: 'game/src/shield/shield.mjs', + function: 'resolveShieldHit', + lineno: 14, + in_app: true, + }, + ], + { + shield_state: { ...encounter.failure }, + }, + ); + + const confirmed = await this.postEnvelope(envelope); + + return { referenceId: eventId, delivery: confirmed ? 'confirmed' : 'unconfirmed' }; + } + + /** Build a v2 span item for one child resource load inside `boss-spawn`. */ + buildChildSpanItem(traceId, parentSpanId, name, operation, startSeconds, endSeconds) { + return { + name, + span_id: randomHex(16), + trace_id: traceId, + parent_span_id: parentSpanId, + start_timestamp: startSeconds, + end_timestamp: endSeconds, + is_segment: false, + status: 'ok', + attributes: { + 'sentry.origin': spanAttribute('manual'), + 'sentry.op': spanAttribute(operation), + 'sentry.trace_lifecycle': spanAttribute('stream'), + 'sentry.segment.name': spanAttribute('boss-spawn'), + 'sentry.segment.id': spanAttribute(parentSpanId), + 'sentry.sdk.name': spanAttribute('sentry.javascript.browser'), + 'sentry.sdk.version': spanAttribute('11.7.0'), + 'sentry.release': spanAttribute(this.identity.release), + 'sentry.environment': spanAttribute(this.settings.environment), + }, + }; + } + + /** Build the 3-line v2 span envelope for the `boss-spawn` root segment and its child spans. */ + buildSpanEnvelope(traceId, rootSpanId, startSeconds, endSeconds, childItems) { + const rootItem = { + name: 'boss-spawn', + span_id: rootSpanId, + trace_id: traceId, + start_timestamp: startSeconds, + end_timestamp: endSeconds, + is_segment: true, + status: 'ok', + attributes: { + 'sentry.origin': spanAttribute('manual'), + 'sentry.op': spanAttribute('game.boss'), + 'sentry.segment.name.source': spanAttribute('custom'), + 'sentry.sample_rate': spanAttribute(1, 'integer'), + run_id: spanAttribute(this.identity.attemptId), + bug: spanAttribute('boss'), + 'sentry.trace_lifecycle': spanAttribute('stream'), + 'sentry.segment.name': spanAttribute('boss-spawn'), + 'sentry.segment.id': spanAttribute(rootSpanId), + 'sentry.sdk.name': spanAttribute('sentry.javascript.browser'), + 'sentry.sdk.version': spanAttribute('11.7.0'), + 'sentry.release': spanAttribute(this.identity.release), + 'sentry.environment': spanAttribute(this.settings.environment), + }, + }; + const items = [...childItems, rootItem]; + const header = { + sent_at: new Date().toISOString(), + trace: { + environment: this.settings.environment, + release: this.identity.release, + public_key: this.parsedDsn.publicKey, + trace_id: traceId, + ...(this.parsedDsn.orgId === undefined ? {} : { org_id: this.parsedDsn.orgId }), + transaction: 'boss-spawn', + sampled: 'true', + sample_rate: '1', + }, + sdk: { name: 'sentry.javascript.browser', version: '11.7.0' }, + }; + const itemHeader = { + type: 'span', + item_count: items.length, + content_type: 'application/vnd.sentry.items.span.v2+json', + }; + + return `${JSON.stringify(header)}\n${JSON.stringify(itemHeader)}\n${JSON.stringify({ version: 2, items })}\n`; + } + + /** Build the 3-line Sentry error event envelope for `bug`. */ + buildEventEnvelope(eventId, bug, errorType, message, frames, contexts) { + const header = { + event_id: eventId, + sent_at: new Date().toISOString(), + sdk: { name: 'sentry.javascript.browser', version: '11.7.0' }, + trace: { + environment: this.settings.environment, + release: this.identity.release, + public_key: this.parsedDsn.publicKey, + trace_id: this.traceId, + ...(this.parsedDsn.orgId === undefined ? {} : { org_id: this.parsedDsn.orgId }), + }, + }; + const tags = { + run_id: this.identity.attemptId, + bug, + ...(this.identity.stationId === null ? {} : { station_id: this.identity.stationId }), + ...(this.identity.sessionId === null ? {} : { boothstrap_session_id: this.identity.sessionId }), + }; + const payload = { + event_id: eventId, + timestamp: Date.now() / 1_000, + platform: 'javascript', + level: 'error', + environment: this.settings.environment, + release: this.identity.release, + sdk: { name: 'sentry.javascript.browser', version: '11.7.0' }, + tags, + fingerprint: ['glitch-invaders', bug], + contexts: { + trace: { trace_id: this.traceId, span_id: randomHex(16) }, + ...contexts, + }, + breadcrumbs: [...this.breadcrumbs], + exception: { + values: [ + { + type: errorType, + value: message, + stacktrace: { frames }, + mechanism: { type: 'generic', handled: true }, + }, + ], + }, + }; + + return `${JSON.stringify(header)}\n${JSON.stringify({ type: 'event' })}\n${JSON.stringify(payload)}\n`; + } + + /** POST `envelope` to Sentry within `DELIVERY_TIMEOUT_MS`, returning true when accepted. */ + async postEnvelope(envelope) { + let timer; + try { + const request = Promise.resolve( + this.fetchImpl(this.parsedDsn.envelopeUrl, { + method: 'POST', + body: envelope, + }), + ).then((response) => Boolean(response?.ok)); + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), DELIVERY_TIMEOUT_MS); + }); + + return await Promise.race([request, timeout]); + } catch { + return false; + } finally { + clearTimeout(timer); + } + } +} diff --git a/challenges/glitch-invaders/game/src/session/inactivity.mjs b/challenges/glitch-invaders/game/src/session/inactivity.mjs new file mode 100644 index 0000000..a52fd38 --- /dev/null +++ b/challenges/glitch-invaders/game/src/session/inactivity.mjs @@ -0,0 +1,60 @@ +/** + * The "still there?" behavior: after a quiet stretch the game warns, counts down, and resets to the + * intro so the next person never inherits a half-played run. Time is injected so tests need no + * clock. + */ + +/** Watches for quiet. Call {@link InactivityMonitor.noteActivity} on input and {@link InactivityMonitor.tick} regularly. */ +export class InactivityMonitor { + options; + lastActivityAt; + warnedAt = null; + + constructor(options) { + this.options = options; + this.lastActivityAt = options.now(); + } + + /** Whether the warning is showing. */ + get state() { + return this.warnedAt === null ? 'active' : 'warning'; + } + + /** Whole seconds left before reset while warning; `0` when active. */ + secondsLeft() { + if (this.warnedAt === null) { + return 0; + } + const remaining = this.warnedAt + this.options.resetAfterWarningMs - this.options.now(); + + return Math.max(0, Math.ceil(remaining / 1_000)); + } + + /** The participant did something. Dismisses a warning. */ + noteActivity() { + this.lastActivityAt = this.options.now(); + if (this.warnedAt !== null) { + this.warnedAt = null; + this.options.onResume(); + } + } + + /** Check the clock: raise the warning after quiet, and reset once the countdown ends. */ + tick() { + const now = this.options.now(); + if (this.warnedAt === null) { + if (now - this.lastActivityAt >= this.options.warningAfterMs) { + this.warnedAt = now; + this.options.onWarning(); + } + + return; + } + + if (now - this.warnedAt >= this.options.resetAfterWarningMs) { + this.warnedAt = null; + this.lastActivityAt = now; + this.options.onReset(); + } + } +} 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..eafdd54 --- /dev/null +++ b/challenges/glitch-invaders/game/src/session/monitor-state.mjs @@ -0,0 +1,31 @@ +/** + * 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 visually hidden text; the full snapshot + * is also on `window` for tests and for a guide that can evaluate scripts. + */ +import { monitorStateLine } from '../../../shared/progress.mjs'; + +/** The id of the element holding the state line. */ +export const STATE_ELEMENT_ID = 'boothstrap-state'; + +/** The `window` property holding the latest progress snapshot. */ +export const STATE_WINDOW_KEY = '__GLITCH_STATE__'; + +/** Write `progress` to the marker element and `window`. */ +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; + } + Object.defineProperty(document.defaultView ?? globalThis, STATE_WINDOW_KEY, { + value: structuredClone(progress), + configurable: true, + enumerable: false, + writable: true, + }); +} 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..0cae27f --- /dev/null +++ b/challenges/glitch-invaders/game/src/session/progress-store.mjs @@ -0,0 +1,81 @@ +/** 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 in memory only. Used under `vite dev` and in tests. */ +export class MemoryProgressStore { + saved = null; + + async load(attemptId) { + return this.saved?.attemptId === attemptId ? structuredClone(this.saved) : null; + } + + async save(progress) { + this.saved = structuredClone(progress); + } + + async clear() { + this.saved = null; + } +} + +/** 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..cbc0732 --- /dev/null +++ b/challenges/glitch-invaders/game/src/styles.css @@ -0,0 +1,1149 @@ +/* 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; +} + +.fatal { + padding: 32px; + font-size: 20px; + color: var(--danger); +} + +.app { + position: relative; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + height: 100dvh; +} + +.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(--border); + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.badge[data-mode="demo"] { + border-color: var(--mlh-yellow); + color: var(--mlh-yellow); +} + +.badge[data-mode="live"] { + border-color: var(--success); + color: var(--success); +} + +.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); +} + +.source-badge[data-source="live"] { + border-color: var(--success); + color: var(--success); +} + +.source-badge[data-source="live-unconfirmed"] { + border-color: var(--warning); + color: var(--warning); +} + +.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-compact { + padding: 8px 12px; +} + +.sentry-callout-main { + display: flex; + flex-direction: column; + gap: 3px; +} + +.sentry-callout-copy { + color: var(--muted); + font: 13px / 1.35 var(--sans); +} + +.button-sentry { + flex-shrink: 0; + border-color: var(--mlh-yellow); + border-radius: 6px; + background: var(--mlh-blue); + color: #ffffff; + font: 700 13px var(--sans); + text-decoration: none; + white-space: nowrap; + text-align: center; +} + +.button-sentry:hover { + border-color: #ffffff; +} + +.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; +} + +.sentry-briefing { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); +} + +.sentry-briefing-title { + margin: 0; + color: var(--accent); + font: 700 13px var(--mono); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.sentry-briefing-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.sentry-briefing-item { + display: flex; + gap: 10px; + align-items: flex-start; + color: var(--fg); + font: 14px / 1.45 var(--sans); +} + +.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..da91ddc --- /dev/null +++ b/challenges/glitch-invaders/game/src/ui/hud.mjs @@ -0,0 +1,87 @@ +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'); + badge = requireElement('mode-badge'); + objectiveText = requireElement('objective-text'); + objectiveDetail = requireElement('objective-detail'); + incidents = requireElement('incidents'); + chipSignature = ''; + + /** Show the mode badge once; it never changes during a run. */ + showMode(mode, degraded) { + this.badge.dataset.mode = mode; + if (mode === 'demo') { + setText(this.badge, 'DEMO · simulated evidence'); + this.badge.title = 'No Sentry credentials: the diagnostics are realistic simulations.'; + } else { + setText(this.badge, degraded ? 'LIVE · Sentry unreachable' : 'LIVE · Sentry'); + this.badge.title = 'Real errors, breadcrumbs, and traces are sent to Sentry.'; + } + } + + /** 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..68abb1d --- /dev/null +++ b/challenges/glitch-invaders/game/src/ui/incident-console.mjs @@ -0,0 +1,658 @@ +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.', + }, + live: { label: 'LIVE SENTRY', note: 'Captured by the game and delivered to Sentry.' }, + 'live-unconfirmed': { + label: 'LIVE · LOCAL COPY', + note: 'Sentry could not confirm delivery. This is the game’s own capture of what it sent.', + }, +}; + +/** 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: in Live mode, directs the participant to Sentry; in Demo mode, shows simulated capture. */ + evidencePane() { + const evidence = this.evidence; + if (evidence === null) { + return h( + 'p', + { class: 'status', role: 'status' }, + h('span', { class: 'spinner', 'aria-hidden': 'true' }, '◌'), + ' Transmitting telemetry to Sentry…', + ); + } + + if (evidence.link !== null) { + return h( + 'div', + { class: 'evidence' }, + this.sentryHero(evidence), + renderSentryBriefing(evidence), + h( + 'div', + { class: 'pane-actions' }, + h( + 'span', + { class: 'muted' }, + `${evidence.bug === 'boss' ? 'Trace' : 'Event'} ${evidence.referenceId} · transmitted to Sentry`, + ), + h('span', { class: 'spacer' }), + onClick( + h( + 'button', + { type: 'button', class: 'button', 'data-key': 'to-diagnose' }, + 'Continue to Diagnose ▶', + ), + () => { + this.pane = 'diagnose'; + this.render(); + }, + ), + ), + ); + } + + 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(); + }, + ), + ), + ); + } + + /** Hero banner with the exception/trace headline and the primary "Investigate in Sentry" CTA. */ + sentryHero(evidence) { + const isTrace = evidence.bug === 'boss'; + const subtitle = + evidence.link !== null + ? isTrace + ? 'Performance telemetry has been sent to Sentry. Open the trace in Sentry in a new tab, then return here to submit your findings.' + : 'Telemetry has been sent to Sentry. Open the issue in Sentry in a new tab to investigate, then return here to submit your findings.' + : 'Demo mode (Sentry not connected): inspect the simulated telemetry snapshot to identify the root cause.'; + + const sentryLink = + evidence.link === null + ? null + : h( + 'a', + { + class: 'button button-sentry', + href: evidence.link.url, + target: '_blank', + rel: 'noopener', + 'data-key': 'open-sentry', + }, + `🔍 ${evidence.link.label} ↗`, + ); + + if (sentryLink !== null) { + sentryLink.addEventListener('click', () => { + this.pane = 'diagnose'; + this.render(); + }); + } + + 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' }, subtitle), + ), + sentryLink, + ); + } + + /** 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' }, + this.evidence !== null && this.evidence.link !== null + ? h( + 'div', + { class: 'sentry-callout sentry-callout-compact' }, + h( + 'span', + { class: 'sentry-callout-copy' }, + 'Need another look at the event in Sentry? Refresh the Sentry tab if spans are still indexing.', + ), + h( + 'a', + { + class: 'button button-sentry', + href: this.evidence.link.url, + target: '_blank', + rel: 'noopener', + 'data-key': 'open-sentry-diagnose', + }, + `🔍 ${this.evidence.link.label} ↗`, + ), + ) + : null, + 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(); + } + } +} + +/** What to inspect inside the Sentry dashboard for each failure in Live mode. */ +const SENTRY_BRIEFINGS = { + weapon: [ + 'Click "Investigate in Sentry" above to open the captured issue in a new tab.', + 'Inspect the Stack Trace to see which function and file threw the error.', + 'Check the weapon_state context section in Sentry to compare the requested weapon ID against the registered weapon configurations.', + ], + shield: [ + 'Click "Investigate in Sentry" above to open the captured issue in a new tab.', + 'Read the Breadcrumbs timeline in Sentry leading up to hull destruction.', + 'Check the shield_state context section in Sentry to compare incoming projectile damage against the damage applied to your hull.', + ], + boss: [ + 'Click "Investigate trace in Sentry" above to open the performance trace in a new tab.', + 'Inspect the Waterfall view under the boss-spawn root span.', + 'Notice that all four game.resource.load child spans run one after another (sequentially) totaling 4.50 s, and identify which operation takes the longest.', + ], +}; + +/** Render the Sentry investigation checklist shown in Live mode. */ +function renderSentryBriefing(evidence) { + return h( + 'div', + { class: 'sentry-briefing', 'aria-label': 'How to investigate in Sentry' }, + h('h3', { class: 'sentry-briefing-title' }, 'What to look for in Sentry'), + h( + 'ol', + { class: 'sentry-briefing-list' }, + ...SENTRY_BRIEFINGS[evidence.bug].map((item, index) => + h( + 'li', + { class: 'sentry-briefing-item' }, + h('span', { class: 'option-letter', 'aria-hidden': 'true' }, String(index + 1)), + h('span', {}, item), + ), + ), + ), + ); +} + +/** 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, + ); +} + +/** 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' }, + h('span', { + class: 'span-bar', + style: `left:${percent(span.startMs)};width:${percent(span.durationMs)}`, + }), + h('span', { + class: 'span-budget', + style: `left:${percent(BOSS_SPAWN_BUDGET_MS)}`, + 'aria-hidden': 'true', + }), + ), + 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..b5e5e18 --- /dev/null +++ b/challenges/glitch-invaders/game/src/ui/input.mjs @@ -0,0 +1,162 @@ +/** + * 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(); + const pointer = () => this.options.onActivity(); + target.addEventListener('keydown', keyDown); + target.addEventListener('keyup', keyUp); + target.addEventListener('pointerdown', pointer); + 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); + target.removeEventListener('pointerdown', pointer); + 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.options.onActivity(); + 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.options.onActivity(); + 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) { + this.options.onActivity(); + 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..06cbbcc --- /dev/null +++ b/challenges/glitch-invaders/game/src/ui/overlays.mjs @@ -0,0 +1,217 @@ +import { BUG_DEFINITIONS, BUG_ORDER } from '../bugs/bug-definitions.mjs'; +import { focusableElements, h, onClick, setText } 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) { + const modeNote = + options.mode === 'demo' + ? 'Demo mode: the diagnostics are realistic simulations.' + : 'Live mode: real errors and traces go to Sentry.'; + + 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' }, modeNote), + 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 inactivity card, with a countdown the caller updates. */ +export function inactivityCard(onStay) { + const countdown = h('strong', { class: 'countdown' }, '30'); + const element = card( + 'Still there?', + 'idle-title', + h( + 'p', + { class: 'lede' }, + 'This run resets in ', + countdown, + ' s so the next person starts fresh.', + ), + onClick( + h( + 'button', + { type: 'button', class: 'button button-primary button-large', 'data-autofocus': true }, + 'I’m here ▶', + ), + onStay, + ), + ); + + return { + element, + setSeconds: (seconds) => setText(countdown, String(seconds)), + }; +} + +/** 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..351168b --- /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 one sends telemetry to Sentry: a stack trace, breadcrumbs, or a +performance trace. Open it in Sentry, 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..7bfd330 --- /dev/null +++ b/challenges/glitch-invaders/monitor/SKILL.md @@ -0,0 +1,25 @@ +--- +name: monitor +description: "Headless monitor for Glitch Invaders: On-Call. Reports steps from the game's state marker." +--- + +# Glitch Invaders monitor + +Take one snapshot of the game page. Find the paragraph 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..ba13c54 --- /dev/null +++ b/challenges/glitch-invaders/server/cleanup.mjs @@ -0,0 +1,90 @@ +/** + * 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 { readFile, rm } from 'node:fs/promises'; +import { resolveStatePaths } from './paths.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)); +} + +/** Stop the server recorded in the pid file, if it is still running. */ +export async function stopServer(paths) { + let pid; + try { + pid = Number((await readFile(paths.pidFile, 'utf8')).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 rm(paths.stateDir, { recursive: true, force: true }); + await rm(paths.progressFile, { force: true }); +} + +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/live-config.mjs b/challenges/glitch-invaders/server/live-config.mjs new file mode 100644 index 0000000..82acc2d --- /dev/null +++ b/challenges/glitch-invaders/server/live-config.mjs @@ -0,0 +1,130 @@ +/** + * Live Sentry settings. Boothstrap hooks only receive the vault's variables, so there is no + * station-level channel for a Sentry DSN yet; staff supply the settings either as environment + * variables or in a git-ignored `live.config.json` next to `challenge.json`. Environment wins. + */ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { LIVE_SETTING_NAMES } from '../shared/runtime-config.mjs'; + +/** The file name staff provision. */ +export const LIVE_CONFIG_FILE_NAME = 'live.config.json'; + +/** Optional `.env` file in the package root, supporting both `GLITCH_*` and `VITE_*` keys. */ +export const DOTENV_FILE_NAME = '.env'; + +/** Map `VITE_*` keys from the original Glitch Invaders `.env` format to `GLITCH_*` keys. */ +const ENV_ALIASES = [ + ['GLITCH_SENTRY_DSN', 'VITE_SENTRY_DSN'], + ['GLITCH_SENTRY_ORG', 'VITE_SENTRY_ORG'], + ['GLITCH_SENTRY_PROJECT', 'VITE_SENTRY_PROJECT'], + ['GLITCH_SENTRY_ENVIRONMENT', 'VITE_SENTRY_ENVIRONMENT'], +]; + +/** Parse a simple `.env` file for `GLITCH_*` or `VITE_*` Sentry settings. */ +async function readDotenvFile(file) { + let raw; + try { + raw = await readFile(file, 'utf8'); + } catch { + return {}; + } + + const entries = {}; + for (const rawLine of raw.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line === '' || line.startsWith('#')) { + continue; + } + const eqIndex = line.indexOf('='); + if (eqIndex <= 0) { + continue; + } + const key = line.slice(0, eqIndex).trim(); + let value = line.slice(eqIndex + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (value !== '') { + entries[key] = value; + } + } + + const values = {}; + for (const [target, alias] of ENV_ALIASES) { + if (entries[alias] !== undefined) { + values[target] = entries[alias]; + } + } + for (const name of LIVE_SETTING_NAMES) { + if (entries[name] !== undefined) { + values[name] = entries[name]; + } + } + + return values; +} + +/** Read the staff-provisioned file, returning nothing for a missing one and a note for a bad one. */ +async function readLiveFile(file) { + let raw; + try { + raw = await readFile(file, 'utf8'); + } catch { + return { values: {}, note: null }; + } + + try { + const parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { values: {}, note: `${path.basename(file)} must be a JSON object` }; + } + + const record = parsed; + const values = {}; + for (const [target, alias] of ENV_ALIASES) { + const value = record[alias]; + if (typeof value === 'string') { + values[target] = value; + } + } + for (const name of LIVE_SETTING_NAMES) { + const value = record[name]; + if (typeof value === 'string') { + values[name] = value; + } + } + + return { values, note: null }; + } catch { + return { values: {}, note: `${path.basename(file)} is not valid JSON` }; + } +} + +/** Combine `.env`, `live.config.json`, and the environment into one settings source. */ +export async function readLiveSource(env, packageDir) { + const fromDotenv = + env.GLITCH_LIVE_CONFIG === undefined + ? await readDotenvFile(path.join(packageDir, DOTENV_FILE_NAME)) + : {}; + const file = env.GLITCH_LIVE_CONFIG ?? path.join(packageDir, LIVE_CONFIG_FILE_NAME); + const fromFile = await readLiveFile(file); + const source = { ...fromDotenv, ...fromFile.values }; + for (const [target, alias] of ENV_ALIASES) { + const value = env[alias]; + if (value !== undefined && value !== '') { + source[target] = value; + } + } + for (const name of LIVE_SETTING_NAMES) { + const value = env[name]; + if (value !== undefined && value !== '') { + source[name] = value; + } + } + + return { source, note: fromFile.note }; +} diff --git a/challenges/glitch-invaders/server/page-server.mjs b/challenges/glitch-invaders/server/page-server.mjs new file mode 100644 index 0000000..495d2c4 --- /dev/null +++ b/challenges/glitch-invaders/server/page-server.mjs @@ -0,0 +1,208 @@ +/** + * The page server: serves the built 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 { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import { MAX_PROGRESS_BYTES, parseProgress } from '../shared/progress.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. */ +const BASE_HEADERS = { + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', +}; + +/** 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, refusing anything over `limit` bytes. */ +async function readBody(request, limit) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + const buffer = chunk; + size += buffer.length; + if (size > limit) { + return null; + } + chunks.push(buffer); + } + + return Buffer.concat(chunks).toString('utf8'); +} + +/** Write `contents` to `file` atomically, so a reader never sees half a file. */ +async function writeAtomic(file, contents) { + await mkdir(path.dirname(file), { recursive: true }); + const temporary = `${file}.${process.pid}.tmp`; + await writeFile(temporary, contents, { mode: 0o600 }); + await rename(temporary, file); +} + +/** Handle `/api/progress`. */ +async function handleProgress(request, response, options) { + if (request.method === 'GET') { + try { + const contents = await readFile(options.progressFile, 'utf8'); + response.writeHead(200, { ...BASE_HEADERS, 'content-type': CONTENT_TYPES['.json'] }); + response.end(contents); + } catch { + sendStatus(response, 204); + } + + return; + } + + if (!originIsOwn(request)) { + sendStatus(response, 403, 'cross-origin writes are not allowed'); + + return; + } + + if (request.method === 'DELETE') { + await rm(options.progressFile, { force: true }); + sendStatus(response, 204); + + return; + } + + if (request.method !== 'PUT') { + sendStatus(response, 405); + + return; + } + + if (!(request.headers['content-type'] ?? '').startsWith('application/json')) { + sendStatus(response, 415, 'progress must be JSON'); + + return; + } + + const body = await readBody(request, MAX_PROGRESS_BYTES); + if (body === null) { + sendStatus(response, 413, 'progress is too large'); + + return; + } + + try { + const progress = parseProgress(JSON.parse(body)); + if (progress.attemptId !== options.runtimeConfig.attemptId) { + sendStatus(response, 409, 'progress belongs to another attempt'); + + return; + } + await writeAtomic(options.progressFile, `${JSON.stringify(progress, null, 2)}\n`); + sendStatus(response, 204); + } catch (error) { + sendStatus(response, 400, error instanceof Error ? error.message : 'invalid progress'); + } +} + +/** Serve a file from the game directory (or `/shared/`), with `/` meaning `index.html`. */ +async function handleStatic(pathname, response, distDir) { + const decoded = decodeURIComponent(pathname); + const isShared = decoded.startsWith('/shared/'); + const baseDir = isShared ? path.resolve(distDir, '..', 'shared') : path.resolve(distDir); + 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 } = new URL(request.url ?? '/', 'http://localhost'); + 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.distDir); + } 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..8d04c91 --- /dev/null +++ b/challenges/glitch-invaders/server/serve.mjs @@ -0,0 +1,56 @@ +/** + * 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?.()); +} + +/** Read a required environment variable. */ +function requiredEnv(name) { + const value = process.env[name]; + if (value === undefined || value === '') { + throw new Error(`${name} is not set`); + } + + return value; +} + +/** Start serving. */ +function main() { + const port = Number(requiredEnv('GLITCH_PORT')); + const runtimeConfig = parseRuntimeConfig( + JSON.parse(readFileSync(requiredEnv('GLITCH_RUNTIME_CONFIG_FILE'), 'utf8')), + ); + const server = createPageServer({ + distDir: path.resolve(requiredEnv('GLITCH_DIST_DIR')), + progressFile: requiredEnv('GLITCH_PROGRESS_FILE'), + 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..7995eac --- /dev/null +++ b/challenges/glitch-invaders/server/setup.mjs @@ -0,0 +1,147 @@ +/** + * Setup: prepare a clean attempt and start the page server. Deterministic and fast: it resolves + * live or demo mode, writes a fresh runtime config, serves the prebuilt 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 { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + DEFAULT_INACTIVITY_RESET_MS, + DEFAULT_INACTIVITY_WARNING_MS, + decideMode, +} from '../shared/runtime-config.mjs'; +import { stopServer } from './cleanup.mjs'; +import { readLiveSource } from './live-config.mjs'; +import { resolveStatePaths } from './paths.mjs'; + +/** How long setup waits for the page server to listen. */ +const READY_TIMEOUT_MS = 10_000; + +/** The package directory: where `challenge.json`, `dist/`, 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); +} + +/** A duration override from the environment, or the default when unset or out of range. */ +function resolveDuration(value, fallback, max) { + const parsed = Number(value); + + return value !== undefined && Number.isFinite(parsed) && parsed >= 1_000 && parsed <= max + ? parsed + : fallback; +} + +/** Start the page server in the background and wait for it to listen. */ +function startServer(paths, port) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(PACKAGE_DIR, 'server', 'serve.mjs')], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + env: { + ...process.env, + GLITCH_PORT: String(port), + GLITCH_DIST_DIR: path.join(PACKAGE_DIR, 'game'), + GLITCH_PROGRESS_FILE: paths.progressFile, + GLITCH_RUNTIME_CONFIG_FILE: paths.runtimeConfigFile, + }, + }); + 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 mkdir(paths.stateDir, { recursive: true }); + await rm(paths.progressFile, { force: true }); + + const { source, note } = await readLiveSource(env, PACKAGE_DIR); + const decision = decideMode(source); + const config = { + attemptId: randomUUID(), + mode: decision.sentry === null ? 'demo' : 'live', + stationId: env.BOOTHSTRAP_STATION_ID?.slice(0, 128) ?? null, + sessionId: env.BOOTHSTRAP_SESSION_ID?.slice(0, 128) ?? null, + inactivityWarningMs: resolveDuration( + env.GLITCH_INACTIVITY_WARNING_MS, + DEFAULT_INACTIVITY_WARNING_MS, + 3_600_000, + ), + inactivityResetMs: resolveDuration( + env.GLITCH_INACTIVITY_RESET_MS, + DEFAULT_INACTIVITY_RESET_MS, + 600_000, + ), + sentry: decision.sentry, + }; + await writeFile(paths.runtimeConfigFile, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + + const port = resolvePort(env); + const serverPid = await startServer(paths, port); + await writeFile(paths.pidFile, `${serverPid}\n`, { mode: 0o600 }); + + return { + url: `http://127.0.0.1:${port}/`, + mode: config.mode, + attemptId: config.attemptId, + demoReason: decision.demoReason ?? note, + serverPid, + }; +} + +if (import.meta.main) { + runSetup(process.env, process.cwd()).then( + (result) => { + process.stdout.write(`Glitch Invaders is ready at ${result.url} (${result.mode} mode)\n`); + if (result.mode === 'demo' && result.demoReason !== null) { + process.stdout.write(`note: live mode is off: ${result.demoReason}\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/setup b/challenges/glitch-invaders/setup new file mode 100755 index 0000000..aaee2a1 --- /dev/null +++ b/challenges/glitch-invaders/setup @@ -0,0 +1,5 @@ +#!/bin/sh +# Setup hook: resolve demo or live mode, 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..ad760dc --- /dev/null +++ b/challenges/glitch-invaders/shared/progress.mjs @@ -0,0 +1,189 @@ +/** + * 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; + +/** A fresh, unplayed run. */ +export function createProgress(attemptId, mode) { + return { + version: 1, + attemptId, + 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 (!isOneOf(['demo', 'live'], record.mode)) { + throw new InvalidProgressError('mode must be demo or live'); + } + 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: record.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..509b4d5 --- /dev/null +++ b/challenges/glitch-invaders/shared/runtime-config.mjs @@ -0,0 +1,180 @@ +/** The environment names read for live settings, shared by the env and file readers. */ +export const LIVE_SETTING_NAMES = [ + 'GLITCH_SENTRY_DSN', + 'GLITCH_SENTRY_ORG', + 'GLITCH_SENTRY_PROJECT', + 'GLITCH_SENTRY_ENVIRONMENT', + 'GLITCH_MODE', +]; + +/** Default idle time before the warning, under Boothstrap's own 3 minute idle timeout. */ +export const DEFAULT_INACTIVITY_WARNING_MS = 120_000; + +/** How long the warning counts down before the run resets. */ +export const DEFAULT_INACTIVITY_RESET_MS = 30_000; + +/** 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'; + } +} + +/** Whether `dsn` looks like `https://@/`. */ +export function isValidDsn(dsn) { + try { + const url = new URL(dsn); + return ( + url.protocol === 'https:' && + url.username.length > 0 && + url.hostname.length > 0 && + /^\/\d+$/.test(url.pathname) + ); + } catch { + return false; + } +} + +/** Whether `value` is a Sentry slug, as used in organization and project URLs. */ +function isSlug(value) { + return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(value); +} + +/** + * Choose live or demo from the provided settings. Live needs a valid DSN plus the organization and + * project slugs that build evidence links; anything less is demo, with a reason naming what is + * missing so staff can fix it without the game ever failing to start. + */ +export function decideMode(source) { + if (source.GLITCH_MODE === 'demo') { + return { sentry: null, demoReason: 'GLITCH_MODE is demo' }; + } + + const dsn = source.GLITCH_SENTRY_DSN?.trim() ?? ''; + const org = source.GLITCH_SENTRY_ORG?.trim() ?? ''; + const project = source.GLITCH_SENTRY_PROJECT?.trim() ?? ''; + if (dsn === '' && org === '' && project === '') { + return { sentry: null, demoReason: null }; + } + + const problems = []; + if (!isValidDsn(dsn)) { + problems.push('GLITCH_SENTRY_DSN is missing or not a Sentry DSN'); + } + if (!isSlug(org)) { + problems.push('GLITCH_SENTRY_ORG is missing or not a slug'); + } + if (!isSlug(project)) { + problems.push('GLITCH_SENTRY_PROJECT is missing or not a slug'); + } + if (problems.length > 0) { + return { sentry: null, demoReason: `live settings incomplete: ${problems.join('; ')}` }; + } + + const environment = source.GLITCH_SENTRY_ENVIRONMENT?.trim(); + + return { + sentry: { dsn, org, project, environment: environment || 'booth' }, + demoReason: null, + }; +} + +/** Read `key` from `record` as a finite number within `[min, max]`. */ +function readDuration(record, key, min, max) { + const value = record[key]; + if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) { + throw new InvalidRuntimeConfigError(`${key} must be a number from ${min} to ${max}`); + } + + return value; +} + +/** Read `key` from `record` as a string or `null`. */ +function readNullableText(record, key) { + const value = record[key]; + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'string' || value.length > 128) { + throw new InvalidRuntimeConfigError(`${key} must be a short string or null`); + } + + return value; +} + +/** Validate the Sentry part of a served config. */ +function parseSentrySettings(value) { + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'object' || Array.isArray(value)) { + throw new InvalidRuntimeConfigError('sentry must be an object or null'); + } + + const record = value; + const { dsn, org, project, environment } = record; + if (typeof dsn !== 'string' || !isValidDsn(dsn)) { + throw new InvalidRuntimeConfigError('sentry.dsn must be a Sentry DSN'); + } + if (typeof org !== 'string' || !isSlug(org) || typeof project !== 'string' || !isSlug(project)) { + throw new InvalidRuntimeConfigError('sentry.org and sentry.project must be slugs'); + } + if (typeof environment !== 'string' || environment === '') { + throw new InvalidRuntimeConfigError('sentry.environment must be a non-empty string'); + } + + return { dsn, org, project, environment }; +} + +/** + * Validate untrusted JSON as a {@link RuntimeConfig}, requiring `mode` to agree with `sentry`. + * + * @throws {InvalidRuntimeConfigError} when a field is missing, malformed, or contradicts `mode`. + */ +export function parseRuntimeConfig(value) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new InvalidRuntimeConfigError('config must be an object'); + } + + const record = value; + const { attemptId, mode } = record; + 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', + ); + } + if (mode !== 'demo' && mode !== 'live') { + throw new InvalidRuntimeConfigError('mode must be demo or live'); + } + + const sentry = parseSentrySettings(record.sentry); + if ((mode === 'live') !== (sentry !== null)) { + throw new InvalidRuntimeConfigError( + 'live mode needs sentry settings, and demo mode must have none', + ); + } + + return { + attemptId, + mode, + stationId: readNullableText(record, 'stationId'), + sessionId: readNullableText(record, 'sessionId'), + inactivityWarningMs: readDuration(record, 'inactivityWarningMs', 1_000, 3_600_000), + inactivityResetMs: readDuration(record, 'inactivityResetMs', 1_000, 600_000), + sentry, + }; +} + +/** The config the game falls back to when none is served, such as under `vite dev`. */ +export function createDemoRuntimeConfig(attemptId) { + return { + attemptId, + mode: 'demo', + stationId: null, + sessionId: null, + inactivityWarningMs: DEFAULT_INACTIVITY_WARNING_MS, + inactivityResetMs: DEFAULT_INACTIVITY_RESET_MS, + sentry: null, + }; +} 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..009b25d --- /dev/null +++ b/challenges/glitch-invaders/skills/start-challenge/SKILL.md @@ -0,0 +1,94 @@ +--- +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. +- `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` (`demo` means +the evidence is simulated, `live` means it went to Sentry), and 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`. 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. In `live` mode, tell them to click + `Investigate in Sentry` (or `Investigate trace in Sentry`) in the Incident + Console to open the telemetry in Sentry. For the weapon bug, tell them to + check the stack trace and `weapon_state` context in Sentry. For the shield + bug, tell them to read the breadcrumbs and `shield_state` context in Sentry. + For the boss bug, tell them to inspect the `boss.spawn` trace waterfall in + Sentry (and refresh after 5 seconds if spans are still indexing). +- `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. If the +evidence says `SIMULATED`, that is expected in demo mode. 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`. 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": ""}`, 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. +- The game asks "Still there?": they were idle. Any key continues. +- 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..6a92ff4 --- /dev/null +++ b/test/glitch-invaders.test.mjs @@ -0,0 +1,629 @@ +// 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 +// zero-dependency SentryReporter builds valid event and span v2 envelopes; the +// page server serves native .mjs modules, enforces origin/host/size checks on +// progress writes, and starts and stops cleanly through setup and cleanup. + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { 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 { BreadcrumbRecorder } from '../challenges/glitch-invaders/game/src/diagnostics/breadcrumbs.mjs'; +import { WEAPON_STACK_FRAMES } from '../challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs'; +import { LiveEvidenceProvider } from '../challenges/glitch-invaders/game/src/diagnostics/live-evidence.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 { parseDsn, SentryReporter } from '../challenges/glitch-invaders/game/src/sentry/sentry-reporter.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; + +/** 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', 'demo'); + 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)); + }); + }); +} + +/** The environment of a station that provides no live Sentry settings. */ +function demoEnvironment() { + return { + PATH: process.env.PATH, + GLITCH_LIVE_CONFIG: path.join(os.tmpdir(), 'glitch-test-no-live-config.json'), + }; +} + +/** 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 = { + ...demoEnvironment(), + 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; + } +} + +/** The status of `/healthz` when the request claims to be for `host`. */ +function statusWithHost(url, host) { + return new Promise((resolve, reject) => { + const probe = request(`${url}healthz`, { headers: { host } }, (response) => { + response.resume(); + resolve(response.statusCode ?? 0); + }); + probe.once('error', reject); + probe.end(); + }); +} + +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', 'demo')); + 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 and LiveEvidenceProvider build structured evidence and links', 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.link, null); + assert.ok(simulated.sections.some((section) => section.kind === 'stacktrace')); + + const live = new LiveEvidenceProvider({ + reporter: { + add: () => undefined, + traceBossSpawn: async (work) => ({ + result: await work((_name, _op, load) => load()), + traceId: null, + }), + report: async () => ({ referenceId: 'evt1', delivery: 'confirmed' }), + }, + settings: { + dsn: 'https://key@o1.ingest.sentry.io/1', + org: 'acme', + project: 'booth', + environment: 'booth', + }, + attemptId: 'attempt-1', + recorder: new BreadcrumbRecorder(() => 0), + clock: () => 0, + }); + const liveEvidence = await live.evidenceFor(weaponEncounter); + assert.equal(liveEvidence.source, 'live'); + assert.ok(liveEvidence.link?.url.includes('https://acme.sentry.io/issues/')); +}); + +test('SentryReporter prewarms the boss-spawn v2 span envelope and sends one event envelope per bug', async () => { + const requests = []; + const fakeFetch = async (url, options) => { + requests.push({ url, body: options.body }); + + return { ok: true }; + }; + const settings = { + dsn: 'https://publickey@o42.ingest.us.sentry.io/99', + org: 'acme', + project: 'booth', + environment: 'booth', + }; + const parsed = parseDsn(settings.dsn); + assert.equal(parsed.publicKey, 'publickey'); + assert.equal(parsed.projectId, '99'); + assert.equal(parsed.orgId, '42'); + + const reporter = new SentryReporter( + settings, + { + attemptId: 'attempt-1', + stationId: 'station-1', + sessionId: null, + release: 'glitch-invaders@1.0.0', + }, + fakeFetch, + ); + + assert.equal(requests.length, 1); + const [, spanHeaderLine, spanPayloadLine] = requests[0].body.trim().split('\n'); + assert.equal(JSON.parse(spanHeaderLine).type, 'span'); + assert.equal(JSON.parse(spanPayloadLine).items.length, 5); + + const traced = await reporter.traceBossSpawn(async () => 'done'); + assert.match(traced.traceId, /^[0-9a-f]{32}$/); + assert.equal(traced.result, 'done'); + + reporter.add({ category: 'game.shield', message: 'Shield enabled', data: { shieldActive: 'true' } }); + const jam = { + bug: 'weapon', + jam: { + weaponId: 'fanout', + availableWeapons: ['single'], + error: new Error('Configuration missing for weapon: fanout'), + }, + }; + const first = await reporter.report(jam); + const second = await reporter.report(jam); + + assert.equal(requests.length, 2); + assert.deepEqual(second, first); + assert.equal(first.delivery, 'confirmed'); + const [, eventHeaderLine, eventPayloadLine] = requests[1].body.trim().split('\n'); + assert.equal(JSON.parse(eventHeaderLine).type, 'event'); + assert.equal(JSON.parse(eventPayloadLine).exception.values[0].type, 'WeaponConfigError'); +}); + +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 = {}) => + fetch(`${base}/api/progress`, { + method: 'PUT', + headers: { 'content-type': 'application/json', ...headers }, + body, + }); + + 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.equal(config.mode, 'demo'); + assert.equal(config.sentry, null); + assert.equal(config.attemptId, stack.attemptId); + + const progress = createProgress(stack.attemptId, 'demo'); + 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', 'demo'); + 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 statusWithHost(stack.url, 'evil.example'), 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 = { + ...demoEnvironment(), + 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); +}); From 8c803aaa6f3c8c62f4ce3cd0662b8d9af6e72acb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 01:19:05 +0000 Subject: [PATCH 2/8] docs: describe demo-only Sentry evidence and the standalone skill branch --- challenges/glitch-invaders/README.md | 53 ++++++++----------- challenges/glitch-invaders/challenge.json | 8 +-- challenges/glitch-invaders/intro.md | 6 +-- challenges/glitch-invaders/monitor/SKILL.md | 10 ++-- .../skills/start-challenge/SKILL.md | 34 ++++++------ 5 files changed, 52 insertions(+), 59 deletions(-) diff --git a/challenges/glitch-invaders/README.md b/challenges/glitch-invaders/README.md index ab59c82..a7ba279 100644 --- a/challenges/glitch-invaders/README.md +++ b/challenges/glitch-invaders/README.md @@ -1,11 +1,16 @@ # Glitch Invaders: On-Call Fly a patch drone in Chrome, trigger three intentional bugs while defending the -cluster, and debug each one with Sentry telemetry and Pi. Each failure opens an +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 | @@ -15,43 +20,26 @@ in play. | `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` and live-config resolver | -| `shared/` | Progress and runtime configuration validators | -| `game/` | Browser arcade game, Incident Console, and Sentry envelope reporter | +| `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. +There is no `install` hook: the package has no dependencies beyond Node and +browser ES modules. ## How steps advance -The browser game writes `#boothstrap-state` with a single-line marker such as: - -```text -glitch-state v1 launched=yes weapon=repaired shield=unseen boss=unseen completed=no mode=demo -``` - -The headless monitor reads that marker from the page's accessibility tree and -reports `launch-game`, `fix-weapon`, `fix-shield`, and `fix-boss` as each step -reaches `yes` or `repaired`. 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 telemetry clue helped -most and reports `debrief`. +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. -## Modes - -- **Demo** (default): needs no Sentry credentials and renders deterministic - simulated evidence in the Incident Console. -- **Live**: sends real error events (`WeaponConfigError`, `GameLogicError`) and - a `boss-spawn` performance trace to Sentry over HTTP envelopes, and links - directly to the issue or trace in Sentry. Set `GLITCH_SENTRY_DSN`, - `GLITCH_SENTRY_ORG`, and `GLITCH_SENTRY_PROJECT` (plus optional - `GLITCH_SENTRY_ENVIRONMENT`) in the environment, `.env`, or - `live.config.json`. Set `GLITCH_MODE=demo` to force demo mode. - ## Run without boothstrap Needs Node 26 and a `GEMINI_API_KEY`. Follow the standalone steps in @@ -79,5 +67,6 @@ When done: ``` `cleanup` stops the server and removes `.glitch/` and `glitch-progress.json`. -Without boothstrap there is no `report_step` or `emit_event`, so the skill -acknowledges finished steps in plain words. +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 index 96c01f7..9aa2b42 100644 --- a/challenges/glitch-invaders/challenge.json +++ b/challenges/glitch-invaders/challenge.json @@ -25,25 +25,25 @@ "id": "launch-game", "title": "Launch your patch drone", "hint": "Press Start in the game, then shoot the first glitches.", - "done_when": "The page's #boothstrap-state text includes \"launched=yes\"." + "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's #boothstrap-state text includes \"weapon=repaired\"." + "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's #boothstrap-state text includes \"shield=repaired\"." + "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's #boothstrap-state text includes \"boss=repaired\"." + "done_when": "The page shows the line \"glitch-state v1 …\" containing \"boss=repaired\"." }, { "id": "debrief", diff --git a/challenges/glitch-invaders/intro.md b/challenges/glitch-invaders/intro.md index 351168b..e4694d2 100644 --- a/challenges/glitch-invaders/intro.md +++ b/challenges/glitch-invaders/intro.md @@ -7,9 +7,9 @@ Three things will fail while you play: - a shield that makes damage worse - a boss that takes forever to spawn -Each one sends telemetry to Sentry: a stack trace, breadcrumbs, or a -performance trace. Open it in Sentry, find the root cause, choose the -right patch, and watch the fix work in the game. +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. diff --git a/challenges/glitch-invaders/monitor/SKILL.md b/challenges/glitch-invaders/monitor/SKILL.md index 7bfd330..a65b12f 100644 --- a/challenges/glitch-invaders/monitor/SKILL.md +++ b/challenges/glitch-invaders/monitor/SKILL.md @@ -1,12 +1,16 @@ --- name: monitor -description: "Headless monitor for Glitch Invaders: On-Call. Reports steps from the game's state marker." +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. Find the paragraph that starts with -`glitch-state v1`. It is one line of fixed words, for example: +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 diff --git a/challenges/glitch-invaders/skills/start-challenge/SKILL.md b/challenges/glitch-invaders/skills/start-challenge/SKILL.md index 009b25d..f943e34 100644 --- a/challenges/glitch-invaders/skills/start-challenge/SKILL.md +++ b/challenges/glitch-invaders/skills/start-challenge/SKILL.md @@ -17,15 +17,20 @@ them to read the evidence themselves. Keep replies to a few short sentences. 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` (`demo` means -the evidence is simulated, `live` means it went to Sentry), and 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. +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 @@ -41,22 +46,18 @@ 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. In `live` mode, tell them to click - `Investigate in Sentry` (or `Investigate trace in Sentry`) in the Incident - Console to open the telemetry in Sentry. For the weapon bug, tell them to - check the stack trace and `weapon_state` context in Sentry. For the shield - bug, tell them to read the breadcrumbs and `shield_state` context in Sentry. - For the boss bug, tell them to inspect the `boss.spawn` trace waterfall in - Sentry (and refresh after 5 seconds if spans are still indexing). +- `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. If the -evidence says `SIMULATED`, that is expected in demo mode. Do not call it a -problem. +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 @@ -78,7 +79,7 @@ bugs are repaired. 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": ""}`, leaving out `bug` for +`{"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 @@ -89,6 +90,5 @@ file before forwarding, so a restart never repeats events. - 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. -- The game asks "Still there?": they were idle. Any key continues. - 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. From 14b305d1948bd72b9f25ac65a0cb1f12c2b8da73 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 01:24:25 +0000 Subject: [PATCH 3/8] fix: ship Glitch Invaders demo-only with a CSP and a guarded progress file --- challenges/glitch-invaders/game/index.html | 4 +- .../glitch-invaders/game/src/app/app.mjs | 160 +------- .../glitch-invaders/game/src/app/resume.mjs | 5 +- .../game/src/boss/boss-loader.mjs | 8 +- .../game/src/diagnostics/breadcrumbs.mjs | 150 -------- .../src/diagnostics/evidence-builders.mjs | 5 +- .../game/src/diagnostics/evidence.mjs | 0 .../game/src/diagnostics/live-evidence.mjs | 63 --- .../src/diagnostics/simulated-evidence.mjs | 5 +- challenges/glitch-invaders/game/src/main.mjs | 64 +-- .../glitch-invaders/game/src/sentry/links.mjs | 19 - .../game/src/sentry/offline-reporter.mjs | 18 - .../game/src/sentry/reporter.mjs | 0 .../game/src/sentry/sentry-reporter.mjs | 364 ------------------ .../game/src/session/inactivity.mjs | 60 --- .../game/src/session/monitor-state.mjs | 14 +- .../game/src/session/progress-store.mjs | 17 - .../glitch-invaders/game/src/styles.css | 86 +---- .../glitch-invaders/game/src/ui/hud.mjs | 13 - .../game/src/ui/incident-console.mjs | 165 ++------ .../glitch-invaders/game/src/ui/input.mjs | 6 - .../glitch-invaders/game/src/ui/overlays.mjs | 38 +- .../glitch-invaders/server/live-config.mjs | 130 ------- .../glitch-invaders/server/page-server.mjs | 209 +++++++--- challenges/glitch-invaders/server/serve.mjs | 27 +- challenges/glitch-invaders/server/setup.mjs | 65 +--- .../glitch-invaders/server/workspace-file.mjs | 139 +++++++ challenges/glitch-invaders/setup | 2 +- .../glitch-invaders/shared/progress.mjs | 13 +- .../glitch-invaders/shared/runtime-config.mjs | 165 +------- 30 files changed, 416 insertions(+), 1598 deletions(-) delete mode 100644 challenges/glitch-invaders/game/src/diagnostics/breadcrumbs.mjs delete mode 100644 challenges/glitch-invaders/game/src/diagnostics/evidence.mjs delete mode 100644 challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs delete mode 100644 challenges/glitch-invaders/game/src/sentry/links.mjs delete mode 100644 challenges/glitch-invaders/game/src/sentry/offline-reporter.mjs delete mode 100644 challenges/glitch-invaders/game/src/sentry/reporter.mjs delete mode 100644 challenges/glitch-invaders/game/src/sentry/sentry-reporter.mjs delete mode 100644 challenges/glitch-invaders/game/src/session/inactivity.mjs delete mode 100644 challenges/glitch-invaders/server/live-config.mjs create mode 100644 challenges/glitch-invaders/server/workspace-file.mjs diff --git a/challenges/glitch-invaders/game/index.html b/challenges/glitch-invaders/game/index.html index d7b6987..2c41114 100644 --- a/challenges/glitch-invaders/game/index.html +++ b/challenges/glitch-invaders/game/index.html @@ -21,7 +21,7 @@

  1. SQUASHED 0
  2. SCORE 0
  3. - + DEMO · simulated evidence @@ -63,7 +63,7 @@

    Controls

    -

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

    +

    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 index 2470fa1..a519cc6 100644 --- a/challenges/glitch-invaders/game/src/app/app.mjs +++ b/challenges/glitch-invaders/game/src/app/app.mjs @@ -4,15 +4,10 @@ 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 { attachBreadcrumbs, BreadcrumbRecorder } from '../diagnostics/breadcrumbs.mjs'; - -import { LiveEvidenceProvider } from '../diagnostics/live-evidence.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 { InactivityMonitor } from '../session/inactivity.mjs'; import { publishState } from '../session/monitor-state.mjs'; import { BossLoadingView } from '../ui/boss-loading.mjs'; @@ -20,13 +15,7 @@ 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, - inactivityCard, - introCard, - OverlayHost, - pauseCard, -} from '../ui/overlays.mjs'; +import { completionCard, introCard, OverlayHost, pauseCard } from '../ui/overlays.mjs'; import { GameLoop } from './game-loop.mjs'; import { canonicalEncounter, codeForProgress, firstOpenBug, waveForBug } from './resume.mjs'; @@ -59,10 +48,8 @@ export class GameApp { hud = new Hud(); overlays = new OverlayHost(requireElement('overlay-root')); bossView = new BossLoadingView(requireElement('boss-loading')); - recorder; engine; - provider; - simulated = new SimulatedEvidenceProvider(); + evidence = new SimulatedEvidenceProvider(); incidentConsole; input; loop; @@ -71,23 +58,17 @@ export class GameApp { progress; code; lastShieldHit = null; - lastBossTraceId = null; bannerTimer = null; - inactivity = null; pauseOpen = false; pendingStart = null; constructor(options, saved) { this.options = options; this.now = options.now ?? (() => performance.now()); - this.progress = - saved === null - ? createProgress(options.config.attemptId, options.config.mode) - : { ...saved, mode: options.config.mode }; + this.progress = saved ?? createProgress(options.config.attemptId); this.tracker = new IncidentTracker(this.progress); this.code = codeForProgress(this.progress); - this.recorder = new BreadcrumbRecorder(() => this.engine.world.time); this.engine = new GameEngine({ random: createRandom(options.seed ?? DEFAULT_SEED), code: this.code, @@ -95,13 +76,6 @@ export class GameApp { now: this.now, particleScale: options.reducedMotion ? 0.25 : 1, }); - attachBreadcrumbs(this.engine, { - add: (breadcrumb) => { - this.recorder.add(breadcrumb); - options.reporter.add(breadcrumb); - }, - }); - this.provider = this.createProvider(); this.incidentConsole = new IncidentConsole({ progressOf: (bug) => this.progress.bugs[bug], markEvidenceViewed: (bug) => this.tracker.markEvidenceViewed(bug), @@ -113,7 +87,6 @@ export class GameApp { const canvas = requireElement('arena'); const renderer = new CanvasRenderer(canvas, { reducedMotion: options.reducedMotion }); this.input = new InputController({ - onActivity: () => this.inactivity?.noteActivity(), onPauseKey: () => this.togglePause(), isGameActive: () => !this.overlays.isOpen && this.engine.world.phase !== 'ready', }); @@ -129,7 +102,6 @@ export class GameApp { begin() { this.tracker.subscribe((progress) => this.onProgress(progress)); this.engine.on((event) => this.onEvent(event)); - this.hud.showMode(this.options.config.mode, false); publishState(document, this.progress); this.input.attach(document, { @@ -139,7 +111,6 @@ export class GameApp { }); document.addEventListener('keydown', (event) => this.onPauseCardKey(event)); onClick(requireElement('skip-ahead'), () => { - this.inactivity?.noteActivity(); this.engine.fastForward(); this.stage.focus(); }); @@ -149,49 +120,25 @@ export class GameApp { onClick(button, () => this.resetRun()); } } - this.exposeDiagnostics(); - this.startInactivityMonitor(); this.showOpening(); this.loop.start(); } - /** Choose the evidence provider for the mode. */ - createProvider() { - const { config, reporter } = this.options; - if (config.mode === 'live' && config.sentry !== null) { - return new LiveEvidenceProvider({ - reporter, - settings: config.sentry, - attemptId: config.attemptId, - recorder: this.recorder, - clock: () => this.engine.world.time, - }); - } - - return this.simulated; - } - - /** Run the boss spawn through the reporter, keeping the trace ID for the evidence link. */ - async spawnBoss(strategy, onTask) { - const traced = await this.options.reporter.traceBossSpawn((runSpan) => - loadBossResources({ - strategy, - sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), - now: this.now, - runSpan, - ...(onTask === undefined ? {} : { onTask }), - }), - ); - this.lastBossTraceId = traced.traceId; - - return traced.result; + /** 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({ mode: this.progress.mode, returning: false, onStart: () => this.startRun() }), + introCard({ returning: false, onStart: () => this.startRun() }), ); return; @@ -226,7 +173,7 @@ export class GameApp { this.openRestoredIncident(bug); }; this.overlays.show( - introCard({ mode: this.progress.mode, returning: true, onStart: () => this.resumeRun() }), + introCard({ returning: true, onStart: () => this.resumeRun() }), ); } @@ -322,7 +269,6 @@ export class GameApp { startMs: span.startMs, durationMs: span.durationMs, })), - traceId: this.lastBossTraceId, }, }); } @@ -368,10 +314,10 @@ export class GameApp { `${definition.incidentLabel}: ${definition.title}. The Incident Console is open.`, ); - void this.provider.evidenceFor(encounter).then((evidence) => this.deliverEvidence(evidence)); + void this.evidence.evidenceFor(encounter).then((evidence) => this.deliverEvidence(evidence)); } - /** A failure was restored from a saved run: reopen the console on simulated evidence. */ + /** A failure was restored from a saved run: reopen the console on its evidence. */ openRestoredIncident(bug) { this.input.releaseAll(); this.incidentConsole.open(bug); @@ -382,7 +328,7 @@ export class GameApp { return; } - void this.simulated + void this.evidence .evidenceFor(canonicalEncounter(bug)) .then((evidence) => this.deliverEvidence(evidence)); } @@ -488,78 +434,4 @@ export class GameApp { announce(message) { setText(this.announcer, message); } - - /** Warn after a quiet stretch, then reset the run so nobody inherits it. */ - startInactivityMonitor() { - const { config } = this.options; - let prior = null; - let pausedBefore = false; - const card = inactivityCard(() => this.inactivity?.noteActivity()); - - this.inactivity = new InactivityMonitor({ - warningAfterMs: config.inactivityWarningMs, - resetAfterWarningMs: config.inactivityResetMs, - now: this.now, - onWarning: () => { - prior = this.overlays.current; - pausedBefore = this.engine.world.paused; - this.engine.pause(); - this.input.releaseAll(); - this.overlays.show(card.element); - }, - onResume: () => { - if (prior === null) { - this.overlays.hide(this.stage); - } else { - this.overlays.show(prior); - } - if (!pausedBefore) { - this.engine.resume(); - } - }, - onReset: () => { - void this.options.store.clear().then(() => this.options.reload()); - }, - }); - - setInterval(() => { - if (!this.progress.launched) { - this.inactivity?.noteActivity(); - - return; - } - this.inactivity?.tick(); - if (this.inactivity?.state === 'warning') { - card.setSeconds(this.inactivity.secondsLeft()); - } - }, 500); - } - - /** A read-only view of the arena for tests and for diagnosing a stuck booth. */ - exposeDiagnostics() { - Object.defineProperty(window, '__GLITCH_WORLD__', { - configurable: true, - value: () => { - const world = this.engine.world; - - return { - phase: world.phase, - wave: world.wave, - paused: world.paused, - kills: world.kills, - player: { - x: world.player.x, - width: world.player.width, - weapon: world.player.weapon, - shield: world.player.shield, - hull: world.player.hull, - }, - pickups: world.pickups.map((pickup) => ({ kind: pickup.kind, x: pickup.x, y: pickup.y })), - bossAlive: world.boss !== null, - enemiesAlive: world.enemies.filter((enemy) => enemy.alive).length, - enemyBullets: world.bullets.filter((bullet) => bullet.owner === 'enemy').length, - }; - }, - }); - } } diff --git a/challenges/glitch-invaders/game/src/app/resume.mjs b/challenges/glitch-invaders/game/src/app/resume.mjs index ae03f78..ab4c82a 100644 --- a/challenges/glitch-invaders/game/src/app/resume.mjs +++ b/challenges/glitch-invaders/game/src/app/resume.mjs @@ -1,7 +1,6 @@ /** * Rebuilding a saved run after a refresh: the code the repaired bugs left behind, and the evidence - * for a failure that was still open. Evidence rebuilt this way is always the simulated fixture, - * because the live capture is gone and a second Sentry report would be a duplicate. + * 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'; @@ -51,7 +50,7 @@ export function canonicalEncounter(bug) { case 'boss': return { bug, - stall: { totalMs: 4_500, spans: [...SEQUENTIAL_BOSS_SPANS], traceId: null }, + 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 index adac919..98c4ff1 100644 --- a/challenges/glitch-invaders/game/src/boss/boss-loader.mjs +++ b/challenges/glitch-invaders/game/src/boss/boss-loader.mjs @@ -47,16 +47,12 @@ export function plannedLoadMs(strategy) { return durations.reduce((total, duration) => total + duration, 0); } -/** Runs each load directly, recording nothing. */ -export const runWithoutSpans = (_name, _operation, work) => work(); - -/** Run one load under the span runner, timing it against `startedAt`. */ +/** Run one load, timing it against `startedAt`. */ async function runTask(task, options, startedAt, spans) { - const runSpan = options.runSpan ?? runWithoutSpans; const startMs = options.now() - startedAt; options.onTask?.({ name: task.name, phase: 'start', atMs: startMs }); - await runSpan(task.name, LOAD_SPAN_OP, () => options.sleep(task.durationMs)); + await options.sleep(task.durationMs); const endMs = options.now() - startedAt; spans.push({ name: task.name, op: LOAD_SPAN_OP, startMs, durationMs: endMs - startMs }); diff --git a/challenges/glitch-invaders/game/src/diagnostics/breadcrumbs.mjs b/challenges/glitch-invaders/game/src/diagnostics/breadcrumbs.mjs deleted file mode 100644 index 5221c62..0000000 --- a/challenges/glitch-invaders/game/src/diagnostics/breadcrumbs.mjs +++ /dev/null @@ -1,150 +0,0 @@ -/** The most breadcrumbs the recorder keeps. */ -const MAX_BREADCRUMBS = 40; - -/** Keeps the latest breadcrumbs with game timestamps. */ -export class BreadcrumbRecorder { - entries = []; - clock; - - constructor(clock) { - this.clock = clock; - } - - /** Record `breadcrumb` at the current game time. */ - add(breadcrumb) { - this.entries.push({ ...breadcrumb, atMs: this.clock(), level: breadcrumb.level ?? 'info' }); - if (this.entries.length > MAX_BREADCRUMBS) { - this.entries.shift(); - } - } - - /** The recorded trail, oldest first. */ - trail() { - return this.entries; - } -} - -/** Format how long before `failureAtMs` something happened, such as `-1.2 s`. */ -export function formatOffset(failureAtMs, atMs) { - const seconds = Math.max(0, failureAtMs - atMs) / 1_000; - - return seconds < 0.05 ? '0.0 s' : `-${seconds.toFixed(1)} s`; -} - -/** Turn recorded breadcrumbs into console entries, with offsets measured from the failure. */ -export function toBreadcrumbEntries(trail, failureAtMs, limit) { - return trail.slice(-limit).map((crumb) => ({ - offsetLabel: formatOffset(failureAtMs, crumb.atMs), - category: crumb.category, - message: crumb.message, - level: crumb.level, - data: crumb.data ?? null, - })); -} - -/** Format a number of seconds for breadcrumb text, such as `4.5 s`. */ -function secondsText(milliseconds) { - return `${(milliseconds / 1_000).toFixed(1)} s`; -} - -/** Subscribe `sink` to the engine's events, turning each into the breadcrumb a real service would log. */ -export function attachBreadcrumbs(engine, sink) { - return engine.on((event) => { - switch (event.type) { - case 'wave-started': - sink.add({ category: 'game.wave', message: `Wave ${event.wave} started` }); - break; - case 'pickup-collected': - if (event.kind === 'fanout') { - sink.add({ - category: 'game.weapon', - message: 'Weapon selected: fanout', - data: { weaponId: 'fanout' }, - }); - } else { - sink.add({ - category: 'game.shield', - message: 'Shield collected', - data: { type: 'aegis' }, - }); - sink.add({ - category: 'game.shield', - message: 'Shield enabled', - data: { shieldActive: 'true' }, - }); - } - break; - case 'volley-fired': - if (event.weapon !== 'single') { - sink.add({ - category: 'game.weapon', - message: `Fired ${event.weapon}`, - data: { projectiles: String(event.projectiles) }, - }); - } - break; - case 'weapon-jammed': - sink.add({ - category: 'game.weapon', - message: `Firing weapon: ${event.error.weaponId}`, - data: { weaponId: event.error.weaponId }, - }); - sink.add({ - category: 'game.weapon', - message: 'Weapon fire failed: configuration missing', - level: 'error', - data: { availableWeapons: event.availableWeapons.join(', ') }, - }); - break; - case 'shield-hit': - sink.add({ - category: 'game.combat', - message: `Incoming damage: ${event.incomingDamage}`, - data: { damage: String(event.incomingDamage) }, - }); - sink.add({ - category: 'game.shield', - message: 'Shield active: true', - data: { shieldActive: 'true' }, - }); - sink.add({ - category: 'game.combat', - message: `Actual damage applied: ${event.outcome.actualDamage}`, - level: event.outcome.actualDamage === 0 ? 'info' : 'warning', - data: { actualDamage: String(event.outcome.actualDamage), expected: '0' }, - }); - break; - case 'ship-down': - if (event.cause === 'shield-failure') { - sink.add({ - category: 'game.combat', - message: 'Ship destroyed: shield failed to protect', - level: 'error', - }); - } - break; - case 'boss-loading': - sink.add({ - category: 'game.boss', - message: 'Boss spawn initiated', - data: { strategy: event.strategy }, - }); - break; - case 'boss-online': - sink.add({ - category: 'game.boss', - message: `Boss ready after ${secondsText(event.result.totalMs)}`, - level: event.stalled ? 'warning' : 'info', - }); - break; - case 'verification-passed': - sink.add({ category: 'game.repair', message: `Repair verified: ${event.bug}` }); - break; - case 'enemy-destroyed': - case 'pickup-spawned': - case 'ship-restored': - case 'run-finished': - break; - } - }); -} diff --git a/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs index a7dd949..b60612d 100644 --- a/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs +++ b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs @@ -29,7 +29,7 @@ export const WEAPON_STACK_FRAMES = [ line: 160, inApp: true, }, - { functionName: 'GameLoop.frame', file: 'game/src/app/app.mjs', line: 121, inApp: true }, + { functionName: 'GameLoop.frame', file: 'game/src/app/app.mjs', line: 94, inApp: true }, { functionName: 'requestAnimationFrame', file: '', line: 0, inApp: false }, ]; @@ -107,8 +107,7 @@ export function evidenceTitle(encounter) { } /** - * The sections for `encounter`. `breadcrumbs` is the trail to show for the shield failure; the - * simulated provider passes its fixture and the live provider passes the recorded trail. + * 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) { diff --git a/challenges/glitch-invaders/game/src/diagnostics/evidence.mjs b/challenges/glitch-invaders/game/src/diagnostics/evidence.mjs deleted file mode 100644 index e69de29..0000000 diff --git a/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs b/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs deleted file mode 100644 index 8184d51..0000000 --- a/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs +++ /dev/null @@ -1,63 +0,0 @@ -import { issueSearchUrl, traceUrl } from '../sentry/links.mjs'; - -import { toBreadcrumbEntries } from './breadcrumbs.mjs'; - -import { evidenceSections, evidenceTitle } from './evidence-builders.mjs'; - -/** How many of the most recent breadcrumbs the console shows. */ -const SHOWN_BREADCRUMBS = 8; - -/** Reports each encounter to Sentry and shows the local capture of what was sent. */ -export class LiveEvidenceProvider { - options; - - constructor(options) { - this.options = options; - } - - /** Report `encounter`, then build evidence from the local capture. Never rejects. */ - async evidenceFor(encounter) { - const { recorder, clock } = this.options; - const entries = toBreadcrumbEntries(recorder.trail(), clock(), SHOWN_BREADCRUMBS); - const spans = encounter.bug === 'boss' ? encounter.stall.spans : []; - const outcome = await this.report(encounter); - - return { - bug: encounter.bug, - source: outcome.delivery === 'confirmed' ? 'live' : 'live-unconfirmed', - title: evidenceTitle(encounter), - referenceId: outcome.referenceId, - sections: evidenceSections(encounter, entries, spans), - link: this.linkFor(encounter, outcome), - }; - } - - /** Report to Sentry, degrading to an unconfirmed outcome if reporting itself fails. */ - async report(encounter) { - try { - return await this.options.reporter.report(encounter); - } catch { - return { referenceId: 'not-sent', delivery: 'unconfirmed' }; - } - } - - /** The Sentry page for this failure, when there is something to link to. */ - linkFor(encounter, outcome) { - const { settings, attemptId } = this.options; - if (encounter.bug === 'boss') { - const traceId = encounter.stall.traceId; - - return traceId === null - ? null - : { label: 'Investigate trace in Sentry', url: traceUrl(settings, traceId) }; - } - if (outcome.referenceId === 'not-sent') { - return null; - } - - return { - label: 'Investigate in Sentry', - url: issueSearchUrl(settings, attemptId, encounter.bug), - }; - } -} diff --git a/challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs b/challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs index cf1935a..57b152e 100644 --- a/challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs +++ b/challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs @@ -1,6 +1,6 @@ /** * Demo-mode evidence: realistic and deterministic, built without any Sentry credentials. It is - * clearly labelled simulated, and carries the same decisions as the live evidence. + * clearly labelled simulated. */ import { @@ -13,7 +13,7 @@ import { /** Produces fixed evidence, whatever the run measured. */ export class SimulatedEvidenceProvider { - /** Evidence for `encounter`, labelled simulated and with no Sentry link. */ + /** Evidence for `encounter`, labelled simulated. */ async evidenceFor(encounter) { return { bug: encounter.bug, @@ -21,7 +21,6 @@ export class SimulatedEvidenceProvider { title: evidenceTitle(encounter), referenceId: simulatedReference(encounter.bug), sections: evidenceSections(encounter, SIMULATED_SHIELD_BREADCRUMBS, SEQUENTIAL_BOSS_SPANS), - link: null, }; } } diff --git a/challenges/glitch-invaders/game/src/main.mjs b/challenges/glitch-invaders/game/src/main.mjs index be8cb33..b618964 100644 --- a/challenges/glitch-invaders/game/src/main.mjs +++ b/challenges/glitch-invaders/game/src/main.mjs @@ -1,48 +1,16 @@ -/** The page entry: read this attempt's configuration, choose live or demo, and start the game. */ -import { createDemoRuntimeConfig, parseRuntimeConfig } from '../../shared/runtime-config.mjs'; +/** 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'; -import { OfflineReporter } from './sentry/offline-reporter.mjs'; - -import { SentryReporter } from './sentry/sentry-reporter.mjs'; -import { HttpProgressStore, MemoryProgressStore } from './session/progress-store.mjs'; - -/** The release name live events carry, so Sentry groups them with this challenge. */ -const RELEASE = 'glitch-invaders-on-call@1'; - -/** Read `/runtime-config.json`, falling back to demo mode when there is no page server. */ +/** Read `/runtime-config.json`, which the page server writes for every attempt. */ async function loadConfig() { - try { - const response = await fetch('/runtime-config.json', { cache: 'no-store' }); - if (response.ok) { - return { config: parseRuntimeConfig(await response.json()), served: true }; - } - } catch { - // No page server, such as under `vite dev`: demo mode below. + 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 { config: createDemoRuntimeConfig('local-dev'), served: false }; -} - -/** Build the live reporter, or fall back to demo mode if Sentry cannot start. */ -function chooseReporter(config) { - if (config.mode === 'live' && config.sentry !== null) { - try { - return { - config, - reporter: new SentryReporter(config.sentry, { - attemptId: config.attemptId, - stationId: config.stationId, - sessionId: config.sessionId, - release: RELEASE, - }), - }; - } catch { - return { config: { ...config, mode: 'demo', sentry: null }, reporter: new OfflineReporter() }; - } - } - - return { config, reporter: new OfflineReporter() }; + return parseRuntimeConfig(await response.json()); } /** Show a plain message when the game cannot start at all. */ @@ -58,17 +26,14 @@ function showFatal(error) { /** Start the game. */ async function main() { - const loaded = await loadConfig(); - const { config, reporter } = chooseReporter(loaded.config); + const config = await loadConfig(); const query = new URLSearchParams(window.location.search); const seed = Number(query.get('seed')); - const store = loaded.served - ? new HttpProgressStore( - '/api/progress', - (input, init) => fetch(input, init), - () => {}, - ) - : new MemoryProgressStore(); + const store = new HttpProgressStore( + '/api/progress', + (input, init) => fetch(input, init), + () => {}, + ); let saved = null; try { @@ -81,7 +46,6 @@ async function main() { { config, store, - reporter, reload: () => window.location.reload(), reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches, ...(Number.isInteger(seed) && query.has('seed') ? { seed } : {}), diff --git a/challenges/glitch-invaders/game/src/sentry/links.mjs b/challenges/glitch-invaders/game/src/sentry/links.mjs deleted file mode 100644 index 8a203b0..0000000 --- a/challenges/glitch-invaders/game/src/sentry/links.mjs +++ /dev/null @@ -1,19 +0,0 @@ -/** The Sentry web address of the organization. */ -function organizationUrl(settings) { - return `https://${encodeURIComponent(settings.org)}.sentry.io`; -} - -/** - * A search for the issue about `bug`. Searching by the stable `bug` tag rather than a fresh - * per-run UUID matches the issue in Sentry immediately before single-event indexing finishes. - */ -export function issueSearchUrl(settings, _attemptId, bug) { - const query = encodeURIComponent(`bug:${bug}`); - - return `${organizationUrl(settings)}/issues/?query=${query}&statsPeriod=14d`; -} - -/** The trace view for `traceId`. */ -export function traceUrl(settings, traceId) { - return `${organizationUrl(settings)}/explore/traces/trace/${encodeURIComponent(traceId)}/`; -} diff --git a/challenges/glitch-invaders/game/src/sentry/offline-reporter.mjs b/challenges/glitch-invaders/game/src/sentry/offline-reporter.mjs deleted file mode 100644 index d9c2c89..0000000 --- a/challenges/glitch-invaders/game/src/sentry/offline-reporter.mjs +++ /dev/null @@ -1,18 +0,0 @@ -/** The reporter demo mode uses: it sends nothing anywhere. */ -import { runWithoutSpans } from '../boss/boss-loader.mjs'; - -/** Reports to no one, so demo mode needs no credentials and makes no network requests. */ -export class OfflineReporter { - /** Breadcrumbs are kept by the local recorder alone. */ - add() {} - - /** Run `work` with no tracing. */ - async traceBossSpawn(work) { - return { result: await work(runWithoutSpans), traceId: null }; - } - - /** There is nothing to deliver, so nothing is confirmed. */ - async report() { - return { referenceId: 'offline', delivery: 'unconfirmed' }; - } -} diff --git a/challenges/glitch-invaders/game/src/sentry/reporter.mjs b/challenges/glitch-invaders/game/src/sentry/reporter.mjs deleted file mode 100644 index e69de29..0000000 diff --git a/challenges/glitch-invaders/game/src/sentry/sentry-reporter.mjs b/challenges/glitch-invaders/game/src/sentry/sentry-reporter.mjs deleted file mode 100644 index 7228347..0000000 --- a/challenges/glitch-invaders/game/src/sentry/sentry-reporter.mjs +++ /dev/null @@ -1,364 +0,0 @@ -/** - * Live reporting through Sentry's v7 Envelope HTTP endpoint: one real exception for the weapon jam, - * one for the shield failure, and one real span v2 trace for the boss spawn, each fingerprinted and - * tagged so the participant's issue and trace links open immediately in Sentry. - */ -import { ALL_LOAD_TASKS, LOAD_SPAN_OP, runWithoutSpans } from '../boss/boss-loader.mjs'; -import { WEAPON_STACK_FRAMES } from '../diagnostics/evidence-builders.mjs'; - -/** How long to wait for Sentry to confirm a report before showing the local capture. */ -export const DELIVERY_TIMEOUT_MS = 2_500; - -/** Generate a lowercase hex string of `length` characters using Web Crypto. */ -function randomHex(length) { - const bytes = new Uint8Array(length / 2); - crypto.getRandomValues(bytes); - - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); -} - -/** Parse a Sentry DSN into its ingestion endpoint and public key. */ -export function parseDsn(dsn) { - const url = new URL(dsn); - const publicKey = url.username; - const projectId = url.pathname.replace(/^\/+/, ''); - const orgMatch = /^o(\d+)\./.exec(url.hostname); - const params = new URLSearchParams({ - sentry_version: '7', - sentry_key: publicKey, - sentry_client: 'sentry.javascript.browser/11.7.0', - }); - - return { - publicKey, - projectId, - orgId: orgMatch?.[1] ?? undefined, - envelopeUrl: `${url.protocol}//${url.host}/api/${projectId}/envelope/?${params.toString()}`, - }; -} - -/** Build a typed span attribute entry for Sentry's v2 span envelope item. */ -function spanAttribute(value, type = 'string') { - return { value, type }; -} - -/** Convert curated stack frames into Sentry's oldest-to-newest stacktrace frame array. */ -function toSentryFrames(frames) { - return [...frames].reverse().map((frame) => ({ - filename: frame.file, - function: frame.functionName, - lineno: frame.line, - in_app: frame.inApp, - })); -} - -/** Sends the game's intentional failures to Sentry over HTTP envelopes. */ -export class SentryReporter { - constructor(settings, identity, fetchImpl = globalThis.fetch) { - this.settings = settings; - this.identity = identity; - this.fetchImpl = fetchImpl; - this.parsedDsn = parseDsn(settings.dsn); - this.traceId = randomHex(32); - this.breadcrumbs = []; - this.outcomes = new Map(); - this.prewarmedBossTraceId = null; - this.prewarmBossTrace(); - } - - /** Record a breadcrumb so the next captured event carries the trail. */ - add(breadcrumb) { - this.breadcrumbs.push({ - timestamp: Date.now() / 1_000, - category: breadcrumb.category, - message: breadcrumb.message, - level: breadcrumb.level ?? 'info', - ...(breadcrumb.data === undefined ? {} : { data: breadcrumb.data }), - }); - } - - /** Run the boss spawn, returning the prewarmed trace ID on the first unpatched stall. */ - async traceBossSpawn(work) { - if (this.prewarmedBossTraceId !== null) { - const traceId = this.prewarmedBossTraceId; - this.prewarmedBossTraceId = null; - - return { - traceId, - result: await work(runWithoutSpans), - }; - } - - const traceId = randomHex(32); - const rootSpanId = randomHex(16); - const childItems = []; - const startSeconds = Date.now() / 1_000; - const runSpan = async (name, operation, load) => { - const spanStart = Date.now() / 1_000; - const value = await load(); - const spanEnd = Date.now() / 1_000; - childItems.push( - this.buildChildSpanItem(traceId, rootSpanId, name, operation, spanStart, spanEnd), - ); - - return value; - }; - - const result = await work(runSpan); - const endSeconds = Date.now() / 1_000; - const envelope = this.buildSpanEnvelope(traceId, rootSpanId, startSeconds, endSeconds, childItems); - void this.postEnvelope(envelope); - - return { traceId, result }; - } - - /** - * Pre-send the stalled `boss-spawn` trace at startup so Sentry's trace ingestion pipeline has - * already indexed it by the time the participant reaches Wave 3. - */ - prewarmBossTrace() { - const traceId = randomHex(32); - const rootSpanId = randomHex(16); - const totalSeconds = ALL_LOAD_TASKS.reduce((sum, task) => sum + task.durationMs, 0) / 1_000; - const baseSeconds = Date.now() / 1_000 - totalSeconds; - let cursorSeconds = baseSeconds; - const childItems = []; - - for (const task of ALL_LOAD_TASKS) { - const startSeconds = cursorSeconds; - cursorSeconds += task.durationMs / 1_000; - childItems.push( - this.buildChildSpanItem( - traceId, - rootSpanId, - task.name, - LOAD_SPAN_OP, - startSeconds, - cursorSeconds, - ), - ); - } - - const envelope = this.buildSpanEnvelope( - traceId, - rootSpanId, - baseSeconds, - cursorSeconds, - childItems, - ); - this.prewarmedBossTraceId = traceId; - const outcome = this.postEnvelope(envelope).then((confirmed) => ({ - referenceId: traceId, - delivery: confirmed ? 'confirmed' : 'unconfirmed', - })); - this.outcomes.set('boss', outcome); - } - - /** Report `encounter` once, caching the outcome so repeats send nothing. */ - report(encounter) { - const existing = this.outcomes.get(encounter.bug); - if (existing !== undefined) { - return existing; - } - - const outcome = this.sendEncounter(encounter); - this.outcomes.set(encounter.bug, outcome); - - return outcome; - } - - /** Build and send the Sentry event envelope for `encounter`. */ - async sendEncounter(encounter) { - if (encounter.bug === 'boss') { - return { - referenceId: encounter.stall.traceId ?? 'unavailable', - delivery: 'unconfirmed', - }; - } - - const eventId = randomHex(32); - const envelope = - encounter.bug === 'weapon' - ? this.buildEventEnvelope( - eventId, - 'weapon', - 'WeaponConfigError', - encounter.jam.error.message, - toSentryFrames(WEAPON_STACK_FRAMES), - { - weapon_state: { - attemptedWeapon: encounter.jam.weaponId, - availableWeapons: encounter.jam.availableWeapons, - }, - }, - ) - : this.buildEventEnvelope( - eventId, - 'shield', - 'GameLogicError', - `Shield was active but ${encounter.failure.actualDamage} damage was applied (expected 0). Ship destroyed despite an active shield.`, - [ - { - filename: 'game/src/shield/shield.mjs', - function: 'resolveShieldHit', - lineno: 14, - in_app: true, - }, - ], - { - shield_state: { ...encounter.failure }, - }, - ); - - const confirmed = await this.postEnvelope(envelope); - - return { referenceId: eventId, delivery: confirmed ? 'confirmed' : 'unconfirmed' }; - } - - /** Build a v2 span item for one child resource load inside `boss-spawn`. */ - buildChildSpanItem(traceId, parentSpanId, name, operation, startSeconds, endSeconds) { - return { - name, - span_id: randomHex(16), - trace_id: traceId, - parent_span_id: parentSpanId, - start_timestamp: startSeconds, - end_timestamp: endSeconds, - is_segment: false, - status: 'ok', - attributes: { - 'sentry.origin': spanAttribute('manual'), - 'sentry.op': spanAttribute(operation), - 'sentry.trace_lifecycle': spanAttribute('stream'), - 'sentry.segment.name': spanAttribute('boss-spawn'), - 'sentry.segment.id': spanAttribute(parentSpanId), - 'sentry.sdk.name': spanAttribute('sentry.javascript.browser'), - 'sentry.sdk.version': spanAttribute('11.7.0'), - 'sentry.release': spanAttribute(this.identity.release), - 'sentry.environment': spanAttribute(this.settings.environment), - }, - }; - } - - /** Build the 3-line v2 span envelope for the `boss-spawn` root segment and its child spans. */ - buildSpanEnvelope(traceId, rootSpanId, startSeconds, endSeconds, childItems) { - const rootItem = { - name: 'boss-spawn', - span_id: rootSpanId, - trace_id: traceId, - start_timestamp: startSeconds, - end_timestamp: endSeconds, - is_segment: true, - status: 'ok', - attributes: { - 'sentry.origin': spanAttribute('manual'), - 'sentry.op': spanAttribute('game.boss'), - 'sentry.segment.name.source': spanAttribute('custom'), - 'sentry.sample_rate': spanAttribute(1, 'integer'), - run_id: spanAttribute(this.identity.attemptId), - bug: spanAttribute('boss'), - 'sentry.trace_lifecycle': spanAttribute('stream'), - 'sentry.segment.name': spanAttribute('boss-spawn'), - 'sentry.segment.id': spanAttribute(rootSpanId), - 'sentry.sdk.name': spanAttribute('sentry.javascript.browser'), - 'sentry.sdk.version': spanAttribute('11.7.0'), - 'sentry.release': spanAttribute(this.identity.release), - 'sentry.environment': spanAttribute(this.settings.environment), - }, - }; - const items = [...childItems, rootItem]; - const header = { - sent_at: new Date().toISOString(), - trace: { - environment: this.settings.environment, - release: this.identity.release, - public_key: this.parsedDsn.publicKey, - trace_id: traceId, - ...(this.parsedDsn.orgId === undefined ? {} : { org_id: this.parsedDsn.orgId }), - transaction: 'boss-spawn', - sampled: 'true', - sample_rate: '1', - }, - sdk: { name: 'sentry.javascript.browser', version: '11.7.0' }, - }; - const itemHeader = { - type: 'span', - item_count: items.length, - content_type: 'application/vnd.sentry.items.span.v2+json', - }; - - return `${JSON.stringify(header)}\n${JSON.stringify(itemHeader)}\n${JSON.stringify({ version: 2, items })}\n`; - } - - /** Build the 3-line Sentry error event envelope for `bug`. */ - buildEventEnvelope(eventId, bug, errorType, message, frames, contexts) { - const header = { - event_id: eventId, - sent_at: new Date().toISOString(), - sdk: { name: 'sentry.javascript.browser', version: '11.7.0' }, - trace: { - environment: this.settings.environment, - release: this.identity.release, - public_key: this.parsedDsn.publicKey, - trace_id: this.traceId, - ...(this.parsedDsn.orgId === undefined ? {} : { org_id: this.parsedDsn.orgId }), - }, - }; - const tags = { - run_id: this.identity.attemptId, - bug, - ...(this.identity.stationId === null ? {} : { station_id: this.identity.stationId }), - ...(this.identity.sessionId === null ? {} : { boothstrap_session_id: this.identity.sessionId }), - }; - const payload = { - event_id: eventId, - timestamp: Date.now() / 1_000, - platform: 'javascript', - level: 'error', - environment: this.settings.environment, - release: this.identity.release, - sdk: { name: 'sentry.javascript.browser', version: '11.7.0' }, - tags, - fingerprint: ['glitch-invaders', bug], - contexts: { - trace: { trace_id: this.traceId, span_id: randomHex(16) }, - ...contexts, - }, - breadcrumbs: [...this.breadcrumbs], - exception: { - values: [ - { - type: errorType, - value: message, - stacktrace: { frames }, - mechanism: { type: 'generic', handled: true }, - }, - ], - }, - }; - - return `${JSON.stringify(header)}\n${JSON.stringify({ type: 'event' })}\n${JSON.stringify(payload)}\n`; - } - - /** POST `envelope` to Sentry within `DELIVERY_TIMEOUT_MS`, returning true when accepted. */ - async postEnvelope(envelope) { - let timer; - try { - const request = Promise.resolve( - this.fetchImpl(this.parsedDsn.envelopeUrl, { - method: 'POST', - body: envelope, - }), - ).then((response) => Boolean(response?.ok)); - const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve(false), DELIVERY_TIMEOUT_MS); - }); - - return await Promise.race([request, timeout]); - } catch { - return false; - } finally { - clearTimeout(timer); - } - } -} diff --git a/challenges/glitch-invaders/game/src/session/inactivity.mjs b/challenges/glitch-invaders/game/src/session/inactivity.mjs deleted file mode 100644 index a52fd38..0000000 --- a/challenges/glitch-invaders/game/src/session/inactivity.mjs +++ /dev/null @@ -1,60 +0,0 @@ -/** - * The "still there?" behavior: after a quiet stretch the game warns, counts down, and resets to the - * intro so the next person never inherits a half-played run. Time is injected so tests need no - * clock. - */ - -/** Watches for quiet. Call {@link InactivityMonitor.noteActivity} on input and {@link InactivityMonitor.tick} regularly. */ -export class InactivityMonitor { - options; - lastActivityAt; - warnedAt = null; - - constructor(options) { - this.options = options; - this.lastActivityAt = options.now(); - } - - /** Whether the warning is showing. */ - get state() { - return this.warnedAt === null ? 'active' : 'warning'; - } - - /** Whole seconds left before reset while warning; `0` when active. */ - secondsLeft() { - if (this.warnedAt === null) { - return 0; - } - const remaining = this.warnedAt + this.options.resetAfterWarningMs - this.options.now(); - - return Math.max(0, Math.ceil(remaining / 1_000)); - } - - /** The participant did something. Dismisses a warning. */ - noteActivity() { - this.lastActivityAt = this.options.now(); - if (this.warnedAt !== null) { - this.warnedAt = null; - this.options.onResume(); - } - } - - /** Check the clock: raise the warning after quiet, and reset once the countdown ends. */ - tick() { - const now = this.options.now(); - if (this.warnedAt === null) { - if (now - this.lastActivityAt >= this.options.warningAfterMs) { - this.warnedAt = now; - this.options.onWarning(); - } - - return; - } - - if (now - this.warnedAt >= this.options.resetAfterWarningMs) { - this.warnedAt = null; - this.lastActivityAt = now; - this.options.onReset(); - } - } -} diff --git a/challenges/glitch-invaders/game/src/session/monitor-state.mjs b/challenges/glitch-invaders/game/src/session/monitor-state.mjs index eafdd54..f001a85 100644 --- a/challenges/glitch-invaders/game/src/session/monitor-state.mjs +++ b/challenges/glitch-invaders/game/src/session/monitor-state.mjs @@ -1,17 +1,13 @@ /** * 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 visually hidden text; the full snapshot - * is also on `window` for tests and for a guide that can evaluate scripts. + * 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'; -/** The `window` property holding the latest progress snapshot. */ -export const STATE_WINDOW_KEY = '__GLITCH_STATE__'; - -/** Write `progress` to the marker element and `window`. */ +/** Write `progress` to the marker element. */ export function publishState(document, progress) { const element = document.getElementById(STATE_ELEMENT_ID); if (element === null) { @@ -22,10 +18,4 @@ export function publishState(document, progress) { if (element.textContent !== line) { element.textContent = line; } - Object.defineProperty(document.defaultView ?? globalThis, STATE_WINDOW_KEY, { - value: structuredClone(progress), - configurable: true, - enumerable: false, - writable: true, - }); } diff --git a/challenges/glitch-invaders/game/src/session/progress-store.mjs b/challenges/glitch-invaders/game/src/session/progress-store.mjs index 0cae27f..7e479f8 100644 --- a/challenges/glitch-invaders/game/src/session/progress-store.mjs +++ b/challenges/glitch-invaders/game/src/session/progress-store.mjs @@ -1,23 +1,6 @@ /** 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 in memory only. Used under `vite dev` and in tests. */ -export class MemoryProgressStore { - saved = null; - - async load(attemptId) { - return this.saved?.attemptId === attemptId ? structuredClone(this.saved) : null; - } - - async save(progress) { - this.saved = structuredClone(progress); - } - - async clear() { - this.saved = null; - } -} - /** Keeps progress on the page server, which writes it into the participant's workspace. */ export class HttpProgressStore { endpoint; diff --git a/challenges/glitch-invaders/game/src/styles.css b/challenges/glitch-invaders/game/src/styles.css index cbc0732..06f89a8 100644 --- a/challenges/glitch-invaders/game/src/styles.css +++ b/challenges/glitch-invaders/game/src/styles.css @@ -77,11 +77,17 @@ ul { 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; - height: 100dvh; + min-height: 0; } .topbar { @@ -130,22 +136,13 @@ ul { .badge { padding: 2px 10px; - border: 2px solid var(--border); + border: 2px solid var(--mlh-yellow); + color: var(--mlh-yellow); font-size: 12px; font-weight: 700; white-space: nowrap; } -.badge[data-mode="demo"] { - border-color: var(--mlh-yellow); - color: var(--mlh-yellow); -} - -.badge[data-mode="live"] { - border-color: var(--success); - color: var(--success); -} - .topbar-reset { padding: 3px 10px; border: 1px solid var(--border); @@ -663,16 +660,6 @@ kbd { color: var(--mlh-yellow); } -.source-badge[data-source="live"] { - border-color: var(--success); - color: var(--success); -} - -.source-badge[data-source="live-unconfirmed"] { - border-color: var(--warning); - color: var(--warning); -} - .sentry-callout { display: flex; flex-direction: column; @@ -691,10 +678,6 @@ kbd { } } -.sentry-callout-compact { - padding: 8px 12px; -} - .sentry-callout-main { display: flex; flex-direction: column; @@ -706,22 +689,6 @@ kbd { font: 13px / 1.35 var(--sans); } -.button-sentry { - flex-shrink: 0; - border-color: var(--mlh-yellow); - border-radius: 6px; - background: var(--mlh-blue); - color: #ffffff; - font: 700 13px var(--sans); - text-decoration: none; - white-space: nowrap; - text-align: center; -} - -.button-sentry:hover { - border-color: #ffffff; -} - .context-pills { display: flex; flex-wrap: wrap; @@ -749,41 +716,6 @@ kbd { font-weight: 700; } -.sentry-briefing { - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px 14px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg); -} - -.sentry-briefing-title { - margin: 0; - color: var(--accent); - font: 700 13px var(--mono); - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.sentry-briefing-list { - display: flex; - flex-direction: column; - gap: 8px; - margin: 0; - padding: 0; - list-style: none; -} - -.sentry-briefing-item { - display: flex; - gap: 10px; - align-items: flex-start; - color: var(--fg); - font: 14px / 1.45 var(--sans); -} - .panel { padding: 10px 12px; border: 1px solid var(--border); diff --git a/challenges/glitch-invaders/game/src/ui/hud.mjs b/challenges/glitch-invaders/game/src/ui/hud.mjs index da91ddc..0acc831 100644 --- a/challenges/glitch-invaders/game/src/ui/hud.mjs +++ b/challenges/glitch-invaders/game/src/ui/hud.mjs @@ -27,24 +27,11 @@ export class Hud { shield = requireElement('stat-shield'); kills = requireElement('stat-kills'); score = requireElement('stat-score'); - badge = requireElement('mode-badge'); objectiveText = requireElement('objective-text'); objectiveDetail = requireElement('objective-detail'); incidents = requireElement('incidents'); chipSignature = ''; - /** Show the mode badge once; it never changes during a run. */ - showMode(mode, degraded) { - this.badge.dataset.mode = mode; - if (mode === 'demo') { - setText(this.badge, 'DEMO · simulated evidence'); - this.badge.title = 'No Sentry credentials: the diagnostics are realistic simulations.'; - } else { - setText(this.badge, degraded ? 'LIVE · Sentry unreachable' : 'LIVE · Sentry'); - this.badge.title = 'Real errors, breadcrumbs, and traces are sent to Sentry.'; - } - } - /** Refresh every stat from the latest world and progress. */ update(progress, world) { const hullText = world.player.restoreAt === null ? '♥'.repeat(world.player.hull) : '✖'; diff --git a/challenges/glitch-invaders/game/src/ui/incident-console.mjs b/challenges/glitch-invaders/game/src/ui/incident-console.mjs index 68abb1d..437750f 100644 --- a/challenges/glitch-invaders/game/src/ui/incident-console.mjs +++ b/challenges/glitch-invaders/game/src/ui/incident-console.mjs @@ -10,11 +10,6 @@ const SOURCE_BADGES = { label: 'DEMO · SIMULATED', note: 'Demo mode: realistic diagnostics, no Sentry account needed.', }, - live: { label: 'LIVE SENTRY', note: 'Captured by the game and delivered to Sentry.' }, - 'live-unconfirmed': { - label: 'LIVE · LOCAL COPY', - note: 'Sentry could not confirm delivery. This is the game’s own capture of what it sent.', - }, }; /** The glyphs for breadcrumb levels; level is never color alone. */ @@ -184,7 +179,7 @@ export class IncidentConsole { } } - /** Evidence step: in Live mode, directs the participant to Sentry; in Demo mode, shows simulated capture. */ + /** Evidence step: the simulated Sentry capture of the failure. */ evidencePane() { const evidence = this.evidence; if (evidence === null) { @@ -192,37 +187,7 @@ export class IncidentConsole { 'p', { class: 'status', role: 'status' }, h('span', { class: 'spinner', 'aria-hidden': 'true' }, '◌'), - ' Transmitting telemetry to Sentry…', - ); - } - - if (evidence.link !== null) { - return h( - 'div', - { class: 'evidence' }, - this.sentryHero(evidence), - renderSentryBriefing(evidence), - h( - 'div', - { class: 'pane-actions' }, - h( - 'span', - { class: 'muted' }, - `${evidence.bug === 'boss' ? 'Trace' : 'Event'} ${evidence.referenceId} · transmitted to Sentry`, - ), - h('span', { class: 'spacer' }), - onClick( - h( - 'button', - { type: 'button', class: 'button', 'data-key': 'to-diagnose' }, - 'Continue to Diagnose ▶', - ), - () => { - this.pane = 'diagnose'; - this.render(); - }, - ), - ), + ' Capturing telemetry…', ); } @@ -260,38 +225,8 @@ export class IncidentConsole { ); } - /** Hero banner with the exception/trace headline and the primary "Investigate in Sentry" CTA. */ + /** Banner with the exception or trace headline and how to read the capture. */ sentryHero(evidence) { - const isTrace = evidence.bug === 'boss'; - const subtitle = - evidence.link !== null - ? isTrace - ? 'Performance telemetry has been sent to Sentry. Open the trace in Sentry in a new tab, then return here to submit your findings.' - : 'Telemetry has been sent to Sentry. Open the issue in Sentry in a new tab to investigate, then return here to submit your findings.' - : 'Demo mode (Sentry not connected): inspect the simulated telemetry snapshot to identify the root cause.'; - - const sentryLink = - evidence.link === null - ? null - : h( - 'a', - { - class: 'button button-sentry', - href: evidence.link.url, - target: '_blank', - rel: 'noopener', - 'data-key': 'open-sentry', - }, - `🔍 ${evidence.link.label} ↗`, - ); - - if (sentryLink !== null) { - sentryLink.addEventListener('click', () => { - this.pane = 'diagnose'; - this.render(); - }); - } - return h( 'div', { class: 'sentry-callout' }, @@ -299,9 +234,12 @@ export class IncidentConsole { 'div', { class: 'sentry-callout-main' }, h('p', { class: 'evidence-head' }, evidence.title), - h('p', { class: 'sentry-callout-copy' }, subtitle), + h( + 'p', + { class: 'sentry-callout-copy' }, + 'Demo mode (Sentry not connected): inspect the simulated telemetry snapshot to identify the root cause.', + ), ), - sentryLink, ); } @@ -315,28 +253,6 @@ export class IncidentConsole { return h( 'div', { class: 'diagnose' }, - this.evidence !== null && this.evidence.link !== null - ? h( - 'div', - { class: 'sentry-callout sentry-callout-compact' }, - h( - 'span', - { class: 'sentry-callout-copy' }, - 'Need another look at the event in Sentry? Refresh the Sentry tab if spans are still indexing.', - ), - h( - 'a', - { - class: 'button button-sentry', - href: this.evidence.link.url, - target: '_blank', - rel: 'noopener', - 'data-key': 'open-sentry-diagnose', - }, - `🔍 ${this.evidence.link.label} ↗`, - ), - ) - : null, h('p', { class: 'question' }, definition.question), h( 'div', @@ -488,46 +404,6 @@ export class IncidentConsole { } } -/** What to inspect inside the Sentry dashboard for each failure in Live mode. */ -const SENTRY_BRIEFINGS = { - weapon: [ - 'Click "Investigate in Sentry" above to open the captured issue in a new tab.', - 'Inspect the Stack Trace to see which function and file threw the error.', - 'Check the weapon_state context section in Sentry to compare the requested weapon ID against the registered weapon configurations.', - ], - shield: [ - 'Click "Investigate in Sentry" above to open the captured issue in a new tab.', - 'Read the Breadcrumbs timeline in Sentry leading up to hull destruction.', - 'Check the shield_state context section in Sentry to compare incoming projectile damage against the damage applied to your hull.', - ], - boss: [ - 'Click "Investigate trace in Sentry" above to open the performance trace in a new tab.', - 'Inspect the Waterfall view under the boss-spawn root span.', - 'Notice that all four game.resource.load child spans run one after another (sequentially) totaling 4.50 s, and identify which operation takes the longest.', - ], -}; - -/** Render the Sentry investigation checklist shown in Live mode. */ -function renderSentryBriefing(evidence) { - return h( - 'div', - { class: 'sentry-briefing', 'aria-label': 'How to investigate in Sentry' }, - h('h3', { class: 'sentry-briefing-title' }, 'What to look for in Sentry'), - h( - 'ol', - { class: 'sentry-briefing-list' }, - ...SENTRY_BRIEFINGS[evidence.bug].map((item, index) => - h( - 'li', - { class: 'sentry-briefing-item' }, - h('span', { class: 'option-letter', 'aria-hidden': 'true' }, String(index + 1)), - h('span', {}, item), - ), - ), - ), - ); -} - /** Render context tags as scannable pill badges. */ function renderContextPills(section) { return h( @@ -608,6 +484,19 @@ function crumbRow(entry) { ); } +/** + * 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); @@ -637,14 +526,12 @@ function waterfall(totalMs, spans) { h( 'span', { class: 'span-track', role: 'cell' }, - h('span', { - class: 'span-bar', - style: `left:${percent(span.startMs)};width:${percent(span.durationMs)}`, + positioned(h('span', { class: 'span-bar' }), { + left: percent(span.startMs), + width: percent(span.durationMs), }), - h('span', { - class: 'span-budget', - style: `left:${percent(BOSS_SPAWN_BUDGET_MS)}`, - 'aria-hidden': 'true', + positioned(h('span', { class: 'span-budget', 'aria-hidden': 'true' }), { + left: percent(BOSS_SPAWN_BUDGET_MS), }), ), h( diff --git a/challenges/glitch-invaders/game/src/ui/input.mjs b/challenges/glitch-invaders/game/src/ui/input.mjs index b5e5e18..148a169 100644 --- a/challenges/glitch-invaders/game/src/ui/input.mjs +++ b/challenges/glitch-invaders/game/src/ui/input.mjs @@ -39,10 +39,8 @@ export class InputController { const keyDown = (event) => this.keyDown(event); const keyUp = (event) => this.keyUp(event); const blur = () => this.releaseAll(); - const pointer = () => this.options.onActivity(); target.addEventListener('keydown', keyDown); target.addEventListener('keyup', keyUp); - target.addEventListener('pointerdown', pointer); window.addEventListener('blur', blur); for (const control of Object.keys(pads)) { @@ -52,7 +50,6 @@ export class InputController { return () => { target.removeEventListener('keydown', keyDown); target.removeEventListener('keyup', keyUp); - target.removeEventListener('pointerdown', pointer); window.removeEventListener('blur', blur); this.releaseAll(); }; @@ -73,7 +70,6 @@ export class InputController { attachPad(control, button) { const press = (event) => { event.preventDefault(); - this.options.onActivity(); this.padsDown.set(control, (this.padsDown.get(control) ?? 0) + 1); this.recompute(); }; @@ -93,7 +89,6 @@ export class InputController { if (event.detail !== 0) { return; } - this.options.onActivity(); this.pulse(control); }); } @@ -113,7 +108,6 @@ export class InputController { /** Handle a key press. */ keyDown(event) { - this.options.onActivity(); if (event.ctrlKey || event.metaKey || event.altKey) { return; } diff --git a/challenges/glitch-invaders/game/src/ui/overlays.mjs b/challenges/glitch-invaders/game/src/ui/overlays.mjs index 06cbbcc..6d765b1 100644 --- a/challenges/glitch-invaders/game/src/ui/overlays.mjs +++ b/challenges/glitch-invaders/game/src/ui/overlays.mjs @@ -1,5 +1,5 @@ import { BUG_DEFINITIONS, BUG_ORDER } from '../bugs/bug-definitions.mjs'; -import { focusableElements, h, onClick, setText } from './dom.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']; @@ -65,11 +65,6 @@ function card(title, label, ...children) { /** The briefing shown before play, or the welcome-back card after a refresh. */ export function introCard(options) { - const modeNote = - options.mode === 'demo' - ? 'Demo mode: the diagnostics are realistic simulations.' - : 'Live mode: real errors and traces go to Sentry.'; - return card( options.returning ? 'Welcome back' : 'Incident briefing', 'intro-title', @@ -99,7 +94,7 @@ export function introCard(options) { h('li', {}, h('kbd', {}, 'Space'), ' fires'), h('li', {}, h('kbd', {}, 'P'), ' pauses'), ), - h('p', { class: 'muted' }, modeNote), + h('p', { class: 'muted' }, 'Demo mode: the diagnostics are realistic simulations.'), onClick( h( 'button', @@ -128,35 +123,6 @@ export function pauseCard(onResume) { ); } -/** The inactivity card, with a countdown the caller updates. */ -export function inactivityCard(onStay) { - const countdown = h('strong', { class: 'countdown' }, '30'); - const element = card( - 'Still there?', - 'idle-title', - h( - 'p', - { class: 'lede' }, - 'This run resets in ', - countdown, - ' s so the next person starts fresh.', - ), - onClick( - h( - 'button', - { type: 'button', class: 'button button-primary button-large', 'data-autofocus': true }, - 'I’m here ▶', - ), - onStay, - ), - ); - - return { - element, - setSeconds: (seconds) => setText(countdown, String(seconds)), - }; -} - /** The recap shown once all three incidents are closed. */ export function completionCard(progress, onContinue, onReset) { return card( diff --git a/challenges/glitch-invaders/server/live-config.mjs b/challenges/glitch-invaders/server/live-config.mjs deleted file mode 100644 index 82acc2d..0000000 --- a/challenges/glitch-invaders/server/live-config.mjs +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Live Sentry settings. Boothstrap hooks only receive the vault's variables, so there is no - * station-level channel for a Sentry DSN yet; staff supply the settings either as environment - * variables or in a git-ignored `live.config.json` next to `challenge.json`. Environment wins. - */ -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import { LIVE_SETTING_NAMES } from '../shared/runtime-config.mjs'; - -/** The file name staff provision. */ -export const LIVE_CONFIG_FILE_NAME = 'live.config.json'; - -/** Optional `.env` file in the package root, supporting both `GLITCH_*` and `VITE_*` keys. */ -export const DOTENV_FILE_NAME = '.env'; - -/** Map `VITE_*` keys from the original Glitch Invaders `.env` format to `GLITCH_*` keys. */ -const ENV_ALIASES = [ - ['GLITCH_SENTRY_DSN', 'VITE_SENTRY_DSN'], - ['GLITCH_SENTRY_ORG', 'VITE_SENTRY_ORG'], - ['GLITCH_SENTRY_PROJECT', 'VITE_SENTRY_PROJECT'], - ['GLITCH_SENTRY_ENVIRONMENT', 'VITE_SENTRY_ENVIRONMENT'], -]; - -/** Parse a simple `.env` file for `GLITCH_*` or `VITE_*` Sentry settings. */ -async function readDotenvFile(file) { - let raw; - try { - raw = await readFile(file, 'utf8'); - } catch { - return {}; - } - - const entries = {}; - for (const rawLine of raw.split(/\r?\n/)) { - const line = rawLine.trim(); - if (line === '' || line.startsWith('#')) { - continue; - } - const eqIndex = line.indexOf('='); - if (eqIndex <= 0) { - continue; - } - const key = line.slice(0, eqIndex).trim(); - let value = line.slice(eqIndex + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - if (value !== '') { - entries[key] = value; - } - } - - const values = {}; - for (const [target, alias] of ENV_ALIASES) { - if (entries[alias] !== undefined) { - values[target] = entries[alias]; - } - } - for (const name of LIVE_SETTING_NAMES) { - if (entries[name] !== undefined) { - values[name] = entries[name]; - } - } - - return values; -} - -/** Read the staff-provisioned file, returning nothing for a missing one and a note for a bad one. */ -async function readLiveFile(file) { - let raw; - try { - raw = await readFile(file, 'utf8'); - } catch { - return { values: {}, note: null }; - } - - try { - const parsed = JSON.parse(raw); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - return { values: {}, note: `${path.basename(file)} must be a JSON object` }; - } - - const record = parsed; - const values = {}; - for (const [target, alias] of ENV_ALIASES) { - const value = record[alias]; - if (typeof value === 'string') { - values[target] = value; - } - } - for (const name of LIVE_SETTING_NAMES) { - const value = record[name]; - if (typeof value === 'string') { - values[name] = value; - } - } - - return { values, note: null }; - } catch { - return { values: {}, note: `${path.basename(file)} is not valid JSON` }; - } -} - -/** Combine `.env`, `live.config.json`, and the environment into one settings source. */ -export async function readLiveSource(env, packageDir) { - const fromDotenv = - env.GLITCH_LIVE_CONFIG === undefined - ? await readDotenvFile(path.join(packageDir, DOTENV_FILE_NAME)) - : {}; - const file = env.GLITCH_LIVE_CONFIG ?? path.join(packageDir, LIVE_CONFIG_FILE_NAME); - const fromFile = await readLiveFile(file); - const source = { ...fromDotenv, ...fromFile.values }; - for (const [target, alias] of ENV_ALIASES) { - const value = env[alias]; - if (value !== undefined && value !== '') { - source[target] = value; - } - } - for (const name of LIVE_SETTING_NAMES) { - const value = env[name]; - if (value !== undefined && value !== '') { - source[name] = value; - } - } - - return { source, note: fromFile.note }; -} diff --git a/challenges/glitch-invaders/server/page-server.mjs b/challenges/glitch-invaders/server/page-server.mjs index 495d2c4..4d2afe5 100644 --- a/challenges/glitch-invaders/server/page-server.mjs +++ b/challenges/glitch-invaders/server/page-server.mjs @@ -3,10 +3,16 @@ * 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 { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +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 = { @@ -21,12 +27,20 @@ const CONTENT_TYPES = { '.txt': 'text/plain; charset=utf-8', }; -/** Headers every response carries: nothing is cached, because a replay must never see old state. */ +/** + * 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'] }); @@ -60,93 +74,178 @@ function originIsOwn(request) { return origin === undefined || allowedOrigins(request).includes(origin); } -/** Read the request body, refusing anything over `limit` bytes. */ -async function readBody(request, limit) { - const chunks = []; - let size = 0; - for await (const chunk of request) { - const buffer = chunk; - size += buffer.length; - if (size > limit) { - return null; - } - chunks.push(buffer); - } +/** 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 Buffer.concat(chunks).toString('utf8'); -} + return; + } + chunks.push(chunk); + }; + + /** Resolve with the whole body. */ + const onEnd = () => resolve(Buffer.concat(chunks).toString('utf8')); -/** Write `contents` to `file` atomically, so a reader never sees half a file. */ -async function writeAtomic(file, contents) { - await mkdir(path.dirname(file), { recursive: true }); - const temporary = `${file}.${process.pid}.tmp`; - await writeFile(temporary, contents, { mode: 0o600 }); - await rename(temporary, file); + request.on('data', onData); + request.on('end', onEnd); + request.once('error', reject); + }); } -/** Handle `/api/progress`. */ -async function handleProgress(request, response, options) { - if (request.method === 'GET') { - try { - const contents = await readFile(options.progressFile, 'utf8'); - response.writeHead(200, { ...BASE_HEADERS, 'content-type': CONTENT_TYPES['.json'] }); - response.end(contents); - } catch { - sendStatus(response, 204); +/** + * 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'); +} - return; - } - - if (!originIsOwn(request)) { - sendStatus(response, 403, 'cross-origin writes are not allowed'); +/** 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; + return; + } + throw error; } - - if (request.method === 'DELETE') { - await rm(options.progressFile, { force: true }); + if (contents === null) { sendStatus(response, 204); return; } - if (request.method !== 'PUT') { - sendStatus(response, 405); + response.writeHead(200, { ...BASE_HEADERS, 'content-type': CONTENT_TYPES['.json'] }); + response.end(contents); +} - return; +/** 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) { - sendStatus(response, 413, 'progress is too large'); + 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 { - const progress = parseProgress(JSON.parse(body)); - if (progress.attemptId !== options.runtimeConfig.attemptId) { - sendStatus(response, 409, 'progress belongs to another attempt'); + await write(); + } catch (error) { + if (error instanceof RefusedFileError) { + sendStatus(response, 422, error.message); return; } - await writeAtomic(options.progressFile, `${JSON.stringify(progress, null, 2)}\n`); - sendStatus(response, 204); - } catch (error) { - sendStatus(response, 400, error instanceof Error ? error.message : 'invalid progress'); + 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, distDir) { - const decoded = decodeURIComponent(pathname); + 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(distDir, '..', 'shared') : path.resolve(distDir); const relative = @@ -185,8 +284,10 @@ export function createPageServer(options) { return; } - const { pathname } = new URL(request.url ?? '/', 'http://localhost'); - if (pathname === '/healthz') { + 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); diff --git a/challenges/glitch-invaders/server/serve.mjs b/challenges/glitch-invaders/server/serve.mjs index 8d04c91..f61c885 100644 --- a/challenges/glitch-invaders/server/serve.mjs +++ b/challenges/glitch-invaders/server/serve.mjs @@ -12,27 +12,24 @@ function report(message) { process.send?.(message, () => process.disconnect?.()); } -/** Read a required environment variable. */ -function requiredEnv(name) { - const value = process.env[name]; - if (value === undefined || value === '') { - throw new Error(`${name} is not set`); +/** 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 value; + return { port: Number(portText), runtimeConfigFile, progressFile }; } /** Start serving. */ function main() { - const port = Number(requiredEnv('GLITCH_PORT')); - const runtimeConfig = parseRuntimeConfig( - JSON.parse(readFileSync(requiredEnv('GLITCH_RUNTIME_CONFIG_FILE'), 'utf8')), - ); - const server = createPageServer({ - distDir: path.resolve(requiredEnv('GLITCH_DIST_DIR')), - progressFile: requiredEnv('GLITCH_PROGRESS_FILE'), - runtimeConfig, - }); + const { port, runtimeConfigFile, progressFile } = readArguments(process.argv.slice(2)); + const runtimeConfig = parseRuntimeConfig(JSON.parse(readFileSync(runtimeConfigFile, 'utf8'))); + const server = createPageServer({ distDir: GAME_DIR, progressFile, runtimeConfig }); server.once('error', (error) => { report({ listening: false, error: error.message }); diff --git a/challenges/glitch-invaders/server/setup.mjs b/challenges/glitch-invaders/server/setup.mjs index 7995eac..bc10a00 100644 --- a/challenges/glitch-invaders/server/setup.mjs +++ b/challenges/glitch-invaders/server/setup.mjs @@ -1,26 +1,20 @@ /** - * Setup: prepare a clean attempt and start the page server. Deterministic and fast: it resolves - * live or demo mode, writes a fresh runtime config, serves the prebuilt game in the background, - * and exits 0 only once the server answers, so Chrome never opens an unready page. + * 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 { mkdir, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { - DEFAULT_INACTIVITY_RESET_MS, - DEFAULT_INACTIVITY_WARNING_MS, - decideMode, -} from '../shared/runtime-config.mjs'; import { stopServer } from './cleanup.mjs'; -import { readLiveSource } from './live-config.mjs'; import { resolveStatePaths } from './paths.mjs'; /** How long setup waits for the page server to listen. */ const READY_TIMEOUT_MS = 10_000; -/** The package directory: where `challenge.json`, `dist/`, and `server/` live. */ +/** 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. */ @@ -33,27 +27,17 @@ function resolvePort(env) { return Number(new URL(manifest.browser.start_url).port); } -/** A duration override from the environment, or the default when unset or out of range. */ -function resolveDuration(value, fallback, max) { - const parsed = Number(value); - - return value !== undefined && Number.isFinite(parsed) && parsed >= 1_000 && parsed <= max - ? parsed - : fallback; -} - /** Start the page server in the background and wait for it to listen. */ function startServer(paths, port) { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [path.join(PACKAGE_DIR, 'server', 'serve.mjs')], { + 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'], - env: { - ...process.env, - GLITCH_PORT: String(port), - GLITCH_DIST_DIR: path.join(PACKAGE_DIR, 'game'), - GLITCH_PROGRESS_FILE: paths.progressFile, - GLITCH_RUNTIME_CONFIG_FILE: paths.runtimeConfigFile, - }, }); const timer = setTimeout(() => { child.kill('SIGKILL'); @@ -96,25 +80,7 @@ export async function runSetup(env, cwd) { await mkdir(paths.stateDir, { recursive: true }); await rm(paths.progressFile, { force: true }); - const { source, note } = await readLiveSource(env, PACKAGE_DIR); - const decision = decideMode(source); - const config = { - attemptId: randomUUID(), - mode: decision.sentry === null ? 'demo' : 'live', - stationId: env.BOOTHSTRAP_STATION_ID?.slice(0, 128) ?? null, - sessionId: env.BOOTHSTRAP_SESSION_ID?.slice(0, 128) ?? null, - inactivityWarningMs: resolveDuration( - env.GLITCH_INACTIVITY_WARNING_MS, - DEFAULT_INACTIVITY_WARNING_MS, - 3_600_000, - ), - inactivityResetMs: resolveDuration( - env.GLITCH_INACTIVITY_RESET_MS, - DEFAULT_INACTIVITY_RESET_MS, - 600_000, - ), - sentry: decision.sentry, - }; + const config = { attemptId: randomUUID() }; await writeFile(paths.runtimeConfigFile, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); const port = resolvePort(env); @@ -123,9 +89,7 @@ export async function runSetup(env, cwd) { return { url: `http://127.0.0.1:${port}/`, - mode: config.mode, attemptId: config.attemptId, - demoReason: decision.demoReason ?? note, serverPid, }; } @@ -133,10 +97,7 @@ export async function runSetup(env, cwd) { if (import.meta.main) { runSetup(process.env, process.cwd()).then( (result) => { - process.stdout.write(`Glitch Invaders is ready at ${result.url} (${result.mode} mode)\n`); - if (result.mode === 'demo' && result.demoReason !== null) { - process.stdout.write(`note: live mode is off: ${result.demoReason}\n`); - } + process.stdout.write(`Glitch Invaders is ready at ${result.url}\n`); process.exit(0); }, (error) => { diff --git a/challenges/glitch-invaders/server/workspace-file.mjs b/challenges/glitch-invaders/server/workspace-file.mjs new file mode 100644 index 0000000..d964767 --- /dev/null +++ b/challenges/glitch-invaders/server/workspace-file.mjs @@ -0,0 +1,139 @@ +/** + * 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. + */ +import { randomBytes } from 'node:crypto'; +import { constants } from 'node:fs'; +import { lstat, open, rename, rm } 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'; + } +} + +/** 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) { + let stats; + try { + stats = await lstat(file); + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + throw error; + } + 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(); + await rename(temporary, file); +} + +/** + * 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 }); + } +} diff --git a/challenges/glitch-invaders/setup b/challenges/glitch-invaders/setup index aaee2a1..3e8a00f 100755 --- a/challenges/glitch-invaders/setup +++ b/challenges/glitch-invaders/setup @@ -1,5 +1,5 @@ #!/bin/sh -# Setup hook: resolve demo or live mode, write a fresh runtime config into the workspace, and +# 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 index ad760dc..8472920 100644 --- a/challenges/glitch-invaders/shared/progress.mjs +++ b/challenges/glitch-invaders/shared/progress.mjs @@ -30,12 +30,15 @@ export class InvalidProgressError extends Error { /** 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, mode) { +export function createProgress(attemptId) { return { version: 1, attemptId, - mode, + mode: MODE, launched: false, completed: false, score: 0, @@ -148,8 +151,8 @@ export function parseProgress(value) { 'attemptId must be 4 to 64 lowercase letters, digits, or hyphens', ); } - if (!isOneOf(['demo', 'live'], record.mode)) { - throw new InvalidProgressError('mode must be demo or live'); + 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) { @@ -159,7 +162,7 @@ export function parseProgress(value) { return { version: 1, attemptId: record.attemptId, - mode: record.mode, + mode: MODE, launched: readFlag(record, 'launched', 'progress'), completed: readFlag(record, 'completed', 'progress'), score: readCount(record, 'score', 'progress'), diff --git a/challenges/glitch-invaders/shared/runtime-config.mjs b/challenges/glitch-invaders/shared/runtime-config.mjs index 509b4d5..14a1add 100644 --- a/challenges/glitch-invaders/shared/runtime-config.mjs +++ b/challenges/glitch-invaders/shared/runtime-config.mjs @@ -1,17 +1,7 @@ -/** The environment names read for live settings, shared by the env and file readers. */ -export const LIVE_SETTING_NAMES = [ - 'GLITCH_SENTRY_DSN', - 'GLITCH_SENTRY_ORG', - 'GLITCH_SENTRY_PROJECT', - 'GLITCH_SENTRY_ENVIRONMENT', - 'GLITCH_MODE', -]; - -/** Default idle time before the warning, under Boothstrap's own 3 minute idle timeout. */ -export const DEFAULT_INACTIVITY_WARNING_MS = 120_000; - -/** How long the warning counts down before the run resets. */ -export const DEFAULT_INACTIVITY_RESET_MS = 30_000; +/** + * 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 { @@ -21,160 +11,23 @@ export class InvalidRuntimeConfigError extends Error { } } -/** Whether `dsn` looks like `https://@/`. */ -export function isValidDsn(dsn) { - try { - const url = new URL(dsn); - return ( - url.protocol === 'https:' && - url.username.length > 0 && - url.hostname.length > 0 && - /^\/\d+$/.test(url.pathname) - ); - } catch { - return false; - } -} - -/** Whether `value` is a Sentry slug, as used in organization and project URLs. */ -function isSlug(value) { - return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(value); -} - /** - * Choose live or demo from the provided settings. Live needs a valid DSN plus the organization and - * project slugs that build evidence links; anything less is demo, with a reason naming what is - * missing so staff can fix it without the game ever failing to start. - */ -export function decideMode(source) { - if (source.GLITCH_MODE === 'demo') { - return { sentry: null, demoReason: 'GLITCH_MODE is demo' }; - } - - const dsn = source.GLITCH_SENTRY_DSN?.trim() ?? ''; - const org = source.GLITCH_SENTRY_ORG?.trim() ?? ''; - const project = source.GLITCH_SENTRY_PROJECT?.trim() ?? ''; - if (dsn === '' && org === '' && project === '') { - return { sentry: null, demoReason: null }; - } - - const problems = []; - if (!isValidDsn(dsn)) { - problems.push('GLITCH_SENTRY_DSN is missing or not a Sentry DSN'); - } - if (!isSlug(org)) { - problems.push('GLITCH_SENTRY_ORG is missing or not a slug'); - } - if (!isSlug(project)) { - problems.push('GLITCH_SENTRY_PROJECT is missing or not a slug'); - } - if (problems.length > 0) { - return { sentry: null, demoReason: `live settings incomplete: ${problems.join('; ')}` }; - } - - const environment = source.GLITCH_SENTRY_ENVIRONMENT?.trim(); - - return { - sentry: { dsn, org, project, environment: environment || 'booth' }, - demoReason: null, - }; -} - -/** Read `key` from `record` as a finite number within `[min, max]`. */ -function readDuration(record, key, min, max) { - const value = record[key]; - if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) { - throw new InvalidRuntimeConfigError(`${key} must be a number from ${min} to ${max}`); - } - - return value; -} - -/** Read `key` from `record` as a string or `null`. */ -function readNullableText(record, key) { - const value = record[key]; - if (value === null || value === undefined) { - return null; - } - if (typeof value !== 'string' || value.length > 128) { - throw new InvalidRuntimeConfigError(`${key} must be a short string or null`); - } - - return value; -} - -/** Validate the Sentry part of a served config. */ -function parseSentrySettings(value) { - if (value === null || value === undefined) { - return null; - } - if (typeof value !== 'object' || Array.isArray(value)) { - throw new InvalidRuntimeConfigError('sentry must be an object or null'); - } - - const record = value; - const { dsn, org, project, environment } = record; - if (typeof dsn !== 'string' || !isValidDsn(dsn)) { - throw new InvalidRuntimeConfigError('sentry.dsn must be a Sentry DSN'); - } - if (typeof org !== 'string' || !isSlug(org) || typeof project !== 'string' || !isSlug(project)) { - throw new InvalidRuntimeConfigError('sentry.org and sentry.project must be slugs'); - } - if (typeof environment !== 'string' || environment === '') { - throw new InvalidRuntimeConfigError('sentry.environment must be a non-empty string'); - } - - return { dsn, org, project, environment }; -} - -/** - * Validate untrusted JSON as a {@link RuntimeConfig}, requiring `mode` to agree with `sentry`. + * Validate untrusted JSON as a runtime config. * - * @throws {InvalidRuntimeConfigError} when a field is missing, malformed, or contradicts `mode`. + * @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 record = value; - const { attemptId, mode } = record; + 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', ); } - if (mode !== 'demo' && mode !== 'live') { - throw new InvalidRuntimeConfigError('mode must be demo or live'); - } - - const sentry = parseSentrySettings(record.sentry); - if ((mode === 'live') !== (sentry !== null)) { - throw new InvalidRuntimeConfigError( - 'live mode needs sentry settings, and demo mode must have none', - ); - } - - return { - attemptId, - mode, - stationId: readNullableText(record, 'stationId'), - sessionId: readNullableText(record, 'sessionId'), - inactivityWarningMs: readDuration(record, 'inactivityWarningMs', 1_000, 3_600_000), - inactivityResetMs: readDuration(record, 'inactivityResetMs', 1_000, 600_000), - sentry, - }; -} -/** The config the game falls back to when none is served, such as under `vite dev`. */ -export function createDemoRuntimeConfig(attemptId) { - return { - attemptId, - mode: 'demo', - stationId: null, - sessionId: null, - inactivityWarningMs: DEFAULT_INACTIVITY_WARNING_MS, - inactivityResetMs: DEFAULT_INACTIVITY_RESET_MS, - sentry: null, - }; + return { attemptId }; } From cf44f805db3c67c0ec3b3586a5c76019efbbaed3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 01:27:56 +0000 Subject: [PATCH 4/8] test: cover Glitch Invaders hook edge cases and server refusals --- test/glitch-invaders.test.mjs | 447 +++++++++++++++++++++++++--------- 1 file changed, 326 insertions(+), 121 deletions(-) diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index 6a92ff4..c449401 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -1,15 +1,15 @@ // 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 -// zero-dependency SentryReporter builds valid event and span v2 envelopes; the -// page server serves native .mjs modules, enforces origin/host/size checks on -// progress writes, and starts and stops cleanly through setup and cleanup. +// 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 } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { request } from 'node:http'; +import { mkdtemp, readFile, 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'; @@ -29,9 +29,7 @@ import { IllegalBugTransitionError, IncidentTracker, } from '../challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs'; -import { BreadcrumbRecorder } from '../challenges/glitch-invaders/game/src/diagnostics/breadcrumbs.mjs'; import { WEAPON_STACK_FRAMES } from '../challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs'; -import { LiveEvidenceProvider } from '../challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs'; import { SimulatedEvidenceProvider } from '../challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs'; import { BOSS_SPAWN_BUDGET_MS, @@ -43,7 +41,6 @@ import { 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 { parseDsn, SentryReporter } from '../challenges/glitch-invaders/game/src/sentry/sentry-reporter.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'; @@ -54,6 +51,9 @@ 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 { @@ -143,7 +143,7 @@ function createHarness(code = createBrokenCodeState(), seed = 7) { /** A code state where `bugs` have been repaired with their correct patches. */ function repaired(...bugs) { - const progress = createProgress('test-attempt', 'demo'); + const progress = createProgress('test-attempt'); for (const bug of bugs) { progress.bugs[bug].stage = 'repaired'; } @@ -164,12 +164,102 @@ function freePort() { }); } -/** The environment of a station that provides no live Sentry settings. */ -function demoEnvironment() { - return { - PATH: process.env.PATH, - GLITCH_LIVE_CONFIG: path.join(os.tmpdir(), 'glitch-test-no-live-config.json'), - }; +/** 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`. */ @@ -177,7 +267,7 @@ async function startStack(testContext, extraEnv = {}) { const workspace = await mkdtemp(path.join(os.tmpdir(), 'glitch-ws-')); const port = await freePort(); const env = { - ...demoEnvironment(), + PATH: process.env.PATH, BOOTHSTRAP_WORKSPACE: workspace, BOOTHSTRAP_STATION_ID: 'test-station', BOOTHSTRAP_SESSION_ID: 'test-session', @@ -215,18 +305,40 @@ async function answers(port) { } } -/** The status of `/healthz` when the request claims to be for `host`. */ -function statusWithHost(url, host) { +/** + * 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 probe = request(`${url}healthz`, { headers: { host } }, (response) => { - response.resume(); - resolve(response.statusCode ?? 0); - }); + 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', @@ -419,7 +531,7 @@ test('continues into a tougher boss showdown after completion and finishes when }); test('IncidentTracker enforces stage order, numbers milestones once, and formats the state marker', () => { - const tracker = new IncidentTracker(createProgress('test-attempt', 'demo')); + const tracker = new IncidentTracker(createProgress('test-attempt')); const correctDiagnosis = { weapon: 'missing-config', shield: 'amplified', @@ -448,7 +560,7 @@ test('IncidentTracker enforces stage order, numbers milestones once, and formats ); }); -test('SimulatedEvidenceProvider and LiveEvidenceProvider build structured evidence and links', async () => { +test('SimulatedEvidenceProvider builds labelled evidence with a stack trace for the weapon jam', async () => { const weaponEncounter = { bug: 'weapon', jam: { @@ -457,102 +569,18 @@ test('SimulatedEvidenceProvider and LiveEvidenceProvider build structured eviden error: new Error('Configuration missing for weapon: fanout'), }, }; + const simulated = await new SimulatedEvidenceProvider().evidenceFor(weaponEncounter); + assert.equal(simulated.source, 'simulated'); - assert.equal(simulated.link, null); + assert.equal(simulated.bug, 'weapon'); assert.ok(simulated.sections.some((section) => section.kind === 'stacktrace')); - - const live = new LiveEvidenceProvider({ - reporter: { - add: () => undefined, - traceBossSpawn: async (work) => ({ - result: await work((_name, _op, load) => load()), - traceId: null, - }), - report: async () => ({ referenceId: 'evt1', delivery: 'confirmed' }), - }, - settings: { - dsn: 'https://key@o1.ingest.sentry.io/1', - org: 'acme', - project: 'booth', - environment: 'booth', - }, - attemptId: 'attempt-1', - recorder: new BreadcrumbRecorder(() => 0), - clock: () => 0, - }); - const liveEvidence = await live.evidenceFor(weaponEncounter); - assert.equal(liveEvidence.source, 'live'); - assert.ok(liveEvidence.link?.url.includes('https://acme.sentry.io/issues/')); -}); - -test('SentryReporter prewarms the boss-spawn v2 span envelope and sends one event envelope per bug', async () => { - const requests = []; - const fakeFetch = async (url, options) => { - requests.push({ url, body: options.body }); - - return { ok: true }; - }; - const settings = { - dsn: 'https://publickey@o42.ingest.us.sentry.io/99', - org: 'acme', - project: 'booth', - environment: 'booth', - }; - const parsed = parseDsn(settings.dsn); - assert.equal(parsed.publicKey, 'publickey'); - assert.equal(parsed.projectId, '99'); - assert.equal(parsed.orgId, '42'); - - const reporter = new SentryReporter( - settings, - { - attemptId: 'attempt-1', - stationId: 'station-1', - sessionId: null, - release: 'glitch-invaders@1.0.0', - }, - fakeFetch, - ); - - assert.equal(requests.length, 1); - const [, spanHeaderLine, spanPayloadLine] = requests[0].body.trim().split('\n'); - assert.equal(JSON.parse(spanHeaderLine).type, 'span'); - assert.equal(JSON.parse(spanPayloadLine).items.length, 5); - - const traced = await reporter.traceBossSpawn(async () => 'done'); - assert.match(traced.traceId, /^[0-9a-f]{32}$/); - assert.equal(traced.result, 'done'); - - reporter.add({ category: 'game.shield', message: 'Shield enabled', data: { shieldActive: 'true' } }); - const jam = { - bug: 'weapon', - jam: { - weaponId: 'fanout', - availableWeapons: ['single'], - error: new Error('Configuration missing for weapon: fanout'), - }, - }; - const first = await reporter.report(jam); - const second = await reporter.report(jam); - - assert.equal(requests.length, 2); - assert.deepEqual(second, first); - assert.equal(first.delivery, 'confirmed'); - const [, eventHeaderLine, eventPayloadLine] = requests[1].body.trim().split('\n'); - assert.equal(JSON.parse(eventHeaderLine).type, 'event'); - assert.equal(JSON.parse(eventPayloadLine).exception.values[0].type, 'WeaponConfigError'); }); 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 = {}) => - fetch(`${base}/api/progress`, { - method: 'PUT', - headers: { 'content-type': 'application/json', ...headers }, - body, - }); + const putProgress = (body, headers = {}) => putProgressTo(stack.url, body, headers); assert.equal((await fetch(`${base}/healthz`)).status, 200); @@ -569,11 +597,9 @@ test('the page server serves native .mjs modules, stores progress, and refuses c assert.match(sharedResponse.headers.get('content-type') ?? '', /text\/javascript/); const config = await (await fetch(`${base}/runtime-config.json`)).json(); - assert.equal(config.mode, 'demo'); - assert.equal(config.sentry, null); - assert.equal(config.attemptId, stack.attemptId); + assert.deepEqual(config, { attemptId: stack.attemptId }); - const progress = createProgress(stack.attemptId, 'demo'); + const progress = createProgress(stack.attemptId); progress.launched = true; assert.equal((await putProgress(JSON.stringify(progress))).status, 204); @@ -581,7 +607,7 @@ test('the page server serves native .mjs modules, stores progress, and refuses c assert.equal(stored.launched, true); assert.equal(JSON.parse(await readFile(stack.paths.progressFile, 'utf8')).launched, true); - const other = createProgress('another-attempt', 'demo'); + 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); @@ -591,7 +617,7 @@ test('the page server serves native .mjs modules, stores progress, and refuses c (await putProgress(JSON.stringify(progress), { origin: 'https://evil.example' })).status, 403, ); - assert.equal(await statusWithHost(stack.url, 'evil.example'), 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); @@ -602,7 +628,7 @@ test('the shipped setup and cleanup hooks start and stop the server and clean up const workspace = await mkdtemp(path.join(os.tmpdir(), 'glitch-hooks-')); const port = await freePort(); const env = { - ...demoEnvironment(), + PATH: process.env.PATH, BOOTHSTRAP_WORKSPACE: workspace, BOOTHSTRAP_PACKAGE_DIR: packageDirectory, GLITCH_PORT: String(port), @@ -627,3 +653,182 @@ test('the shipped setup and cleanup hooks start and stop the server and clean up 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 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('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); +}); From 5988b7541fd7f70beb3a1104aa0d0b0ffce7d386 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 01:32:05 +0000 Subject: [PATCH 5/8] fix: tidy Glitch Invaders naming, badge text and temp-file cleanup --- challenges/glitch-invaders/game/index.html | 2 +- challenges/glitch-invaders/server/page-server.mjs | 8 ++++---- challenges/glitch-invaders/server/serve.mjs | 2 +- challenges/glitch-invaders/server/workspace-file.mjs | 8 +++++++- .../glitch-invaders/skills/start-challenge/SKILL.md | 10 ++++++---- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/challenges/glitch-invaders/game/index.html b/challenges/glitch-invaders/game/index.html index 2c41114..2173e8d 100644 --- a/challenges/glitch-invaders/game/index.html +++ b/challenges/glitch-invaders/game/index.html @@ -21,7 +21,7 @@

  4. SQUASHED 0
  5. SCORE 0
  6. - DEMO · simulated evidence + DEMO · simulated evidence diff --git a/challenges/glitch-invaders/server/page-server.mjs b/challenges/glitch-invaders/server/page-server.mjs index 4d2afe5..f12d7dc 100644 --- a/challenges/glitch-invaders/server/page-server.mjs +++ b/challenges/glitch-invaders/server/page-server.mjs @@ -1,5 +1,5 @@ /** - * The page server: serves the built game, this attempt's runtime config, and the progress file + * 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. */ @@ -237,7 +237,7 @@ async function handleProgress(request, response, options) { } /** Serve a file from the game directory (or `/shared/`), with `/` meaning `index.html`. */ -async function handleStatic(pathname, response, distDir) { +async function handleStatic(pathname, response, gameDir) { let decoded; try { decoded = decodeURIComponent(pathname); @@ -247,7 +247,7 @@ async function handleStatic(pathname, response, distDir) { return; } const isShared = decoded.startsWith('/shared/'); - const baseDir = isShared ? path.resolve(distDir, '..', 'shared') : path.resolve(distDir); + 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); @@ -294,7 +294,7 @@ export function createPageServer(options) { } else if (pathname === '/api/progress') { await handleProgress(request, response, options); } else if (request.method === 'GET' || request.method === 'HEAD') { - await handleStatic(pathname, response, options.distDir); + await handleStatic(pathname, response, options.gameDir); } else { sendStatus(response, 405); } diff --git a/challenges/glitch-invaders/server/serve.mjs b/challenges/glitch-invaders/server/serve.mjs index f61c885..38fd5dc 100644 --- a/challenges/glitch-invaders/server/serve.mjs +++ b/challenges/glitch-invaders/server/serve.mjs @@ -29,7 +29,7 @@ function readArguments(argv) { function main() { const { port, runtimeConfigFile, progressFile } = readArguments(process.argv.slice(2)); const runtimeConfig = parseRuntimeConfig(JSON.parse(readFileSync(runtimeConfigFile, 'utf8'))); - const server = createPageServer({ distDir: GAME_DIR, progressFile, runtimeConfig }); + const server = createPageServer({ gameDir: GAME_DIR, progressFile, runtimeConfig }); server.once('error', (error) => { report({ listening: false, error: error.message }); diff --git a/challenges/glitch-invaders/server/workspace-file.mjs b/challenges/glitch-invaders/server/workspace-file.mjs index d964767..63698fb 100644 --- a/challenges/glitch-invaders/server/workspace-file.mjs +++ b/challenges/glitch-invaders/server/workspace-file.mjs @@ -124,7 +124,13 @@ export async function writeWorkspaceFile(file, contents) { throw error; } await handle.close(); - await rename(temporary, file); + + try { + await rename(temporary, file); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } } /** diff --git a/challenges/glitch-invaders/skills/start-challenge/SKILL.md b/challenges/glitch-invaders/skills/start-challenge/SKILL.md index f943e34..d6fa595 100644 --- a/challenges/glitch-invaders/skills/start-challenge/SKILL.md +++ b/challenges/glitch-invaders/skills/start-challenge/SKILL.md @@ -34,8 +34,9 @@ have not pressed Start. ## Start -1. Call `session_info`. Greet them by name. If steps are already complete, - resume from the first incomplete one instead of starting over. +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. @@ -64,8 +65,9 @@ evidence is labeled `SIMULATED`; that is expected. Do not call it a problem. `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`. If -it is not done yet, say "Checking the page, give it a few seconds." +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 From e774283897f1a8cb645aad670fdb27fefedbe9fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 01:35:45 +0000 Subject: [PATCH 6/8] fix: guard Glitch Invaders setup and cleanup against planted workspace paths --- challenges/glitch-invaders/server/cleanup.mjs | 7 +- challenges/glitch-invaders/server/setup.mjs | 14 ++-- .../glitch-invaders/server/workspace-file.mjs | 70 ++++++++++++++--- test/glitch-invaders.test.mjs | 77 ++++++++++++++++++- 4 files changed, 149 insertions(+), 19 deletions(-) diff --git a/challenges/glitch-invaders/server/cleanup.mjs b/challenges/glitch-invaders/server/cleanup.mjs index ba13c54..5e7124d 100644 --- a/challenges/glitch-invaders/server/cleanup.mjs +++ b/challenges/glitch-invaders/server/cleanup.mjs @@ -3,8 +3,9 @@ * setup, or with nothing set up at all. It exits 0 in every one of those cases. */ import { execFileSync } from 'node:child_process'; -import { readFile, rm } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { resolveStatePaths } from './paths.mjs'; +import { clearWorkspacePath } from './workspace-file.mjs'; /** How long the server gets to stop on SIGTERM before it is killed. */ const GRACE_MS = 3_000; @@ -75,8 +76,8 @@ export async function stopServer(paths) { /** Stop the server and remove every file the attempt created. */ export async function runCleanup(paths) { await stopServer(paths); - await rm(paths.stateDir, { recursive: true, force: true }); - await rm(paths.progressFile, { force: true }); + await clearWorkspacePath(paths.stateDir); + await clearWorkspacePath(paths.progressFile); } if (import.meta.main) { diff --git a/challenges/glitch-invaders/server/setup.mjs b/challenges/glitch-invaders/server/setup.mjs index bc10a00..ec25176 100644 --- a/challenges/glitch-invaders/server/setup.mjs +++ b/challenges/glitch-invaders/server/setup.mjs @@ -6,10 +6,14 @@ import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; -import { mkdir, rm, writeFile } from 'node:fs/promises'; 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; @@ -77,15 +81,15 @@ export async function runSetup(env, cwd) { if (env.GLITCH_PORT === undefined && paths.workspace !== PACKAGE_DIR) { await stopServer(resolveStatePaths({}, PACKAGE_DIR)); } - await mkdir(paths.stateDir, { recursive: true }); - await rm(paths.progressFile, { force: true }); + await ensureWorkspaceDirectory(paths.stateDir); + await clearWorkspacePath(paths.progressFile); const config = { attemptId: randomUUID() }; - await writeFile(paths.runtimeConfigFile, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + await writeWorkspaceFile(paths.runtimeConfigFile, `${JSON.stringify(config, null, 2)}\n`); const port = resolvePort(env); const serverPid = await startServer(paths, port); - await writeFile(paths.pidFile, `${serverPid}\n`, { mode: 0o600 }); + await writeWorkspaceFile(paths.pidFile, `${serverPid}\n`); return { url: `http://127.0.0.1:${port}/`, diff --git a/challenges/glitch-invaders/server/workspace-file.mjs b/challenges/glitch-invaders/server/workspace-file.mjs index 63698fb..1f03963 100644 --- a/challenges/glitch-invaders/server/workspace-file.mjs +++ b/challenges/glitch-invaders/server/workspace-file.mjs @@ -2,11 +2,12 @@ * 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. + * 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, open, rename, rm } from 'node:fs/promises'; +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. */ @@ -23,6 +24,18 @@ export class RefusedFileError extends Error { } } +/** 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; @@ -40,14 +53,9 @@ function notLoneRegularFile(file) { * it exists at all. */ async function checkExisting(file) { - let stats; - try { - stats = await lstat(file); - } catch (error) { - if (error.code === 'ENOENT') { - return false; - } - throw error; + const stats = await lstatIfPresent(file); + if (stats === null) { + return false; } if (!isLoneRegularFile(stats)) { throw notLoneRegularFile(file); @@ -143,3 +151,45 @@ export async function removeWorkspaceFile(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/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index c449401..f9aa050 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -8,7 +8,7 @@ import assert from 'node:assert/strict'; import { execFile, spawn } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; -import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +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'; @@ -814,6 +814,81 @@ test('cleanup exits 0 when the workspace does not exist', async (testContext) => 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 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); From a46fb59ba27ced2707a11a395dc53ab2f1bce1cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 01:42:32 +0000 Subject: [PATCH 7/8] fix: show a banner when Glitch Invaders can't save progress --- challenges/glitch-invaders/game/index.html | 1 + challenges/glitch-invaders/game/src/main.mjs | 10 +++++++++- challenges/glitch-invaders/game/src/styles.css | 8 ++++++++ test/glitch-invaders.test.mjs | 14 ++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/challenges/glitch-invaders/game/index.html b/challenges/glitch-invaders/game/index.html index 2173e8d..5851ab9 100644 --- a/challenges/glitch-invaders/game/index.html +++ b/challenges/glitch-invaders/game/index.html @@ -64,6 +64,7 @@

    Controls

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

    +
    diff --git a/challenges/glitch-invaders/game/src/main.mjs b/challenges/glitch-invaders/game/src/main.mjs index b618964..32e19eb 100644 --- a/challenges/glitch-invaders/game/src/main.mjs +++ b/challenges/glitch-invaders/game/src/main.mjs @@ -24,6 +24,14 @@ function showFatal(error) { ); } +/** 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(); @@ -32,7 +40,7 @@ async function main() { const store = new HttpProgressStore( '/api/progress', (input, init) => fetch(input, init), - () => {}, + showSaveError, ); let saved = null; diff --git a/challenges/glitch-invaders/game/src/styles.css b/challenges/glitch-invaders/game/src/styles.css index 06f89a8..c4572fa 100644 --- a/challenges/glitch-invaders/game/src/styles.css +++ b/challenges/glitch-invaders/game/src/styles.css @@ -71,6 +71,14 @@ ul { 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; diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index f9aa050..2ab6cdc 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -41,6 +41,7 @@ import { 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 { HttpProgressStore } from '../challenges/glitch-invaders/game/src/session/progress-store.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'; @@ -666,6 +667,19 @@ test('the page server sends the CSP on the page and on an error response', async assert.equal(missing.headers['content-security-policy'], CONTENT_SECURITY_POLICY); }); +test('a refused progress save reports the failure instead of swallowing it', async () => { + const errors = []; + const store = new HttpProgressStore( + '/api/progress', + async () => new Response('refused', { status: 422 }), + (error) => errors.push(error.message), + ); + + await store.save(createProgress('attempt-1')); + + assert.deepEqual(errors, ['progress save failed with status 422']); +}); + 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'); From ecc41f464c9eb39b30d62f5b09e82a56975f35f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 01:46:21 +0000 Subject: [PATCH 8/8] fix: keep a planted Glitch Invaders pid symlink from leaking the server --- challenges/glitch-invaders/server/cleanup.mjs | 13 +++-- challenges/glitch-invaders/server/setup.mjs | 8 ++- test/glitch-invaders.test.mjs | 49 ++++++++++++++----- 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/challenges/glitch-invaders/server/cleanup.mjs b/challenges/glitch-invaders/server/cleanup.mjs index 5e7124d..3d92ca6 100644 --- a/challenges/glitch-invaders/server/cleanup.mjs +++ b/challenges/glitch-invaders/server/cleanup.mjs @@ -3,9 +3,8 @@ * setup, or with nothing set up at all. It exits 0 in every one of those cases. */ import { execFileSync } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; import { resolveStatePaths } from './paths.mjs'; -import { clearWorkspacePath } from './workspace-file.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; @@ -43,11 +42,17 @@ function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } -/** Stop the server recorded in the pid file, if it is still running. */ +/** 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 readFile(paths.pidFile, 'utf8')).trim()); + pid = Number(((await readWorkspaceFile(paths.pidFile, PID_FILE_MAX_BYTES)) ?? '').trim()); } catch { return; } diff --git a/challenges/glitch-invaders/server/setup.mjs b/challenges/glitch-invaders/server/setup.mjs index ec25176..56457b3 100644 --- a/challenges/glitch-invaders/server/setup.mjs +++ b/challenges/glitch-invaders/server/setup.mjs @@ -83,13 +83,19 @@ export async function runSetup(env, cwd) { } 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); - await writeWorkspaceFile(paths.pidFile, `${serverPid}\n`); + try { + await writeWorkspaceFile(paths.pidFile, `${serverPid}\n`); + } catch (error) { + process.kill(serverPid, 'SIGKILL'); + throw error; + } return { url: `http://127.0.0.1:${port}/`, diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index 2ab6cdc..7aa19df 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -41,7 +41,6 @@ import { 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 { HttpProgressStore } from '../challenges/glitch-invaders/game/src/session/progress-store.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'; @@ -667,17 +666,16 @@ test('the page server sends the CSP on the page and on an error response', async assert.equal(missing.headers['content-security-policy'], CONTENT_SECURITY_POLICY); }); -test('a refused progress save reports the failure instead of swallowing it', async () => { - const errors = []; - const store = new HttpProgressStore( - '/api/progress', - async () => new Response('refused', { status: 422 }), - (error) => errors.push(error.message), - ); - - await store.save(createProgress('attempt-1')); +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.deepEqual(errors, ['progress save failed with status 422']); + 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 () => { @@ -856,6 +854,35 @@ test('cleanup exits 0 and unlinks a symlinked progress file, leaving its target 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);