Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 58 additions & 7 deletions challenges/glitch-invaders/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ Incident Console with a stack trace, breadcrumbs, or a performance trace, asks
for the root cause, and applies your chosen patch so you can watch the fix work
in play.

The game always runs in demo mode: the Incident Console shows simulated Sentry
evidence and no telemetry leaves the machine. Live Sentry mode returns once
boothstrap provides sponsor settings; see
[boothstrap.sh issue 519](https://github.com/MLH/boothstrap.sh/issues/519).
The game runs in demo mode unless [live Sentry mode](#live-sentry-mode) is
set up: the Incident Console shows simulated Sentry evidence and no telemetry
leaves the machine.

## Files

| File | Purpose |
| --- | --- |
| `challenge.json`, `plugin.json` | Manifest and Agent Plugin metadata |
| `sentry.json` | Public Sentry settings for live mode: DSN, org slug, project slug |
| `intro.md` | Intro screen text |
| `skills/start-challenge/SKILL.md` | The guide Pi follows |
| `monitor/SKILL.md` | Headless browser monitor skill |
Expand All @@ -40,11 +40,62 @@ which clue helped most and reports `debrief`.
The server listens on port 4373. Set `GLITCH_PORT` to use another port, though
the challenge manifest expects 4373.

## Live Sentry mode

Live mode needs both halves:

- **`sentry.json` filled in.** The DSN, org slug, and project slug are public
values, so they live in the package. While any value is still a
`<placeholder>`, the game runs in demo mode. Once filled in, a malformed value
fails setup naming it. An optional `apiUrl` replaces the Sentry API origin,
`https://sentry.io`, with a subdomain of it or, for the tests' local
stand-in, a loopback address; the token goes to no other host.
- **`SENTRY_AUTH_TOKEN` in the environment.** On a station, the event vault's
`sentry` service sets it to a placeholder that the vault proxy swaps for the
real token. `boothstrap dev` and a standalone run read the real token from
Comment thread
theycallmeswift marked this conversation as resolved.
your shell.

`boothstrap dev` refuses to play the package unless `SENTRY_AUTH_TOKEN` or
`SENTRY_API_KEY` is set, because `vault_services` names `sentry`. Any value
keeps demo mode while `sentry.json` holds placeholders, and `SENTRY_API_KEY`
satisfies dev but leaves the game in demo mode, since the package reads only
`SENTRY_AUTH_TOKEN`.

In live mode the page reports each failure to the DSN: an error event for the
weapon and the shield, and a `boss-spawn` transaction with one span per load for
the boss. The page server then reads Sentry's record of it from the Sentry API
(the issue, its stack trace or breadcrumbs, or the trace's spans) and the
Incident Console shows that, with a link to it in Sentry. The boss's
`boot_state` pills are the game's own state, labelled as such. Only requests
from the page itself are answered, and repeated requests for one report share a
single lookup. The token stays in the page server: it is never served to the
page or written anywhere. The page's CSP lets it contact only this server and
the DSN's ingest origin. If Sentry doesn't accept the report or can't return it
within about 15 seconds, that incident shows simulated evidence with a note
saying so. Setup prints `(live Sentry mode)` after the URL when live mode is on,
and a note when `sentry.json` is filled in but the token is missing.

Because `vault_services` names `sentry`, the event vault must hold a `sentry`
service, or the station lists the challenge as unavailable, on every boothstrap
version:

| Field | Value |
| --- | --- |
| Name | `sentry` |
| Auth | `bearer`, token `SENTRY_AUTH_TOKEN` |
| Host | `sentry.io` |

The token needs the `event:read`, `project:read`, and `org:read` scopes. The
station also needs a boothstrap that sets each `vault_services` credential in
the challenge's hooks. Release `0.1.0` predates that, so on it the challenge gets
no token and runs in demo mode.

## Run without boothstrap

Needs Node 26 and a `GEMINI_API_KEY`. Follow the standalone steps in
boothstrap.sh's `docs/authoring-challenges.md` to install Pi and load the
package as an Agent Plugin, then:
Needs Node 26 and a `GEMINI_API_KEY`; export `SENTRY_AUTH_TOKEN` too for live
mode. Follow the standalone steps in boothstrap.sh's
`docs/authoring-challenges.md` to install Pi and load the package as an Agent
Plugin, then:

```sh
challenge=/absolute/path/to/glitch-invaders
Expand Down
3 changes: 2 additions & 1 deletion challenges/glitch-invaders/challenge.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"agent": "pi",
"model": "google/gemini-3.8-flash",
"vault_services": [
"gemini"
"gemini",
"sentry"
],
"points": 150,
"estimated_minutes": 9,
Expand Down
46 changes: 37 additions & 9 deletions challenges/glitch-invaders/game/src/app/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { loadBossResources } from '../boss/boss-loader.mjs';
import { BUG_DEFINITIONS } from '../bugs/bug-definitions.mjs';
import { tryRepair } from '../bugs/code-state.mjs';
import { IncidentTracker } from '../bugs/incident-tracker.mjs';
import { LiveEvidenceProvider } from '../diagnostics/live-evidence.mjs';
import { SimulatedEvidenceProvider } from '../diagnostics/simulated-evidence.mjs';
import { GameEngine } from '../engine/game-engine.mjs';
import { createRandom } from '../engine/random.mjs';
import { CanvasRenderer } from '../render/canvas-renderer.mjs';
import { attachBreadcrumbs } from '../sentry/breadcrumbs.mjs';
import { publishState } from '../session/monitor-state.mjs';

import { BossLoadingView } from '../ui/boss-loading.mjs';
Expand Down Expand Up @@ -49,7 +51,8 @@ export class GameApp {
overlays = new OverlayHost(requireElement('overlay-root'));
bossView = new BossLoadingView(requireElement('boss-loading'));
engine;
evidence = new SimulatedEvidenceProvider();
simulated = new SimulatedEvidenceProvider();
live = null;
incidentConsole;
input;
loop;
Expand All @@ -62,10 +65,16 @@ export class GameApp {
pauseOpen = false;
pendingStart = null;

/**
* @param {object} options the page's config, store, and hooks; `reporter` is given only in live
* mode and sends the failures to Sentry.
* @param {object | null} saved the run to resume, if any.
*/
constructor(options, saved) {
this.options = options;
this.now = options.now ?? (() => performance.now());
this.progress = saved ?? createProgress(options.config.attemptId);
const mode = options.reporter === undefined ? 'demo' : 'live';
this.progress = saved ?? createProgress(options.config.attemptId, mode);
this.tracker = new IncidentTracker(this.progress);
this.code = codeForProgress(this.progress);

Expand All @@ -76,6 +85,14 @@ export class GameApp {
now: this.now,
particleScale: options.reducedMotion ? 0.25 : 1,
});
if (options.reporter !== undefined) {
attachBreadcrumbs(this.engine, options.reporter);
this.live = new LiveEvidenceProvider({
reporter: options.reporter,
simulated: this.simulated,
fetchImpl: (input, init) => fetch(input, init),
});
}
this.incidentConsole = new IncidentConsole({
progressOf: (bug) => this.progress.bugs[bug],
markEvidenceViewed: (bug) => this.tracker.markEvidenceViewed(bug),
Expand All @@ -102,6 +119,9 @@ export class GameApp {
begin() {
this.tracker.subscribe((progress) => this.onProgress(progress));
this.engine.on((event) => this.onEvent(event));
if (this.live !== null) {
this.hud.showLiveMode();
}
publishState(document, this.progress);

this.input.attach(document, {
Expand Down Expand Up @@ -138,7 +158,7 @@ export class GameApp {
showOpening() {
if (!this.progress.launched) {
this.overlays.show(
introCard({ returning: false, onStart: () => this.startRun() }),
introCard({ live: this.live !== null, returning: false, onStart: () => this.startRun() }),
);

return;
Expand Down Expand Up @@ -173,7 +193,7 @@ export class GameApp {
this.openRestoredIncident(bug);
};
this.overlays.show(
introCard({ returning: true, onStart: () => this.resumeRun() }),
introCard({ live: this.live !== null, returning: true, onStart: () => this.resumeRun() }),
);
}

Expand Down Expand Up @@ -314,10 +334,15 @@ export class GameApp {
`${definition.incidentLabel}: ${definition.title}. The Incident Console is open.`,
);

void this.evidence.evidenceFor(encounter).then((evidence) => this.deliverEvidence(evidence));
void (this.live ?? this.simulated)
.evidenceFor(encounter)
.then((evidence) => this.deliverEvidence(evidence));
}

/** A failure was restored from a saved run: reopen the console on its evidence. */
/**
* A failure was restored from a saved run: reopen the console on its evidence. The live capture is
* gone and a second report would be a duplicate, so live mode falls back to simulated evidence.
*/
openRestoredIncident(bug) {
this.input.releaseAll();
this.incidentConsole.open(bug);
Expand All @@ -328,9 +353,12 @@ export class GameApp {

return;
}
void this.evidence
.evidenceFor(canonicalEncounter(bug))
.then((evidence) => this.deliverEvidence(evidence));
const encounter = canonicalEncounter(bug);
const restored =
this.live === null
? this.simulated.evidenceFor(encounter)
: this.live.fallback(encounter, 'restored');
void restored.then((evidence) => this.deliverEvidence(evidence));
}

/** Remember the evidence and show it if its console is still open. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,20 @@ export const WEAPON_STACK_FRAMES = [
line: 160,
inApp: true,
},
{ functionName: 'GameLoop.frame', file: 'game/src/app/app.mjs', line: 94, inApp: true },
{ functionName: 'GameLoop.frame', file: 'game/src/app/app.mjs', line: 111, inApp: true },
{ functionName: 'requestAnimationFrame', file: '<browser>', line: 0, inApp: false },
];

/** Where the shield failure's live report says it happened; the same unit test checks this line. */
export const SHIELD_STACK_FRAMES = [
{
functionName: 'resolveShieldHit',
file: 'game/src/shield/shield.mjs',
line: 17,
inApp: true,
},
];

/** The canonical boss waterfall: the four loads, back to back. */
export const SEQUENTIAL_BOSS_SPANS = [
{ name: 'loadTextures', operation: 'game.resource.load', startMs: 0, durationMs: 1_200 },
Expand Down Expand Up @@ -155,27 +165,34 @@ export function evidenceSections(encounter, breadcrumbs, spans) {
},
];
case 'boss':
return [
{
kind: 'waterfall',
title: 'Trace: boss-spawn',
totalMs: spans.reduce((end, span) => Math.max(end, span.startMs + span.durationMs), 0),
spans: [...spans],
},
{
kind: 'context',
title: 'boot_state',
contextName: 'boot_state',
entries: {
animationsLoaded: 'at game start',
spawnBudget: seconds(BOSS_SPAWN_BUDGET_MS),
strategy: 'sequential',
},
},
];
return [waterfallSection(spans), bootStateSection()];
}
}

/** The boss-spawn trace as a waterfall section, its total the end of the last span. */
export function waterfallSection(spans) {
return {
kind: 'waterfall',
title: 'Trace: boss-spawn',
totalMs: spans.reduce((end, span) => Math.max(end, span.startMs + span.durationMs), 0),
spans: [...spans],
};
}

/** The boss failure's context: what the shipped boot code already did and the spawn budget. */
export function bootStateSection() {
return {
kind: 'context',
title: 'boot_state',
contextName: 'boot_state',
entries: {
animationsLoaded: 'at game start',
spawnBudget: seconds(BOSS_SPAWN_BUDGET_MS),
strategy: 'sequential',
},
};
}

/** Format an issue reference for the simulated provider, such as `SIM-WEAPON-1`. */
export function simulatedReference(bug) {
return `SIM-${bug.toUpperCase()}-1`;
Expand Down
56 changes: 56 additions & 0 deletions challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Live-mode evidence: report the failure to Sentry, then ask the page server for what Sentry
* recorded. When either step fails, the console still gets the simulated evidence, labelled with
* why, so it is never left blank.
*/

/** How long the page waits for the server, which gives up on Sentry sooner than this. */
const EVIDENCE_TIMEOUT_MS = 20_000;

/** The notes a fallback carries, by what went wrong. */
export const FALLBACK_NOTES = {
unsent: 'Sentry did not accept the report, so this is simulated evidence.',
unavailable: 'Sentry could not return the report in time, so this is simulated evidence.',
restored: 'The page reloaded after this report, so this is simulated evidence.',
};

/** Builds evidence from Sentry's own record of each report. */
export class LiveEvidenceProvider {
/**
* @param {{ reporter: { report: Function }, simulated: { evidenceFor: Function },
* fetchImpl: typeof fetch }} options `fetchImpl` reaches the page server.
*/
constructor(options) {
this.options = options;
}

/** Report `encounter` and fetch Sentry's evidence for it. Never rejects. */
async evidenceFor(encounter) {
const outcome = await this.options.reporter.report(encounter);
if (!outcome.delivered) {
return this.fallback(encounter, 'unsent');
}

try {
const query = new URLSearchParams({ bug: encounter.bug, id: outcome.id });
const response = await this.options.fetchImpl(`/api/sentry-evidence?${query}`, {
cache: 'no-store',
signal: AbortSignal.timeout(EVIDENCE_TIMEOUT_MS),
});
if (!response.ok) {
return this.fallback(encounter, 'unavailable');
}

return await response.json();
} catch {
return this.fallback(encounter, 'unavailable');
}
}

/** The simulated evidence for `encounter`, labelled as a fallback with the note for `reason`. */
async fallback(encounter, reason) {
const simulated = await this.options.simulated.evidenceFor(encounter);

return { ...simulated, source: 'fallback', note: FALLBACK_NOTES[reason] };
}
}
13 changes: 12 additions & 1 deletion challenges/glitch-invaders/game/src/main.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/** The page entry: read this attempt's configuration and saved progress, and start the game. */
/**
* The page entry: read this attempt's configuration and saved progress, and start the game, in live
* Sentry mode when the config carries Sentry settings.
*/
import { parseRuntimeConfig } from '../../shared/runtime-config.mjs';
import { GameApp } from './app/app.mjs';
import { SentryReporter } from './sentry/reporter.mjs';
import { HttpProgressStore } from './session/progress-store.mjs';

/** Read `/runtime-config.json`, which the page server writes for every attempt. */
Expand Down Expand Up @@ -56,6 +60,13 @@ async function main() {
store,
reload: () => window.location.reload(),
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
...(config.sentry === undefined
? {}
: {
reporter: new SentryReporter(config.sentry, config.attemptId, (input, init) =>
fetch(input, init),
),
}),
...(Number.isInteger(seed) && query.has('seed') ? { seed } : {}),
},
saved,
Expand Down
Loading
Loading