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
Progress can't be saved; reload the page.
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..4a67aa8 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; } @@ -127,6 +128,7 @@ body { display: flex; flex: 1; gap: 18px; + white-space: nowrap; color: var(--muted); font-size: 13px; letter-spacing: 0.04em; @@ -142,6 +144,13 @@ body { color: var(--accent); } +.topbar-end { + display: flex; + align-items: center; + gap: 20px; + margin-left: auto; +} + .badge { padding: 2px 10px; border: 2px solid var(--mlh-yellow); @@ -201,9 +210,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 +436,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 +468,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 +649,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 +953,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 +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; @@ -1106,16 +1181,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..e6ab3a7 100644 --- a/challenges/glitch-invaders/game/src/ui/dom.mjs +++ b/challenges/glitch-invaders/game/src/ui/dom.mjs @@ -30,6 +30,32 @@ 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; + } +} + +/** 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) { + 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. */ 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..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; @@ -124,11 +126,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 +177,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 +212,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 +231,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 +252,8 @@ export class IncidentConsole { this.render(); }, ), - ), - ); + ], + }; } /** @@ -281,10 +300,13 @@ 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; - return h( + const content = h( 'div', { class: 'diagnose' }, h('p', { class: 'question' }, definition.question), @@ -314,26 +336,24 @@ 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 + hintItems.length === 0 ? null - : h( - 'ol', - { class: 'hints', 'aria-label': 'Hints' }, - ...hints.map((hint) => h('li', {}, hint)), - ), - h( - 'div', - { class: 'pane-actions' }, + : h('ol', { class: 'hints', 'aria-label': 'Hints' }, ...hintItems), + ); + + 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( @@ -345,9 +365,9 @@ export class IncidentConsole { () => { this.host.useHint(this.bug); this.render(); + this.newestHint?.scrollIntoView({ block: 'nearest' }); }, ), - h('span', { class: 'spacer' }), solved ? onClick( h( @@ -361,8 +381,8 @@ export class IncidentConsole { }, ) : null, - ), - ); + ], + }; } /** Submit a diagnosis; a right one moves on to the patch pane. */ @@ -378,10 +398,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 +440,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..2f3368f 100644 --- a/challenges/glitch-invaders/game/src/ui/input.mjs +++ b/challenges/glitch-invaders/game/src/ui/input.mjs @@ -1,13 +1,20 @@ /** * 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); + +/** 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'], @@ -17,13 +24,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; @@ -58,25 +73,31 @@ 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); + const pointers = this.pointersDown.get(control) ?? new Set(); + pointers.add(event.pointerId); + this.pointersDown.set(control, pointers); + if (OPPOSITE_PADS.has(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(); } }; @@ -93,17 +114,54 @@ export class InputController { }); } - /** Hold `control` briefly. */ + /** + * 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; + 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.padsDown.set(control, (this.padsDown.get(control) ?? 0) + 1); + 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); 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(); - }, PAD_PULSE_MS); - 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. */ @@ -141,7 +199,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/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/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/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..d74956b 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,12 @@ 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, 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 +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..c91b40b 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,11 @@ 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 { 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'; import { BOSS_SPAWN_BUDGET_MS, @@ -913,6 +918,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 +961,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 +1047,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 +1073,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 +1218,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 +1779,428 @@ 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. It keeps its + * children, attributes, and listeners, and reads a style back normalized as a browser does. + */ +class RecordingElement { + writes = []; + hidden = false; + children = []; + attributes = new Map(); + listeners = new Map(); + scrolledIntoView = []; + dataset; + style; + + constructor() { + this.dataset = this.#recordingBag('dataset', (value) => value); + this.style = this.#recordingBag('style', normalizedCssValue); + } + + get textContent() { + return this.children + .map((child) => (typeof child === 'string' ? child : child.textContent)) + .join(''); + } + + set textContent(text) { + this.writes.push(`textContent=${text}`); + this.children = [text]; + } + + setAttribute(name, value) { + this.attributes.set(name, value); + } + + getAttribute(name) { + return this.attributes.get(name) ?? null; + } + + append(...children) { + this.children.push(...children); + } + + 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 }); + } + } + + scrollIntoView(options) { + this.scrolledIntoView.push(options); + } + + #recordingBag(label, readBack) { + const values = {}; + + return new Proxy(values, { + set: (target, name, value) => { + this.writes.push(`${label}.${name}=${value}`); + target[name] = readBack(value); + + return true; + }, + }); + } +} + +/** 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(); + 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 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(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('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'); + }); +}); + +/** 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(); + 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, 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 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('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('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; + + 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'); + + 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', () => { + withRecordingPage(() => { + const progress = createProgress('test-attempt'); + + const card = completionCard(progress); + + assert.match(card.textContent, /Last step: tell Pi which clue helped most\./); + }); +});