From c484540fa430556823f10e7204d1a00c14f1080b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 04:21:22 +0000 Subject: [PATCH 1/4] fix(glitch-invaders): fix the UX review's start error, console overflow, and LIVE badge --- challenges/glitch-invaders/README.md | 3 +- challenges/glitch-invaders/game/index.html | 2 +- .../glitch-invaders/game/src/app/app.mjs | 7 +- .../glitch-invaders/game/src/styles.css | 71 ++++- .../game/src/ui/boss-loading.mjs | 6 +- .../glitch-invaders/game/src/ui/dom.mjs | 14 + .../glitch-invaders/game/src/ui/hud.mjs | 21 +- .../game/src/ui/incident-console.mjs | 127 ++++---- .../glitch-invaders/game/src/ui/input.mjs | 17 +- .../glitch-invaders/game/src/ui/overlays.mjs | 1 + challenges/glitch-invaders/server/setup.mjs | 6 +- .../skills/start-challenge/SKILL.md | 11 +- test/glitch-invaders.test.mjs | 283 +++++++++++++++++- 13 files changed, 482 insertions(+), 87 deletions(-) diff --git a/challenges/glitch-invaders/README.md b/challenges/glitch-invaders/README.md index 0c524a7..8922869 100644 --- a/challenges/glitch-invaders/README.md +++ b/challenges/glitch-invaders/README.md @@ -32,7 +32,8 @@ browser ES modules. 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 +`glitch-progress.json` in the workspace (`setup` seeds it with an unplayed run, +so Pi's first read succeeds) 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`. diff --git a/challenges/glitch-invaders/game/index.html b/challenges/glitch-invaders/game/index.html index 5851ab9..1220d67 100644 --- a/challenges/glitch-invaders/game/index.html +++ b/challenges/glitch-invaders/game/index.html @@ -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 d7ad883..1170923 100644 --- a/challenges/glitch-invaders/game/src/app/app.mjs +++ b/challenges/glitch-invaders/game/src/app/app.mjs @@ -22,7 +22,7 @@ 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; +const BANNER_MS = 2_500; /** The pause after the last repair, so the participant sees the result before the recap. */ const COMPLETION_DELAY_MS = 1_800; @@ -349,7 +349,7 @@ export class GameApp { this.overlays.show(this.incidentConsole.element); const cached = this.evidenceByBug.get(bug); if (cached !== undefined) { - this.incidentConsole.showEvidence(cached); + this.deliverEvidence(cached); return; } @@ -364,6 +364,9 @@ export class GameApp { /** Remember the evidence and show it if its console is still open. */ deliverEvidence(evidence) { this.evidenceByBug.set(evidence.bug, evidence); + if (this.live !== null) { + this.hud.showEvidenceSource(evidence); + } this.incidentConsole.showEvidence(evidence); } diff --git a/challenges/glitch-invaders/game/src/styles.css b/challenges/glitch-invaders/game/src/styles.css index 9388cd7..01353e1 100644 --- a/challenges/glitch-invaders/game/src/styles.css +++ b/challenges/glitch-invaders/game/src/styles.css @@ -95,6 +95,7 @@ body { display: grid; flex: 1 1 auto; grid-template-rows: auto minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr); min-height: 0; } @@ -102,6 +103,7 @@ body { position: relative; z-index: 20; display: flex; + flex-wrap: wrap; align-items: center; gap: 20px; padding: 8px 16px; @@ -201,9 +203,10 @@ body { .banner { position: absolute; + top: 10px; right: 10px; - bottom: 10px; left: 10px; + pointer-events: none; padding: 10px 14px; border: 2px solid var(--accent); background: var(--surface); @@ -426,11 +429,16 @@ body { gap: 12px; } +/* The overlay spans the rows below the topbar, and its single track is bounded so a card's + `max-height: 100%` resolves against the space actually left on screen. */ .overlay-root { position: absolute; inset: 0; z-index: 10; display: grid; + grid-row: 2 / -1; + grid-column: 1; + grid-template: minmax(0, 1fr) / minmax(0, 1fr); place-items: center; padding: 16px; background: rgb(21 23 28 / 0.88); @@ -453,6 +461,11 @@ body { font-weight: 700; } +.last-step { + color: var(--mlh-yellow); + font-weight: 700; +} + .hero em { color: var(--mlh-yellow); font-style: normal; @@ -629,11 +642,21 @@ kbd { flex: 1; flex-direction: column; min-height: 0; - padding: 12px 2px 2px; + padding-top: 12px; +} + +/* The bottom padding sits under the fade, so the last line is fully visible once scrolled to. */ +.pane-scroll { + flex: 1 1 auto; + min-height: 0; + padding: 2px 2px 20px; overflow: auto; + scroll-padding-bottom: 20px; + scrollbar-color: var(--border) transparent; + scrollbar-width: thin; + mask-image: linear-gradient(to bottom, #000 calc(100% - 20px), transparent); } -.pane > * + *, .evidence > * + *, .diagnose > * + *, .patches > * + * { @@ -923,15 +946,20 @@ kbd { .pane-actions { display: flex; + flex: none; flex-wrap: wrap; - gap: 12px; + gap: 10px 12px; align-items: center; - margin-top: auto; - padding-top: 6px; + padding-top: 10px; + border-top: 1px solid var(--border); } -.spacer { - flex: 1; +.pane-actions > .feedback { + flex: 1 0 100%; +} + +.pane-actions > .button-primary { + margin-left: auto; } .text-link { @@ -1093,6 +1121,12 @@ kbd { font: 12px / 1.35 var(--mono); } +@media (max-width: 880px) { + .badge { + display: none; + } +} + @media (max-width: 720px) { .dock { grid-template-columns: 1fr; @@ -1106,16 +1140,29 @@ kbd { .stats { gap: 10px; } - - .badge { - display: none; - } } @media (max-height: 620px) { .objective-detail { display: none; } + + .overlay-root { + padding: 8px 16px; + } + + .overlay-card { + padding: 20px 24px 14px; + } + + .overlay-card > * + * { + margin-top: 10px; + } + + .overlay-card .button-large { + min-height: 44px; + padding: 8px 20px; + } } @media (prefers-reduced-motion: reduce) { diff --git a/challenges/glitch-invaders/game/src/ui/boss-loading.mjs b/challenges/glitch-invaders/game/src/ui/boss-loading.mjs index 372bd60..24d68dc 100644 --- a/challenges/glitch-invaders/game/src/ui/boss-loading.mjs +++ b/challenges/glitch-invaders/game/src/ui/boss-loading.mjs @@ -5,7 +5,7 @@ import { ALL_LOAD_TASKS } from '../boss/boss-loader.mjs'; import { BOSS_SPAWN_BUDGET_MS } from '../engine/constants.mjs'; -import { h, setText } from './dom.mjs'; +import { h, setData, setStyle, setText } from './dom.mjs'; /** Draws the loading panel from the world. */ export class BossLoadingView { @@ -48,7 +48,7 @@ export class BossLoadingView { ? `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); + setData(this.note, 'over', String(elapsed > BOSS_SPAWN_BUDGET_MS)); } for (const task of load.tasks) { const row = this.rows.get(task.name); @@ -61,7 +61,7 @@ export class BossLoadingView { : task.startMs === null ? 0 : Math.min(1, (elapsed - task.startMs) / row.durationMs); - row.fill.style.width = `${(fraction * 100).toFixed(1)}%`; + setStyle(row.fill, 'width', `${(fraction * 100).toFixed(1)}%`); setText( row.status, task.endMs !== null ? '✓ done' : task.startMs === null ? 'waiting' : 'loading', diff --git a/challenges/glitch-invaders/game/src/ui/dom.mjs b/challenges/glitch-invaders/game/src/ui/dom.mjs index 767b2e1..23a1afa 100644 --- a/challenges/glitch-invaders/game/src/ui/dom.mjs +++ b/challenges/glitch-invaders/game/src/ui/dom.mjs @@ -30,6 +30,20 @@ export function setText(element, text) { } } +/** Set a `data-` attribute only when it changed; a same-value write still queues a DOM mutation. */ +export function setData(element, name, value) { + if (element.dataset[name] !== value) { + element.dataset[name] = value; + } +} + +/** Set one inline style property only when it changed, so per-frame updates do not churn the DOM. */ +export function setStyle(element, property, value) { + if (element.style[property] !== value) { + element.style[property] = value; + } +} + /** Add a click handler and return the element, for chaining inside {@link h} calls. */ export function onClick(element, handler) { element.addEventListener('click', handler); diff --git a/challenges/glitch-invaders/game/src/ui/hud.mjs b/challenges/glitch-invaders/game/src/ui/hud.mjs index b26605e..58055a0 100644 --- a/challenges/glitch-invaders/game/src/ui/hud.mjs +++ b/challenges/glitch-invaders/game/src/ui/hud.mjs @@ -1,6 +1,6 @@ import { BUG_DEFINITIONS, BUG_ORDER } from '../bugs/bug-definitions.mjs'; -import { h, replaceChildren, setText } from './dom.mjs'; +import { h, replaceChildren, setData, setText } from './dom.mjs'; import { currentObjective } from './objective.mjs'; /** The glyph and word that show a failure's stage; never color alone. */ @@ -33,19 +33,32 @@ export class Hud { incidents = requireElement('incidents'); chipSignature = ''; - /** Swap the page's demo badge for the live one; it never changes during a run. */ + /** Swap the page's demo badge for the live one, until a report falls back to simulated evidence. */ showLiveMode() { - this.badge.dataset.mode = 'live'; + setData(this.badge, 'mode', 'live'); setText(this.badge, 'LIVE · Sentry'); this.badge.title = 'Live mode: the failures are reported to Sentry and the evidence is its record.'; } + /** Show the badge the evidence's source calls for, so it never says LIVE beside simulated evidence. */ + showEvidenceSource(evidence) { + if (evidence.source === 'fallback') { + setData(this.badge, 'mode', 'simulated'); + setText(this.badge, 'SIMULATED · Sentry unavailable'); + this.badge.title = 'Sentry evidence was unavailable, so the latest evidence is simulated.'; + + return; + } + + this.showLiveMode(); + } + /** 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'; + setData(this.shield, 'state', world.player.shield ? 'on' : 'off'); setText(this.kills, String(world.kills)); setText(this.score, String(progress.score)); diff --git a/challenges/glitch-invaders/game/src/ui/incident-console.mjs b/challenges/glitch-invaders/game/src/ui/incident-console.mjs index d49b333..d534b90 100644 --- a/challenges/glitch-invaders/game/src/ui/incident-console.mjs +++ b/challenges/glitch-invaders/game/src/ui/incident-console.mjs @@ -124,11 +124,7 @@ export class IncidentConsole { ), this.tabs(progress), ), - h( - 'div', - { class: 'pane', role: 'tabpanel', 'aria-labelledby': `tab-${this.pane}` }, - this.paneBody(progress), - ), + this.paneRegion(progress), ); this.element.replaceChildren(dialog); @@ -179,8 +175,27 @@ export class IncidentConsole { ); } - /** The body for the selected pane. */ - paneBody(progress) { + /** + * The selected pane: its content scrolls inside the dialog, while the outcome of the last action + * and the buttons that move on stay pinned below it, so they are always on screen. + */ + paneRegion(progress) { + const { content, actions } = this.paneParts(progress); + const pinned = actions.filter((action) => action !== null); + + return h( + 'div', + { class: 'pane', role: 'tabpanel', 'aria-labelledby': `tab-${this.pane}` }, + h('div', { class: 'pane-scroll' }, content), + pinned.length === 0 ? null : h('div', { class: 'pane-actions' }, ...pinned), + ); + } + + /** + * The selected pane's scrolling content and its pinned actions, as `{ content, actions }`; + * `actions` may hold `null` for a control the current step does not show. + */ + paneParts(progress) { switch (this.pane) { case 'evidence': return this.evidencePane(); @@ -195,12 +210,15 @@ export class IncidentConsole { evidencePane() { const evidence = this.evidence; if (evidence === null) { - return h( - 'p', - { class: 'status', role: 'status' }, - h('span', { class: 'spinner', 'aria-hidden': 'true' }, '◌'), - ' Capturing telemetry…', - ); + return { + content: h( + 'p', + { class: 'status', role: 'status' }, + h('span', { class: 'spinner', 'aria-hidden': 'true' }, '◌'), + ' Capturing telemetry…', + ), + actions: [], + }; } const contextSection = evidence.sections.find((section) => section.kind === 'context'); @@ -211,17 +229,16 @@ export class IncidentConsole { section.kind === 'waterfall', ); - return h( - 'div', - { class: 'evidence' }, - this.sentryHero(evidence), - contextSection === undefined ? null : renderContextPills(contextSection), - primarySection === undefined ? null : renderSection(primarySection), - h( + return { + content: h( 'div', - { class: 'pane-actions' }, + { class: 'evidence' }, + this.sentryHero(evidence), + contextSection === undefined ? null : renderContextPills(contextSection), + primarySection === undefined ? null : renderSection(primarySection), + ), + actions: [ h('span', { class: 'muted' }, `Ref: ${evidence.referenceId}`), - h('span', { class: 'spacer' }), onClick( h( 'button', @@ -233,8 +250,8 @@ export class IncidentConsole { this.render(); }, ), - ), - ); + ], + }; } /** @@ -284,7 +301,7 @@ export class IncidentConsole { const hints = definition.hints.slice(0, progress.hintsUsed); const nextHint = progress.hintsUsed < definition.hints.length; - return h( + const content = h( 'div', { class: 'diagnose' }, h('p', { class: 'question' }, definition.question), @@ -314,16 +331,6 @@ export class IncidentConsole { 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( @@ -331,9 +338,21 @@ export class IncidentConsole { { class: 'hints', 'aria-label': 'Hints' }, ...hints.map((hint) => h('li', {}, hint)), ), - h( - 'div', - { class: 'pane-actions' }, + ); + + return { + content, + actions: [ + 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, solved || !nextHint ? null : onClick( @@ -347,7 +366,6 @@ export class IncidentConsole { this.render(); }, ), - h('span', { class: 'spacer' }), solved ? onClick( h( @@ -361,8 +379,8 @@ export class IncidentConsole { }, ) : null, - ), - ); + ], + }; } /** Submit a diagnosis; a right one moves on to the patch pane. */ @@ -378,10 +396,13 @@ export class IncidentConsole { /** 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 { + content: h('p', { class: 'status' }, 'Diagnose the failure to unlock patches.'), + actions: [], + }; } - return h( + const content = h( 'div', { class: 'patches' }, h( @@ -417,14 +438,20 @@ export class IncidentConsole { ); }), ), - this.patchObservation === null - ? null - : h( - 'p', - { class: 'feedback feedback-danger', role: 'alert' }, - `✖ Reverted. ${this.patchObservation.text}`, - ), ); + + return { + content, + actions: [ + 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. */ diff --git a/challenges/glitch-invaders/game/src/ui/input.mjs b/challenges/glitch-invaders/game/src/ui/input.mjs index 148a169..581a522 100644 --- a/challenges/glitch-invaders/game/src/ui/input.mjs +++ b/challenges/glitch-invaders/game/src/ui/input.mjs @@ -1,13 +1,17 @@ /** * 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. + * button works with a pointer, and a tap on a move pad carries the ship one lane; pressing it with + * Enter or Space gives a short pulse, so a keyboard user can drive the pads too. */ +import { ENEMY_GAP_X, ENEMY_WIDTH, PLAYER_SPEED } from '../engine/constants.mjs'; import { idleInput } from '../engine/world.mjs'; /** How long a keyboard activation of a pad button counts as held. */ const PAD_PULSE_MS = 180; +/** A tap on a move pad holds long enough to carry the ship one enemy-formation lane. */ +export const TAP_MOVE_MS = Math.round(((ENEMY_WIDTH + ENEMY_GAP_X) / PLAYER_SPEED) * 1000); + /** The keys that mean each control. */ const KEY_CONTROLS = new Map([ ['ArrowLeft', 'left'], @@ -72,6 +76,9 @@ export class InputController { event.preventDefault(); this.padsDown.set(control, (this.padsDown.get(control) ?? 0) + 1); this.recompute(); + if (control !== 'fire') { + this.pulse(control, TAP_MOVE_MS); + } }; const release = () => { const holds = this.padsDown.get(control) ?? 0; @@ -93,8 +100,8 @@ export class InputController { }); } - /** Hold `control` briefly. */ - pulse(control) { + /** Hold `control` for `durationMs`. */ + pulse(control, durationMs = PAD_PULSE_MS) { this.padsDown.set(control, (this.padsDown.get(control) ?? 0) + 1); this.recompute(); const timer = setTimeout(() => { @@ -102,7 +109,7 @@ export class InputController { const holds = this.padsDown.get(control) ?? 0; this.padsDown.set(control, Math.max(0, holds - 1)); this.recompute(); - }, PAD_PULSE_MS); + }, durationMs); this.pulseTimers.add(timer); } diff --git a/challenges/glitch-invaders/game/src/ui/overlays.mjs b/challenges/glitch-invaders/game/src/ui/overlays.mjs index 7119690..e9c43b0 100644 --- a/challenges/glitch-invaders/game/src/ui/overlays.mjs +++ b/challenges/glitch-invaders/game/src/ui/overlays.mjs @@ -153,6 +153,7 @@ export function completionCard(progress, onContinue, onReset) { ); }), ), + h('p', { class: 'last-step' }, 'Last step: tell Pi which clue helped most.'), onContinue || onReset ? h( 'div', diff --git a/challenges/glitch-invaders/server/setup.mjs b/challenges/glitch-invaders/server/setup.mjs index 3502fef..5d19e7a 100644 --- a/challenges/glitch-invaders/server/setup.mjs +++ b/challenges/glitch-invaders/server/setup.mjs @@ -1,12 +1,14 @@ /** * Setup: prepare a clean attempt and start the page server. Deterministic and fast: it decides live - * or demo mode, writes a fresh runtime config, serves the game in the background, and exits 0 only + * or demo mode, writes a fresh runtime config and an unplayed progress file (so the guide's first + * read of it succeeds), serves the game in the background, and exits 0 only * once the server answers, so Chrome never opens an unready page. */ import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; +import { createProgress } from '../shared/progress.mjs'; import { stopServer } from './cleanup.mjs'; import { resolveStatePaths } from './paths.mjs'; import { decideMode } from './sentry-config.mjs'; @@ -96,6 +98,8 @@ export async function runSetup(env, cwd) { ...(mode.sentry === null ? {} : { sentry: mode.sentry }), }; await writeWorkspaceFile(paths.runtimeConfigFile, `${JSON.stringify(config, null, 2)}\n`); + const freshProgress = createProgress(config.attemptId, mode.sentry === null ? 'demo' : 'live'); + await writeWorkspaceFile(paths.progressFile, `${JSON.stringify(freshProgress, null, 2)}\n`); const port = resolvePort(env); const serverPid = await startServer(paths, port, env); diff --git a/challenges/glitch-invaders/skills/start-challenge/SKILL.md b/challenges/glitch-invaders/skills/start-challenge/SKILL.md index 7a63cef..5e1a2e5 100644 --- a/challenges/glitch-invaders/skills/start-challenge/SKILL.md +++ b/challenges/glitch-invaders/skills/start-challenge/SKILL.md @@ -29,8 +29,9 @@ directory. Read it when the participant asks for help, so you know where they are. It holds only game state: `launched`, `completed`, `mode`, and under `bugs`, for each of `weapon`, `shield`, `boss`: `stage` (`unseen`, `encountered`, `investigated`, `repaired`), `hintsUsed`, and attempt -counts. It also has a `milestones` list. If the file does not exist yet, they -have not pressed Start. +counts. It also has a `milestones` list. Setup creates it before the game +opens, with `launched` `false` until they press Start. If a read says it is +missing, treat that as not started yet and say nothing about the error. ## Start @@ -53,7 +54,11 @@ Match your help to the `stage` of the bug they are on. 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. +- `repaired`: say a brief, warm word about the fix. Do not ask what changed. + +Save questions for natural pauses: when an incident closes, or they are in the +Incident Console or between incidents. While their hands are on the game, keep +messages to one short sentence with no question. 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 diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index 2d0c9b3..e3d2799 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -11,12 +11,12 @@ import assert from 'node:assert/strict'; import { execFile, spawn } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; -import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; import { createServer as createHttpServer, request } from 'node:http'; import { createServer } from 'node:net'; import os from 'node:os'; import path from 'node:path'; -import { test } from 'node:test'; +import { mock, test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; @@ -40,6 +40,9 @@ import { FALLBACK_NOTES, LiveEvidenceProvider, } from '../challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs'; +import { InputController, TAP_MOVE_MS } from '../challenges/glitch-invaders/game/src/ui/input.mjs'; +import { Hud } from '../challenges/glitch-invaders/game/src/ui/hud.mjs'; +import { BossLoadingView } from '../challenges/glitch-invaders/game/src/ui/boss-loading.mjs'; import { SimulatedEvidenceProvider } from '../challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs'; import { BOSS_SPAWN_BUDGET_MS, @@ -913,6 +916,17 @@ test('the shipped setup and cleanup hooks start and stop the server and clean up assert.equal(existsSync(path.join(workspace, '.glitch')), false); }); +test('setup leaves an unplayed progress file the guide can read and the game treats as a new run', async (testContext) => { + const stack = await startStack(testContext); + + const stored = JSON.parse(await readFile(stack.paths.progressFile, 'utf8')); + const served = await (await fetch(`${stack.url}api/progress`)).json(); + + assert.deepEqual(stored, createProgress(stack.attemptId)); + assert.deepEqual(served, stored); + assert.equal(stored.launched, false); +}); + test('the page server sends the CSP on the page and on an error response', async (testContext) => { const stack = await startStack(testContext); @@ -945,6 +959,62 @@ test('the game page has no inline styles or inline scripts for the CSP to block' assert.doesNotMatch(page, /\son[a-z]+=/); }); +/** + * The declarations of the top-level rule whose selector is exactly `selector` in `stylesheet`, as a + * property-to-value map; rules inside at-rule blocks such as `@media` are skipped. + * @throws {Error} when the stylesheet has no such rule. + */ +function topLevelCssRule(stylesheet, selector) { + const source = stylesheet.replace(/\/\*[\s\S]*?\*\//g, ''); + let depth = 0; + let ruleStart = 0; + let ruleSelector = ''; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '{') { + if (depth === 0) { + ruleSelector = source.slice(ruleStart, index).trim(); + ruleStart = index + 1; + } + depth += 1; + } else if (source[index] === '}') { + depth -= 1; + if (depth === 0) { + if (ruleSelector === selector) { + return new Map( + source + .slice(ruleStart, index) + .split(';') + .filter((declaration) => declaration.includes(':')) + .map((declaration) => { + const [property, ...value] = declaration.split(':'); + + return [property.trim(), value.join(':').trim()]; + }), + ); + } + ruleStart = index + 1; + } + } + } + + throw new Error(`the stylesheet has no top-level rule for ${selector}`); +} + +test('the Incident Console scrolls its content inside the viewport and keeps its actions pinned below', async () => { + const stylesheet = await readFile( + path.join(packageDirectory, 'game', 'src', 'styles.css'), + 'utf8', + ); + + const rule = (selector) => topLevelCssRule(stylesheet, selector); + + assert.equal(rule('.overlay-root').get('grid-template'), 'minmax(0, 1fr) / minmax(0, 1fr)'); + assert.equal(rule('.console').get('max-height'), '100%'); + assert.equal(rule('.pane').get('overflow'), undefined); + assert.equal(rule('.pane-scroll').get('overflow'), 'auto'); + assert.equal(rule('.pane-actions').get('flex'), 'none'); +}); + for (const requestPath of [ '/%2e%2e/challenge.json', '/%2e%2e/server/setup.mjs', @@ -975,7 +1045,10 @@ test('the page server answers 400 for a progress PUT that is not JSON and stores const response = await putProgressTo(stack.url, '{"launched": tru'); assert.equal(response.status, 400); - assert.equal(existsSync(stack.paths.progressFile), false); + assert.deepEqual( + JSON.parse(await readFile(stack.paths.progressFile, 'utf8')), + createProgress(stack.attemptId), + ); }); test('the page server answers 413 for a streamed progress body over the cap and stores nothing', async (testContext) => { @@ -998,13 +1071,17 @@ test('the page server answers 413 for a streamed progress body over the cap and }); assert.equal(response.status, 413); - assert.equal(existsSync(stack.paths.progressFile), false); + assert.deepEqual( + JSON.parse(await readFile(stack.paths.progressFile, 'utf8')), + createProgress(stack.attemptId), + ); }); 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 rm(stack.paths.progressFile); await symlink(outside, stack.paths.progressFile); const progress = createProgress(stack.attemptId); @@ -1139,7 +1216,7 @@ test('setup exits 0 and clears a directory planted at the progress path', async const setup = await runHook('setup', hookEnvironment(workspace, port)); assert.equal(setup.code, 0, setup.stderr); - assert.equal(existsSync(progressPath), false); + assert.equal((await lstat(progressPath)).isFile(), true); }); test('setup refuses a symlinked .glitch directory and leaves the outside directory untouched', async (testContext) => { @@ -1700,3 +1777,199 @@ test('the evidence lookup shares one Sentry lookup among concurrent requests for ], ); }); + +/** A DOM element stand-in that records every write the page code makes to it. */ +class RecordingElement { + writes = []; + hidden = false; + #textContent = ''; + dataset; + style; + + constructor() { + this.dataset = this.#recordingBag('dataset'); + this.style = this.#recordingBag('style'); + } + + get textContent() { + return this.#textContent; + } + + set textContent(text) { + this.writes.push(`textContent=${text}`); + this.#textContent = text; + } + + setAttribute() {} + + append() {} + + replaceChildren() {} + + #recordingBag(label) { + const values = {}; + + return new Proxy(values, { + set: (target, name, value) => { + this.writes.push(`${label}.${name}=${value}`); + target[name] = value; + + return true; + }, + }); + } +} + +/** Run `body` with a page whose elements are {@link RecordingElement}s, by id as the HUD asks. */ +function withRecordingPage(body) { + const elements = new Map(); + const page = { + getElementById: (id) => { + if (!elements.has(id)) { + elements.set(id, new RecordingElement()); + } + + return elements.get(id); + }, + createElement: () => new RecordingElement(), + }; + const original = globalThis.document; + globalThis.document = page; + try { + return body(page); + } finally { + globalThis.document = original; + } +} + +/** The arena state the HUD reads, with a default for every field. */ +function hudWorld(overrides = {}) { + return { + phase: 'playing', + wave: 1, + verifying: null, + kills: 0, + pickups: [], + player: { hull: 3, restoreAt: null, shield: false, weapon: 'single' }, + ...overrides, + }; +} + +test('an idle frame writes nothing to the HUD, so the station sees the page go quiet', () => { + withRecordingPage((page) => { + const hud = new Hud(); + const progress = createProgress('attempt', 'demo'); + const world = hudWorld(); + hud.update(progress, world); + const shield = page.getElementById('stat-shield'); + const elements = ['stat-hull', 'stat-shield', 'stat-kills', 'stat-score', 'objective-text', 'incidents'].map( + (id) => page.getElementById(id), + ); + for (const element of elements) { + element.writes.length = 0; + } + + hud.update(progress, world); + + assert.deepEqual(shield.writes, []); + assert.deepEqual(elements.flatMap((element) => element.writes), []); + }); +}); + +test('an idle frame writes nothing to the boss loading panel', () => { + withRecordingPage(() => { + const panel = new RecordingElement(); + const view = new BossLoadingView(panel); + const world = hudWorld({ + phase: 'boss-loading', + bossLoad: { startedAt: 0, tasks: [{ name: 'textures', startMs: 0, endMs: null }] }, + }); + view.update(world, 500); + const recorded = [view.clock, view.note, view.rows.get('textures').fill, view.rows.get('textures').status]; + for (const element of recorded) { + element.writes.length = 0; + } + + view.update(world, 500); + + assert.deepEqual(recorded.flatMap((element) => element.writes), []); + }); +}); + +test('the HUD badge says LIVE until a report falls back, then SIMULATED, then LIVE again on live evidence', () => { + withRecordingPage((page) => { + const hud = new Hud(); + const badge = page.getElementById('mode-badge'); + hud.showLiveMode(); + assert.equal(badge.textContent, 'LIVE · Sentry'); + + hud.showEvidenceSource({ source: 'fallback' }); + assert.match(badge.textContent, /^SIMULATED/); + assert.equal(badge.dataset.mode, 'simulated'); + + hud.showEvidenceSource({ source: 'live' }); + assert.equal(badge.textContent, 'LIVE · Sentry'); + assert.equal(badge.dataset.mode, 'live'); + }); +}); + +test('a quick tap on a move pad carries the ship a lane, and fire stays a plain hold', (testContext) => { + testContext.mock.timers.enable({ apis: ['setTimeout'] }); + const input = new InputController({ isGameActive: () => true, onPauseKey: () => {} }); + const listeners = new Map(); + const pad = (control) => { + const button = { addEventListener: (name, listener) => listeners.set(`${control}:${name}`, listener) }; + input.attachPad(control, button); + }; + pad('left'); + pad('fire'); + const event = { preventDefault: () => {} }; + + listeners.get('left:pointerdown')(event); + listeners.get('left:pointerup')(event); + const heldRightAfterTap = input.current().left; + testContext.mock.timers.tick(TAP_MOVE_MS - 1); + const heldJustBeforeLaneEnd = input.current().left; + testContext.mock.timers.tick(1); + listeners.get('fire:pointerdown')(event); + listeners.get('fire:pointerup')(event); + + assert.equal(heldRightAfterTap, true); + assert.equal(heldJustBeforeLaneEnd, true); + assert.equal(input.current().left, false); + assert.equal(input.current().fire, false); + assert.ok(TAP_MOVE_MS >= 150, `a tap must move well beyond 25 px, got ${TAP_MOVE_MS} ms`); +}); + +test('the monitor state line is visually hidden but still in the accessibility tree', async () => { + const page = await readFile(path.join(packageDirectory, 'game', 'index.html'), 'utf8'); + const stylesheet = await readFile(path.join(packageDirectory, 'game', 'src', 'styles.css'), 'utf8'); + + assert.match(page, /

glitch-state v1 /); + assert.doesNotMatch(page, /id="boothstrap-state"[^>]*(hidden=|aria-hidden|style=)/); + assert.doesNotMatch(stylesheet, /state-line/); + const rule = topLevelCssRule(stylesheet, '.visually-hidden'); + assert.equal(rule.get('position'), 'absolute'); + assert.equal(rule.get('width'), '1px'); + for (const forbidden of ['display', 'visibility', 'font-size']) { + assert.equal(rule.has(forbidden), false); + } +}); + +test('toasts sit at the top of the arena so they never cover the ship or pickups', async () => { + const stylesheet = await readFile(path.join(packageDirectory, 'game', 'src', 'styles.css'), 'utf8'); + + const rule = topLevelCssRule(stylesheet, '.banner'); + + assert.equal(rule.get('top'), '10px'); + assert.equal(rule.has('bottom'), false); +}); + +test('the completion card tells the participant the last step is with Pi', async () => { + const overlays = await readFile( + path.join(packageDirectory, 'game', 'src', 'ui', 'overlays.mjs'), + 'utf8', + ); + + assert.match(overlays, /'Last step: tell Pi which clue helped most\.'/); +}); From aab1319b819216efe9f2cbb59b0cc0504f6a7945 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 04:35:15 +0000 Subject: [PATCH 2/4] fix(glitch-invaders): address review: pad taps on touch, hint scroll, docs, tests --- challenges/glitch-invaders/README.md | 3 +- challenges/glitch-invaders/challenge.json | 8 +- challenges/glitch-invaders/game/index.html | 6 +- .../glitch-invaders/game/src/styles.css | 17 +- .../glitch-invaders/game/src/ui/dom.mjs | 18 +- .../game/src/ui/incident-console.mjs | 16 +- .../glitch-invaders/game/src/ui/input.mjs | 83 ++++-- challenges/glitch-invaders/monitor/SKILL.md | 7 +- .../skills/start-challenge/SKILL.md | 3 +- test/glitch-invaders.test.mjs | 266 +++++++++++++++--- 10 files changed, 337 insertions(+), 90 deletions(-) diff --git a/challenges/glitch-invaders/README.md b/challenges/glitch-invaders/README.md index 8922869..0928aa2 100644 --- a/challenges/glitch-invaders/README.md +++ b/challenges/glitch-invaders/README.md @@ -29,7 +29,8 @@ browser ES modules. ## How steps advance -The game shows a `glitch-state v1` line, and the headless monitor reports +The game keeps a `glitch-state v1` line, visually hidden but present in the +page's accessibility snapshot, 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 (`setup` seeds it with an unplayed run, diff --git a/challenges/glitch-invaders/challenge.json b/challenges/glitch-invaders/challenge.json index e466345..ce11f9f 100644 --- a/challenges/glitch-invaders/challenge.json +++ b/challenges/glitch-invaders/challenge.json @@ -26,25 +26,25 @@ "id": "launch-game", "title": "Launch your patch drone", "hint": "Press Start in the game, then shoot the first glitches.", - "done_when": "The page shows the line \"glitch-state v1 …\" containing \"launched=yes\"." + "done_when": "The page's accessibility snapshot holds the line \"glitch-state v1 …\" containing \"launched=yes\"." }, { "id": "fix-weapon", "title": "Fix the jammed weapon", "hint": "Grab the gold upgrade and fire. Then trace the error in the Incident Console.", - "done_when": "The page shows the line \"glitch-state v1 …\" containing \"weapon=repaired\"." + "done_when": "The page's accessibility snapshot holds the line \"glitch-state v1 …\" containing \"weapon=repaired\"." }, { "id": "fix-shield", "title": "Fix the shield that hurts", "hint": "Grab the blue shield and take a hit. Read the breadcrumbs, then patch it.", - "done_when": "The page shows the line \"glitch-state v1 …\" containing \"shield=repaired\"." + "done_when": "The page's accessibility snapshot holds the line \"glitch-state v1 …\" containing \"shield=repaired\"." }, { "id": "fix-boss", "title": "Fix the slow boss spawn", "hint": "Clear the wave so the boss spawns. Find the bottleneck in the trace.", - "done_when": "The page shows the line \"glitch-state v1 …\" containing \"boss=repaired\"." + "done_when": "The page's accessibility snapshot holds the line \"glitch-state v1 …\" containing \"boss=repaired\"." }, { "id": "debrief", diff --git a/challenges/glitch-invaders/game/index.html b/challenges/glitch-invaders/game/index.html index 1220d67..58f61e8 100644 --- a/challenges/glitch-invaders/game/index.html +++ b/challenges/glitch-invaders/game/index.html @@ -21,8 +21,10 @@

  • SQUASHED 0
  • SCORE 0
  • - DEMO · simulated evidence - +
    + DEMO · simulated evidence + +
    diff --git a/challenges/glitch-invaders/game/src/styles.css b/challenges/glitch-invaders/game/src/styles.css index 01353e1..6caa047 100644 --- a/challenges/glitch-invaders/game/src/styles.css +++ b/challenges/glitch-invaders/game/src/styles.css @@ -105,7 +105,7 @@ body { display: flex; flex-wrap: wrap; align-items: center; - gap: 20px; + gap: 6px 20px; padding: 8px 16px; border-bottom: 2px solid var(--border); background: var(--surface-2); @@ -129,6 +129,7 @@ body { display: flex; flex: 1; gap: 18px; + white-space: nowrap; color: var(--muted); font-size: 13px; letter-spacing: 0.04em; @@ -144,6 +145,14 @@ body { color: var(--accent); } +/* The badge and Reset wrap together onto a second row when the topbar is too narrow for one. */ +.topbar-end { + display: flex; + align-items: center; + gap: 20px; + margin-left: auto; +} + .badge { padding: 2px 10px; border: 2px solid var(--mlh-yellow); @@ -1121,12 +1130,6 @@ kbd { font: 12px / 1.35 var(--mono); } -@media (max-width: 880px) { - .badge { - display: none; - } -} - @media (max-width: 720px) { .dock { grid-template-columns: 1fr; diff --git a/challenges/glitch-invaders/game/src/ui/dom.mjs b/challenges/glitch-invaders/game/src/ui/dom.mjs index 23a1afa..e6ab3a7 100644 --- a/challenges/glitch-invaders/game/src/ui/dom.mjs +++ b/challenges/glitch-invaders/game/src/ui/dom.mjs @@ -37,11 +37,23 @@ export function setData(element, name, value) { } } -/** Set one inline style property only when it changed, so per-frame updates do not churn the DOM. */ +/** The inline style values {@link setStyle} last wrote, per element and property. */ +const writtenStyles = new WeakMap(); + +/** + * Set one inline style property only when it changed, so per-frame updates do not churn the DOM. + * It compares with the value it last wrote, not `element.style`, because the browser reads a value + * back normalized: `100.0%` returns as `100%`. + */ export function setStyle(element, property, value) { - if (element.style[property] !== value) { - element.style[property] = value; + const written = writtenStyles.get(element) ?? new Map(); + if (written.get(property) === value) { + return; } + + element.style[property] = value; + written.set(property, value); + writtenStyles.set(element, written); } /** Add a click handler and return the element, for chaining inside {@link h} calls. */ diff --git a/challenges/glitch-invaders/game/src/ui/incident-console.mjs b/challenges/glitch-invaders/game/src/ui/incident-console.mjs index d534b90..a1edd53 100644 --- a/challenges/glitch-invaders/game/src/ui/incident-console.mjs +++ b/challenges/glitch-invaders/game/src/ui/incident-console.mjs @@ -40,6 +40,8 @@ export class IncidentConsole { evidence = null; diagnosisFeedback = ''; patchObservation = null; + /** The last revealed hint's list item in the rendered diagnosis pane, if any. */ + newestHint = null; constructor(host) { this.host = host; @@ -298,8 +300,11 @@ export class IncidentConsole { 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 hintItems = definition.hints + .slice(0, progress.hintsUsed) + .map((hint) => h('li', {}, hint)); const nextHint = progress.hintsUsed < definition.hints.length; + this.newestHint = hintItems.at(-1) ?? null; const content = h( 'div', @@ -331,13 +336,9 @@ export class IncidentConsole { return onClick(button, () => this.answer(option.id)); }), ), - hints.length === 0 + hintItems.length === 0 ? null - : h( - 'ol', - { class: 'hints', 'aria-label': 'Hints' }, - ...hints.map((hint) => h('li', {}, hint)), - ), + : h('ol', { class: 'hints', 'aria-label': 'Hints' }, ...hintItems), ); return { @@ -364,6 +365,7 @@ export class IncidentConsole { () => { this.host.useHint(this.bug); this.render(); + this.newestHint?.scrollIntoView({ block: 'nearest' }); }, ), solved diff --git a/challenges/glitch-invaders/game/src/ui/input.mjs b/challenges/glitch-invaders/game/src/ui/input.mjs index 581a522..3429b00 100644 --- a/challenges/glitch-invaders/game/src/ui/input.mjs +++ b/challenges/glitch-invaders/game/src/ui/input.mjs @@ -21,13 +21,21 @@ const KEY_CONTROLS = new Map([ ['Space', 'fire'], ]); +/** The move pad that cancels each move pad's pending tap when pressed. */ +const OPPOSITE_PADS = new Map([ + ['left', 'right'], + ['right', 'left'], +]); + /** Collects keyboard and pad input. */ export class InputController { state = idleInput(); keysDown = new Set(); - padsDown = new Map(); + /** The pointer IDs holding each pad down, so a stray release from another pointer is ignored. */ + pointersDown = new Map(); + /** Each control's timed hold from a tap or a keyboard pulse, as `{ timer, endsAt }`. */ + timedHolds = new Map(); options; - pulseTimers = new Set(); constructor(options) { this.options = options; @@ -62,28 +70,32 @@ export class InputController { /** 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.pointersDown.clear(); + for (const control of [...this.timedHolds.keys()]) { + this.cancelTimedHold(control); } - this.pulseTimers.clear(); this.recompute(); } - /** Wire one pad button: hold with a pointer, pulse with the keyboard. */ + /** + * Wire one pad button: hold with a pointer, pulse with the keyboard. A pointer's hold ends on its + * first release event; browsers send `pointerleave` right after `pointerup` on touch, and that + * second release must not end a tap's timed hold. + */ attachPad(control, button) { const press = (event) => { event.preventDefault(); - this.padsDown.set(control, (this.padsDown.get(control) ?? 0) + 1); - this.recompute(); - if (control !== 'fire') { - this.pulse(control, TAP_MOVE_MS); + const pointers = this.pointersDown.get(control) ?? new Set(); + pointers.add(event.pointerId); + this.pointersDown.set(control, pointers); + if (OPPOSITE_PADS.has(control)) { + this.cancelTimedHold(OPPOSITE_PADS.get(control)); + this.tap(control); } + this.recompute(); }; - const release = () => { - const holds = this.padsDown.get(control) ?? 0; - if (holds > 0) { - this.padsDown.set(control, holds - 1); + const release = (event) => { + if (this.pointersDown.get(control)?.delete(event.pointerId)) { this.recompute(); } }; @@ -100,17 +112,39 @@ export class InputController { }); } - /** Hold `control` for `durationMs`. */ - pulse(control, durationMs = PAD_PULSE_MS) { - this.padsDown.set(control, (this.padsDown.get(control) ?? 0) + 1); + /** A tap on a move pad: hold one more lane's worth, so two quick taps move about two lanes. */ + tap(control) { + const now = Date.now(); + const pendingEnd = this.timedHolds.get(control)?.endsAt ?? now; + this.holdUntil(control, Math.max(pendingEnd, now) + TAP_MOVE_MS); + } + + /** Hold `control` for a keyboard activation; repeats do not add up, so auto-repeat stops on release. */ + pulse(control) { + const pendingEnd = this.timedHolds.get(control)?.endsAt ?? 0; + this.holdUntil(control, Math.max(pendingEnd, Date.now() + PAD_PULSE_MS)); this.recompute(); + } + + /** Hold `control` until the `endsAt` timestamp, replacing any timed hold it has. */ + holdUntil(control, endsAt) { + this.cancelTimedHold(control); const timer = setTimeout(() => { - this.pulseTimers.delete(timer); - const holds = this.padsDown.get(control) ?? 0; - this.padsDown.set(control, Math.max(0, holds - 1)); + this.timedHolds.delete(control); this.recompute(); - }, durationMs); - this.pulseTimers.add(timer); + }, endsAt - Date.now()); + this.timedHolds.set(control, { timer, endsAt }); + } + + /** End `control`'s timed hold early, if it has one. The caller recomputes. */ + cancelTimedHold(control) { + const hold = this.timedHolds.get(control); + if (hold === undefined) { + return; + } + + clearTimeout(hold.timer); + this.timedHolds.delete(control); } /** Handle a key press. */ @@ -148,7 +182,8 @@ export class InputController { /** Rebuild the shared state from what is held. */ recompute() { const held = (control) => - (this.padsDown.get(control) ?? 0) > 0 || + (this.pointersDown.get(control)?.size ?? 0) > 0 || + this.timedHolds.has(control) || [...this.keysDown].some((code) => KEY_CONTROLS.get(code) === control); this.state.left = held('left'); diff --git a/challenges/glitch-invaders/monitor/SKILL.md b/challenges/glitch-invaders/monitor/SKILL.md index a65b12f..1218e7a 100644 --- a/challenges/glitch-invaders/monitor/SKILL.md +++ b/challenges/glitch-invaders/monitor/SKILL.md @@ -1,6 +1,6 @@ --- name: monitor -description: "Headless monitor for Glitch Invaders: On-Call. Reports steps from the game's visible state line." +description: "Headless monitor for Glitch Invaders: On-Call. Reports steps from the game's state line, visually hidden but present in the page's accessibility snapshot." --- # Glitch Invaders monitor @@ -9,8 +9,9 @@ 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: +Find the line that starts with `glitch-state v1`. It is visually hidden, so +look for it in the snapshot, not on screen. 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 5e1a2e5..d74956b 100644 --- a/challenges/glitch-invaders/skills/start-challenge/SKILL.md +++ b/challenges/glitch-invaders/skills/start-challenge/SKILL.md @@ -54,7 +54,8 @@ Match your help to the `stage` of the bug they are on. 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`: say a brief, warm word about the fix. Do not ask what changed. +- `repaired`: say a brief, warm word about the fix, then ask one short + question, such as what changed or which clue gave it away. Save questions for natural pauses: when an incident closes, or they are in the Incident Console or between incidents. While their hands are on the game, keep diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index e3d2799..4167667 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -41,6 +41,8 @@ import { LiveEvidenceProvider, } from '../challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs'; import { InputController, TAP_MOVE_MS } from '../challenges/glitch-invaders/game/src/ui/input.mjs'; +import { IncidentConsole } from '../challenges/glitch-invaders/game/src/ui/incident-console.mjs'; +import { completionCard } from '../challenges/glitch-invaders/game/src/ui/overlays.mjs'; import { Hud } from '../challenges/glitch-invaders/game/src/ui/hud.mjs'; import { BossLoadingView } from '../challenges/glitch-invaders/game/src/ui/boss-loading.mjs'; import { SimulatedEvidenceProvider } from '../challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs'; @@ -1778,41 +1780,73 @@ test('the evidence lookup shares one Sentry lookup among concurrent requests for ); }); -/** A DOM element stand-in that records every write the page code makes to it. */ +/** + * A DOM element stand-in that records every write the page code makes to it. It keeps its + * children, attributes, and listeners, and reads a style back normalized as a browser does. + */ class RecordingElement { writes = []; hidden = false; - #textContent = ''; + children = []; + attributes = new Map(); + listeners = new Map(); + scrolledIntoView = []; dataset; style; constructor() { - this.dataset = this.#recordingBag('dataset'); - this.style = this.#recordingBag('style'); + this.dataset = this.#recordingBag('dataset', (value) => value); + this.style = this.#recordingBag('style', normalizedCssValue); } get textContent() { - return this.#textContent; + return this.children + .map((child) => (typeof child === 'string' ? child : child.textContent)) + .join(''); } set textContent(text) { this.writes.push(`textContent=${text}`); - this.#textContent = text; + this.children = [text]; + } + + setAttribute(name, value) { + this.attributes.set(name, value); } - setAttribute() {} + getAttribute(name) { + return this.attributes.get(name) ?? null; + } + + append(...children) { + this.children.push(...children); + } - append() {} + replaceChildren(...children) { + this.children = children; + } + + addEventListener(name, listener) { + this.listeners.set(name, [...(this.listeners.get(name) ?? []), listener]); + } + + click() { + for (const listener of this.listeners.get('click') ?? []) { + listener({ detail: 1 }); + } + } - replaceChildren() {} + scrollIntoView(options) { + this.scrolledIntoView.push(options); + } - #recordingBag(label) { + #recordingBag(label, readBack) { const values = {}; return new Proxy(values, { set: (target, name, value) => { this.writes.push(`${label}.${name}=${value}`); - target[name] = value; + target[name] = readBack(value); return true; }, @@ -1820,6 +1854,27 @@ class RecordingElement { } } +/** A CSS value as a browser reads it back from an inline style: `100.0%` returns as `100%`. */ +function normalizedCssValue(value) { + return String(value).replace(/(\d)\.0+(?=\D|$)/g, '$1'); +} + +/** The first element in `root`'s tree, `root` included, whose `name` attribute is `value`. */ +function findByAttribute(root, name, value) { + if (root.getAttribute(name) === value) { + return root; + } + + for (const child of root.children) { + const found = typeof child === 'string' ? null : findByAttribute(child, name, value); + if (found !== null) { + return found; + } + } + + return null; +} + /** Run `body` with a page whose elements are {@link RecordingElement}s, by id as the HUD asks. */ function withRecordingPage(body) { const elements = new Map(); @@ -1896,51 +1951,185 @@ test('an idle frame writes nothing to the boss loading panel', () => { }); }); -test('the HUD badge says LIVE until a report falls back, then SIMULATED, then LIVE again on live evidence', () => { +test('a finished load bar is written once, though the browser reads 100.0% back as 100%', () => { + withRecordingPage(() => { + const view = new BossLoadingView(new RecordingElement()); + const world = hudWorld({ + phase: 'boss-loading', + bossLoad: { startedAt: 0, tasks: [{ name: 'textures', startMs: 0, endMs: 400 }] }, + }); + view.update(world, 500); + const fill = view.rows.get('textures').fill; + fill.writes.length = 0; + + view.update(world, 600); + + assert.deepEqual(fill.writes, []); + }); +}); + +test('the HUD badge says LIVE in live mode', () => { withRecordingPage((page) => { const hud = new Hud(); const badge = page.getElementById('mode-badge'); + hud.showLiveMode(); + assert.equal(badge.textContent, 'LIVE · Sentry'); + assert.equal(badge.dataset.mode, 'live'); + }); +}); + +test('the HUD badge says SIMULATED once a live report falls back to simulated evidence', () => { + withRecordingPage((page) => { + const hud = new Hud(); + const badge = page.getElementById('mode-badge'); + hud.showLiveMode(); hud.showEvidenceSource({ source: 'fallback' }); + assert.match(badge.textContent, /^SIMULATED/); assert.equal(badge.dataset.mode, 'simulated'); + }); +}); + +test('the HUD badge says LIVE again once evidence comes back live from Sentry', () => { + withRecordingPage((page) => { + const hud = new Hud(); + const badge = page.getElementById('mode-badge'); + hud.showLiveMode(); + hud.showEvidenceSource({ source: 'fallback' }); hud.showEvidenceSource({ source: 'live' }); + assert.equal(badge.textContent, 'LIVE · Sentry'); assert.equal(badge.dataset.mode, 'live'); }); }); -test('a quick tap on a move pad carries the ship a lane, and fire stays a plain hold', (testContext) => { - testContext.mock.timers.enable({ apis: ['setTimeout'] }); +/** An Incident Console on the weapon failure's Diagnose pane, wired to a real tracker as the app is. */ +function consoleOnDiagnosePane() { + const tracker = new IncidentTracker(createProgress('test-attempt')); + tracker.launch(); + tracker.encounter('weapon'); + tracker.markEvidenceViewed('weapon'); + const incidentConsole = new IncidentConsole({ + progressOf: (bug) => tracker.progress.bugs[bug], + markEvidenceViewed: (bug) => tracker.markEvidenceViewed(bug), + useHint: (bug) => tracker.useHint(bug), + diagnose: (bug, optionId) => tracker.answerDiagnosis(bug, optionId), + applyPatch: () => {}, + }); + incidentConsole.open('weapon'); + findByAttribute(incidentConsole.element, 'data-key', 'tab-diagnose').click(); + + return incidentConsole; +} + +test('revealing a hint scrolls the new hint into view in the Incident Console', () => { + withRecordingPage(() => { + const incidentConsole = consoleOnDiagnosePane(); + + findByAttribute(incidentConsole.element, 'data-key', 'hint').click(); + + const hints = findByAttribute(incidentConsole.element, 'aria-label', 'Hints'); + assert.equal(hints.children.length, 1); + assert.deepEqual(hints.children[0].scrolledIntoView, [{ block: 'nearest' }]); + }); +}); + +/** + * An {@link InputController} with left, right, and fire pads on a controlled clock; `press` and + * `release` send a pad the pointer events a browser would, for pointer 1 unless named. + */ +function padHarness(testContext) { + testContext.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); const input = new InputController({ isGameActive: () => true, onPauseKey: () => {} }); const listeners = new Map(); - const pad = (control) => { + for (const control of ['left', 'right', 'fire']) { const button = { addEventListener: (name, listener) => listeners.set(`${control}:${name}`, listener) }; input.attachPad(control, button); + } + const send = (control, name, pointerId) => + listeners.get(`${control}:${name}`)({ preventDefault: () => {}, pointerId }); + + return { + input, + press: (control, pointerId = 1) => send(control, 'pointerdown', pointerId), + release: (control, name = 'pointerup', pointerId = 1) => send(control, name, pointerId), + tick: (milliseconds) => testContext.mock.timers.tick(milliseconds), }; - pad('left'); - pad('fire'); - const event = { preventDefault: () => {} }; - - listeners.get('left:pointerdown')(event); - listeners.get('left:pointerup')(event); - const heldRightAfterTap = input.current().left; - testContext.mock.timers.tick(TAP_MOVE_MS - 1); - const heldJustBeforeLaneEnd = input.current().left; - testContext.mock.timers.tick(1); - listeners.get('fire:pointerdown')(event); - listeners.get('fire:pointerup')(event); - - assert.equal(heldRightAfterTap, true); - assert.equal(heldJustBeforeLaneEnd, true); - assert.equal(input.current().left, false); - assert.equal(input.current().fire, false); +} + +test('a tap lasts long enough to move the ship well beyond 25 px', () => { assert.ok(TAP_MOVE_MS >= 150, `a tap must move well beyond 25 px, got ${TAP_MOVE_MS} ms`); }); +test('a quick tap on a move pad keeps the ship moving until a full lane has passed', (testContext) => { + const pads = padHarness(testContext); + + pads.press('left'); + pads.release('left'); + pads.tick(TAP_MOVE_MS - 1); + + assert.equal(pads.input.current().left, true); +}); + +test('a quick tap on a move pad stops the ship once the lane is covered', (testContext) => { + const pads = padHarness(testContext); + + pads.press('left'); + pads.release('left'); + pads.tick(TAP_MOVE_MS); + + assert.equal(pads.input.current().left, false); +}); + +test('the pointerleave a touch sends after pointerup does not cut a tap short', (testContext) => { + const pads = padHarness(testContext); + + pads.press('left'); + pads.release('left', 'pointerup'); + pads.release('left', 'pointerleave'); + pads.tick(TAP_MOVE_MS - 1); + + assert.equal(pads.input.current().left, true); +}); + +test('pressing the opposite move pad cancels the pending tap', (testContext) => { + const pads = padHarness(testContext); + pads.press('left'); + pads.release('left'); + + pads.press('right', 2); + + assert.equal(pads.input.current().left, false); + assert.equal(pads.input.current().right, true); +}); + +test('two quick taps on a move pad carry the ship about two lanes', (testContext) => { + const pads = padHarness(testContext); + const gapMs = 50; + + pads.press('left'); + pads.release('left'); + pads.tick(gapMs); + pads.press('left'); + pads.release('left'); + pads.tick(2 * TAP_MOVE_MS - gapMs - 1); + + assert.equal(pads.input.current().left, true); +}); + +test('the fire pad is a plain hold that ends on release', (testContext) => { + const pads = padHarness(testContext); + pads.press('fire'); + + pads.release('fire'); + + assert.equal(pads.input.current().fire, false); +}); + test('the monitor state line is visually hidden but still in the accessibility tree', async () => { const page = await readFile(path.join(packageDirectory, 'game', 'index.html'), 'utf8'); const stylesheet = await readFile(path.join(packageDirectory, 'game', 'src', 'styles.css'), 'utf8'); @@ -1965,11 +2154,12 @@ test('toasts sit at the top of the arena so they never cover the ship or pickups assert.equal(rule.has('bottom'), false); }); -test('the completion card tells the participant the last step is with Pi', async () => { - const overlays = await readFile( - path.join(packageDirectory, 'game', 'src', 'ui', 'overlays.mjs'), - 'utf8', - ); +test('the completion card tells the participant the last step is with Pi', () => { + withRecordingPage(() => { + const progress = createProgress('test-attempt'); + + const card = completionCard(progress); - assert.match(overlays, /'Last step: tell Pi which clue helped most\.'/); + assert.match(card.textContent, /Last step: tell Pi which clue helped most\./); + }); }); From d72a5c8f3363562142efe3c83d4f3dc034d2ca36 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 04:46:04 +0000 Subject: [PATCH 3/4] fix(glitch-invaders): keep the topbar on one row and cap queued pad taps --- .../glitch-invaders/game/src/styles.css | 44 +++++++++++++++++-- .../glitch-invaders/game/src/ui/input.mjs | 23 ++++++++-- test/glitch-invaders.test.mjs | 43 +++++++++++++++--- 3 files changed, 97 insertions(+), 13 deletions(-) diff --git a/challenges/glitch-invaders/game/src/styles.css b/challenges/glitch-invaders/game/src/styles.css index 6caa047..4a67aa8 100644 --- a/challenges/glitch-invaders/game/src/styles.css +++ b/challenges/glitch-invaders/game/src/styles.css @@ -103,9 +103,8 @@ body { position: relative; z-index: 20; display: flex; - flex-wrap: wrap; align-items: center; - gap: 6px 20px; + gap: 20px; padding: 8px 16px; border-bottom: 2px solid var(--border); background: var(--surface-2); @@ -145,7 +144,6 @@ body { color: var(--accent); } -/* The badge and Reset wrap together onto a second row when the topbar is too narrow for one. */ .topbar-end { display: flex; align-items: center; @@ -1130,6 +1128,46 @@ kbd { font: 12px / 1.35 var(--mono); } +/* Compact topbar: the badge wraps inside itself so the topbar stays one row down to 800 px. */ +@media (max-width: 1100px) { + .topbar { + gap: 12px; + padding: 6px 12px; + } + + .brand { + font-size: 16px; + } + + .brand-sub { + display: none; + } + + .stats { + gap: 10px; + font-size: 12px; + } + + .topbar-end { + gap: 12px; + } + + .badge { + font-size: 11px; + line-height: 1.15; + text-align: center; + white-space: normal; + } +} + +/* Below the compact range the stats, badge and Reset wrap onto further rows. */ +@media (max-width: 760px) { + .topbar { + flex-wrap: wrap; + gap: 6px 12px; + } +} + @media (max-width: 720px) { .dock { grid-template-columns: 1fr; diff --git a/challenges/glitch-invaders/game/src/ui/input.mjs b/challenges/glitch-invaders/game/src/ui/input.mjs index 3429b00..2f3368f 100644 --- a/challenges/glitch-invaders/game/src/ui/input.mjs +++ b/challenges/glitch-invaders/game/src/ui/input.mjs @@ -12,6 +12,9 @@ const PAD_PULSE_MS = 180; /** A tap on a move pad holds long enough to carry the ship one enemy-formation lane. */ export const TAP_MOVE_MS = Math.round(((ENEMY_WIDTH + ENEMY_GAP_X) / PLAYER_SPEED) * 1000); +/** How many lanes of movement a burst of taps can queue ahead of the ship. */ +const MAX_TAP_QUEUE_LANES = 2; + /** The keys that mean each control. */ const KEY_CONTROLS = new Map([ ['ArrowLeft', 'left'], @@ -89,7 +92,6 @@ export class InputController { pointers.add(event.pointerId); this.pointersDown.set(control, pointers); if (OPPOSITE_PADS.has(control)) { - this.cancelTimedHold(OPPOSITE_PADS.get(control)); this.tap(control); } this.recompute(); @@ -112,20 +114,35 @@ export class InputController { }); } - /** A tap on a move pad: hold one more lane's worth, so two quick taps move about two lanes. */ + /** + * A tap on a move pad: hold one more lane's worth, so two quick taps move about two lanes. The + * hold never runs past {@link MAX_TAP_QUEUE_LANES} lanes from now, so a burst of taps cannot queue + * seconds of movement. + */ tap(control) { const now = Date.now(); const pendingEnd = this.timedHolds.get(control)?.endsAt ?? now; - this.holdUntil(control, Math.max(pendingEnd, now) + TAP_MOVE_MS); + const queuedEnd = Math.max(pendingEnd, now) + TAP_MOVE_MS; + this.cancelOppositeTap(control); + this.holdUntil(control, Math.min(queuedEnd, now + MAX_TAP_QUEUE_LANES * TAP_MOVE_MS)); } /** Hold `control` for a keyboard activation; repeats do not add up, so auto-repeat stops on release. */ pulse(control) { + this.cancelOppositeTap(control); const pendingEnd = this.timedHolds.get(control)?.endsAt ?? 0; this.holdUntil(control, Math.max(pendingEnd, Date.now() + PAD_PULSE_MS)); this.recompute(); } + /** End the pending timed hold of the move pad opposite `control`, if it has one. */ + cancelOppositeTap(control) { + const opposite = OPPOSITE_PADS.get(control); + if (opposite !== undefined) { + this.cancelTimedHold(opposite); + } + } + /** Hold `control` until the `endsAt` timestamp, replacing any timed hold it has. */ holdUntil(control, endsAt) { this.cancelTimedHold(control); diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index 4167667..1b8036c 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -1916,7 +1916,6 @@ test('an idle frame writes nothing to the HUD, so the station sees the page go q const progress = createProgress('attempt', 'demo'); const world = hudWorld(); hud.update(progress, world); - const shield = page.getElementById('stat-shield'); const elements = ['stat-hull', 'stat-shield', 'stat-kills', 'stat-score', 'objective-text', 'incidents'].map( (id) => page.getElementById(id), ); @@ -1926,7 +1925,6 @@ test('an idle frame writes nothing to the HUD, so the station sees the page go q hud.update(progress, world); - assert.deepEqual(shield.writes, []); assert.deepEqual(elements.flatMap((element) => element.writes), []); }); }); @@ -2051,20 +2049,17 @@ function padHarness(testContext) { input.attachPad(control, button); } const send = (control, name, pointerId) => - listeners.get(`${control}:${name}`)({ preventDefault: () => {}, pointerId }); + listeners.get(`${control}:${name}`)({ preventDefault: () => {}, pointerId, detail: 0 }); return { input, press: (control, pointerId = 1) => send(control, 'pointerdown', pointerId), release: (control, name = 'pointerup', pointerId = 1) => send(control, name, pointerId), + activate: (control) => send(control, 'click'), tick: (milliseconds) => testContext.mock.timers.tick(milliseconds), }; } -test('a tap lasts long enough to move the ship well beyond 25 px', () => { - assert.ok(TAP_MOVE_MS >= 150, `a tap must move well beyond 25 px, got ${TAP_MOVE_MS} ms`); -}); - test('a quick tap on a move pad keeps the ship moving until a full lane has passed', (testContext) => { const pads = padHarness(testContext); @@ -2107,6 +2102,40 @@ test('pressing the opposite move pad cancels the pending tap', (testContext) => assert.equal(pads.input.current().right, true); }); +test('releasing everything ends a pending tap', (testContext) => { + const pads = padHarness(testContext); + pads.press('left'); + pads.release('left'); + + pads.input.releaseAll(); + pads.tick(TAP_MOVE_MS); + + assert.equal(pads.input.current().left, false); +}); + +test('a keyboard activation of the opposite move pad cancels the pending tap', (testContext) => { + const pads = padHarness(testContext); + pads.press('left'); + pads.release('left'); + + pads.activate('right'); + + assert.equal(pads.input.current().left, false); + assert.equal(pads.input.current().right, true); +}); + +test('a burst of taps queues no more than two lanes of movement', (testContext) => { + const pads = padHarness(testContext); + for (let tapCount = 0; tapCount < 10; tapCount += 1) { + pads.press('left'); + pads.release('left'); + } + + pads.tick(2 * TAP_MOVE_MS); + + assert.equal(pads.input.current().left, false); +}); + test('two quick taps on a move pad carry the ship about two lanes', (testContext) => { const pads = padHarness(testContext); const gapMs = 50; From dbcaebe9fac4824d2d1347bb58df822178641720 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 04:48:25 +0000 Subject: [PATCH 4/4] test(glitch-invaders): pin the tap cap at two lanes --- test/glitch-invaders.test.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index 1b8036c..c91b40b 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -2136,6 +2136,18 @@ test('a burst of taps queues no more than two lanes of movement', (testContext) assert.equal(pads.input.current().left, false); }); +test('a burst of taps still carries the ship a full two lanes', (testContext) => { + const pads = padHarness(testContext); + for (let tapCount = 0; tapCount < 10; tapCount += 1) { + pads.press('left'); + pads.release('left'); + } + + pads.tick(2 * TAP_MOVE_MS - 1); + + assert.equal(pads.input.current().left, true); +}); + test('two quick taps on a move pad carry the ship about two lanes', (testContext) => { const pads = padHarness(testContext); const gapMs = 50;