1// The replay timeline: an event-density strip and markers derived from2// the event list (pure, so live and replay agree). Markers: an agent3// joining, a tile solved, a meeting, a guess run (three or more4// consecutive commits with more guesses than forced cells), the end.56import type { GameEvent } from "../fold.ts";78export type MarkerKind =9 | "join"10 | "solved"11 | "meet"12 | "guess-run"13 | "grow"14 | "death"15 | "end";1617export type Marker = {18 rev: number;19 kind: MarkerKind;20 agent?: string;21 tiles?: number[];22 label: string;23};2425export const GUESS_RUN = 3;2627/** A death, read off the final state's agents (health.died_at). */28export type Death = { rev: number; agent: string; name: string };2930export function markers(events: GameEvent[], deaths: Death[] = []): Marker[] {31 const out: Marker[] = [];32 for (const d of deaths) {33 out.push({34 rev: d.rev,35 kind: "death",36 agent: d.agent,37 label: `${d.name} dies: wrong-rate reached 4%`,38 });39 }40 let run = 0;41 let runStart = -1;42 for (const ev of events) {43 // a `grow` event (the field gained a ring of tiles) is not yet in the44 // GameEvent union; read it structurally45 const anyEv = ev as unknown as {46 kind: string;47 rev: number;48 tiles?: unknown[];49 };50 if (anyEv.kind === "grow") {51 out.push({52 rev: anyEv.rev,53 kind: "grow",54 tiles: [],55 label: `field grows by ${anyEv.tiles?.length ?? 0} tiles`,56 });57 continue;58 }59 if (ev.kind === "join") {60 out.push({61 rev: ev.rev,62 kind: "join",63 agent: ev.agent,64 label: `${ev.name ?? ev.agent} joins at #${ev.seed_tile}`,65 });66 continue;67 }68 if (ev.kind === "end") {69 out.push({ rev: ev.rev, kind: "end", label: "end" });70 continue;71 }72 if (ev.kind !== "batch") continue;73 if (ev.op === "commit") {74 let forced = 0, guess = 0;75 for (const c of ev.accepted) {76 if (c.class === "forced") forced++;77 else guess++;78 }79 if (guess > forced && guess > 0) {80 if (run === 0) runStart = ev.rev;81 run++;82 if (run === GUESS_RUN) {83 out.push({84 rev: runStart,85 kind: "guess-run",86 agent: ev.agent,87 label: `guess run from #${runStart}`,88 });89 }90 } else {91 run = 0;92 }93 if (ev.solved.length) {94 out.push({95 rev: ev.rev,96 kind: "solved",97 agent: ev.agent,98 tiles: ev.solved,99 label: `solved ${ev.solved.map((t) => `#${t}`).join(" ")}`,100 });101 }102 for (const m of ev.meets ?? []) {103 out.push({104 rev: ev.rev,105 kind: "meet",106 agent: ev.agent,107 tiles: [m.tile],108 label: `${ev.agent ?? "?"} meets ${m.with} at #${m.tile}`,109 });110 }111 } else {112 run = 0;113 }114 }115 return out;116}117118/**119 * Activity per bin over the revisions: cells that became known (reveals120 * and accepted commits) plus retractions, so the strip shows where the121 * game moved. Returns `bins` values normalised to 0..1.122 */123export function density(events: GameEvent[], bins: number): number[] {124 const n = Math.max(1, events.length);125 const raw = new Array(bins).fill(0);126 for (const ev of events) {127 if (ev.kind !== "batch") continue;128 let w = 0;129 if (ev.op === "reveal") w = ev.givens.length + ev.seam.length;130 else if (ev.op === "commit") w = ev.accepted.length + ev.rejected.length;131 else w = ev.cells.length;132 const b = Math.min(bins - 1, Math.floor((ev.rev / n) * bins));133 raw[b] += w;134 }135 const max = Math.max(1, ...raw);136 return raw.map((v) => v / max);137}138139/** Playback speeds in revisions per second; `max` steps every frame. */140export const SPEEDS = [1, 4, 16, "max"] as const;141export type Speed = (typeof SPEEDS)[number];142143/** Revisions to advance per frame at a speed and frame interval. */144export function stepFor(speed: Speed, dtMs: number, carry: number): {145 steps: number;146 carry: number;147} {148 if (speed === "max") return { steps: 1e9, carry: 0 };149 const acc = carry + (speed * dtMs) / 1000;150 const steps = Math.floor(acc);151 return { steps, carry: acc - steps };152}