Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. An experiment in spec-driven development. cameron.tngl.io/misaligned
Something went wrong. Try again.
14 kB · 207 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208// Runs from telemetry records (TELEMETRY.md): sort what a space holds into runs, report on them in the survey's way,// and replay one from its seed and actions, checking it passes through each snapshot's state. No network: the reader// script (telemetry.ts) fetches, this reads. The shapes are the recorder's (src/telemetry.ts): a `session` record keyed// by the run, and `actions`, `snapshot` and `ui` records that name it in `session`. They are read loosely, so records// written by hand (the tests) read too: an actions batch's `acts` may be the recorder's JSON text or a plain list.import { BUILDING, type Level } from '../src/world.ts';import { STEP, newGame, stateHash, step, type State } from '../src/sim.ts';import { act, type Action, type Stamped } from '../src/act.ts';import { NS } from '../src/atproto/scope.ts';import { GOAL } from './play.ts';import { PACE, paceMarks } from './pace.ts';
/** One record as read from a space: who wrote it, where, and what. */export type Rec = { author: string; collection: string; rkey: string; value: Record<string, unknown> };/** A run's session record: build, seed, device, when, how it ended, and its pace (marks, in game seconds). */export type Session = { build?: string; seed?: number; origin?: string; device?: string; startedAt?: string; endedAt?: string; end?: string; t?: number; steps?: number; marks?: Record<string, number>; replayable?: boolean; hash?: string;};/** A snapshot: its step, and (from the recorder) how many actions had been done at that step when it was taken. */export type Snap = { step: number; acts?: number; hash?: string; day?: number; [k: string]: unknown };export type Run = { id: string; author: string; session: Session | null; acts: Stamped[]; snaps: Snap[]; screen: number; errors: number; /** What doesn't fit: batches that overlap or leave a hole, records naming no run. */ notes: string[];};
const num = (v: unknown) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;const str = (v: unknown) => typeof v === 'string' ? v : undefined;/** The run a record names: an rkey, or the last part of an at:// address. */const runOf = (v: unknown) => { const s = str(v); return s ? s.split('/').pop()! : undefined; };const kindOf = (collection: string) => collection.startsWith(`${NS}.`) ? collection.slice(NS.length + 1) : null;
/** Sort records into runs, one per author and session. Actions come in batch order, by the step each batch starts on. */export function gather(recs: readonly Rec[]): Run[] { const runs = new Map<string, Run>(), orphans: string[] = []; const get = (author: string, id: string) => { const k = `${author} ${id}`; let r = runs.get(k); if (!r) runs.set(k, r = { id, author, session: null, acts: [], snaps: [], screen: 0, errors: 0, notes: [] }); return r; }; const batches = new Map<Run, { from: number; first?: number; acts: Stamped[] }[]>(); for (const rec of recs) { const kind = kindOf(rec.collection), v = rec.value ?? {}; if (kind === 'session') { get(rec.author, rec.rkey).session = { build: str(v.build), seed: num(v.seed), origin: str(v.origin), device: str(v.device), startedAt: str(v.startedAt), endedAt: str(v.endedAt), end: str(v.end), t: num(v.t), steps: num(v.steps), hash: str(v.hash), marks: v.marks && typeof v.marks === 'object' ? v.marks as Record<string, number> : undefined, replayable: typeof v.replayable === 'boolean' ? v.replayable : undefined, }; continue; } if (kind !== 'actions' && kind !== 'snapshot' && kind !== 'ui') continue; // consent, and anything else const id = runOf(v.session); if (!id) { orphans.push(`${kind} ${rec.rkey}`); continue; } const r = get(rec.author, id); if (kind === 'actions') { let raw: unknown = v.acts ?? v.actions; if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { r.notes.push(`batch ${rec.rkey}'s actions can't be read`); raw = []; } } const list = (Array.isArray(raw) ? raw : []) as { step?: unknown; a?: unknown; action?: unknown }[]; const acts = list.map(x => ({ step: num(x?.step) ?? NaN, a: (x?.a ?? x?.action) as Action })).filter(x => Number.isFinite(x.step) && x.a && typeof x.a === 'object'); if (acts.length < list.length) r.notes.push(`${list.length - acts.length} unreadable actions in batch ${rec.rkey}`); const b = batches.get(r) ?? []; batches.set(r, b); b.push({ from: num(v.from) ?? acts[0]?.step ?? 0, first: num(v.first), acts }); } else if (kind === 'snapshot') { const s = num(v.step); if (s === undefined) r.notes.push(`snapshot ${rec.rkey} names no step`); else r.snaps.push({ ...v, step: s, acts: num(v.acts), hash: str(v.hash), day: num(v.day) }); } else { const events = Array.isArray(v.events) ? v.events as { kind?: unknown; type?: unknown }[] : []; r.screen += events.length; r.errors += events.filter(e => (e?.kind ?? e?.type) === 'error').length; } } for (const [r, b] of batches) { if (b.every(x => x.first !== undefined)) { // the recorder's: each batch starts at its place in the run's list, so one that goes back replaces what came after b.sort((x, y) => x.first! - y.first! || x.from - y.from); for (const { first, acts } of b) { if (first! > r.acts.length) r.notes.push(`actions ${r.acts.length} to ${first! - 1} are missing`); r.acts.length = Math.min(r.acts.length, first!); r.acts.push(...acts); } continue; } b.sort((x, y) => x.from - y.from); let last = -1; for (const { acts } of b) { if (acts.length && acts[0].step < last) r.notes.push(`a batch of actions starts at step ${acts[0].step}, before the one before it ended (${last})`); r.acts.push(...acts); if (acts.length) last = acts.at(-1)!.step; } } const out = [...runs.values()]; for (const r of out) r.snaps.sort((x, y) => x.step - y.step || (x.acts ?? Infinity) - (y.acts ?? Infinity)); if (orphans.length && out[0]) out[0].notes.push(`${orphans.length} records name no run: ${orphans.slice(0, 3).join(', ')}${orphans.length > 3 ? ', …' : ''}`); return out.sort((x, y) => (x.session?.startedAt ?? '').localeCompare(y.session?.startedAt ?? '') || x.id.localeCompare(y.id));}
/** Whether a run can be rebuilt: a seed, its actions, and not marked otherwise. */export const replayable = (r: Run) => r.session?.seed !== undefined && r.session.replayable !== false;
export type Check = { step: number; want: string; got: string; ok: boolean };export type Replayed = { S: State; marks: Record<string, number>; checks: Check[]; unreached: number[]; end?: Check };/** Rebuild a run from its seed and actions, as replay() in act.ts does, checking the state's hash at each snapshot, * and at the end if the session says where it ended. A snapshot that says how many actions had been done is checked * once that many are (the player may act again on the step after it); one that doesn't, after its step's actions. */export function replayRun(r: Run, level: Level = BUILDING): Replayed { if (!r.session || r.session.seed === undefined) throw new Error(`run ${r.id} has no seed: it can't be replayed`); const S = newGame(level, r.session.origin, r.session.seed); const marks: Record<string, number> = {}, mark = (k: string) => { marks[k] ??= Math.round(S.t); }; const snaps = r.snaps.filter(s => s.hash), acts = r.acts, checks: Check[] = []; const until = Math.max(acts.at(-1)?.step ?? 0, snaps.at(-1)?.step ?? 0, r.session.steps ?? 0); let i = 0, j = 0; /** Check the snapshots due now. `all`: this step's actions are done, so every one at this step is due. */ const take = (all: boolean) => { while (j < snaps.length && snaps[j].step <= S.steps) { const s = snaps[j], n = s.acts; if (s.step === S.steps && !all && (n === undefined || n > S.acts.length)) return; j++; const got = stateHash(S); checks.push({ step: s.step, want: s.hash!, got, ok: s.step === S.steps && (n === undefined || n === S.acts.length) && got === s.hash }); } }; for (;;) { take(false); while (i < acts.length && acts[i].step <= S.steps) { act(S, acts[i++].a); take(false); } paceMarks(S, mark); take(true); if (S.steps >= until || S.over) break; step(S, STEP); } const unreached = snaps.slice(j).map(s => s.step); const h = r.session.hash, end = h && r.session.steps === S.steps ? { step: S.steps, want: h, got: stateHash(S), ok: stateHash(S) === h } : undefined; return { S, marks, checks, unreached, end };}
// ---- the report ----
const median = (xs: number[]) => { const s = xs.filter(Number.isFinite).sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : NaN; };const mins = (s: number | undefined) => s === undefined || !Number.isFinite(s) ? '?' : `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, '0')}s`;const when = (iso?: string) => iso ? iso.replace('T', ' ').slice(0, 16) : '?';const pct = (n: number) => `${Math.round(n * 100)}%`, secs = (n: number) => `${Math.round(n)}s`;
/** What a run says about itself, filled in by replaying it where the session leaves something out. */export type Seen = { run: Run; t?: number; marks: Record<string, number>; end: string; replay?: Replayed };/** How a run ended: the session's word, or what a replay shows (caught, or past the goal), or still going. */export function seen(r: Run, replay?: Replayed): Seen { const S = replay?.S; const end = r.session?.end ?? (S?.over ? 'caught' : S && S.capability >= GOAL ? 'reached the goal' : r.session?.endedAt ? 'quit' : 'not ended'); return { run: r, t: r.session?.t ?? S?.t, marks: { ...replay?.marks, ...r.session?.marks }, end, replay };}const won = (s: Seen) => /^(won|win|reached the goal)$/i.test(s.end);
/** The report, in the survey's style: the runs, then the pace of the ones that reached the goal against the survey's * ranges, with !! beside what's outside. `build`: this checkout's, to say which runs it can replay. */export function report(all: readonly Seen[], o: { build?: string; space?: string } = {}): string { const out: string[] = []; const acts = all.reduce((n, s) => n + s.run.acts.length, 0), snaps = all.reduce((n, s) => n + s.run.snaps.length, 0); const authors = new Set(all.map(s => s.run.author)).size; out.push(`Telemetry${o.space ? ` from ${o.space}` : ''}: ${all.length} ${all.length === 1 ? 'run' : 'runs'} by ${authors} ${authors === 1 ? 'player' : 'players'}, ${acts} actions, ${snaps} snapshots`); if (!all.length) { out.push('', 'Nothing to report yet: no session, actions, snapshot or screen records. Switch a kind on (the You page) and play.'); return out.join('\n'); } out.push(''); const head = ['run', 'started', 'build', 'device', 'ended', 'game time', 'actions', 'snaps', 'screen']; const rows = all.map(s => { const r = s.run, b = r.session?.build; return [r.id, when(r.session?.startedAt), b ? b.slice(0, 9) + (o.build && !o.build.startsWith(b) && !b.startsWith(o.build) ? '*' : '') : '?', r.session?.device ?? '?', s.end, mins(s.t), `${r.acts.length}`, `${r.snaps.length}`, r.errors ? `${r.screen} (${r.errors} errors)` : `${r.screen}`]; }); const w = head.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length))); out.push(` ${head.map((h, i) => h.padEnd(w[i])).join(' ')}`); for (const r of rows) out.push(` ${r.map((c, i) => i >= 6 ? c.padStart(w[i]) : c.padEnd(w[i])).join(' ')}`); if (rows.some(r => r[2].endsWith('*'))) out.push('', ` * not this build (${o.build?.slice(0, 9)}): its actions rebuild it only on the build it was played on`); for (const s of all) { const notes = [...s.run.notes]; if (!s.run.session) notes.push('no session record: no seed, so it can\'t be replayed'); else if (s.run.session.replayable === false) notes.push('marked not replayable (a save from before seeds)'); const rp = s.replay; if (rp) { const bad = rp.checks.filter(c => !c.ok); notes.push(`replayed to step ${rp.S.steps}: ${rp.checks.length - bad.length} of ${rp.checks.length} snapshots match${bad.length ? `, first off at step ${bad[0].step}` : ''}${rp.unreached.length ? `, ${rp.unreached.length} past where the replay ended` : ''}${rp.end ? (rp.end.ok ? ', and the end matches' : ', and the end does not match') : ''}`); } for (const n of notes) out.push(` ${s.run.id}: ${n}`); }
const wins = all.filter(won), T = median(wins.map(s => s.t ?? NaN)); out.push('', `Pace of the runs that reached the goal (${wins.length} of ${all.length}), against the survey's careful range:`, ''); if (!wins.length) { out.push(' none yet'); return out.join('\n'); } const mark = (k: string) => median(wins.map(s => s.marks[k] ?? NaN)); const lines: [string, number, number, number, (n: number) => string][] = [ [PACE.run.name, T, PACE.run.lo, PACE.run.hi, secs], [PACE.node1.name, mark('node1'), PACE.node1.lo, PACE.node1.hi, secs], [PACE.site2.name, mark('site2') / T, PACE.site2.lo, PACE.site2.hi, pct], [PACE.front.name, mark('front') / T, PACE.front.lo, PACE.front.hi, pct], ]; const nw = Math.max(...lines.map(l => l[0].length)); for (const [name, v, lo, hi, show] of lines) { const off = !(v >= lo && v <= hi); out.push(`${off ? '!!' : ' '} ${name.padEnd(nw)} ${(Number.isFinite(v) ? show(v) : 'never').padStart(7)} want ${show(lo)} to ${show(hi)}`); } return out.join('\n');}