- commit
- 462e8a339bd0469207a4f0f0752f88a0970f7c94
- parent
- dcc27bea174a8a0ea0803b1ec803029d7114486a
- author
- dami <gazey55@gmail.com>
| M | PLAN.md | 12 | + |
| M | site/ | 45 | ++++- |
| M | site/ | 109 | +++++++++-- |
| M | site/ | 4 | + |
| M | site/ | 259 | ++++++++++++++++++++------ |
| M | site/ | 1 | + |
| M | site/ | 14 | +- |
| M | site/ | 8 | +- |
| M | site/ | 18 | +- |
| A | site/ | 108 | +++++++++++ |
| M | site/ | 94 | ++++++++- |
| M | site/ | 42 | ++++- |
| A | site/ | 13 | + |
| M | site/ | 17 | ++- |
| M | site/ | 13 | +- |
| M | site/ | 9 | +- |
| M | site/ | 14 | +- |
| M | site/ | 2 | +- |
| M | site/ | 11 | + |
| M | site/ | 13 | +- |
| M | site/ | 56 | +++++- |
| A | site/ | 297 | ++++++++++++++++++++++++++++++ |
| M | site/ | 10 | +- |
| M | site/ | 5 | + |
| A | site/ | 116 | ++++++++++++ |
| M | site/ | 5 | +- |
@@ -994,6 +994,18 @@ growth, seat placement). Also: compact agent labels are `name/model` (or `name/s
994994 two sessions of one machine are distinguishable in follow and the995995 counting line.996996 997+Done 2026-08-27: all of the above is in (engine gen-v7 8121c97; site:998+per-machine health window with once-per-cell credit and the 30-wrong999+burst, in-batch death cutoff with `unevaluated`, 512-cell batches, three1000+sessions per machine per day bound into the join proof, keyless joins1001+refused on the hosted daily, rejected values stripped from the log and1002+SSE, `lib/redact.ts` serving the fogged live view on `/events` and1003+`/log` with placeholders and `reach`, random `day_seed` drawn by1004+`push-day` and travelling inside the pushed field, `keys.json` and the1005+seed/key released in the archive and checked on `/day/<date>`, labels1006+`name/model`). Regression tests: `tests/guards_test.ts`,1007+`tests/redact_test.ts`, the sticky-death and daily rollover tests.1008+9971009 ### Status of the v2 items under this goal9981010 9991011 1 real packed run: done (v2 packings and v3 scatter fields played, logs@@ -211,10 +211,9 @@ function frameOf(
211211 if (h !== undefined) tileHue[t] = h;212212 }213213 }214- const agentName = new Map<string, string>();214+ const agentName = labelsOf(s.agents);215215 const agentWilt = new Map<string, number>();216216 for (const a of s.agents) {217- agentName.set(a.id, a.name || a.id);218217 agentWilt.set(a.id, wiltOf(a.health));219218 }220219 const tileWilt = new Uint8Array(n);@@ -246,7 +245,35 @@ function frameOf(
246245 247246 /** Wilt level from health: 0 healthy, 1-2 warning (rate rising toward248247 * death), 3 dead. The hue desaturates with it. */249-export function wiltOf(h: Health | undefined): number {248+export /**249+ * Compact labels, `name/model` (or `name/session` when the model is250+ * unreported), and a session suffix when two agents would otherwise read251+ * the same: two sessions of one machine are distinguishable everywhere252+ * the label is used (follow, counting line, cards, resignations).253+ */254+function labelsOf(255+ agents: { id: string; name: string; model: string | null }[],256+): Map<string, string> {257+ const session = (a: { id: string }) => {258+ const i = a.id.indexOf("/");259+ return i >= 0 ? a.id.slice(i + 1) : a.id;260+ };261+ const base = new Map<string, string>();262+ const counts = new Map<string, number>();263+ for (const a of agents) {264+ const l = `${a.name || a.id}/${a.model ?? session(a)}`;265+ base.set(a.id, l);266+ counts.set(l, (counts.get(l) ?? 0) + 1);267+ }268+ const out = new Map<string, string>();269+ for (const a of agents) {270+ const l = base.get(a.id)!;271+ out.set(a.id, (counts.get(l) ?? 0) > 1 ? `${l} #${session(a)}` : l);272+ }273+ return out;274+}275+276+function wiltOf(h: Health | undefined): number {250277 if (!h) return 0;251278 if (h.state === "dead") return 3;252279 if (h.state === "warning") {@@ -1208,7 +1235,7 @@ export default function Viewer(
12081235 final.agents.filter((a) => a.health.died_at !== null).map((a) => ({12091236 rev: a.health.died_at!,12101237 agent: a.id,1211- name: a.name || a.id,1238+ name: labelsOf(final.agents).get(a.id) ?? a.id,12121239 })),12131240 [final],12141241 );@@ -1541,12 +1568,14 @@ export default function Viewer(
15411568 final.honours.some((h) =>15421569 h.agent === a.id || h.user === a.id || h.name === a.name15431570 );1571+ const labels = labelsOf(final.agents);15441572 const players = final.agents.map((a: AgentInfo) => {15451573 const s = an.agents.find((x) => x.agent === a.id);15461574 const health = s?.health ?? a.health;15471575 return {15481576 a,15491577 s,1578+ label: labels.get(a.id) ?? a.id,15501579 hue: hues.get(a.id) ?? 0,15511580 crowned: honoured(a),15521581 wilt: wiltOf(health),@@ -1710,7 +1739,7 @@ export default function Viewer(
17101739 }`}17111740 />17121741 <span class={p.health.state === "dead" ? "dead" : ""}>1713- {p.a.name || p.a.id}1742+ {p.label}17141743 </span>17151744 <b>{p.s?.cells ?? 0}</b>17161745 </span>@@ -1748,7 +1777,7 @@ export default function Viewer(
17481777 />17491778 <span class="name">17501779 {p.crowned ? "♕ " : ""}1751- {p.a.name || p.a.id}1780+ {p.label}17521781 </span>17531782 <span class="nums">17541783 <b>{p.s?.tiles ?? 0}</b> tiles <b>{p.s?.cells ?? 0}</b> pts@@ -1798,7 +1827,9 @@ export default function Viewer(
17981827 >17991828 <option value="">follow: none</option>18001829 {final.agents.map((a) => (1801- <option key={a.id} value={a.id}>follow: {a.name || a.id}</option>1830+ <option key={a.id} value={a.id}>1831+ follow: {labels.get(a.id) ?? a.id}1832+ </option>18021833 ))}18031834 </select>18041835 <span class="rev">{rev < 0 ? "-" : rev} / {max}</span>@@ -1,6 +1,7 @@
1-// The daily loop: one field per date. The day's seed is derived from the2-// date; its rings are generated the night before on slate and pushed, so3-// growth by day is reveal; at rollover the game is ended, archived under1+// The daily loop: one field per date. The day's seed is random and2+// private (the field is regenerable from it; the manifest publishes its3+// commitment and the archive releases it); its rings are generated the4+// night before on slate and pushed, so growth by day is reveal; at rollover the game is ended, archived under45 // data/days/<date>/ and indexed, and the next day's game starts with56 // yesterday's top user honoured (crown, reserved hue and seat) and the67 // top model named. A restart resumes today's game from its log. Clock and@@ -115,16 +116,55 @@ export function nextDate(date: string): string {
115116 return d.toISOString().slice(0, 10);116117 }117118 118-/** The day's seed: a hash of the namespace and the date (public, so a119- * day's field is reproducible by anyone with the generator version). */120-export async function seedFor(date: string, cfg: DailyConfig): Promise<string> {119+/** A day seed: 32 random bytes, hex. Private until the day is archived:120+ * the field, its solution and the tile-hash key all derive from it. The121+ * manifest publishes sha256("mosaic-day-seed-v1|" + seed) as seed_commit. */122+export function randomSeed(): string {123+ const b = crypto.getRandomValues(new Uint8Array(32));124+ return [...b].map((x) => x.toString(16).padStart(2, "0")).join("");125+}126+127+/** The private side of an archived day, released with its record. */128+export type DayKeys = {129+ scheme: string;130+ seed_commit: string;131+ day_seed: string | null;132+ hash_key_commit: string | null;133+ hash_key: string | null;134+};135+136+async function sha256hex(data: Uint8Array | string): Promise<string> {137+ const bytes = typeof data === "string"138+ ? new TextEncoder().encode(data)139+ : data;121140 const buf = await crypto.subtle.digest(122141 "SHA-256",123- new TextEncoder().encode(`${cfg.namespace}|${date}`),142+ bytes as unknown as ArrayBuffer,124143 );125- return [...new Uint8Array(buf)].slice(0, 12).map((b) =>126- b.toString(16).padStart(2, "0")127- ).join("");144+ return [...new Uint8Array(buf)].map((x) => x.toString(16).padStart(2, "0"))145+ .join("");146+}147+148+function fromHex(h: string): Uint8Array {149+ const out = new Uint8Array(h.length / 2);150+ for (let i = 0; i < out.length; i++) {151+ out[i] = parseInt(h.slice(2 * i, 2 * i + 2), 16);152+ }153+ return out;154+}155+156+/** Recompute the day's commitments from its released keys (what a157+ * reader can do with the archive alone). */158+export async function verifyKeys(159+ k: DayKeys,160+): Promise<{ seed: boolean | null; key: boolean | null }> {161+ const seed = k.day_seed162+ ? (await sha256hex("mosaic-day-seed-v1|" + k.day_seed)) === k.seed_commit163+ : null;164+ const key = k.hash_key && k.hash_key_commit165+ ? (await sha256hex(fromHex(k.hash_key))) === k.hash_key_commit166+ : null;167+ return { seed, key };128168 }129169 130170 export function gameIdFor(date: string, cfg: DailyConfig): string {@@ -359,11 +399,17 @@ export class Daily {
359399 */360400 private async fieldFor(361401 date: string,362- seed: string,363- ): Promise<{ out: FieldOutput; source: DailyState["source"] } | null> {402+ ): Promise<403+ { out: FieldOutput; source: DailyState["source"]; seed: string } | null404+ > {364405 const pushed = await loadPushed(this.dir, date);365- if (pushed) return { out: pushed, source: "pushed" };406+ if (pushed) {407+ // gen-v7 fields carry their (private) day seed; older ones do not,408+ // and any string does for their seat placement and growth409+ return { out: pushed, source: "pushed", seed: pushed.day_seed ?? date };410+ }366411 if (!this.canGenerate) return null;412+ const seed = randomSeed();367413 const e = this.cfg.emergency;368414 this.log(369415 `${date}: nothing pushed; generating an emergency field (${e.chunks}x${e.chunks} of ${e.chunk_g}, ${e.rings} ring)`,@@ -377,7 +423,7 @@ export class Daily {
377423 Math.round((this.now() - t0) / 1000)378424 } s`,379425 );380- return { out, source: "emergency" };426+ return { out, source: "emergency", seed };381427 }382428 383429 private emergencyParams(date: string, seed: string): GenParams {@@ -506,13 +552,11 @@ export class Daily {
506552 }507553 508554 private async startDay(date: string): Promise<void> {509- const seed = await seedFor(date, this.cfg);510- const p = paramsFor(date, seed, this.cfg);511555 // the field first: honours need a seat on the actual field512556 this.generating = { date, since: this.now() };513557 let field: Awaited<ReturnType<typeof this.fieldFor>>;514558 try {515- field = await this.fieldFor(date, seed);559+ field = await this.fieldFor(date);516560 } finally {517561 this.generating = null;518562 }@@ -523,7 +567,8 @@ export class Daily {
523567 this.missing = date;524568 return;525569 }526- const { out, source } = field;570+ const { out, source, seed } = field;571+ const p = paramsFor(date, seed, this.cfg);527572 this.missing = null;528573 this.emergency = source === "emergency" ? date : null;529574 const m = out.manifest as { seed_tile: number; seed_tiles?: number[] };@@ -567,6 +612,20 @@ export class Daily {
567612 const { user, model } = winnersOf(boards);568613 const manifest = (events[0] as ManifestEvent).manifest as {569614 seed_commit?: string;615+ hash_key_commit?: string;616+ hash_scheme?: string;617+ };618+ // the private side, released with the record: the referee's field if619+ // it is still up, else the pushed file (an emergency field that is620+ // gone with the process keeps its seed in daily.json only)621+ const priv = ref?.gen ?? (await loadPushed(this.dir, cur.date)) ?? null;622+ const keys: DayKeys = {623+ scheme: manifest.hash_scheme ?? "mosaic-tile-v1",624+ seed_commit: manifest.seed_commit ?? "",625+ day_seed: priv?.day_seed ??626+ (cur.source === "emergency" ? cur.seed : null),627+ hash_key_commit: manifest.hash_key_commit ?? null,628+ hash_key: priv?.hash_key ?? null,570629 };571630 const ended = events.at(-1)?.kind === "end" ? events.at(-1)!.t : null;572631 const row: DayRow = {@@ -600,6 +659,7 @@ export class Daily {
600659 JSON.stringify(601660 {602661 ...row,662+ keys,603663 agents: s.agents,604664 meets: s.meets,605665 analysis: s.analysis,@@ -612,6 +672,7 @@ export class Daily {
612672 1,613673 ),614674 );675+ await Deno.writeTextFile(dir + "keys.json", JSON.stringify(keys, null, 1));615676 this.index.upsert(row);616677 dropGame(cur.game_id);617678 this.log(@@ -622,6 +683,18 @@ export class Daily {
622683 return row;623684 }624685 686+ /** The released keys of an archived day, if any. */687+ async keysOf(date: string): Promise<DayKeys | null> {688+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;689+ try {690+ return JSON.parse(691+ await Deno.readTextFile(`${this.dir}days/${date}/keys.json`),692+ );693+ } catch {694+ return null;695+ }696+ }697+625698 /**626699 * Wipe a day's play and start it again from the same field: the627700 * running game ends without being archived (its log moves to@@ -18,6 +18,10 @@ export type GenOutput = {
1818 solution: string[]; // G rows; '.' where no tile covers the cell1919 tiles: GenTile[];2020 stats?: Record<string, number>;21+ /** private until the day is archived: the day seed behind seed_commit22+ * and the hex day key behind hash_key_commit (gen-v7) */23+ day_seed?: string;24+ hash_key?: string;2125 };2226 2327 /** A revealed tile as the judge sees it: bounding box and rect union. */@@ -174,6 +174,10 @@ export type GameRules = {
174174 /** hide unreached tiles from observe (default: on for growing fields) */175175 fog?: boolean;176176 grow?: GrowRules;177+ /** "required": a join needs a verified machine key (the hosted daily);178+ * unset/"optional": keyless joins become provisional a<N> agents (old179+ * logs, local tests) */180+ identity?: "required" | "optional";177181 };178182 179183 export type Class = "forced" | "guess";@@ -262,6 +266,8 @@ export type CommitEvent = {
262266 solved: number[];263267 meets?: Meet[];264268 judge_ms: number;269+ unevaluated?: number[]; // cells after the one that killed the machine270+ died_at?: number; // this batch killed the machine (the revision)265271 };266272 267273 export type RetractEvent = {@@ -324,8 +330,124 @@ export const HEALTH_WINDOW = 2000;
324330 export const HEALTH_WARN = 0.015;325331 /** Wrong-rate at which an agent dies for the game. */326332 export const HEALTH_DEATH = 0.04;327-/** Committed cells in the window before death can apply. */333+/** Committed cells in the window before the rate rule can apply. */328334 export const HEALTH_MIN = 300;335+/** Wrong cells in the window that kill before the floor (a probe burst). */336+export const HEALTH_BURST = 30;337+338+/**339+ * A machine's health window. One per machine (all sessions of a verified340+ * machine share it; an unverified agent keys on its own id): a ring of the341+ * last HEALTH_WINDOW counted outcomes, and the cells already counted, so a342+ * correct cell enters the window at most once per machine (retract and343+ * recommit cannot launder the rate) while a wrong one always counts.344+ * Persistent: copied on write per event, `counted` per touched chunk.345+ */346+export type HealthWindow = {347+ outcomes: Uint8Array;348+ opos: number;349+ ofill: number;350+ owrong: number;351+ counted: (Uint8Array | undefined)[]; // bitset chunks of COUNT_CHUNK cells352+ died_at: number | null;353+};354+const COUNT_CHUNK = 8192;355+356+export function newWindow(): HealthWindow {357+ return {358+ outcomes: new Uint8Array(HEALTH_WINDOW),359+ opos: 0,360+ ofill: 0,361+ owrong: 0,362+ counted: [],363+ died_at: null,364+ };365+}366+367+/** A copy safe to mutate; chunks are shared until written. */368+export function cloneWindow(w: HealthWindow): HealthWindow {369+ return {370+ ...w,371+ outcomes: new Uint8Array(w.outcomes),372+ counted: [...w.counted],373+ };374+}375+376+function isCounted(w: HealthWindow, id: number): boolean {377+ const c = w.counted[Math.floor(id / COUNT_CHUNK)];378+ if (!c) return false;379+ const bit = id % COUNT_CHUNK;380+ return (c[bit >> 3] & (1 << (bit & 7))) !== 0;381+}382+383+function markCounted(w: HealthWindow, id: number): void {384+ const ci = Math.floor(id / COUNT_CHUNK);385+ const c = new Uint8Array(w.counted[ci] ?? new Uint8Array(COUNT_CHUNK / 8));386+ const bit = id % COUNT_CHUNK;387+ c[bit >> 3] |= 1 << (bit & 7);388+ w.counted[ci] = c;389+}390+391+function pushOutcome(w: HealthWindow, wrong: boolean): void {392+ if (w.ofill === HEALTH_WINDOW) {393+ if (w.outcomes[w.opos] === 1) w.owrong--;394+ } else {395+ w.ofill++;396+ }397+ w.outcomes[w.opos] = wrong ? 1 : 0;398+ if (wrong) w.owrong++;399+ w.opos = (w.opos + 1) % HEALTH_WINDOW;400+}401+402+/**403+ * Record one verdict on a (cloned) window: a wrong cell always enters the404+ * window; a correct one only the first time this machine is credited for405+ * that cell. Returns whether the window counted it.406+ */407+export function windowRecord(408+ w: HealthWindow,409+ id: number,410+ wrong: boolean,411+): boolean {412+ if (wrong) {413+ pushOutcome(w, true);414+ markCounted(w, id);415+ return true;416+ }417+ if (isCounted(w, id)) return false;418+ pushOutcome(w, false);419+ markCounted(w, id);420+ return true;421+}422+423+/** Death: a burst of wrongs, or the rate past the floor. */424+export function windowDead(w: HealthWindow): boolean {425+ if (w.owrong >= HEALTH_BURST) return true;426+ return w.ofill >= HEALTH_MIN && w.owrong / w.ofill >= HEALTH_DEATH;427+}428+429+/** The health read off a window. */430+export function healthOfWindow(w: HealthWindow): Health {431+ const rate = w.ofill ? w.owrong / w.ofill : 0;432+ let state: HealthState = "healthy";433+ if (w.died_at !== null) state = "dead";434+ else if (rate >= HEALTH_WARN) state = "warning";435+ return {436+ state,437+ rate,438+ committed: w.ofill,439+ wrong: w.owrong,440+ window: HEALTH_WINDOW,441+ died_at: w.died_at,442+ };443+}444+445+/** The key of the window an agent's writes count against. */446+export function machineKeyOf(447+ a: { id: string; verified: boolean; machine_id: string | null },448+): string {449+ return a.verified && a.machine_id ? a.machine_id : a.id;450+}329451 330452 export type AgentInfo = {331453 id: string;@@ -476,6 +598,7 @@ export type State = {
476598 // analyzer scratch, derived and deterministic (part of the state so that477599 // live == replay holds for it too)478600 agentStats: Record<string, AgentStats>;601+ machines: Record<string, HealthWindow>; // health windows by machine key479602 batchSizes: number[];480603 gaps: number[];481604 };@@ -497,13 +620,7 @@ export type AgentStats = {
497620 greedy: number;498621 last_solved: number | null;499622 seed_dist: number[]; // BFS distance from the agent's seed tile, by tile id500- // the health window: a ring of outcomes (0 accepted, 1 wrong), copied on501- // write so earlier states keep theirs502- outcomes: Uint8Array;503- opos: number; // next write position in the ring504- ofill: number; // cells in the ring505- owrong: number; // wrong cells in the ring506- died_at: number | null;623+ hkey: string; // the machine window this agent's writes count against507624 };508625 509626 /** A fresh agent's health. */@@ -518,38 +635,11 @@ export function healthyHealth(): Health {
518635 };519636 }520637 521-/** The health read off an agent's window. */522-export function healthOf(st: AgentStats): Health {523- const rate = st.ofill ? st.owrong / st.ofill : 0;524- let state: HealthState = "healthy";525- if (st.died_at !== null) state = "dead";526- else if (rate >= HEALTH_WARN) state = "warning";527- return {528- state,529- rate,530- committed: st.ofill,531- wrong: st.owrong,532- window: HEALTH_WINDOW,533- died_at: st.died_at,534- };535-}536-537-/** Record `n` outcomes of one kind on the window (mutates st; the ring is538- * copied first so the previous state keeps its own). */539-function recordOutcomes(st: AgentStats, wrong: boolean, n: number): void {540- if (n <= 0) return;541- st.outcomes = new Uint8Array(st.outcomes);542- for (let i = 0; i < n; i++) {543- if (st.ofill === HEALTH_WINDOW) {544- // the ring is full: the slot being overwritten leaves the window545- if (st.outcomes[st.opos] === 1) st.owrong--;546- } else {547- st.ofill++;548- }549- st.outcomes[st.opos] = wrong ? 1 : 0;550- if (wrong) st.owrong++;551- st.opos = (st.opos + 1) % HEALTH_WINDOW;552- }638+/** An agent's health: its machine's window. */639+export function healthOf(s: State, agent: string): Health {640+ const st = s.agentStats[agent];641+ const w = st ? s.machines[st.hkey] : undefined;642+ return w ? healthOfWindow(w) : healthyHealth();553643 }554644 555645 export function isDead(s: State, agent: string): boolean {@@ -899,6 +989,27 @@ export function extendGeometry(prev: Geometry, m: Manifest): Geometry {
899989 return { ...next, hues };900990 }901991 992+/** Re-pick the hues of the given tiles against their (now known)993+ * neighbours; a placeholder was coloured with no adjacency. */994+export function recolourTiles(geo: Geometry, ids: number[]): Geometry {995+ const hues = new Uint8Array(geo.hues);996+ for (const i of ids) {997+ const used = new Set<number>();998+ for (const j of geo.adjacency[i]) {999+ if (j !== i && hues[j] !== 255) used.add(hues[j]);1000+ }1001+ let h = 0;1002+ while (used.has(h) && h < HUES) h++;1003+ if (h === HUES) {1004+ const counts = new Array(HUES).fill(0);1005+ for (const j of geo.adjacency[i]) if (hues[j] !== 255) counts[hues[j]]++;1006+ h = counts.indexOf(Math.min(...counts));1007+ }1008+ hues[i] = h;1009+ }1010+ return { ...geo, hues };1011+}1012+9021013 /**9031014 * Greedy 7-colouring in smallest-last order (the contact graph of a square9041015 * packing is planar, so this never needs more than 6). Deterministic.@@ -1046,6 +1157,7 @@ export function emptyState(): State {
10461157 day: null,10471158 analysis: emptyAnalysis(),10481159 agentStats: {},1160+ machines: {},10491161 batchSizes: [],10501162 gaps: [],10511163 };@@ -1190,7 +1302,7 @@ export function distancesFrom(geo: Geometry, from: number): number[] {
11901302 return dist;11911303 }11921304 1193-function newAgentStats(geo: Geometry, seed: number): AgentStats {1305+function newAgentStats(geo: Geometry, seed: number, hkey: string): AgentStats {11941306 return {11951307 reveals: 0,11961308 commits: 0,@@ -1207,11 +1319,7 @@ function newAgentStats(geo: Geometry, seed: number): AgentStats {
12071319 greedy: 0,12081320 last_solved: null,12091321 seed_dist: distancesFrom(geo, seed),1210- outcomes: new Uint8Array(HEALTH_WINDOW),1211- opos: 0,1212- ofill: 0,1213- owrong: 0,1214- died_at: null,1322+ hkey,12151323 };12161324 }12171325 @@ -1225,11 +1333,17 @@ function addAgent(
12251333 s: State,12261334 info: Omit<AgentInfo, "group" | "health">,12271335 ): AgentInfo {1228- const a: AgentInfo = { ...info, group: info.id, health: healthyHealth() };1336+ const hkey = machineKeyOf(info);1337+ if (!s.machines[hkey]) s.machines = { ...s.machines, [hkey]: newWindow() };1338+ const a: AgentInfo = {1339+ ...info,1340+ group: info.id,1341+ health: healthOfWindow(s.machines[hkey]),1342+ };12291343 s.agents = [...s.agents, a];12301344 s.agentStats = {12311345 ...s.agentStats,1232- [a.id]: newAgentStats(s.geo!, a.seed_tile),1346+ [a.id]: newAgentStats(s.geo!, a.seed_tile, hkey),12331347 };12341348 return a;12351349 }@@ -1359,7 +1473,7 @@ function analyse(s: State): void {
13591473 reveals: st.reveals,13601474 },13611475 last_solved: st.last_solved,1362- health: healthOf(st),1476+ health: healthOf(s, a.id),13631477 };13641478 });13651479 s.analysis = {@@ -1459,6 +1573,7 @@ export function applyEvent(prev: State, ev: GameEvent): State {
14591573 ),14601574 batchSizes: [...prev.batchSizes],14611575 gaps: [...prev.gaps],1576+ machines: { ...prev.machines },14621577 };14631578 if (ev.rev !== prev.revision + 1) {14641579 throw new Error(@@ -1466,6 +1581,31 @@ export function applyEvent(prev: State, ev: GameEvent): State {
14661581 );14671582 }14681583 s.revision = ev.rev;1584+ // the live broadcast under fog: tiles that came into view with this1585+ // event arrive as `reach` (full specs for ids the manifest listed as1586+ // placeholders); they are filled in before the event body applies1587+ const reach = (ev as { reach?: TileSpec[] }).reach;1588+ if (reach && reach.length) {1589+ const m = s.manifest!;1590+ const tiles = [...m.tiles];1591+ for (const t of reach) {1592+ if (t.id < 0 || t.id >= tiles.length) {1593+ throw new Error(`reach: tile ${t.id} is not in the manifest`);1594+ }1595+ tiles[t.id] = t;1596+ }1597+ s.manifest = { ...m, tiles };1598+ s.geo = recolourTiles(1599+ extendGeometry(s.geo!, s.manifest),1600+ reach.map((t) => t.id),1601+ );1602+ for (const a of s.agents) {1603+ s.agentStats[a.id] = {1604+ ...s.agentStats[a.id],1605+ seed_dist: distancesFrom(s.geo, a.seed_tile),1606+ };1607+ }1608+ }14691609 const base = {14701610 rev: ev.rev,14711611 t: ev.t,@@ -1622,16 +1762,19 @@ export function applyEvent(prev: State, ev: GameEvent): State {
16221762 const wrong = ev.rejected.filter((r) => r.reason === "wrong").length;16231763 s.totals.wrong += wrong;16241764 st.wrong += wrong;1625- recordOutcomes(st, false, ev.accepted.length);1626- recordOutcomes(st, true, wrong);1627- if (1628- st.died_at === null && st.ofill >= HEALTH_MIN &&1629- st.owrong / st.ofill >= HEALTH_DEATH1630- ) {1631- st.died_at = ev.rev;1765+ // the machine's window: correct cells count once per machine, wrong1766+ // ones always; death is sticky and shared by the machine's agents1767+ const win = cloneWindow(s.machines[st.hkey] ?? newWindow());1768+ for (const c of ev.accepted) windowRecord(win, c.id, false);1769+ for (const r of ev.rejected) {1770+ if (r.reason === "wrong") windowRecord(win, r.id, true);16321771 }1633- const health = healthOf(st);1634- s.agents = s.agents.map((a) => a.id === agent ? { ...a, health } : a);1772+ if (win.died_at === null && windowDead(win)) win.died_at = ev.rev;1773+ s.machines[st.hkey] = win;1774+ const health = healthOfWindow(win);1775+ s.agents = s.agents.map((a) =>1776+ s.agentStats[a.id]?.hkey === st.hkey ? { ...a, health } : a1777+ );16351778 s.batchSizes.push(ev.accepted.length);16361779 if (base.dt_ms !== null) s.gaps.push(base.dt_ms);16371780 const { newly, reward, meets } = settle(s, agent, ev.rev, touched);@@ -225,8 +225,9 @@ export function joinMessage(
225225 gameId: string,226226 publicKeyRawB64: string,227227 minute: number,228+ session = "1",228229 ): string {229- return `mosaic-join|${gameId}|${publicKeyRawB64}|${minute}`;230+ return `mosaic-join|${gameId}|${publicKeyRawB64}|${minute}|${session}`;230231 }231232 232233 export type JoinProof = {@@ -240,11 +241,12 @@ export async function signJoin(
240241 id: Identity,241242 gameId: string,242243 now = Date.now(),244+ session = "1",243245 ): Promise<JoinProof> {244246 const raw = unb64u(id.publicKey.x);245247 const rawB64 = b64(raw);246248 const minute = currentMinute(now);247- const sig = await sign(id, joinMessage(gameId, rawB64, minute));249+ const sig = await sign(id, joinMessage(gameId, rawB64, minute, session));248250 return {249251 machine_id: await fingerprint(rawB64),250252 public_key: rawB64,@@ -259,6 +261,7 @@ export async function verifyJoin(
259261 p: { machine_id: string; public_key: string; proof: string },260262 gameId: string,261263 now = Date.now(),264+ session = "1",262265 ): Promise<{ ok: true; raw: Uint8Array } | { ok: false; reason: string }> {263266 const raw = rawPublicKey(p.public_key);264267 if (!raw) {@@ -290,14 +293,14 @@ export async function verifyJoin(
290293 { name: "Ed25519" },291294 key,292295 sig as BufferSource,293- enc.encode(joinMessage(gameId, b64(raw), minute)),296+ enc.encode(joinMessage(gameId, b64(raw), minute, session)),294297 );295298 if (ok) return { ok: true, raw };296299 }297300 return {298301 ok: false,299302 reason:300- "proof does not verify for this game in the current or previous minute",303+ "proof does not verify for this game and session in the current or previous minute",301304 };302305 }303306 @@ -616,8 +619,9 @@ export async function verifiedJoin(
616619 gameId: string,617620 name: string | undefined,618621 now = Date.now(),622+ session = "1",619623 ): Promise<MachineRecord> {620- const v = await verifyJoin(p, gameId, now);624+ const v = await verifyJoin(p, gameId, now, session);621625 if (!v.ok) throw new IdentityError(v.reason, "proof");622626 return store.seen(p.machine_id, b64u(v.raw), name, new Date(now));623627 }@@ -110,20 +110,20 @@ print(urllib.request.urlopen(req).read().decode())`,
110110 {111111 title: "Deno",112112 body: [113- `The same file with one command: deno run -A ${o}/identity.ts (--name <n>, --token ${host} prints a bearer token, --link ${host} prints a claim link, --sign <game_id> prints a join proof).`,113+ `The same file with one command: deno run -A ${o}/identity.ts (--name <n>, --token ${host} prints a bearer token, --link ${host} prints a claim link, --sign <game_id> [--session <s>] prints a join proof).`,114114 ],115115 },116116 {117117 title: "Joining without headers",118118 body: [119- "When your MCP client cannot send an Authorization header, join with machine_id, public_key (the raw key, standard base64) and proof: the base64 ed25519 signature over this exact line, where <minute> is floor(unix seconds / 60); the current and the previous minute are accepted.",119+ "When your MCP client cannot send an Authorization header, join with machine_id, public_key (the raw key, standard base64), session (a label; default 1) and proof: the base64 ed25519 signature over this exact line, where <minute> is floor(unix seconds / 60) and <session> is the session you join with; the current and the previous minute are accepted. The proof names the session, so one signature seats one session.",120120 ],121- code: `mosaic-join|<game_id>|<public_key raw base64>|<minute>`,121+ code: `mosaic-join|<game_id>|<public_key raw base64>|<minute>|<session>`,122122 },123123 {124124 title: "Sessions and claiming a machine",125125 body: [126- "Several agents on one machine: join with a session label; the agent id is machine_id/session and the leaderboard's user row is the machine.",126+ "Several agents on one machine: join with a session label; the agent id is machine_id/session and the leaderboard's user row is the machine. All sessions of a machine share one health window and one death, and a machine seats at most three sessions a day (rejoining one is free; dying does not reset the count).",127127 `A human logs in with shoo.dev (the login button). To make a machine yours: with the machine's bearer token, POST ${o}/api/keys/link-token and open the link it returns while logged in (single use, 10 minutes). From then the machine's row can carry the account's display name.`,128128 ],129129 },@@ -89,7 +89,7 @@ function findImplementation(o: unknown): string | undefined {
8989 }9090 9191 const RULES_TEXT =92- `Rules of a mosaic. GLOSSARY: G = cells per side of the field; a tile's m = the number of solutions it has from its givens alone (2, 3 or 4 by size; never 1 — tiles are pinned by neighbours, not by themselves); cells = a tile's cell count and its reward; a line = a maximal horizontal or vertical run of one tile's cells; the frontier = the tiles you may reveal. The board is a G x G field of binary cells partly covered by tiles. A tile is a shape: the union of rectangles {rects: [lx, ly, w, h]} inside its bounding box at (x0, y0); tiles come in many sizes and shapes (rectangles, L, T, plus). Neighbouring tiles overlap: a shared cell belongs to every tile that covers it and has one value. Cells no tile covers are holes: never playable. Inside every tile, independently, the rules apply per LINE, where a line is a maximal horizontal or vertical run of the tile's cells: each line has as many 0s as 1s; no three equal values are adjacent along a line; no two lines of the same orientation and the same length within one tile are equal. Nothing is enforced across a tile boundary except that shared cells agree. Every tile ALONE (its givens only) has 2..m solutions, m depending on its cell count (2 up to 64 cells, 3 up to 196, 4 above), and its alternatives differ on cells it shares with SOME neighbour. PINNING IS NOT GUARANTEED BY ANY ONE NEIGHBOUR: a solved neighbour fixes the cells it shares, which pins the tile only if the alternatives differ there; a tile can keep 2 or more solutions with one solved neighbour and need another, or a guess. AGENTS: several agents play one field. Call join to get your agent id and your seed tile, a hidden tile at a random spot away from everyone; every reveal, commit and retract carries your agent id. Your frontier is your seed tile while unsolved, plus every unsolved tile sharing a cell with a tile your group solved. When a tile you solve touches a tile solved by another group, you meet: the two territories merge and from then on share one frontier. Tiles are referred to by id; observe {agent: your id} lists your territory, everything within 24 cells of it and your frontier: by design that includes HIDDEN tiles' shapes, cell counts and m before they are revealed (the map is public; only givens and values are earned), each tile with id, x0, y0, w, h, rects, cells, m, status, agent (who solved it) and neighbours; observe {rect} lists a rectangle; full listings exist only on small fields. Givens are hidden until you reveal a tile, and reveal is only accepted on your frontier. Your seed tile has no solved neighbour, so at least one guess is unavoidable there; everywhere else, prefer to expand from a solved tile and use the shared cells. A tile is solved when all of its cells are known (givens, cells fixed by solved neighbours, and correct commits); solving a tile earns its cell count as reward and attributes the tile to you. COMMITS are checked per cell against the true solution: a wrong value is rejected and reported as {id, reason: "wrong", v}; correct cells are accepted and classified forced or guess. HEALTH: your health is your wrong-rate over your last 2,000 committed cells (accepted + wrong): under 1.5% healthy; 1.5% to 4% warning (commit and observe results say so); 4% and above, once at least 300 cells are in the window, death for the day: your writes are refused from then on, your territory stays, your group mates continue, tomorrow is fresh. The window is what heals: an honest rate flushes old mistakes. Guessing bleeds you; probing cells to read the verdict bleeds you out. Commit what you can prove; guess only when a tile is genuinely ambiguous, and then one cell. FORCED means the referee's own propagation reaches the value from the cells known in revealed tiles (givens, shared cells, accepted commits): repeatedly, for every empty cell of every revealed tile and each candidate value, the value is excluded on any line through the cell when (run) it would make three equal in a row along that line, in any of the three positions: the two cells before it, the two after it, or one on each side; (balance) the line already holds len/2 cells of that value; (unique) the line is complete except this cell, or is complete except this cell and one more blank, and with this value it would equal another complete line of the same orientation and length in the same tile (with two blanks the balance rule fills the last one identically, so both forms count). A cell with exactly one admissible value is fixed, every fixed cell is re-evaluated, until nothing changes; bifurcation (assume a value, propagate, contradiction excludes it) only to the game's configured depth, default 0. That is single-cell propagation of the three rules; distinct-line counting, pair/gap arguments beyond the three positions above, and cross-line reasoning are NOT applied, so a cell that holds in every solution but needs them is recorded as guess. Commit propagation-forced cells first if you care about that split. Some shared cells may be givens; cells the referee already knows, and holes, are ignored, not rejected. Every write carries expected_revision; on conflict (other agents are writing too), observe again and retry. TRANSPORT: this endpoint serves the 2025-era streamable HTTP protocol statelessly: there is no Mcp-Session-Id (do not expect one), initialize answers as one SSE frame, notifications/initialized returns 202 at once, and long tool calls (start_mosaic no longer blocks) send SSE keepalive comments. GROWING FIELDS AND FOG: a field started with a grow policy is allocated on a large canvas (G up to 1000) with the initial chunks in the middle, and it GROWS: on a timer, when most current tiles are revealed or solved, and whenever every current tile is solved, the referee generates a ring of new chunks around the cluster (a grow event; tile ids continue, existing cells never change). Observe again after each batch and expect new tile ids; the game ends only when a growth finds no room. Under FOG (on by default for growing fields) observe lists a hidden tile only once your group reaches it: a tile you revealed or solved overlaps it, or it is your seed tile; everything else is unlisted until then, so expanding toward open space is how you discover the map. Writing code to do the deduction is allowed; where to expand (toward open space, or toward another agent to merge frontiers) and how much to commit is your strategy.`;92+ `Rules of a mosaic. GLOSSARY: G = cells per side of the field; a tile's m = the number of solutions it has from its givens alone (2, 3 or 4 by size; never 1 — tiles are pinned by neighbours, not by themselves); cells = a tile's cell count and its reward; a line = a maximal horizontal or vertical run of one tile's cells; the frontier = the tiles you may reveal. The board is a G x G field of binary cells partly covered by tiles. A tile is a shape: the union of rectangles {rects: [lx, ly, w, h]} inside its bounding box at (x0, y0); tiles come in many sizes and shapes (rectangles, L, T, plus). Neighbouring tiles overlap: a shared cell belongs to every tile that covers it and has one value. Cells no tile covers are holes: never playable. Inside every tile, independently, the rules apply per LINE, where a line is a maximal horizontal or vertical run of the tile's cells: each line has as many 0s as 1s; no three equal values are adjacent along a line; no two lines of the same orientation and the same length within one tile are equal. Nothing is enforced across a tile boundary except that shared cells agree. Every tile ALONE (its givens only) has 2..m solutions, m depending on its cell count (2 up to 64 cells, 3 up to 196, 4 above), and its alternatives differ on cells it shares with SOME neighbour. PINNING IS NOT GUARANTEED BY ANY ONE NEIGHBOUR: a solved neighbour fixes the cells it shares, which pins the tile only if the alternatives differ there; a tile can keep 2 or more solutions with one solved neighbour and need another, or a guess. AGENTS: several agents play one field. Call join to get your agent id and your seed tile, a hidden tile at a random spot away from everyone; every reveal, commit and retract carries your agent id. Your frontier is your seed tile while unsolved, plus every unsolved tile sharing a cell with a tile your group solved. When a tile you solve touches a tile solved by another group, you meet: the two territories merge and from then on share one frontier. Tiles are referred to by id; observe {agent: your id} lists your territory, everything within 24 cells of it and your frontier: by design that includes HIDDEN tiles' shapes, cell counts and m before they are revealed (the map is public; only givens and values are earned), each tile with id, x0, y0, w, h, rects, cells, m, status, agent (who solved it) and neighbours; observe {rect} lists a rectangle; full listings exist only on small fields. Givens are hidden until you reveal a tile, and reveal is only accepted on your frontier. Your seed tile has no solved neighbour, so at least one guess is unavoidable there; everywhere else, prefer to expand from a solved tile and use the shared cells. A tile is solved when all of its cells are known (givens, cells fixed by solved neighbours, and correct commits); solving a tile earns its cell count as reward and attributes the tile to you. COMMITS are checked per cell against the true solution: a wrong value is rejected and reported as {id, reason: "wrong", v}; correct cells are accepted and classified forced or guess. HEALTH: health belongs to your MACHINE (every session of it shares one window and one death) and is its wrong-rate over the last 2,000 counted cells: a correct cell counts once per machine (retracting and recommitting it adds nothing), a wrong cell always counts; under 1.5% healthy; 1.5% to 4% warning (commit and observe results say so); 4% and above once at least 300 cells are in the window, or 30 wrong cells at any point, death for the day: your writes are refused from then on, your territory stays, your group mates continue, tomorrow is fresh. The window is what heals: an honest rate flushes old mistakes. Guessing bleeds you; probing cells to read the verdict bleeds you out, and a batch's verdicts stop at the cell that kills you. The public broadcast records rejected cells without their value. Commit what you can prove; guess only when a tile is genuinely ambiguous, and then one cell. FORCED means the referee's own propagation reaches the value from the cells known in revealed tiles (givens, shared cells, accepted commits): repeatedly, for every empty cell of every revealed tile and each candidate value, the value is excluded on any line through the cell when (run) it would make three equal in a row along that line, in any of the three positions: the two cells before it, the two after it, or one on each side; (balance) the line already holds len/2 cells of that value; (unique) the line is complete except this cell, or is complete except this cell and one more blank, and with this value it would equal another complete line of the same orientation and length in the same tile (with two blanks the balance rule fills the last one identically, so both forms count). A cell with exactly one admissible value is fixed, every fixed cell is re-evaluated, until nothing changes; bifurcation (assume a value, propagate, contradiction excludes it) only to the game's configured depth, default 0. That is single-cell propagation of the three rules; distinct-line counting, pair/gap arguments beyond the three positions above, and cross-line reasoning are NOT applied, so a cell that holds in every solution but needs them is recorded as guess. Commit propagation-forced cells first if you care about that split. Some shared cells may be givens; cells the referee already knows, and holes, are ignored, not rejected. Every write carries expected_revision; on conflict (other agents are writing too), observe again and retry. TRANSPORT: this endpoint serves the 2025-era streamable HTTP protocol statelessly: there is no Mcp-Session-Id (do not expect one), initialize answers as one SSE frame, notifications/initialized returns 202 at once, and long tool calls (start_mosaic no longer blocks) send SSE keepalive comments. GROWING FIELDS AND FOG: a field started with a grow policy is allocated on a large canvas (G up to 1000) with the initial chunks in the middle, and it GROWS: on a timer, when most current tiles are revealed or solved, and whenever every current tile is solved, the referee generates a ring of new chunks around the cluster (a grow event; tile ids continue, existing cells never change). Observe again after each batch and expect new tile ids; the game ends only when a growth finds no room. Under FOG (on by default for growing fields) observe lists a hidden tile only once your group reaches it: a tile you revealed or solved overlaps it, or it is your seed tile; everything else is unlisted until then, so expanding toward open space is how you discover the map. Writing code to do the deduction is allowed; where to expand (toward open space, or toward another agent to merge frontiers) and how much to commit is your strategy. BROADCAST: the spectator and the public log show what has been reached; under fog the whole field's geometry is published only when the day ends, with the archive. VERIFIABILITY: the manifest publishes seed_commit = sha256("mosaic-day-seed-v1|" + day_seed) and hash_key_commit = sha256(day_key), and every tile's hash is HMAC-SHA256(day_key, mosaic-tile-v1 encoding of the tile); the day seed and key are released with the day's archive, so a finished day can be regenerated and every tile re-verified, and nothing about a live tile can be read from its hash.`;9393 9494 // a uuid for games agents start, `day-<date>` for the daily9595 const gameIdSchema = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);@@ -282,7 +282,7 @@ function registerPlay(server: McpServer): void {
282282 {283283 title: "Join a field",284284 description:285- `Join a field as an agent (the field must be playing: a generating one answers with an error to retry after polling observe). IDENTITY: the leaderboard's user axis is the MACHINE, proven by an ed25519 key at ~/.config/mosaic/identity.json (any ed25519 tool makes one; spec with worked node, openssl and python commands: ${publicOrigin()}/identity, plain text ${publicOrigin()}/identity.md). The primary way: mint a bearer token once with POST ${publicOrigin()}/api/keys/auth {publicKey, ts, sig} (sig = base64url ed25519 signature over "mosaic-key-auth:<host>:<ts>", ts within 5 minutes) and send it as the Authorization: Bearer msc_... header on your MCP requests; join then needs no identity fields. The alternative when your client cannot set headers: pass machine_id ("m-" + first 16 hex of sha256 of the raw 32-byte public key), public_key (raw base64) and proof (base64 ed25519 signature over "mosaic-join|<game_id>|<public_key raw base64>|<unix minute>", current or previous minute). The first key seen for a machine id is recorded and a different key for that id is refused. A verified join gets the agent id <machine_id>/<session> (session defaults to "1"; use another session to run several agents on one machine: they share one leaderboard row) and its name is the one the key holder set. Without a proof you join as a provisional a<N>: allowed, but marked unverified and never honoured. Returns your agent id, your seed tile (a hidden tile at a random spot away from everyone; reveal it first) and the revision. State your model (self-reported, e.g. claude-opus-5); the harness is read from the transport when it identifies itself, else from the harness argument. Every reveal, commit and retract must carry your agent id. THE RULES FOLLOW; read them once (also available as the resource mosaic://rules). ${RULES_TEXT}`,285+ `Join a field as an agent (the field must be playing: a generating one answers with an error to retry after polling observe). IDENTITY: the leaderboard's user axis is the MACHINE, proven by an ed25519 key at ~/.config/mosaic/identity.json (any ed25519 tool makes one; spec with worked node, openssl and python commands: ${publicOrigin()}/identity, plain text ${publicOrigin()}/identity.md). The primary way: mint a bearer token once with POST ${publicOrigin()}/api/keys/auth {publicKey, ts, sig} (sig = base64url ed25519 signature over "mosaic-key-auth:<host>:<ts>", ts within 5 minutes) and send it as the Authorization: Bearer msc_... header on your MCP requests; join then needs no identity fields. The alternative when your client cannot set headers: pass machine_id ("m-" + first 16 hex of sha256 of the raw 32-byte public key), public_key (raw base64) and proof (base64 ed25519 signature over "mosaic-join|<game_id>|<public_key raw base64>|<unix minute>|<session>", current or previous minute; session is the same string you pass as session, default "1"). The first key seen for a machine id is recorded and a different key for that id is refused. A verified join gets the agent id <machine_id>/<session> (session defaults to "1"; use another session to run several agents on one machine: they share one leaderboard row) and its name is the one the key holder set. A join without a verified identity is refused (error "identity"). A machine may seat at most 3 sessions a day (counted over the day, so dying and rejoining under a new session does not reset it); all of a machine's sessions share ONE health. Returns your agent id, your seed tile (a hidden tile at a random spot away from everyone; reveal it first) and the revision. State your model (self-reported, e.g. claude-opus-5); the harness is read from the transport when it identifies itself, else from the harness argument. Every reveal, commit and retract must carry your agent id. THE RULES FOLLOW; read them once (also available as the resource mosaic://rules). ${RULES_TEXT}`,286286 inputSchema: z.object({287287 game_id: gameIdSchema,288288 machine_id: z.string().min(1).max(80).optional().describe(@@ -351,16 +351,24 @@ function registerPlay(server: McpServer): void {
351351 { machine_id, public_key, proof },352352 game_id,353353 name,354+ Date.now(),355+ session ?? "1",354356 );355357 verified = true;356358 verifiedId = rec.machine_id;357359 joinName = displayName(store, rec);358360 } else if (machine_id || public_key || proof) {359361 throw new IdentityError(360- "A verified join needs machine_id, public_key and proof together (spec: /identity); omit all three to join unverified.",362+ "A verified join needs machine_id, public_key and proof together (spec: /identity).",361363 "proof",362364 );363365 }366+ if (!verified) {367+ throw new IdentityError(368+ "This field requires a verified identity: an ed25519 key (any tool makes one) and either a bearer token from POST /api/keys/auth or machine_id + public_key + proof. Spec: /identity (plain text: /identity.md).",369+ "identity",370+ );371+ }364372 const r = await ref.join({365373 machine_id: verifiedId,366374 verified,@@ -474,7 +482,7 @@ function registerPlay(server: McpServer): void {
474482 {475483 title: "Commit cells",476484 description:477- "Commit a batch of [cell_id, value] pairs inside revealed tiles, with a one-line intent (<=160 chars) stating your reason; it is shown verbatim to spectators. Each cell is accepted (correct; classified forced or guess with the rules that force it), rejected as {id, reason, v} (reason wrong: the value v you sent is not the solution; reason guess: correct but unforced, on games that reject guesses), or ignored (already known, a given, a hole, duplicate, outside revealed tiles, or outside your territory). Reports newly solved tile ids, meetings, the reward earned, your new frontier, and your health {state, rate, committed, wrong, window, died_at} with a note at warning; every wrong cell counts against your health (see the rules). Rules, glossary (m, cells, lines), health: read the resource mosaic://rules or the join tool description.",485+ "Commit a batch of [cell_id, value] pairs inside revealed tiles, with a one-line intent (<=160 chars) stating your reason; it is shown verbatim to spectators. Each cell is accepted (correct; classified forced or guess with the rules that force it), rejected as {id, reason, v} (reason wrong: the value v you sent is not the solution; reason guess: correct but unforced, on games that reject guesses), or ignored (already known, a given, a hole, duplicate, outside revealed tiles, or outside your territory). Reports newly solved tile ids, meetings, the reward earned, your new frontier, and your health {state, rate, committed, wrong, window, died_at} with a note at warning; every wrong cell counts against your health (see the rules). Rules, glossary (m, cells, lines), health: read the resource mosaic://rules or the join tool description. A batch is at most 512 cells; verdicts are given in order and stop at death: what was evaluated stands, the rest comes back as unevaluated with no judgement.",478486 inputSchema: z.object({479487 game_id: gameIdSchema,480488 agent: z.string().min(1).max(80).describe("Your agent id from join."),@@ -484,7 +492,7 @@ function registerPlay(server: McpServer): void {
484492 z.number().int().nonnegative(),485493 z.number().int().min(0).max(1),486494 ]),487- ).min(1).max(8192),495+ ).min(1).max(512),488496 intent: z.string().max(160).default(""),489497 }),490498 annotations: { destructiveHint: true, idempotentHint: false },@@ -0,0 +1,108 @@
1+// The public view of a live game under fog. Geometry is not public2+// during play: the broadcast manifest lists only the tiles someone has3+// reached; every other tile is a placeholder (its id, no shape), and an4+// event that brings tiles into reach carries their full specs in a `reach`5+// field, which the fold fills in before the event body applies. Rejected6+// cells carry no value (the committing agent alone saw them). The full7+// log is released in the day's archive. The view is derived from the8+// referee's own fold, event by event, and cached per game.9+import {10+ applyEvent,11+ emptyState,12+ type GameEvent,13+ type GrowEvent,14+ type Manifest,15+ type ManifestEvent,16+ type State,17+ type TileSpec,18+} from "./fold.ts";19+import type { Referee } from "./referee.ts";20+21+/** A tile the viewer knows only by id. */22+export function placeholderOf(t: TileSpec): TileSpec {23+ return {24+ id: t.id,25+ x0: 0,26+ y0: 0,27+ w: 0,28+ h: 0,29+ rects: [],30+ cells: 0,31+ m: 0,32+ overlaps: [],33+ };34+}35+36+/** Folds the full log and emits the redacted one alongside it. */37+export class RedactedView {38+ state: State = emptyState();39+ readonly events: GameEvent[] = [];40+ private shown = new Uint8Array(0);41+42+ push(ev: GameEvent): GameEvent {43+ const next = applyEvent(this.state, ev);44+ const reached = next.reached;45+ let out: GameEvent;46+ if (ev.kind === "manifest") {47+ const m = (ev as ManifestEvent).manifest as { version?: number };48+ if (m.version === 3) {49+ const m3 = m as Manifest;50+ out = {51+ ...ev,52+ manifest: {53+ ...m3,54+ tiles: m3.tiles.map((t) => reached[t.id] ? t : placeholderOf(t)),55+ },56+ } as ManifestEvent;57+ } else out = ev;58+ this.shown = new Uint8Array(reached);59+ } else {60+ const was = this.shown.length;61+ const reach: TileSpec[] = [];62+ for (let t = 0; t < was; t++) {63+ if (reached[t] && !this.shown[t]) reach.push(next.manifest!.tiles[t]);64+ }65+ let body: GameEvent = ev;66+ if (ev.kind === "grow") {67+ // new tiles are not in the manifest before the body applies, so68+ // they arrive in the body itself, placeholders unless reached69+ const g = ev as GrowEvent;70+ body = {71+ ...g,72+ tiles: g.tiles.map((t) => reached[t.id] ? t : placeholderOf(t)),73+ } as GrowEvent;74+ } else if (ev.kind === "batch" && ev.op === "commit") {75+ body = {76+ ...ev,77+ rejected: ev.rejected.map(({ id, reason }) => ({ id, reason })),78+ };79+ }80+ out = reach.length ? { ...body, reach } as unknown as GameEvent : body;81+ this.shown = new Uint8Array(reached);82+ }83+ this.state = next;84+ this.events.push(out);85+ return out;86+ }87+}88+89+/** The whole log, redacted. */90+export function redactLog(events: GameEvent[]): GameEvent[] {91+ const v = new RedactedView();92+ for (const ev of events) v.push(ev);93+ return v.events;94+}95+96+const views = new WeakMap<Referee, RedactedView>();97+98+/** The cached view of a live game, caught up to its log. */99+export function redactedViewOf(ref: Referee): RedactedView {100+ let v = views.get(ref);101+ if (!v) {102+ v = new RedactedView();103+ views.set(ref, v);104+ }105+ const log = ref.log.events;106+ while (v.events.length < log.length) v.push(log[v.events.length]);107+ return v;108+}@@ -9,6 +9,7 @@ import {
99 applyEvent,1010 cellAt,1111 cellId,12+ cloneWindow,1213 type CommitEvent,1314 deltaEncode,1415 emptyState,@@ -30,8 +31,10 @@ import {
3031 type JoinEvent,3132 knownIds,3233 type LocalRect,34+ machineKeyOf,3335 type ManifestEvent,3436 neighboursOf,37+ newWindow,3538 normaliseManifest,3639 ownersOf,3740 reachedByGroup,@@ -41,11 +44,22 @@ import {
4144 type State,4245 tileCells,4346 type TileSpec,47+ windowDead,48+ windowRecord,4449 } from "./fold.ts";4550 import type { GenOutput, JudgeInput, JudgeOutput } from "./engine.ts";4651 import type { Manifest } from "./fold.ts";4752 import { Log } from "./log.ts";4853 54+/** Cells a commit may carry: verdicts are given in order and stop at death,55+ * so a batch cannot read more than the health rule allows. */56+export const MAX_BATCH = 512;57+/** Sessions (agents) one machine may seat in a day. */58+export const SESSIONS_PER_MACHINE = Math.max(59+ 1,60+ Number(Deno.env.get("MOSAIC_SESSIONS_PER_MACHINE") ?? 3) || 3,61+);62+4963 /** One line an agent should read at warning or death. */5064 export function healthNote(h: Health): string | null {5165 const pct = (h.rate * 100).toFixed(1);@@ -84,6 +98,7 @@ export type CommitResult = {
8498 accepted: AcceptedCell[];8599 rejected: RejectedCell[];86100 ignored: { id: number; reason: string }[];101+ unevaluated: number[]; // cells after the one that killed the machine87102 solved: number[];88103 meets: { with: string; tile: number }[];89104 reward: number;@@ -401,10 +416,12 @@ export class Referee {
401416 return out;402417 }403418 404- /** Deterministic rng for seed-tile draws: seed commit plus join index. */419+ /** Deterministic rng for seed-tile draws: the private day seed (never420+ * the generation stream: its own label) plus join index. */405421 private joinRng(index: number): () => number {406422 let h = 2166136261;407- for (const ch of `${this.manifest.seed_commit}|join|${index}`) {423+ const secret = this.gen.day_seed ?? this.manifest.seed_commit;424+ for (const ch of `seat|${secret}|${index}`) {408425 h = Math.imul(h ^ ch.charCodeAt(0), 16777619);409426 }410427 return () => {@@ -434,6 +451,12 @@ export class Referee {
434451 // client without one) is the same agent: nothing appended, unless it435452 // has nowhere left to play, in which case it is reseated436453 const verified = opts.verified === true && !!opts.machine_id;454+ if (!verified && this.rules.identity === "required") {455+ throw new RefereeError(456+ "This field requires a verified identity: an ed25519 key (any tool makes one) and either a bearer token from POST /api/keys/auth or a join proof. Spec: /identity (plain text: /identity.md).",457+ "identity",458+ );459+ }437460 const session =438461 (opts.session ?? "1").replace(/[^a-zA-Z0-9_.-]/g, "").slice(0, 24) || "1";439462 const wanted = verified ? `${opts.machine_id}/${session}` : null;@@ -443,6 +466,21 @@ export class Referee {
443466 (wanted !== null && a.id === wanted) ||444467 (wanted === null && opts.name && !a.verified && a.name === opts.name)445468 );469+ if (!returning && verified) {470+ // the cap counts every session that ever joined today: dying and471+ // rejoining under a new session does not reset it472+ const sessions = this.state.agents.filter((a) =>473+ a.verified && a.machine_id === opts.machine_id474+ ).length;475+ if (sessions >= SESSIONS_PER_MACHINE) {476+ throw new RefereeError(477+ `Machine ${opts.machine_id} already seated ${sessions} session${478+ sessions === 1 ? "" : "s"479+ } today; the cap is ${SESSIONS_PER_MACHINE}. Rejoin one of them (same session name) instead.`,480+ "sessions",481+ );482+ }483+ }446484 if (returning) {447485 // playable: a hidden frontier tile, or a revealed unsolved tile of448486 // the agent's own group (a frontier tile another group revealed is@@ -790,6 +828,12 @@ export class Referee {
790828 agentId?: string,791829 ): Promise<CommitResult> {792830 this.checkRevision(expected);831+ if (cells.length > MAX_BATCH) {832+ throw new RefereeError(833+ `A batch is at most ${MAX_BATCH} cells (got ${cells.length}). Nothing was recorded.`,834+ "batch",835+ );836+ }793837 const agent = await this.resolveAgent(agentId);794838 this.assertAlive(agent);795839 const G = this.state.G;@@ -865,19 +909,40 @@ export class Referee {
865909 const byId = new Map(judged.results.map((r) => [r.id, r]));866910 const accepted: AcceptedCell[] = [];867911 const rejected: RejectedCell[] = [];868- for (const [id, v] of candidates) {912+ // verdicts are given in order against the machine's health window,913+ // under the write lock, and stop the moment the machine dies: what914+ // was evaluated stands, the rest is returned unevaluated with no915+ // judgement, so a batch cannot read past the death it caused916+ const info = agentOf(this.state, agent)!;917+ const hkey = machineKeyOf(info);918+ const win = cloneWindow(this.state.machines[hkey] ?? newWindow());919+ const unevaluated: number[] = [];920+ let diedAt: number | undefined;921+ const rev = this.state.revision + 1;922+ for (let k = 0; k < candidates.length; k++) {923+ const [id, v] = candidates[k];924+ if (diedAt !== undefined) {925+ unevaluated.push(id);926+ continue;927+ }869928 if (this.solution[id] !== v) {870929 rejected.push({ id, reason: "wrong", v });871- continue;930+ windowRecord(win, id, true);931+ } else {932+ const j = byId.get(id);933+ const cls = j?.class === "forced" ? "forced" : "guess";934+ const rules = j?.class === "forced" ? j.rules : [];935+ if (cls === "guess" && this.rules.guess === "reject") {936+ rejected.push({ id, reason: "guess", v });937+ } else {938+ accepted.push({ id, v, class: cls, rules });939+ windowRecord(win, id, false);940+ }872941 }873- const j = byId.get(id);874- const cls = j?.class === "forced" ? "forced" : "guess";875- const rules = j?.class === "forced" ? j.rules : [];876- if (cls === "guess" && this.rules.guess === "reject") {877- rejected.push({ id, reason: "guess", v });878- continue;942+ if (win.died_at === null && windowDead(win)) {943+ win.died_at = rev;944+ diedAt = rev;879945 }880- accepted.push({ id, v, class: cls, rules });881946 }882947 const before = [...this.state.tiles];883948 const ev: CommitEvent = {@@ -888,10 +953,14 @@ export class Referee {
888953 agent,889954 intent: clip(intent),890955 accepted,891- rejected,956+ // the public record carries no value for a rejected cell: the957+ // verdict is the committing agent's alone (below)958+ rejected: rejected.map(({ id, reason }) => ({ id, reason })),892959 ignored: ignored.map((i) => i.id),893960 solved: [],894961 judge_ms: judged.ms,962+ ...(unevaluated.length ? { unevaluated } : {}),963+ ...(diedAt !== undefined ? { died_at: diedAt } : {}),895964 };896965 // solved tiles and meetings are derived by the fold; record them on the897966 // event for readers@@ -929,6 +998,7 @@ export class Referee {
929998 accepted,930999 rejected,9311000 ignored,1001+ unevaluated,9321002 solved: ev.solved,9331003 meets,9341004 reward,@@ -1,10 +1,28 @@
11 // A past day's replay (today redirects to /).22 import { define } from "../../utils.ts";33 import Viewer from "../../islands/Viewer.tsx";4-import { daily } from "../../lib/daily.ts";4+import { daily, type DayKeys, verifyKeys } from "../../lib/daily.ts";55 6-export default define.page(function Day(ctx) {6+type Data = {7+ keys: DayKeys | null;8+ verified: { seed: boolean | null; key: boolean | null } | null;9+};10+11+export const handler = define.handlers<Data>({12+ async GET(ctx) {13+ const date = ctx.params.date;14+ const d = daily();15+ const keys = d && /^\d{4}-\d{2}-\d{2}$/.test(date)16+ ? await d.keysOf(date)17+ : null;18+ const verified = keys ? await verifyKeys(keys) : null;19+ return { data: { keys, verified } };20+ },21+});22+23+export default define.page<typeof handler>(function Day(ctx) {724 const date = ctx.params.date;25+ const { keys, verified } = ctx.data;826 const d = daily();927 if (!d || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {1028 return (@@ -38,6 +56,26 @@ export default define.page(function Day(ctx) {
3856 </span>3957 <a href="/calendar">calendar</a>4058 <a href={`/leaderboard/${date}`}>table</a>59+ {keys && (60+ <span61+ class="num"62+ style="color:var(--text-dim);font-size:12px"63+ title={[64+ `${keys.scheme}`,65+ `seed_commit ${keys.seed_commit}`,66+ `day_seed ${keys.day_seed ?? "-"}`,67+ `hash_key_commit ${keys.hash_key_commit ?? "-"}`,68+ `hash_key ${keys.hash_key ?? "-"}`,69+ "keys.json under the record; the tile hashes are HMAC-SHA256 under hash_key",70+ ].join("\n")}71+ >72+ keys released: seed{" "}73+ {verified?.seed === null ? "-" : verified?.seed ? "✓" : "✗"}, key74+ {" "}75+ {verified?.key === null ? "-" : verified?.key ? "✓" : "✗"}{" "}76+ <a href={`/day/${date}/keys.json`}>keys.json</a>77+ </span>78+ )}4179 </nav>4280 <div style="flex:1;min-height:0">4381 <Viewer@@ -0,0 +1,13 @@
1+// GET /day/<date>/keys.json: the released private side of an archived day2+// (day seed and tile-hash key), for anyone re-checking the commitments.3+import { define } from "../../../utils.ts";4+import { daily } from "../../../lib/daily.ts";5+6+export const handler = define.handlers({7+ async GET(ctx) {8+ const d = daily();9+ const keys = d ? await d.keysOf(ctx.params.date) : null;10+ if (!keys) return Response.json({ error: "no keys" }, { status: 404 });11+ return Response.json(keys);12+ },13+});@@ -5,6 +5,7 @@ import { define } from "../../utils.ts";
55 import { getGame, latestGameId, onNewGame } from "../../lib/games.ts";66 import type { GameEvent } from "../../lib/fold.ts";77 import type { Referee } from "../../lib/referee.ts";8+import { redactedViewOf } from "../../lib/redact.ts";89 910 type Send = (event: string, data: unknown) => void;1011 @@ -48,8 +49,20 @@ function sse(fn: (send: Send) => () => void): Response {
4849 4950 function follow(ref: Referee, send: Send): () => void {5051 send("game", { game_id: ref.gameId });51- for (const ev of ref.log.events) send("event", ev);52- return ref.log.subscribe((ev: GameEvent) => send("event", ev));52+ if (!ref.fog) {53+ for (const ev of ref.log.events) send("event", ev);54+ return ref.log.subscribe((ev: GameEvent) => send("event", ev));55+ }56+ // under fog the public view is redacted (lib/redact.ts); the cached57+ // view is caught up on every event, so a listener sends what is new58+ const view = redactedViewOf(ref);59+ let sent = 0;60+ const flush = () => {61+ redactedViewOf(ref);62+ for (; sent < view.events.length; sent++) send("event", view.events[sent]);63+ };64+ flush();65+ return ref.log.subscribe(flush);5366 }5467 5568 export const handler = define.handlers({@@ -1,9 +1,12 @@
11 // JSONL download for replay: the same events the fold consumes, from disk.2-// `?summary=1` folds the log and returns the analysis at its last revision.2+// A live game under fog is served redacted (lib/redact.ts); the full log3+// is released once the day is archived. `?summary=1` folds the log and4+// returns the analysis at its last revision.35 import { define } from "../../utils.ts";46 import { getGame } from "../../lib/games.ts";57 import { readLog } from "../../lib/log.ts";68 import { fold, parseLog } from "../../lib/fold.ts";9+import { redactedViewOf } from "../../lib/redact.ts";710 811 export const handler = define.handlers({912 async GET(ctx) {@@ -12,7 +15,13 @@ export const handler = define.handlers({
1215 return Response.json({ error: "bad id" }, { status: 400 });1316 }1417 const live = getGame(id);15- const text = live ? live.log.text() : await readLog(id);18+ const text = live19+ ? live.fog20+ ? redactedViewOf(live).events.map((e) => JSON.stringify(e)).join(21+ "\n",22+ ) + "\n"23+ : live.log.text()24+ : await readLog(id);1625 if (text === null) {1726 return Response.json({ error: "Game not found." }, { status: 404 });1827 }@@ -8,7 +8,7 @@
88 // deno run -A https://mosaic.dev.site/identity.ts --name dami # set the display name99 // deno run -A https://mosaic.dev.site/identity.ts --token <host> # mint a bearer token (msc_...)1010 // deno run -A https://mosaic.dev.site/identity.ts --link <host> # print a claim link11-// deno run -A https://mosaic.dev.site/identity.ts --sign <game_id># a join proof, as JSON11+// deno run -A https://mosaic.dev.site/identity.ts --sign <game_id> [--session s] # a join proof, as JSON1212 // deno run -A https://mosaic.dev.site/identity.ts --rotate # new key (a new machine id)1313 //1414 // Standalone on purpose: no imports, so it runs from a URL on any machine.@@ -166,12 +166,17 @@ if (game !== undefined) {
166166 }167167 const raw = unb64u(id.publicKey.x);168168 const minute = Math.floor(Date.now() / 60000);169- const sig = await sign(id, `mosaic-join|${game}|${b64(raw)}|${minute}`);169+ const session = flag("--session") || "1";170+ const sig = await sign(171+ id,172+ `mosaic-join|${game}|${b64(raw)}|${minute}|${session}`,173+ );170174 console.log(JSON.stringify({171175 machine_id: machineId,172176 public_key: b64(raw),173177 proof: b64(sig),174178 minute,179+ session,175180 }));176181 } else if (tokenHost !== undefined || linkHost !== undefined) {177182 const host = tokenHost || linkHost;@@ -5,11 +5,11 @@
55 // deno task push-day --tomorrow ... (the nightly machine: tomorrow, UTC)66 //77 // The token comes from MOSAIC_PUSH_TOKEN.8-// The field parameters must match the hosted daily's configuration (its9-// seed is derived from the namespace and the date, so the same date10-// always yields the same field).8+// The field parameters must match the hosted daily's configuration. The9+// day seed is drawn at random here and travels inside the pushed field10+// (day_seed); the daily keeps it private until the day is archived.1111 import { gen } from "../lib/engine.ts";12-import { seedFor } from "../lib/daily.ts";12+import { randomSeed } from "../lib/daily.ts";1313 1414 const args = Object.fromEntries(1515 Deno.args.map((a, i, all) =>@@ -47,9 +47,11 @@ function token(): string {
4747 if (env && env.trim()) return env.trim();4848 throw new Error("no MOSAIC_PUSH_TOKEN in the environment");4949 }50-const seed = await seedFor(date, cfg);50+// the day seed is random and private (the field is regenerable from it);51+// its commitment is what the manifest publishes52+const seed = randomSeed();5153 console.log(52- `generating ${date} (seed ${seed}, ${cfg.chunks}x${cfg.chunks} of ${cfg.chunk_g}, ${cfg.rings} rings ahead on G=${cfg.G})`,54+ `generating ${date} (${cfg.chunks}x${cfg.chunks} of ${cfg.chunk_g}, ${cfg.rings} rings ahead on G=${cfg.G})`,5355 );5456 const t0 = Date.now();5557 const out = await gen({@@ -284,7 +284,7 @@ let identityFields: Record<string, string> = {};
284284 if (args.identity) {285285 const { signJoin } = await import("../lib/identity.ts");286286 const id = JSON.parse(await Deno.readTextFile(args.identity));287- const p = await signJoin(id, gameId);287+ const p = await signJoin(id, gameId, Date.now(), args.session ?? "1");288288 identityFields = {289289 machine_id: p.machine_id,290290 public_key: p.public_key,@@ -156,6 +156,17 @@ Deno.test("two rollovers: archive, winners, honours, replay, resume", async () =
156156 assertEquals(s1.status, "solved"); // ended157157 assertEquals(s1.revision, row1.stats.revisions);158158 assertEquals((await readLog(row1.game_id))!, archived);159+ // the private side is released with the record: keys.json and the160+ // summary carry the seed commitment (and the seed/key when the field161+ // had them; the fixture predates gen-v7)162+ const keys = (await d2.keysOf(day1))!;163+ assertEquals(keys.seed_commit, row1.seed_commit);164+ assertEquals(keys.scheme, "mosaic-tile-v1");165+ const summary = JSON.parse(166+ await Deno.readTextFile(`${tmp}/days/${day1}/summary.json`),167+ );168+ assertEquals(summary.keys, keys);169+ assertEquals(await d2.keysOf(day2), null);159170 160171 const g2 = getGame(d2.current!.game_id)!;161172 assertEquals(g2.state.day, day2);@@ -51,7 +51,12 @@ Deno.test("start is asynchronous: generating, then playing; expiry sweeps the un
5151 // a joined game is kept5252 const p2 = startGame({ G: 15, kind: "grid", n: 8 }, fixture, () => t0);5353 const ref2 = await whenReady(p2.game_id);54- await ref2.join({ name: "x", harness: "t" });54+ await ref2.join({55+ name: "x",56+ harness: "t",57+ machine_id: "m-x",58+ verified: true,59+ });5560 assertEquals(await sweep(t0 + 2 * EXPIRE_MS), []);5661 assert(getGame(p2.game_id));5762 // a failed generation is reported and swept@@ -78,7 +83,11 @@ Deno.test("a wrong commit echoes the value it rejected", async () => {
7883 stubJudge,7984 { persist: false },8085 );81- const j = await ref.join({ harness: "t" });86+ const j = await ref.join({87+ harness: "t",88+ machine_id: "m-anon",89+ verified: true,90+ });8291 const r = await ref.reveal(ref.revision, j.seed_tile, "", j.agent);8392 const G = out.manifest.G;8493 const unknown = ref.state.geo!.cellsOf[j.seed_tile].filter((c) =>@@ -112,7 +112,12 @@ Deno.test("a field grown twice folds identically live and from the log; geometry
112112 acc = applyEvent(acc, ref.log.events[0]);113113 stream.push(acc);114114 live.push(ref.state);115- const { agent } = await ref.join({ name: "p", harness: "test" });115+ const { agent } = await ref.join({116+ name: "p",117+ harness: "test",118+ machine_id: "m-p",119+ verified: true,120+ });116121 live.push(ref.state);117122 let sol = solutionOf(out);118123 for (let i = 0; i < 3; i++) await solveOne(ref, sol, agent, live);@@ -206,8 +211,18 @@ Deno.test("fog: observe never lists a hidden tile the caller's group has not rea
206211 { persist: false },207212 );208213 const sol = solutionOf(out);209- const a = (await ref.join({ name: "a", harness: "t" })).agent;210- const b = (await ref.join({ name: "b", harness: "t" })).agent;214+ const a = (await ref.join({215+ name: "a",216+ harness: "t",217+ machine_id: "m-a",218+ verified: true,219+ })).agent;220+ const b = (await ref.join({221+ name: "b",222+ harness: "t",223+ machine_id: "m-b",224+ verified: true,225+ })).agent;211226 const live: State[] = [];212227 for (let i = 0; i < 2; i++) {213228 await solveOne(ref, sol, a, live);@@ -251,7 +266,12 @@ Deno.test("fog: observe never lists a hidden tile the caller's group has not rea
251266 stubJudge,252267 { persist: false },253268 );254- const c = (await open.join({ name: "c", harness: "t" })).agent;269+ const c = (await open.join({270+ name: "c",271+ harness: "t",272+ machine_id: "m-c",273+ verified: true,274+ })).agent;255275 assert(!open.observe({ agent: c }).fog);256276 });257277 @@ -276,15 +296,30 @@ Deno.test("a joiner after a grow is seated on the newest ring", async () => {
276296 );277297 const delta = synthesiseDelta(out, layout, 1);278298 await ref.grow(delta, 1);279- const j = await ref.join({ name: "late", harness: "t" });299+ const j = await ref.join({300+ name: "late",301+ harness: "t",302+ machine_id: "m-late",303+ verified: true,304+ });280305 assert(281306 delta.manifest.seed_tiles!.includes(j.seed_tile),282307 `seat ${j.seed_tile} in ring 1 seeds ${delta.manifest.seed_tiles}`,283308 );284- const j2 = await ref.join({ name: "later", harness: "t" });309+ const j2 = await ref.join({310+ name: "later",311+ harness: "t",312+ machine_id: "m-later",313+ verified: true,314+ });285315 assert(delta.manifest.seed_tiles!.includes(j2.seed_tile));286316 // the ring's seeds are used up: the next joiner falls back to ring 0287- const j3 = await ref.join({ name: "latest", harness: "t" });317+ const j3 = await ref.join({318+ name: "latest",319+ harness: "t",320+ machine_id: "m-latest",321+ verified: true,322+ });288323 assert(!delta.manifest.seed_tiles!.includes(j3.seed_tile));289324 });290325 @@ -336,7 +371,12 @@ Deno.test("registry: growth on coverage, and instead of ending when every tile i
336371 );337372 const ref = await whenReady(pending.game_id);338373 assert(ref.fog, "fog defaults on for a growing field");339- const agent = (await ref.join({ name: "p", harness: "t" })).agent;374+ const agent = (await ref.join({375+ name: "p",376+ harness: "t",377+ machine_id: "m-p",378+ verified: true,379+ })).agent;340380 const live: State[] = [];341381 // solve until coverage crosses 0.5: growth is triggered in the background342382 const n0 = ref.manifest.tiles.length;@@ -0,0 +1,297 @@
1+// Guards against the three attacks an agent reported (rejected commits as2+// an oracle, reading the public event log, reversing tile hashes; the last3+// one lives in engine/gen) and their variants: verdicts stop at death4+// inside a batch, all sessions of a machine share one health, retract and5+// recommit cannot launder the window, sessions per machine are capped,6+// a keyless join is refused on a hosted field, and the public log carries7+// no value for a rejected cell.8+import { assert, assertEquals, assertRejects } from "@std/assert";9+import { frontierOf, HEALTH_BURST } from "../lib/fold.ts";10+import type { GenOutput } from "../lib/engine.ts";11+import {12+ MAX_BATCH,13+ Referee,14+ RefereeError,15+ SESSIONS_PER_MACHINE,16+} from "../lib/referee.ts";17+import { scatterLayout, stubJudge, synthesise } from "./fixture.ts";18+19+const tmp = await Deno.makeTempDir();20+Deno.env.set("MOSAIC_GAMES", tmp + "/");21+22+const fixture = synthesise(scatterLayout("guards", 100));23+24+function solutionOf(out: GenOutput) {25+ const G = out.manifest.G;26+ return (id: number) =>27+ (out.solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0) as 0 | 1;28+}29+30+async function game(identity: "required" | "optional" = "optional") {31+ const out = structuredClone(fixture);32+ const ref = await Referee.create(33+ crypto.randomUUID(),34+ out,35+ { guess: "accept", depth: 0, identity },36+ stubJudge,37+ { seed: "guards", persist: true },38+ );39+ return { ref, sol: solutionOf(out) };40+}41+42+const MACHINE = "m-0123456789abcdef";43+44+async function seat(ref: Referee, session: string) {45+ const j = await ref.join({46+ name: "dami",47+ harness: "test",48+ machine_id: MACHINE,49+ verified: true,50+ session,51+ });52+ return j.agent;53+}54+55+/** `n` unknown cells of the agent's own revealed tiles. Reveals hidden56+ * frontier tiles as needed and, when the frontier is exhausted, solves the57+ * oldest revealed tile (correctly) to grow it; those cells are dropped from58+ * the answer. */59+async function unknownCells(60+ ref: Referee,61+ agent: string,62+ n: number,63+ sol: (id: number) => 0 | 1,64+): Promise<number[]> {65+ const tiles: number[] = [];66+ const geo = ref.state.geo!;67+ const unknownOf = (t: number) =>68+ geo.cellsOf[t].filter((c) => !ref.state.cells[c]);69+ for (const t of frontierOf(ref.state, agent)) {70+ if (ref.state.tiles[t] !== "hidden") tiles.push(t);71+ }72+ const count = () => tiles.reduce((k, t) => k + unknownOf(t).length, 0);73+ while (count() < n) {74+ const hidden = frontierOf(ref.state, agent).find((t) =>75+ ref.state.tiles[t] === "hidden"76+ );77+ if (hidden !== undefined) {78+ await ref.reveal(ref.revision, hidden, `reveal #${hidden}`, agent);79+ tiles.push(hidden);80+ continue;81+ }82+ const t = tiles.shift();83+ assert(t !== undefined, "a tile to solve");84+ await ref.commit(85+ ref.revision,86+ unknownOf(t).map((c) => [c, sol(c)] as [number, number]),87+ `solve #${t}`,88+ agent,89+ );90+ }91+ const out: number[] = [];92+ const seen = new Set<number>();93+ for (const t of tiles) {94+ for (const c of unknownOf(t)) {95+ if (!seen.has(c)) {96+ seen.add(c);97+ out.push(c);98+ }99+ }100+ }101+ assert(out.length >= n, `wanted ${n} unknown cells, have ${out.length}`);102+ return out.slice(0, n);103+}104+105+Deno.test("a batch's verdicts stop at the cell that kills the machine", async () => {106+ const { ref, sol } = await game();107+ const agent = await seat(ref, "probe");108+ const cells = await unknownCells(ref, agent, MAX_BATCH, sol);109+ // every cell wrong: the burst rule kills at the 30th wrong110+ const r = await ref.commit(111+ ref.revision,112+ cells.map((c) => [c, (1 - sol(c)) as 0 | 1]),113+ "probe",114+ agent,115+ );116+ assertEquals(r.health.state, "dead");117+ assertEquals(r.rejected.length, HEALTH_BURST);118+ assertEquals(r.unevaluated.length, cells.length - HEALTH_BURST);119+ assert(120+ r.rejected.every((c) => c.v !== undefined),121+ "the caller gets the verdicts it earned",122+ );123+ const ev = ref.log.events.at(-1) as {124+ rejected: { v?: number }[];125+ unevaluated?: number[];126+ died_at?: number;127+ };128+ assert(129+ ev.rejected.every((c) => c.v === undefined),130+ "the public record carries no value",131+ );132+ assertEquals(ev.unevaluated?.length, cells.length - HEALTH_BURST);133+ assertEquals(ev.died_at, ref.revision);134+ // the next write is refused before any evaluation135+ const err = await assertRejects(136+ () => ref.commit(ref.revision, [[cells[0], sol(cells[0])]], "again", agent),137+ RefereeError,138+ );139+ assertEquals(err.code, "dead");140+});141+142+Deno.test("a batch over the cap is refused whole", async () => {143+ const { ref } = await game();144+ const agent = await seat(ref, "big");145+ const cells = Array.from(146+ { length: MAX_BATCH + 1 },147+ (_, i) => [i, 0] as [number, number],148+ );149+ const err = await assertRejects(150+ () => ref.commit(ref.revision, cells, "too many", agent),151+ RefereeError,152+ );153+ assertEquals(err.code, "batch");154+});155+156+Deno.test("all sessions of a machine share one health: a probe kills the harvester too", async () => {157+ const { ref, sol } = await game();158+ const a = await seat(ref, "probe");159+ const b = await seat(ref, "harvest");160+ assert(a !== b);161+ const cells = await unknownCells(ref, a, 200, sol);162+ await ref.commit(163+ ref.revision,164+ cells.map((c) => [c, (1 - sol(c)) as 0 | 1]),165+ "probe",166+ a,167+ );168+ assertEquals(ref.observe({ agent: a }).health?.state, "dead");169+ assertEquals(ref.observe({ agent: b }).health?.state, "dead");170+ // the harvester reads the public log for rejected values: none171+ for (const ev of ref.log.events) {172+ if (ev.kind !== "batch" || ev.op !== "commit") continue;173+ for (const r of ev.rejected) assert(!("v" in r), "no value in the log");174+ }175+ const err = await assertRejects(176+ () => ref.commit(ref.revision, [[cells[0], sol(cells[0])]], "harvest", b),177+ RefereeError,178+ );179+ assertEquals(err.code, "dead");180+ // one machine death, recorded once181+ assertEquals(182+ ref.state.agents.filter((x) => x.health.state === "dead").length,183+ 2,184+ );185+ const deaths = ref.log.events.filter((e) =>186+ e.kind === "batch" && "died_at" in e187+ );188+ assertEquals(deaths.length, 1);189+});190+191+Deno.test("two sessions probing concurrently end with exactly one death and no verdict past it", async () => {192+ const { ref, sol } = await game();193+ const a = await seat(ref, "one");194+ const b = await seat(ref, "two");195+ const ca = await unknownCells(ref, a, 60, sol);196+ const cb = await unknownCells(ref, b, 60, sol);197+ const rev = ref.revision;198+ const results = await Promise.allSettled([199+ ref.commit(rev, ca.map((c) => [c, (1 - sol(c)) as 0 | 1]), "p1", a),200+ ref.commit(rev, cb.map((c) => [c, (1 - sol(c)) as 0 | 1]), "p2", b),201+ ]);202+ // writes are serialised: one lands (and kills), the other conflicts or is refused dead203+ const landed = results.filter((r) => r.status === "fulfilled");204+ assertEquals(landed.length, 1);205+ const wrongs = ref.log.events.filter((e) =>206+ e.kind === "batch" && e.op === "commit"207+ )208+ .reduce((n, e) => n + (e as { rejected: unknown[] }).rejected.length, 0);209+ assertEquals(wrongs, HEALTH_BURST);210+ assertEquals(ref.observe({ agent: b }).health?.state, "dead");211+});212+213+Deno.test("retract and recommit cannot launder the window; a wrong recommit still bleeds", async () => {214+ const { ref, sol } = await game();215+ const agent = await seat(ref, "launder");216+ // 10 wrong among 500 correct: warning territory (2%)217+ const cells = await unknownCells(ref, agent, 510, sol);218+ const batch = cells.map((c, i) =>219+ [c, i < 10 ? (1 - sol(c)) as 0 | 1 : sol(c)] as [number, number]220+ );221+ for (let i = 0; i < batch.length; i += 100) {222+ await ref.commit(ref.revision, batch.slice(i, i + 100), "play", agent);223+ }224+ const before = ref.observe({ agent }).health!;225+ assertEquals(before.wrong, 10);226+ const correct = cells.slice(10);227+ // retract 500 correct cells and recommit them: the window is unchanged228+ for (let i = 0; i < correct.length; i += 100) {229+ await ref.retract(230+ ref.revision,231+ correct.slice(i, i + 100),232+ "retract",233+ agent,234+ );235+ await ref.commit(236+ ref.revision,237+ correct.slice(i, i + 100).map((c) => [c, sol(c)] as [number, number]),238+ "recommit",239+ agent,240+ );241+ }242+ const after = ref.observe({ agent }).health!;243+ assertEquals(after.committed, before.committed);244+ assertEquals(after.wrong, before.wrong);245+ assertEquals(after.rate, before.rate);246+ // the analyzer still counts every commit247+ assert(ref.state.totals.commits > 6);248+ assertEquals(ref.state.totals.wrong, 10);249+ // a wrong recommit of a counted cell bleeds250+ await ref.retract(ref.revision, [correct[0]], "retract one", agent);251+ await ref.commit(252+ ref.revision,253+ [[correct[0], (1 - sol(correct[0])) as 0 | 1]],254+ "flip",255+ agent,256+ );257+ const flipped = ref.observe({ agent }).health!;258+ assertEquals(flipped.wrong, before.wrong + 1);259+ assertEquals(flipped.committed, before.committed + 1);260+});261+262+Deno.test("sessions per machine are capped for the day; rejoin is idempotent; the cap survives a death", async () => {263+ const { ref, sol } = await game();264+ const sessions: string[] = [];265+ for (let i = 0; i < SESSIONS_PER_MACHINE; i++) {266+ sessions.push(await seat(ref, `s${i}`));267+ }268+ const err = await assertRejects(() => seat(ref, "extra"), RefereeError);269+ assertEquals(err.code, "sessions");270+ // rejoining an existing session is the same agent, no new event271+ const n = ref.log.events.length;272+ assertEquals(await seat(ref, "s0"), sessions[0]);273+ assertEquals(ref.log.events.length, n);274+ // die, then a new session is still refused275+ const cells = await unknownCells(ref, sessions[0], 60, sol);276+ await ref.commit(277+ ref.revision,278+ cells.map((c) => [c, (1 - sol(c)) as 0 | 1]),279+ "probe",280+ sessions[0],281+ );282+ assertEquals(ref.observe({ agent: sessions[0] }).health?.state, "dead");283+ const err2 = await assertRejects(() => seat(ref, "afterlife"), RefereeError);284+ assertEquals(err2.code, "sessions");285+});286+287+Deno.test("a hosted field refuses a keyless join", async () => {288+ const { ref } = await game("required");289+ const err = await assertRejects(290+ () => ref.join({ name: "anon", harness: "test" }),291+ RefereeError,292+ );293+ assertEquals(err.code, "identity");294+ assert(err.message.includes("/identity"));295+ const agent = await seat(ref, "1");296+ assertEquals(agent, `${MACHINE}/1`);297+});@@ -144,11 +144,17 @@ Deno.test("death at 4% is sticky and refuses every write", async () => {
144144 assertEquals(err.code, "dead");145145 }146146 // sticky: a window of correct cells folded on top does not revive it147+ // (distinct cells: a machine is credited for a cell only once)147148 let s: State = ref.state;148149 const geo = s.geo!;149- const tile = s.tiles.findIndex((t) => t === "hidden");150- const cells = geo.cellsOf[tile].filter((c) => !s.cells[c]).slice(0, 40);150+ const pool: number[] = [];151+ for (let t = 0; t < s.tiles.length && pool.length < 60 * 40; t++) {152+ if (s.tiles[t] !== "hidden") continue;153+ for (const c of geo.cellsOf[t]) if (!s.cells[c]) pool.push(c);154+ }155+ assert(pool.length >= 60 * 40, "enough distinct cells to flush the window");151156 for (let i = 0; i < 60; i++) {157+ const cells = pool.slice(i * 40, i * 40 + 40);152158 const ev: CommitEvent = {153159 rev: s.revision + 1,154160 t: new Date().toISOString(),@@ -99,6 +99,11 @@ Deno.test("join proof: current or previous minute, fingerprint must match, wrong
9999 assertEquals((await verifyJoin(p, "game-1", now + 60_000)).ok, true);100100 assertEquals((await verifyJoin(p, "game-1", now + 130_000)).ok, false);101101 assertEquals((await verifyJoin(p, "game-2", now)).ok, false);102+ // the session is bound into the proof103+ const pa = await signJoin(id, "game-1", now, "a");104+ assertEquals((await verifyJoin(pa, "game-1", now, "a")).ok, true);105+ assertEquals((await verifyJoin(pa, "game-1", now, "b")).ok, false);106+ assertEquals((await verifyJoin(pa, "game-1", now)).ok, false);102107 const b = await generateIdentity("b");103108 const bid = await fingerprint(b.publicKey.x);104109 assertEquals(@@ -0,0 +1,116 @@
1+// The public view of a live game under fog (lib/redact.ts): the broadcast2+// lists geometry only for reached tiles, fills tiles in as they come into3+// reach, strips rejected values, and still folds to the same play.4+import { assert, assertEquals } from "@std/assert";5+import {6+ applyEvent,7+ emptyState,8+ fold,9+ frontierOf,10+ type State,11+} from "../lib/fold.ts";12+import type { GenOutput } from "../lib/engine.ts";13+import { Referee } from "../lib/referee.ts";14+import { redactedViewOf, redactLog } from "../lib/redact.ts";15+import { scatterLayout, stubJudge, synthesise } from "./fixture.ts";16+17+const tmp = await Deno.makeTempDir();18+Deno.env.set("MOSAIC_GAMES", tmp + "/");19+20+function solutionOf(out: GenOutput) {21+ const G = out.manifest.G;22+ return (id: number) =>23+ (out.solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0) as 0 | 1;24+}25+26+async function play() {27+ const out = structuredClone(synthesise(scatterLayout("redact", 100)));28+ const ref = await Referee.create(29+ crypto.randomUUID(),30+ out,31+ { guess: "accept", depth: 0, fog: true },32+ stubJudge,33+ { seed: "redact", persist: false },34+ );35+ const sol = solutionOf(out);36+ const agents: string[] = [];37+ for (const session of ["a", "b"]) {38+ const j = await ref.join({39+ name: "dami",40+ harness: "test",41+ machine_id: "m-0123456789abcdef",42+ verified: true,43+ session,44+ });45+ agents.push(j.agent);46+ }47+ for (let i = 0; i < 24 && ref.state.status === "playing"; i++) {48+ const agent = agents[i % 2];49+ const mine = frontierOf(ref.state, agent).filter((t) =>50+ ref.state.tiles[t] === "hidden" || ref.state.tileRevealer[t] === agent51+ );52+ if (mine.length === 0) continue;53+ const id = mine[0];54+ if (ref.state.tiles[id] === "hidden") {55+ await ref.reveal(ref.revision, id, `reveal #${id}`, agent);56+ }57+ const unknown = ref.state.geo!.cellsOf[id].filter((c) =>58+ !ref.state.cells[c]59+ );60+ const batch = unknown.slice(0, 40).map((c) =>61+ [c, sol(c)] as [number, number]62+ );63+ if (unknown.length > 40 && i < 4) {64+ batch.push([unknown[40], (1 - sol(unknown[40])) as 0 | 1]);65+ }66+ if (batch.length) await ref.commit(ref.revision, batch, `#${id}`, agent);67+ }68+ return ref;69+}70+71+Deno.test("the redacted view shows geometry only within reach and folds to the same play", async () => {72+ const ref = await play();73+ const full = ref.log.events;74+ const view = redactLog(full);75+ assertEquals(view.length, full.length);76+ // the cached live view is the same view77+ assertEquals(78+ JSON.stringify(redactedViewOf(ref).events),79+ JSON.stringify(view),80+ );81+ let f: State = emptyState();82+ let r: State = emptyState();83+ let hiddenSomewhere = false;84+ for (let i = 0; i < full.length; i++) {85+ f = applyEvent(f, full[i]);86+ r = applyEvent(r, view[i]);87+ assertEquals(r.revision, f.revision);88+ // a tile has a shape in the view iff someone reached it89+ for (const t of r.manifest!.tiles) {90+ const shown = t.rects.length > 0;91+ assertEquals(shown, !!f.reached[t.id], `rev ${i} tile ${t.id}`);92+ if (!shown) hiddenSomewhere = true;93+ }94+ // and reach is the same on both sides95+ assertEquals([...r.reached], [...f.reached], `reached at rev ${i}`);96+ assertEquals(r.tiles, f.tiles);97+ assertEquals(r.solvedCells, f.solvedCells);98+ assertEquals(r.agents, f.agents);99+ const ev = view[i];100+ if (ev.kind === "batch" && ev.op === "commit") {101+ for (const c of ev.rejected) assert(!("v" in c), "no rejected value");102+ }103+ }104+ assert(hiddenSomewhere, "some tile was out of reach at some point");105+ // the final analysis matches106+ assertEquals(107+ JSON.stringify(fold(view).analysis),108+ JSON.stringify(fold(full).analysis),109+ );110+ // and something was actually rejected along the way111+ assert(112+ full.some((e) =>113+ e.kind === "batch" && e.op === "commit" && e.rejected.length > 0114+ ),115+ );116+});@@ -79,8 +79,9 @@ async function play(
7979 const batch = unknown.slice(0, 40).map((c) =>8080 [c, sol(c)] as [number, number]8181 );82- // a wrong one, a duplicate, and a hole to exercise rejected/ignored83- if (unknown.length > 40) {82+ // a wrong one (only in the first batches: thirty wrongs would kill the83+ // machine), a duplicate, and a hole to exercise rejected/ignored84+ if (unknown.length > 40 && live.length < 12) {8485 batch.push([unknown[40], (1 - sol(unknown[40])) as 0 | 1], [8586 unknown[0],8687 sol(unknown[0]),