1// The referee orders the world. It holds the private generator output, gates2// reveals by the frontier, validates commits against the solution, asks the3// engine whether each accepted cell was forced, and appends exactly one event4// per accepted call. Refusals throw RefereeError and append nothing.5import {6 type AcceptedCell,7 agentOf,8 allSolved,9 applyEvent,10 cellAt,11 cellId,12 cloneWindow,13 type CommitEvent,14 deltaEncode,15 emptyState,16 type EndEvent,17 type FieldInfo,18 frontierOf,19 type GameEvent,20 type GameRules,21 groupOf,22 type GrowEvent,23 type Health,24 HEALTH_DEATH,25 type Honour,26 IMPLICIT_AGENT,27 isHole,28 isOnFrontier,29 isV1,30 joinCandidates,31 type JoinEvent,32 knownIds,33 type LocalRect,34 machineKeyOf,35 type ManifestEvent,36 neighboursOf,37 newWindow,38 normaliseManifest,39 ownersOf,40 reachedByGroup,41 type RejectedCell,42 type RetractEvent,43 type RevealEvent,44 type State,45 tileCells,46 type TileSpec,47 windowDead,48 windowRecord,49} from "./fold.ts";50import type { GenOutput, JudgeInput, JudgeOutput } from "./engine.ts";51import type { Manifest } from "./fold.ts";52import { Log } from "./log.ts";5354/** 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. */56export const MAX_BATCH = 512;57/** Sessions (agents) one machine may seat in a day. */58export const SESSIONS_PER_MACHINE = Math.max(59 1,60 Number(Deno.env.get("MOSAIC_SESSIONS_PER_MACHINE") ?? 3) || 3,61);6263/** One line an agent should read at warning or death. */64export function healthNote(h: Health): string | null {65 const pct = (h.rate * 100).toFixed(1);66 if (h.state === "dead") {67 return `dead: your wrong-rate reached ${68 (HEALTH_DEATH * 100).toFixed(0)69 }% at revision ${h.died_at}; you are out for the day, your territory stays.`;70 }71 if (h.state === "warning") {72 return `warning: your wrong-rate is ${pct}% over your last ${h.committed} committed cells; at ${73 (HEALTH_DEATH * 100).toFixed(0)74 }% you are out for the day. Commit what you can prove.`;75 }76 return null;77}7879/** `{note}` for a result, or nothing when the agent is healthy. */80export function noteOf(h: Health): { note?: string } {81 const n = healthNote(h);82 return n ? { note: n } : {};83}8485export class RefereeError extends Error {86 constructor(message: string, readonly code: string) {87 super(message);88 }89}9091export type Judge = (input: JudgeInput) => Promise<JudgeOutput>;9293export type CommitResult = {94 revision: number;95 agent: string;96 health: Health;97 note?: string; // at warning or death: what the rate means98 accepted: AcceptedCell[];99 rejected: RejectedCell[];100 ignored: { id: number; reason: string }[];101 unevaluated: number[]; // cells after the one that killed the machine102 solved: number[];103 meets: { with: string; tile: number }[];104 reward: number;105 frontier: number[];106 status: State["status"];107};108109export type JoinOptions = {110 machine_id?: string;111 /** the key behind machine_id was verified by the caller (lib/identity);112 * only then is machine_id trusted and the agent id derived from it */113 verified?: boolean;114 /** several agents on one machine: the agent id is machine_id/session */115 session?: string;116 name?: string;117 harness?: string;118 harness_source?: JoinEvent["harness_source"];119 model?: string;120 tags?: Record<string, string>;121};122123export type JoinResult = {124 revision: number;125 agent: string;126 seed_tile: number;127 frontier: number[];128};129130export type ObserveOptions = {131 agent?: string;132 rect?: [number, number, number, number];133 full?: boolean;134 radius?: number;135};136137/** Above this field side, observe needs an agent or a rect. */138export const FULL_OBSERVE_MAX_G = 120;139140/** Cells around an agent's territory that observe {agent} includes. */141export const OBSERVE_RADIUS = 24;142143/** Most tiles one observation lists (keeps a big territory under ~64 KB). */144export const MAX_OBSERVE_TILES = 100;145146export type RevealResult = {147 revision: number;148 tile: number;149 x0: number;150 y0: number;151 w: number;152 h: number;153 rects: LocalRect[];154 cells: number;155 givens: [number, 0 | 1][];156 seam: [number, 0 | 1][];157 frontier: number[];158};159160export type Observation = {161 game_id: string;162 revision: number;163 status: State["status"];164 G: number;165 seed_tile: number;166 scope: { kind: "full" | "agent" | "rect"; agent?: string; rect?: number[] };167 agents: { id: string; name: string; group: string; seed_tile: number }[];168 frontier: number[];169 tiles: {170 id: number;171 x0: number;172 y0: number;173 w: number;174 h: number;175 rects: LocalRect[];176 cells: number;177 m: number;178 status: string;179 known: number;180 agent: string | null;181 neighbours: number[];182 }[];183 tiles_total: number;184 /** the caller's health (observe {agent}) */185 health?: Health;186 note?: string;187 /** set when the listing was cut to MAX_OBSERVE_TILES (frontier and188 * revealed tiles kept, solved ones nearest the agent) */189 truncated?: boolean;190 holes: number;191 field: FieldInfo | null;192 grown: number;193 fog: boolean;194 known_deltas: number[];195 known_values: string;196 committed_deltas: number[];197 totals: State["totals"];198 encoding: Record<string, string>;199};200201export class Referee {202 state: State = emptyState();203 readonly log: Log;204 private solution: Uint8Array;205 private givensByTile = new Map<number, [number, 0 | 1][]>();206 private specs = new Map<number, TileSpec>();207208 /** the manifest as v3 (converted when the generator wrote v2); grows */209 manifest: Manifest;210 /**211 * Set by the game registry: whether the field can still grow (a grow212 * policy is set and the last ring was not exhausted). With growth213 * pending, solving every current tile does not end the game; the214 * registry grows it instead.215 */216 canGrow: () => boolean = () => false;217 /** Set by the registry: called (not awaited) when every tile is solved and growth is pending. */218 onNeedGrow: () => void = () => {};219220 private constructor(221 readonly gameId: string,222 readonly gen: GenOutput,223 readonly rules: GameRules,224 private judgeFn: Judge,225 persist: boolean,226 ) {227 this.log = new Log(gameId, persist);228 this.manifest = normaliseManifest(gen.manifest);229 const G = this.manifest.G;230 this.solution = new Uint8Array(G * G);231 for (let y = 0; y < G; y++) {232 for (let x = 0; x < G; x++) {233 this.solution[cellId(x, y, G)] = gen.solution[y][x] === "1" ? 1 : 0;234 }235 }236 for (const t of gen.tiles) this.givensByTile.set(t.id, t.givens);237 this.covered = new Uint8Array(G * G);238 for (const t of this.manifest.tiles) {239 this.specs.set(t.id, t);240 for (const c of tileCells(t, G)) this.covered[c] = 1;241 }242 }243244 /** cells some tile covers (grows with the field) */245 private covered: Uint8Array;246247 static async create(248 gameId: string,249 gen: GenOutput,250 rules: GameRules,251 judgeFn: Judge,252 opts: {253 persist?: boolean;254 seed?: string;255 honours?: Honour[];256 day?: string;257 } = {},258 ): Promise<Referee> {259 Referee.checkVersion(gen);260 const r = new Referee(gameId, gen, rules, judgeFn, opts.persist ?? true);261 r.seed = opts.seed;262 const ev: ManifestEvent = {263 rev: 0,264 t: new Date().toISOString(),265 kind: "manifest",266 game_id: gameId,267 manifest: gen.manifest,268 rules,269 ...(opts.honours?.length ? { honours: opts.honours } : {}),270 ...(opts.day ? { day: opts.day } : {}),271 };272 await r.apply(ev);273 return r;274 }275276 private static checkVersion(gen: GenOutput): void {277 if (278 isV1(gen.manifest) ||279 (gen.manifest.version !== 2 && gen.manifest.version !== 3)280 ) {281 throw new RefereeError(282 "Generator output is not manifest v2 or v3.",283 "version",284 );285 }286 }287288 /**289 * Resume a game from its log after a restart: the same private output290 * the game started from (the generator is deterministic and cached),291 * the events on disk folded back into state without being re-appended,292 * and every `grow` in the log paired with its private delta (in order)293 * so the solution and givens of grown tiles are known again. Writes294 * continue from the log's last revision.295 */296 static resume(297 gameId: string,298 gen: GenOutput,299 judgeFn: Judge,300 events: GameEvent[],301 deltas: GenOutput[],302 opts: { persist?: boolean; seed?: string } = {},303 ): Referee {304 Referee.checkVersion(gen);305 const first = events[0];306 if (!first || first.kind !== "manifest") {307 throw new RefereeError("Log has no manifest event.", "log");308 }309 const r = new Referee(310 gameId,311 gen,312 first.rules,313 judgeFn,314 opts.persist ?? true,315 );316 r.seed = opts.seed;317 let ring = 0;318 for (const ev of events) {319 if (ev.kind === "grow") {320 const delta = deltas[ring++];321 if (!delta) {322 throw new RefereeError(323 `Log has ${ring} grow events but only ${deltas.length} private deltas.`,324 "log",325 );326 }327 r.absorbDelta(delta, normaliseManifest(delta.manifest));328 }329 r.state = applyEvent(r.state, ev);330 r.log.events.push(ev);331 }332 r.manifest = r.state.manifest!;333 return r;334 }335336 /** The number of grow events folded so far (rings applied). */337 get grown(): number {338 return this.state.grown;339 }340341 /**342 * End the game now (the daily's rollover): one `end` event with the343 * analysis, whatever is solved. No-op when already ended.344 */345 end(): Promise<boolean> {346 return this.locked(async () => {347 if (this.state.status === "solved") return false;348 const end: EndEvent = {349 rev: this.state.revision + 1,350 t: new Date().toISOString(),351 kind: "end",352 seed: this.seed,353 analysis: { ...this.state.analysis, revision: this.state.revision + 1 },354 };355 await this.apply(end);356 return true;357 });358 }359360 private seed?: string;361 private chain: Promise<unknown> = Promise.resolve();362363 /**364 * Run one mutating call at a time. Agents write concurrently; a call that365 * awaited the judge while another landed must not append a stale revision,366 * so every write takes the lock first and checks its expected revision367 * inside it.368 */369 private locked<T>(fn: () => Promise<T>): Promise<T> {370 const run = this.chain.then(fn, fn);371 this.chain = run.catch(() => {});372 return run;373 }374375 private async apply(ev: GameEvent): Promise<void> {376 const next = applyEvent(this.state, ev);377 await this.log.append(ev);378 this.state = next;379 }380381 private checkRevision(expected: number): void {382 if (expected !== this.state.revision) {383 throw new RefereeError(384 `Revision conflict: expected ${expected}, current revision is ${this.state.revision}. Observe again.`,385 "revision",386 );387 }388 if (this.state.status === "solved") {389 throw new RefereeError("The mosaic is already solved.", "ended");390 }391 }392393 get revision(): number {394 return this.state.revision;395 }396397 private spec(tile: number): TileSpec {398 const t = Number.isInteger(tile) ? this.specs.get(tile) : undefined;399 if (!t) {400 throw new RefereeError(401 `Tile ${tile} does not exist; ids are 0..${this.specs.size - 1}.`,402 "bounds",403 );404 }405 return t;406 }407408 /** Known cells of a tile that are not its own givens. */409 private seamKnown(t: TileSpec): [number, 0 | 1][] {410 const out: [number, 0 | 1][] = [];411 const givens = new Set(this.givensByTile.get(t.id)!.map(([id]) => id));412 for (const id of this.state.geo!.cellsOf[t.id]) {413 const c = cellAt(this.state, id);414 if (c && !givens.has(id)) out.push([id, c.v]);415 }416 return out;417 }418419 /** Deterministic rng for seed-tile draws: the private day seed (never420 * the generation stream: its own label) plus join index. */421 private joinRng(index: number): () => number {422 let h = 2166136261;423 const secret = this.gen.day_seed ?? this.manifest.seed_commit;424 for (const ch of `seat|${secret}|${index}`) {425 h = Math.imul(h ^ ch.charCodeAt(0), 16777619);426 }427 return () => {428 h = Math.imul(h ^ (h >>> 15), 2246822507);429 h = Math.imul(h ^ (h >>> 13), 3266489909);430 h ^= h >>> 16;431 return (h >>> 0) / 4294967296;432 };433 }434435 /**436 * An agent joins: gets an id (its machine id, or a provisional a<N>) and a437 * seed tile drawn from the unclaimed seed tiles first, else from the hidden438 * tiles that touch nothing revealed, at random from the seed commit. Appends439 * one join event.440 */441 join(opts: JoinOptions = {}): Promise<JoinResult> {442 return this.locked(() => this.joinLocked(opts));443 }444445 private async joinLocked(opts: JoinOptions): Promise<JoinResult> {446 if (this.state.status === "solved") {447 throw new RefereeError("The mosaic is already solved.", "ended");448 }449 const index = this.state.agents.length;450 // the same agent joining again (same machine id, or same name from a451 // client without one) is the same agent: nothing appended, unless it452 // has nowhere left to play, in which case it is reseated453 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 }460 const session =461 (opts.session ?? "1").replace(/[^a-zA-Z0-9_.-]/g, "").slice(0, 24) || "1";462 const wanted = verified ? `${opts.machine_id}/${session}` : null;463 // the same agent joining again is the same agent: a verified machine464 // by its id and session; an unverified client by its name only465 const returning = this.state.agents.find((a) =>466 (wanted !== null && a.id === wanted) ||467 (wanted === null && opts.name && !a.verified && a.name === opts.name)468 );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 }484 if (returning) {485 // playable: a hidden frontier tile, or a revealed unsolved tile of486 // the agent's own group (a frontier tile another group revealed is487 // theirs to finish)488 const s = this.state;489 const group = groupOf(s, returning.id);490 const playable = frontierOf(s, returning.id).some((t) =>491 s.tiles[t] === "hidden" ||492 (s.tileRevealer[t] !== null && groupOf(s, s.tileRevealer[t]!) === group)493 );494 const stranded = !playable;495 if (!stranded) {496 return {497 revision: this.state.revision,498 agent: returning.id,499 seed_tile: returning.seed_tile,500 frontier: frontierOf(this.state, returning.id),501 };502 }503 return await this.reseat(returning.id);504 }505 // an unverified machine_id is just a claim: the agent is provisional506 const agent = wanted ?? `a${index}`;507 if (agentOf(this.state, agent)) {508 throw new RefereeError(`Agent ${agent} has already joined.`, "joined");509 }510 // a returning winner is seated on the seat reserved for them: only a511 // verified machine can claim an honour512 const honour = verified513 ? this.state.honours.find((h) => h.user === opts.machine_id)514 : undefined;515 const seatFree = (t: number) =>516 this.state.tiles[t] === "hidden" &&517 !this.state.agents.some((a) => a.seed_tile === t);518 let candidates = honour && seatFree(honour.seed_tile)519 ? [honour.seed_tile]520 : joinCandidates(this.state);521 if (candidates.length === 0) {522 // nowhere untouched is left: start next to someone523 candidates = this.manifest.tiles.filter((t) =>524 this.state.tiles[t.id] === "hidden"525 ).map((t) => t.id);526 }527 if (candidates.length === 0) {528 throw new RefereeError("No hidden tile is left to start on.", "full");529 }530 const r = this.joinRng(index);531 const seedTile = candidates[Math.floor(r() * candidates.length)];532 const ev: JoinEvent = {533 rev: this.state.revision + 1,534 t: new Date().toISOString(),535 kind: "join",536 agent,537 ...(opts.name ? { name: clip(opts.name).slice(0, 40) } : {}),538 harness: (opts.harness ?? "unknown").slice(0, 80),539 harness_source: opts.harness_source ??540 (opts.harness ? "argument" : "none"),541 ...(opts.model ? { model: opts.model.slice(0, 80) } : {}),542 ...(opts.tags ? { tags: clipTags(opts.tags) } : {}),543 ...(verified544 ? { machine_id: opts.machine_id, verified: true, session }545 : {}),546 seed_tile: seedTile,547 };548 await this.apply(ev);549 return {550 revision: this.state.revision,551 agent,552 seed_tile: seedTile,553 frontier: frontierOf(this.state, agent),554 };555 }556557 /** Seat an existing agent on a fresh seed tile (a second join event558 * with the same agent id; the fold treats it as a move). */559 private async reseat(agent: string): Promise<JoinResult> {560 const a = agentOf(this.state, agent)!;561 let candidates = joinCandidates(this.state);562 if (candidates.length === 0) {563 const claimed = new Set(this.state.agents.map((x) => x.seed_tile));564 candidates = this.manifest.tiles.filter((t) =>565 this.state.tiles[t.id] === "hidden" && !claimed.has(t.id)566 ).map((t) => t.id);567 }568 if (candidates.length === 0) {569 throw new RefereeError("No hidden tile is left to start on.", "full");570 }571 const r = this.joinRng(this.state.revision + 1);572 const seedTile = candidates[Math.floor(r() * candidates.length)];573 const ev: JoinEvent = {574 rev: this.state.revision + 1,575 t: new Date().toISOString(),576 kind: "join",577 agent,578 name: a.name,579 harness: a.harness,580 harness_source: "none",581 ...(a.model ? { model: a.model } : {}),582 seed_tile: seedTile,583 };584 await this.apply(ev);585 return {586 revision: this.state.revision,587 agent,588 seed_tile: seedTile,589 frontier: frontierOf(this.state, agent),590 };591 }592593 /**594 * Whether a cell lies in the agent's group's territory: some tile595 * covering it was revealed by a member of the group (a tile revealed by596 * group A is A's; contested tiles will be an explicit mechanic later).597 */598 private inTerritory(id: number, agent: string): boolean {599 const s = this.state;600 const group = groupOf(s, agent);601 for (let i = s.geo!.ownerStart[id]; i < s.geo!.ownerStart[id + 1]; i++) {602 const t = s.geo!.ownerList[i];603 const r = s.tileRevealer[t];604 if (r !== null && s.tiles[t] !== "hidden" && groupOf(s, r) === group) {605 return true;606 }607 }608 return false;609 }610611 /**612 * The agent a write belongs to. Writes without an agent are accepted only613 * from the single implicit player (old clients): it is joined on first use614 * with the manifest's seed tile.615 */616 /** A dead agent writes no more today. */617 private assertAlive(agent: string): void {618 const a = agentOf(this.state, agent);619 if (a && a.health.state === "dead") {620 throw new RefereeError(621 `Agent ${agent} is out for the day: its wrong-rate reached ${622 (HEALTH_DEATH * 100).toFixed(0)623 }% at revision ${a.health.died_at}. Its territory stays; it writes no more today.`,624 "dead",625 );626 }627 }628629 private async resolveAgent(agent: string | undefined): Promise<string> {630 if (agent !== undefined) {631 const a = agentOf(this.state, agent);632 if (!a) {633 throw new RefereeError(634 `Agent ${agent} has not joined this game; call join first.`,635 "agent",636 );637 }638 return agent;639 }640 if (this.state.agents.length === 0) {641 const ev: JoinEvent = {642 rev: this.state.revision + 1,643 t: new Date().toISOString(),644 kind: "join",645 agent: IMPLICIT_AGENT,646 harness: "unknown",647 harness_source: "none",648 seed_tile: this.manifest.seed_tile,649 };650 await this.apply(ev);651 return IMPLICIT_AGENT;652 }653 if (this.state.agents.length === 1) return this.state.agents[0].id;654 throw new RefereeError(655 "Several agents have joined; every write needs an agent id.",656 "agent",657 );658 }659660 /** Fog: unreached hidden tiles are not listed to agents. */661 get fog(): boolean {662 return this.rules.fog ?? false;663 }664665 /**666 * Apply a growth delta from the engine (`field --grow`): the new tiles667 * are appended (ids must continue), their givens and solution cells668 * recorded, and one `grow` event appended. Existing cells never change669 * (asserted against the delta's merged solution).670 */671 grow(delta: GenOutput, ring: number): Promise<GrowEvent> {672 return this.locked(() => this.growLocked(delta, ring));673 }674675 private async growLocked(delta: GenOutput, ring: number): Promise<GrowEvent> {676 if (this.state.status === "solved") {677 throw new RefereeError("The mosaic is already solved.", "ended");678 }679 const dm = normaliseManifest(delta.manifest);680 const G = this.manifest.G;681 if (dm.G !== G) {682 throw new RefereeError(`grow: field side ${dm.G} != ${G}`, "grow");683 }684 const before = this.manifest.tiles.length;685 dm.tiles.forEach((t, i) => {686 if (t.id !== before + i) {687 throw new RefereeError(688 `grow: tile ids must continue from ${before}, got ${t.id}`,689 "grow",690 );691 }692 });693 this.absorbDelta(delta, dm);694 const stats = delta.stats as Record<string, unknown> | undefined;695 const ev: GrowEvent = {696 rev: this.state.revision + 1,697 t: new Date().toISOString(),698 kind: "grow",699 ring,700 tiles: dm.tiles,701 // the engine lists the ring's seed tiles under field.seed_tiles702 seed_tiles: dm.seed_tiles?.length703 ? dm.seed_tiles704 : ((dm.field?.seed_tiles as number[] | undefined) ?? []).filter((705 t,706 ) => t >= before),707 ...(dm.field ? { field: dm.field } : {}),708 ...(stats?.exhausted || dm.tiles.length === 0 ? { exhausted: true } : {}),709 };710 await this.apply(ev);711 this.manifest = this.state.manifest!;712 return ev;713 }714715 /** Record a growth delta's private side: merged solution (old cells716 * must be unchanged), the new tiles' specs, coverage and givens. */717 private absorbDelta(delta: GenOutput, dm: Manifest): void {718 const G = this.manifest.G;719 for (let y = 0; y < G; y++) {720 const row = delta.solution[y];721 for (let x = 0; x < G; x++) {722 const ch = row[x];723 const id = cellId(x, y, G);724 const had = this.covered[id];725 if (had && ch !== "." && (ch === "1" ? 1 : 0) !== this.solution[id]) {726 throw new RefereeError(`grow: cell ${id} changed`, "grow");727 }728 if (ch !== ".") this.solution[id] = ch === "1" ? 1 : 0;729 }730 }731 for (const t of dm.tiles) {732 this.specs.set(t.id, t);733 for (const c of tileCells(t, G)) this.covered[c] = 1;734 }735 for (const t of delta.tiles) this.givensByTile.set(t.id, t.givens);736 }737738 reveal(739 expected: number,740 tile: number,741 intent = "",742 agentId?: string,743 ): Promise<RevealResult> {744 return this.locked(() =>745 this.revealLocked(expected, tile, intent, agentId)746 );747 }748749 private async revealLocked(750 expected: number,751 tile: number,752 intent: string,753 agentId?: string,754 ): Promise<RevealResult> {755 this.checkRevision(expected);756 const agent = await this.resolveAgent(agentId);757 this.assertAlive(agent);758 const t = this.spec(tile);759 if (this.state.tiles[t.id] !== "hidden") {760 throw new RefereeError(761 `Tile ${t.id} is already ${this.state.tiles[t.id]}.`,762 "not_hidden",763 );764 }765 if (!isOnFrontier(this.state, t.id, agent)) {766 throw new RefereeError(767 `Tile ${t.id} is not on ${agent}'s frontier (tiles overlapping a tile your group solved, or your seed tile). Frontier: ${768 JSON.stringify(frontierOf(this.state, agent))769 }.`,770 "frontier",771 );772 }773 const givens = this.givensByTile.get(t.id) ?? [];774 const seam = this.seamKnown(t);775 const ev: RevealEvent = {776 rev: this.state.revision + 1,777 t: new Date().toISOString(),778 kind: "batch",779 op: "reveal",780 agent,781 tile: t.id,782 givens,783 seam,784 intent: clip(intent),785 };786 await this.apply(ev);787 return {788 revision: this.state.revision,789 tile: t.id,790 x0: t.x0,791 y0: t.y0,792 w: t.w,793 h: t.h,794 rects: t.rects,795 cells: t.cells,796 givens,797 seam,798 frontier: frontierOf(this.state, agent),799 };800 }801802 private activeShapes(): TileSpec[] {803 return this.manifest.tiles.filter((t) =>804 this.state.tiles[t.id] !== "hidden"805 );806 }807808 private inActiveWindow(id: number): boolean {809 const geo = this.state.geo!;810 return ownersOf(geo, id).some((t) => this.state.tiles[t] !== "hidden");811 }812813 commit(814 expected: number,815 cells: [number, number][],816 intent = "",817 agentId?: string,818 ): Promise<CommitResult> {819 return this.locked(() =>820 this.commitLocked(expected, cells, intent, agentId)821 );822 }823824 private async commitLocked(825 expected: number,826 cells: [number, number][],827 intent: string,828 agentId?: string,829 ): Promise<CommitResult> {830 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 }837 const agent = await this.resolveAgent(agentId);838 this.assertAlive(agent);839 const G = this.state.G;840 const geo = this.state.geo!;841 const ignored: { id: number; reason: string }[] = [];842 const seen = new Set<number>();843 const candidates: [number, 0 | 1][] = [];844 for (const cell of cells) {845 if (!Array.isArray(cell) || cell.length !== 2) {846 throw new RefereeError("Each cell is [id, value].", "shape");847 }848 const [id, v] = cell;849 if (!Number.isInteger(id) || id < 0 || id >= G * G) {850 throw new RefereeError(851 `Cell id ${id} is out of range 0..${G * G - 1}.`,852 "bounds",853 );854 }855 if (v !== 0 && v !== 1) {856 throw new RefereeError(`Cell ${id}: value must be 0 or 1.`, "value");857 }858 if (seen.has(id)) {859 ignored.push({ id, reason: "duplicate" });860 continue;861 }862 seen.add(id);863 if (isHole(geo, id)) {864 ignored.push({ id, reason: "hole" });865 continue;866 }867 const known = cellAt(this.state, id);868 if (known) {869 ignored.push({870 id,871 reason: known.kind === "given" ? "given" : "known",872 });873 continue;874 }875 if (!this.inActiveWindow(id)) {876 ignored.push({ id, reason: "not_revealed" });877 continue;878 }879 if (this.state.agents.length > 1 && !this.inTerritory(id, agent)) {880 ignored.push({ id, reason: "territory" });881 continue;882 }883 candidates.push([id, v]);884 }885 if (candidates.length === 0) {886 throw new RefereeError(887 `No cell to process (${ignored.length} ignored: ${888 summarise(ignored)889 }). Nothing was recorded.`,890 "empty",891 );892 }893 const known: [number, number][] = knownIds(this.state.cellStore).map((894 id,895 ) => [id, cellAt(this.state, id)!.v]);896 const judged = await this.judgeFn({897 G,898 shapes: this.activeShapes().map((t) => ({899 x0: t.x0,900 y0: t.y0,901 w: t.w,902 h: t.h,903 rects: t.rects,904 })),905 known,906 candidates,907 depth: this.rules.depth,908 });909 const byId = new Map(judged.results.map((r) => [r.id, r]));910 const accepted: AcceptedCell[] = [];911 const rejected: RejectedCell[] = [];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 }928 if (this.solution[id] !== v) {929 rejected.push({ id, reason: "wrong", v });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 }941 }942 if (win.died_at === null && windowDead(win)) {943 win.died_at = rev;944 diedAt = rev;945 }946 }947 const before = [...this.state.tiles];948 const ev: CommitEvent = {949 rev: this.state.revision + 1,950 t: new Date().toISOString(),951 kind: "batch",952 op: "commit",953 agent,954 intent: clip(intent),955 accepted,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 })),959 ignored: ignored.map((i) => i.id),960 solved: [],961 judge_ms: judged.ms,962 ...(unevaluated.length ? { unevaluated } : {}),963 ...(diedAt !== undefined ? { died_at: diedAt } : {}),964 };965 // solved tiles and meetings are derived by the fold; record them on the966 // event for readers967 const preview = applyEvent(this.state, ev);968 ev.solved = preview.tiles.map((s, i) => [s, i] as const)969 .filter(([s, i]) => s === "solved" && before[i] !== "solved")970 .map(([, i]) => i);971 const meets = preview.meets.slice(this.state.meets.length).map((m) => ({972 with: m.agents[1],973 tile: m.tile,974 }));975 if (meets.length) ev.meets = meets;976 await this.apply(ev);977 const reward = ev.solved.reduce((a, i) => a + this.specs.get(i)!.cells, 0);978 if (allSolved(this.state) && this.canGrow()) {979 // every current tile is solved but the field can still grow: no end,980 // the registry grows it (the next observe shows the new ring)981 this.onNeedGrow();982 } else if (allSolved(this.state)) {983 const end: EndEvent = {984 rev: this.state.revision + 1,985 t: new Date().toISOString(),986 kind: "end",987 seed: this.seed,988 analysis: { ...this.state.analysis, revision: this.state.revision + 1 },989 };990 await this.apply(end);991 }992 const health = agentOf(this.state, agent)!.health;993 return {994 revision: this.state.revision,995 agent,996 health,997 ...noteOf(health),998 accepted,999 rejected,1000 ignored,1001 unevaluated,1002 solved: ev.solved,1003 meets,1004 reward,1005 frontier: frontierOf(this.state, agent),1006 status: this.state.status,1007 };1008 }10091010 retract(1011 expected: number,1012 ids: number[],1013 intent = "",1014 agentId?: string,1015 ): Promise<{ revision: number; retracted: number[]; ignored: number[] }> {1016 return this.locked(() =>1017 this.retractLocked(expected, ids, intent, agentId)1018 );1019 }10201021 private async retractLocked(1022 expected: number,1023 ids: number[],1024 intent: string,1025 agentId?: string,1026 ): Promise<{ revision: number; retracted: number[]; ignored: number[] }> {1027 this.checkRevision(expected);1028 const agent = await this.resolveAgent(agentId);1029 this.assertAlive(agent);1030 const retracted: number[] = [];1031 const ignored: number[] = [];1032 for (const id of ids) {1033 if (!Number.isInteger(id)) {1034 throw new RefereeError("Cell ids are integers.", "shape");1035 }1036 if (1037 cellAt(this.state, id)?.kind === "commit" &&1038 (this.state.agents.length <= 1 || this.inTerritory(id, agent))1039 ) retracted.push(id);1040 else ignored.push(id);1041 }1042 if (retracted.length === 0) {1043 throw new RefereeError(1044 "None of those cells is a commit of yours. Nothing was recorded.",1045 "empty",1046 );1047 }1048 const ev: RetractEvent = {1049 rev: this.state.revision + 1,1050 t: new Date().toISOString(),1051 kind: "batch",1052 op: "retract",1053 agent,1054 cells: retracted,1055 intent: clip(intent),1056 };1057 await this.apply(ev);1058 return { revision: this.state.revision, retracted, ignored };1059 }10601061 /**1062 * Observe, scoped: with `agent`, the tiles of the agent's group plus every1063 * tile within `radius` cells of them and the group's frontier; with `rect`,1064 * the tiles intersecting it; `full` (or neither, on a small field) lists1065 * everything. Known cells are coded within the returned tiles only.1066 */1067 observe(opts: ObserveOptions = {}): Observation {1068 const s = this.state;1069 const geo = s.geo!;1070 const G = s.G;1071 let scope: Observation["scope"];1072 let chosen: TileSpec[];1073 let frontier: number[];1074 if (opts.agent !== undefined) {1075 const a = agentOf(s, opts.agent);1076 if (!a) {1077 throw new RefereeError(1078 `Agent ${opts.agent} has not joined this game; call join first.`,1079 "agent",1080 );1081 }1082 const group = groupOf(s, opts.agent);1083 const mine = this.manifest.tiles.filter((t) => {1084 const ta = s.tileAgent[t.id];1085 return (ta !== null && groupOf(s, ta) === group) ||1086 s.agents.some((x) => x.group === group && x.seed_tile === t.id);1087 });1088 const radius = opts.radius ?? OBSERVE_RADIUS;1089 const boxes = mine.map((t) => ({1090 x0: t.x0 - radius,1091 y0: t.y0 - radius,1092 x1: t.x0 + t.w + radius,1093 y1: t.y0 + t.h + radius,1094 }));1095 frontier = frontierOf(s, opts.agent);1096 const fr = new Set(frontier);1097 chosen = this.manifest.tiles.filter((t) =>1098 fr.has(t.id) ||1099 boxes.some((b) =>1100 t.x0 < b.x1 && t.x0 + t.w > b.x0 && t.y0 < b.y1 && t.y0 + t.h > b.y01101 )1102 );1103 scope = { kind: "agent", agent: opts.agent };1104 } else if (opts.rect) {1105 const [x, y, w, h] = opts.rect;1106 chosen = this.manifest.tiles.filter((t) =>1107 t.x0 < x + w && t.x0 + t.w > x && t.y0 < y + h && t.y0 + t.h > y1108 );1109 const ids = new Set(chosen.map((t) => t.id));1110 frontier = frontierOf(s).filter((t) => ids.has(t));1111 scope = { kind: "rect", rect: opts.rect };1112 } else {1113 if (!opts.full && G > FULL_OBSERVE_MAX_G) {1114 throw new RefereeError(1115 `The field is ${G}x${G}: observe with {agent} (your territory and its surroundings) or {rect: [x, y, w, h]}; full=true is allowed only up to G=${FULL_OBSERVE_MAX_G}.`,1116 "scope",1117 );1118 }1119 if (opts.full && G > FULL_OBSERVE_MAX_G) {1120 throw new RefereeError(1121 `full observation is allowed only up to G=${FULL_OBSERVE_MAX_G}; use {agent} or {rect}.`,1122 "scope",1123 );1124 }1125 chosen = this.manifest.tiles;1126 frontier = frontierOf(s);1127 scope = { kind: "full" };1128 }1129 if (this.fog) {1130 // fog: a hidden tile is listed only when the caller's group reaches1131 // it (a tile the group revealed or solved overlaps it, or it is a1132 // member's seed tile); without an agent only revealed and solved1133 // tiles are listed1134 const group = opts.agent !== undefined ? groupOf(s, opts.agent) : null;1135 chosen = chosen.filter((t) =>1136 s.tiles[t.id] !== "hidden" ||1137 (group !== null && reachedByGroup(s, t.id, group))1138 );1139 const ids = new Set(chosen.map((t) => t.id));1140 frontier = frontier.filter((t) => ids.has(t));1141 }1142 // a territory's observation stays bounded: past the cap, keep the1143 // frontier and every revealed-unsolved tile, then solved tiles nearest1144 // the agent's last solved tile (or seed), and say so1145 let truncated = false;1146 if (chosen.length > MAX_OBSERVE_TILES) {1147 const fr = new Set(frontier);1148 const keep = chosen.filter((t) =>1149 fr.has(t.id) || s.tiles[t.id] === "revealed"1150 );1151 const rest = chosen.filter((t) =>1152 !fr.has(t.id) && s.tiles[t.id] !== "revealed"1153 );1154 let cx = G / 2, cy = G / 2;1155 if (opts.agent !== undefined) {1156 const a = agentOf(s, opts.agent)!;1157 const last = s.analysis.agents.find((x) => x.agent === a.id)1158 ?.last_solved;1159 const ref = this.manifest.tiles[last ?? a.seed_tile];1160 cx = ref.x0 + ref.w / 2;1161 cy = ref.y0 + ref.h / 2;1162 }1163 const d2 = (t: TileSpec) =>1164 (t.x0 + t.w / 2 - cx) ** 2 + (t.y0 + t.h / 2 - cy) ** 2;1165 rest.sort((a, b) => d2(a) - d2(b) || a.id - b.id);1166 const room = Math.max(0, MAX_OBSERVE_TILES - keep.length);1167 truncated = rest.length > room;1168 chosen = [...keep, ...rest.slice(0, room)].sort((a, b) => a.id - b.id);1169 }1170 const known = new Set<number>();1171 const tiles = [];1172 for (const t of chosen) {1173 const st = s.tiles[t.id];1174 let k = 0;1175 if (st !== "hidden") {1176 for (const id of geo.cellsOf[t.id]) {1177 if (cellAt(s, id)) {1178 k++;1179 known.add(id);1180 }1181 }1182 }1183 tiles.push({1184 id: t.id,1185 x0: t.x0,1186 y0: t.y0,1187 w: t.w,1188 h: t.h,1189 rects: t.rects,1190 cells: t.cells,1191 m: t.m,1192 status: st,1193 known: k,1194 agent: s.tileAgent[t.id] ?? null,1195 neighbours: neighboursOf(t),1196 });1197 }1198 const ids = [...known].sort((a, b) => a - b);1199 const committed = ids.filter((id) => cellAt(s, id)!.kind === "commit");1200 return {1201 game_id: this.gameId,1202 revision: s.revision,1203 status: s.status,1204 G,1205 seed_tile: s.seed_tile,1206 scope,1207 agents: s.agents.map((a) => ({1208 id: a.id,1209 name: a.name,1210 group: a.group,1211 seed_tile: a.seed_tile,1212 })),1213 frontier,1214 tiles,1215 tiles_total: this.manifest.tiles.length,1216 ...(opts.agent && agentOf(s, opts.agent)1217 ? {1218 health: agentOf(s, opts.agent)!.health,1219 ...noteOf(agentOf(s, opts.agent)!.health),1220 }1221 : {}),1222 ...(truncated ? { truncated: true } : {}),1223 holes: geo.holes,1224 field: this.manifest.field ?? null,1225 grown: s.grown,1226 fog: this.fog,1227 known_deltas: deltaEncode(ids),1228 known_values: ids.map((id) => String(cellAt(s, id)!.v)).join(""),1229 committed_deltas: deltaEncode(committed),1230 totals: s.totals,1231 encoding: {1232 cell_id: "id = y * G + x; x and y are global cell coordinates, 0-based",1233 tile_shape:1234 "tile {id, x0, y0, w, h, rects} is the union of rects [lx, ly, rw, rh] in local coordinates inside the w x h box at (x0, y0); cells is its cell count; tiles overlap neighbours by rectangles of shared cells",1235 neighbours:1236 "tile ids sharing at least one cell; a tile solved by your group fixes the shared cells and puts this tile on your frontier",1237 scope:1238 "which tiles are listed: your territory and its surroundings (agent), a rectangle (rect), or everything (full, small fields only); known cells are coded over the listed tiles only; when truncated is set, solved tiles far from your last solved tile were left out (use rect to see them)",1239 known_deltas:1240 "cumulative sum recovers the sorted ids of every known cell in the listed tiles; known_values[i] is the value of the i-th id",1241 committed_deltas:1242 "same coding; the subset that was committed (retractable)",1243 agent:1244 "on a tile: who solved it; agents[].group is the territory it belongs to (groups merge when they meet)",1245 holes: "cells no tile covers; never playable",1246 fog:1247 "when true, hidden tiles are listed only once your group reaches them (a tile you revealed or solved overlaps them, or they are your seed); the field may also grow: observe again after a grow and expect new tile ids",1248 grown: "how many rings of chunks have grown around the initial field",1249 },1250 };1251 }1252}12531254function clip(s: string): string {1255 return (s ?? "").slice(0, 160);1256}12571258function clipTags(tags: Record<string, string>): Record<string, string> {1259 const out: Record<string, string> = {};1260 for (const [k, v] of Object.entries(tags).slice(0, 16)) {1261 out[String(k).slice(0, 32)] = String(v).slice(0, 80);1262 }1263 return out;1264}12651266function summarise(ignored: { id: number; reason: string }[]): string {1267 const counts: Record<string, number> = {};1268 for (const i of ignored) counts[i.reason] = (counts[i.reason] ?? 0) + 1;1269 return Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ") ||1270 "none";1271}