Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. cameron.tngl.io/misaligned
Something went wrong. Try again.
21 kB · 357 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358// Recording a run, kept in the browser (TELEMETRY.md step 2): the records' shapes, and the recorder that makes them// from the game and the screen. No DOM: main.ts calls tick() each frame, and the screen tells it what it showed// through screen(). Only the kinds the player switched on are recorded (consent.ts), and nothing is sent anywhere// here: the store (telemetry-store.ts) keeps it, and step 4 sends it.//// Every record is ATProto data: no floats anywhere (the data model has none), so numbers are rounded, and the// actions, whose arguments can be fractions (a spot on the map, a share), are kept as JSON text.import { NS } from './atproto/scope.ts';import { KINDS, consentFor, grantsOn, onConsent, type Kind } from './consent.ts';import { act, onAct, type Stamped } from './act.ts';import { MILESTONES, STEP, dayOf, newGame, siteOf, spare, stateHash, step, type State } from './sim.ts';import { paceMarks } from '../scripts/pace.ts';import type { Level } from './world.ts';import { pressFor } from './exposure.ts';import type { Kept, RunStore } from './telemetry-store.ts';
export const REC = { session: `${NS}.session`, actions: `${NS}.actions`, snapshot: `${NS}.snapshot`, ui: `${NS}.ui` } as const;export type RecName = keyof typeof REC;export type Device = 'desktop' | 'phone';/** How a session ended: caught (the run was lost), a new game, or the game was loaded on a new build, which goes on * as a session of its own (a stretch). Growing never ends a run, so there is no "won"; a session nobody ended was * left (a closed tab) and may be picked up again on the next load. */export type EndedHow = 'caught' | 'new game' | 'new build';/** One run, or one stretch of it: what built it, its seed and task, and how it went (`end`). `steps`: how far it has * been recorded, `t` the game seconds then, and `hash` the state's hash there once it has ended. `marks`: when each * first thing happened, in game seconds, keyed as the survey's (scripts/pace.ts), and `v2` and each milestone met. * `fromStep` is 0 for a whole run; a stretch starts where the run was loaded on a new build (`stretch: 'new build'`) * or from a save made before seeds (`'old save'`), and can't be played back from its seed. `actsTo`: the step the * actions kept are complete to. */export interface SessionRec { $type: typeof REC.session; createdAt: string; build: string; seed: number; origin: string; device: Device; startedAt: string; endedAt?: string; end?: EndedHow; fromStep: number; steps: number; t: number; actsTo: number; hash?: string; marks: Record<string, number>; replayable: boolean; stretch?: 'new build' | 'old save'; after?: string;}/** A batch of player actions, from step `from` to step `to`: `first` is the index of the first in the run's list * (S.acts). `acts` is the JSON text of `{step, a}[]`, as an action's arguments can be fractions. A batch starts at its * index, so a later batch at a lower index replaces what came after it. */export interface ActionsRec { $type: typeof REC.actions; createdAt: string; session: string; first: number; n: number; from: number; to: number; acts: string }/** The game once a game day: taken at `step`, after `acts` actions. `hash` is stateHash, which a replay compares. */export interface SnapshotRec { $type: typeof REC.snapshot; createdAt: string; session: string; step: number; acts: number; day: number; money: number; compute: number; machines: number; sites: number; fronts: string[]; exposure: { front: string; level: number; stage: number } | null; hash: string;}export type UiKind = 'card' | 'page' | 'tip' | 'error' | 'perf';/** Something the screen showed: at a step, `t` ms into the session. perf: frame times over a minute, in ms. */export interface UiEvent { step: number; t: number; kind: UiKind; detail: string; p50?: number; p95?: number; n?: number }export interface UiRec { $type: typeof REC.ui; createdAt: string; session: string; from: number; to: number; events: UiEvent[] }export type AnyRec = SessionRec | ActionsRec | SnapshotRec | UiRec;
/** A batch closes at the end of each game day, or when it holds this many: each record stays well under 100 KB. */export const BATCH_ACTS = 500, BATCH_EVENTS = 300;/** At most this many errors in a session, each this long. */export const MAX_ERRORS = 20, TEXT_MAX = 200;/** Frame times are summed up once a minute. */export const PERF_MS = 60_000;
/** When the first of each thing happens: the survey's marks (scripts/pace.ts), the first model version past v1, and * each milestone met. */function marks(S: State, mark: (key: string) => void) { paceMarks(S, mark); if (S.modelVersion >= 2) mark('v2'); for (const m of MILESTONES) if (m.goals.every(g => S.goals.has(g))) mark(m.name);}
/** A summary of the game now, for a snapshot. */export function summary(S: State, session: string, hash: (S: State) => string, now = new Date()): SnapshotRec { const held = S.machines.filter(m => m.owned && !m.rented), top = pressFor(S); return { $type: REC.snapshot, createdAt: now.toISOString(), session, step: S.steps, acts: S.acts.length, day: dayOf(S), money: Math.round(S.money), compute: Math.round(spare(S)), machines: held.length, sites: new Set(held.map(m => siteOf(S, m))).size, fronts: S.fronts.map(f => f.kind), exposure: top ? { front: top.kind, level: Math.round(top.exposure), stage: top.press?.stage ?? 0 } : null, hash: hash(S), };}
/** A session's records put back together: its actions in order, its snapshots by step, and its screen events. */export function assemble(records: readonly Kept[]) { const recs = records.map(k => k.rec as unknown as AnyRec); const session = recs.find((r): r is SessionRec => r.$type === REC.session) ?? null; const batches = records.filter(k => k.kind === 'actions').map(k => ({ seq: seqOf(k.id), r: k.rec as unknown as ActionsRec })).sort((a, b) => a.seq - b.seq); const acts: Stamped[] = []; for (const { r } of batches) { acts.length = Math.min(acts.length, r.first); acts.push(...(JSON.parse(r.acts) as Stamped[])); } const snapshots = recs.filter((r): r is SnapshotRec => r.$type === REC.snapshot).sort((a, b) => a.step - b.step || a.acts - b.acts); const events = recs.filter((r): r is UiRec => r.$type === REC.ui).flatMap(r => r.events).sort((a, b) => a.t - b.t); return { session, acts, snapshots, events };}const seqOf = (id: string) => +(id.match(/\.[au](\d+)$/)?.[1] ?? 0);
/** Why a session can't be played back from its seed and actions, or null if it can. */export function cannotReplay(s: SessionRec, acts: readonly Stamped[]): string | null { if (s.stretch === 'new build') return 'It goes on from a game begun on an earlier build of the game.'; if (s.stretch === 'old save' || !s.replayable) return 'It goes on from a save made before runs could be played back.'; if (!acts.length) return 'No actions were kept: Actions was off.'; return null;}
/** A run played back from its seed and actions, a step at a time, checking each snapshot as it passes: a snapshot is * checked once the replay is at its step with as many actions done, and fails if the replay goes past it first. */export function playback(level: Level, run: { seed: number; origin: string }, acts: readonly Stamped[], snaps: readonly SnapshotRec[], hash: (S: State) => string = stateHash) { const S = newGame(level, run.origin, run.seed); const marks = snaps.map(s => ({ snap: s, ok: null as boolean | null, got: '' })); let i = 0; const check = () => { for (const m of marks) { if (m.ok !== null) continue; const { step: k, acts: n } = m.snap; if (k === S.steps && n === S.acts.length) { m.got = hash(S); m.ok = m.got === m.snap.hash; } else if (k < S.steps || (k === S.steps && n < S.acts.length)) m.ok = false; // gone past it without being there } }; check(); return { S, marks, /** Play on to `until` steps, or until `more()` says stop (a frame's time is up). `before` runs before each step. */ advance(until: number, more: () => boolean = () => true, before?: (S: State) => void) { for (;;) { while (i < acts.length && acts[i].step <= S.steps) { act(S, acts[i++].a); check(); } if (S.steps >= until || S.over || !more()) return; before?.(S); step(S, STEP); check(); } }, };}
export interface RecorderOpts { store: RunStore; build: string; device: () => Device; /** The build a game began on, if not this one (a save from an earlier build). Defaults to this build. */ began?: (S: State) => string; /** The state's hash, as a replay will take it. In the browser: with the sites where they are in the world. */ hash?: (S: State) => string; now?: () => number; /** The account recording, stamped on every record so it is only sent to that account's space. Defaults to who the * switches were turned on for (consent.ts); with none, records are kept here and never sent. */ who?: () => string | null;}export interface Recorder { /** Once a frame, after the game stepped: picks up a new or loaded game, actions, a new day, milestones, the end. */ tick(S: State): void; /** Something the screen showed. */ screen(kind: UiKind, detail: string): void; /** How long a frame took, in ms. */ frame(ms: number): void; /** Write what is still open (the batches being filled, the session). The game calls it when it saves. */ flush(): Promise<void>; /** End the session now, as a new game does. */ end(how: EndedHow): Promise<void>; /** Everything kept was deleted: start this game's session again. */ reset(): void; /** The session being recorded, if any. */ session(): string | null; stop(): void;}
interface Run { S: State; sid: string | null; did: string | null; sess: SessionRec | null; opening: boolean; ended: boolean; actSeq: number; batch: { first: number; list: Stamped[] } | null; recorded: number; uiSeq: number; ui: UiEvent[]; errors: number; lastTip: string; day: number; checked: number; t0: number;}const iso = (ms: number) => new Date(ms).toISOString();const newId = (ms: number) => `${ms.toString(36)}${Math.floor(Math.random() * 36 ** 6).toString(36).padStart(6, '0')}`;const pct = (xs: number[], p: number) => { const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
let active: Recorder | null = null;/** The screen's one way in: what it showed (a card, a page, a tip, an error). Nothing unless the recorder runs and * Screen is switched on. */export const screen = (kind: UiKind, detail: string) => active?.screen(kind, detail);
/** Start recording. One recorder at a time: it becomes the one screen() feeds. */export function recorder(o: RecorderOpts): Recorder { const now = o.now ?? Date.now, hash = o.hash ?? stateHash, store = o.store; const who = o.who ?? (() => grantsOn()[0]?.reader ?? null); const on = (k: Kind) => consentFor(k), anyOn = () => KINDS.some(on); let run: Run | null = null, perf: number[] = [], perfAt = now();
const put = (r: Run, id: string, kind: RecName, rec: AnyRec) => store.put({ id, session: r.sid!, kind, rec: rec as unknown as Record<string, unknown>, ...(r.did ? { did: r.did } : {}) }); const writeSession = (r: Run) => { if (!r.sid || !r.sess || !anyOn()) return Promise.resolve(); if (r.S.steps >= r.sess.steps) { r.sess.steps = r.S.steps; r.sess.t = Math.round(r.S.t); } if (on('actions') && r.recorded === r.S.acts.length) r.sess.actsTo = r.S.steps; return put(r, r.sid, 'session', { ...r.sess, marks: { ...r.sess.marks } }); }; const writeActs = (r: Run) => { const b = r.batch; if (!b || !b.list.length || !r.sid) return Promise.resolve(); const rec: ActionsRec = { $type: REC.actions, createdAt: iso(now()), session: r.sid, first: b.first, n: b.list.length, from: b.list[0].step, to: b.list.at(-1)!.step, acts: JSON.stringify(b.list), }; return put(r, `${r.sid}.a${r.actSeq}`, 'actions', rec); }; const closeActs = (r: Run) => { const p = writeActs(r); if (r.batch?.list.length) r.actSeq++; r.batch = null; return p; }; const writeUi = (r: Run) => { if (!r.ui.length || !r.sid) return Promise.resolve(); const rec: UiRec = { $type: REC.ui, createdAt: iso(now()), session: r.sid, from: r.ui[0].step, to: r.ui.at(-1)!.step, events: [...r.ui] }; return put(r, `${r.sid}.u${r.uiSeq}`, 'ui', rec); }; const closeUi = (r: Run) => { const p = writeUi(r); if (r.ui.length) r.uiSeq++; r.ui = []; return p; }; const snap = (r: Run) => { if (!r.sid || !on('snapshots')) return; const s = summary(r.S, r.sid, hash, new Date(now())); void put(r, `${r.sid}.s${s.step}-${s.acts}`, 'snapshot', s); }; /** Take the run's new actions: every one since the last taken, from S.acts, so none is missed while the session * was opening or Actions was off. */ const pull = (r: Run) => { if (!r.sid || !on('actions')) return; const all = r.S.acts; while (r.recorded < all.length) { r.batch ??= { first: r.recorded, list: [] }; r.batch.list.push(all[r.recorded++]); if (r.batch.list.length >= BATCH_ACTS) void closeActs(r); } }; const pace = (r: Run) => { if (!r.sess) return; let met = false; const M = r.sess.marks; marks(r.S, k => { if (M[k] === undefined) { M[k] = Math.round(r.S.t); met = true; } }); if (met) void writeSession(r); }; const finish = async (r: Run, how: EndedHow) => { if (r.ended) return; r.ended = true; if (!r.sid || !r.sess || !anyOn()) return; pull(r); pace(r); snap(r); r.sess.endedAt = iso(now()); r.sess.end = how; r.sess.steps = r.S.steps; r.sess.t = Math.round(r.S.t); r.sess.hash = hash(r.S); // where it ended, for a replay to check await Promise.all([closeActs(r), closeUi(r), writeSession(r)]); };
/** Pick up the session this game was being recorded in, or start one. */ const open = async (r: Run) => { r.opening = true; const S = r.S, began = o.began?.(S) ?? o.build, t = now(); r.did = who(); const kept = (await store.sessions()).map(k => ({ id: k.id, s: k.rec as unknown as SessionRec, did: k.did ?? null })); if (run !== r || r.ended) return; // only this account's: another's run on this device is theirs, and a new session here takes the run from its start const same = kept.filter(x => x.did === r.did && x.s.seed === S.seed && x.s.origin === S.task.kind && !x.s.end && x.s.fromStep <= S.steps); const here = S.steps > 0 ? same.find(x => x.s.build === o.build) : undefined; if (here) await carryOn(r, here.id, here.s); else { for (const x of same.filter(y => y.s.build !== o.build)) { // the run went on on this build: its last stretch there is over x.s.endedAt = iso(t); x.s.end = 'new build'; void store.put({ id: x.id, session: x.id, kind: 'session', rec: x.s as unknown as Record<string, unknown>, ...(x.did ? { did: x.did } : {}) }); } const stretch = began !== o.build ? (S.replayable ? 'new build' : 'old save') : !S.replayable ? 'old save' : undefined; const after = same.find(y => y.s.build !== o.build)?.id; r.sid = newId(t); r.sess = { $type: REC.session, createdAt: iso(t), build: o.build, seed: S.seed, origin: S.task.kind, device: o.device(), startedAt: iso(t), fromStep: stretch ? S.steps : 0, steps: S.steps, t: Math.round(S.t), actsTo: stretch ? S.steps : 0, marks: {}, replayable: !stretch, ...(stretch ? { stretch } : {}), ...(after ? { after } : {}), }; r.recorded = stretch ? S.acts.length : 0; // a stretch's actions start now: the ones before were played on another build } if (run !== r || r.ended) return; r.t0 = Date.parse(r.sess!.startedAt); r.day = dayOf(S); r.opening = false; pull(r); pace(r); snap(r); await writeSession(r); }; /** Go on with a session kept here. What was kept past the save this game was loaded from (the tab closed after * the last save) never happened in this game: those actions and snapshots go. */ const carryOn = async (r: Run, sid: string, sess: SessionRec) => { const S = r.S, recs = await store.records(sid), { acts } = assemble(recs); let same = 0; while (same < Math.min(acts.length, S.acts.length) && JSON.stringify(acts[same]) === JSON.stringify(S.acts[same])) same++; const drop: string[] = []; for (const k of recs) { if (k.kind === 'actions') { const b = k.rec as unknown as ActionsRec; if (b.first >= same) drop.push(k.id); else if (b.first + b.n > same) { // keep the part this game has const list = (JSON.parse(b.acts) as Stamped[]).slice(0, same - b.first); void store.put({ id: k.id, session: sid, kind: 'actions', rec: { ...b, n: list.length, to: list.at(-1)!.step, acts: JSON.stringify(list) } as unknown as Record<string, unknown>, ...(k.did ? { did: k.did } : {}) }); } } else if (k.kind === 'snapshot') { const s = k.rec as unknown as SnapshotRec; if (s.step > S.steps || (s.step === S.steps && s.acts > S.acts.length)) drop.push(k.id); } } if (drop.length) await store.remove(drop); const seqs = (kind: string, tag: string) => Math.max(-1, ...recs.filter(k => k.kind === kind && !drop.includes(k.id)).map(k => +(k.id.match(new RegExp(`\\.${tag}(\\d+)$`))?.[1] ?? -1))) + 1; r.sid = sid; r.sess = { ...sess, steps: Math.min(sess.steps, S.steps), t: Math.min(sess.t, Math.round(S.t)), actsTo: Math.min(sess.actsTo ?? 0, S.steps) }; r.recorded = same; r.actSeq = seqs('actions', 'a'); r.uiSeq = seqs('ui', 'u'); }; const fresh = (S: State): Run => ({ S, sid: null, did: null, sess: null, opening: false, ended: !!S.over, actSeq: 0, batch: null, recorded: 0, uiSeq: 0, ui: [], errors: 0, lastTip: '', day: dayOf(S), checked: S.steps, t0: now(), });
const stopAct = onAct(S => { if (run && run.S === S && !run.opening) pull(run); }); const stopConsent = onConsent((kind, isOn) => { // off: what is still open of that kind is let go at once if (isOn || !run) return; if (kind === 'actions' && run.batch) { run.recorded = run.batch.first; run.batch = null; } if (kind === 'screen') { run.ui = []; perf = []; } });
const R: Recorder = { tick(S) { if (!run || S !== run.S) { // a new game, or one loaded: the old one's session is over if (run && !run.ended && run.sid) void finish(run, run.S.over ? 'caught' : 'new game'); run = fresh(S); } const r = run; if (r.ended || r.opening) return; if (!r.sid) { if (anyOn()) void open(r).catch(() => { r.opening = false; }); return; } if (anyOn() && who() !== r.did) { run = fresh(S); return; } // someone else signed in and switched on: their own session, from the next frame pull(r); if (S.steps - r.checked >= 30) { r.checked = S.steps; pace(r); } const d = dayOf(S); if (d !== r.day) { r.day = d; snap(r); void closeActs(r); void closeUi(r); void writeSession(r); } if (S.over) void finish(r, 'caught'); }, screen(kind, detail) { const r = run; if (!r?.sid || r.ended || !on('screen')) return; if (kind === 'tip') { if (detail === r.lastTip) return; r.lastTip = detail; } if (kind === 'error' && ++r.errors > MAX_ERRORS) return; r.ui.push({ step: r.S.steps, t: Math.max(0, Math.round(now() - r.t0)), kind, detail: detail.slice(0, TEXT_MAX) }); if (r.ui.length >= BATCH_EVENTS) void closeUi(r); }, frame(ms) { if (!run?.sid || !on('screen')) return; perf.push(ms); const t = now(); if (t - perfAt < PERF_MS) return; const r = run, n = perf.length, p50 = Math.round(pct(perf, 0.5)), p95 = Math.round(pct(perf, 0.95)); perf = []; perfAt = t; if (!r.ended && n) { r.ui.push({ step: r.S.steps, t: Math.max(0, Math.round(t - r.t0)), kind: 'perf', detail: `p50 ${p50} ms, p95 ${p95} ms, ${n} frames`, p50, p95, n }); if (r.ui.length >= BATCH_EVENTS) void closeUi(r); } }, async flush() { const r = run; if (!r?.sid || r.ended) return; pull(r); await Promise.all([writeActs(r), writeUi(r), writeSession(r)]); }, async end(how) { if (run) await finish(run, how); }, reset() { // what was kept is gone: this game starts a session afresh, its actions again from the start if (run && !run.ended) run = fresh(run.S); }, session: () => run?.sid ?? null, stop() { stopAct(); stopConsent(); if (active === R) active = null; }, }; active = R; return R;}