clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

main
mosaic / site / islands / Viewer.tsx
Viewer.tsx 67.0K
1// The spectator as a Go match screen in a printed-kifu identity: the board,2// player cards with clocks (health as byo-yomi), a move counter, the3// counting line, the move list, the tournament table, a result card at the4// end of the day. Live mode follows /events/live over SSE; replay mode5// loads /log/:id. Both are the same fold.6//7// Rendering: the field is cut into map tiles (TILE_PX screen pixels at a8// discrete raster zoom), each rasterised once into an offscreen canvas9// and cached; an event dirties only the map tiles whose cells it touches;10// a frame composes cached map tiles at the continuous view scale. Levels:11// L0 flat territory tints on the board, L1 + shape outlines and counting12// marks, L2 + stones (1 black, 0 white; givens marked), L3 + board lines,13// with the frame coordinates and the last-move ring drawn per frame.14import { useEffect, useMemo, useRef, useState } from "preact/hooks";15import {16 type AgentInfo,17 emptyState,18 type GameEvent,19 type Geometry,20 type Health,21 HEALTH_DEATH,22 HEALTH_WARN,23 parseLog,24 type State,25 type TileSpec,26 type TileStatus,27} from "../lib/fold.ts";28import {29 agentColour,30 cellFill,31 DARK,32 fogFill,33 LIGHT,34 neutralFill,35 rgbOf,36 type Theme,37} from "../lib/colour.ts";38import {39 boxOf,40 cellsOfEvent,41 ChangeLog,42 fitBox,43 keysForCells,44 type Level,45 levelFor,46 type MapTile,47 Occupancy,48 occupiedTilesForView,49 TileCache,50 tileCellRange,51 tileScreenRect,52 type View,53} from "../lib/viewer/tilemap.ts";54import { agentHues } from "../lib/viewer/hues.ts";55import { Checkpoints } from "../lib/viewer/checkpoints.ts";56import {57 type Death,58 density,59 markers,60 type Speed,61 SPEEDS,62 stepFor,63} from "../lib/viewer/timeline.ts";64import { type Boards, boardsOf, type Row } from "../lib/leaderboard.ts";6566const RANK: Record<TileStatus, number> = { hidden: 0, revealed: 1, solved: 2 };67const BUCKET = 32; // cells per spatial-index bucket68const EASE_MS = 300;69const SNAP_MS = 140; // a stone lands: scale from 0.55 to 1, transform only70const MAIN_TIME = 20 * 60; // seconds of "main time" a healthy clock shows71const PERIOD = 30; // seconds per byo-yomi period7273// ---------------------------------------------------------------- scene7475/** Everything a raster needs, derived per state (cheaply) and per geometry. */76type GeoDerived = {77 buckets: Map<string, number[]>; // spatial index: tiles by bucket78 masks: Uint8Array[];79 occ: Occupancy; // which map tiles hold any tile80};8182class Scene {83 geo: Geometry | null = null;84 theme: Theme = LIGHT;85 buckets = new Map<string, number[]>();86 masks: Uint8Array[] = [];87 occ: Occupancy | null = null;88 fillCache = new Map<string, string>();89 // per geometry: a grown field has one geometry per growth, and scrubbing90 // across a grow alternates between them91 private derived = new WeakMap<Geometry, GeoDerived>();9293 prepare(geo: Geometry, tiles: TileSpec[], theme: Theme) {94 if (this.geo !== geo) {95 let d = this.derived.get(geo);96 if (!d) {97 const buckets = new Map<string, number[]>();98 const masks = tiles.map((t) => {99 const m = new Uint8Array(t.w * t.h);100 for (const [lx, ly, rw, rh] of t.rects) {101 for (let y = ly; y < ly + rh; y++) {102 for (let x = lx; x < lx + rw; x++) m[y * t.w + x] = 1;103 }104 }105 return m;106 });107 for (const t of tiles) {108 for (109 let by = Math.floor(t.y0 / BUCKET);110 by <= Math.floor((t.y0 + t.h - 1) / BUCKET);111 by++112 ) {113 for (114 let bx = Math.floor(t.x0 / BUCKET);115 bx <= Math.floor((t.x0 + t.w - 1) / BUCKET);116 bx++117 ) {118 const k = `${bx}:${by}`;119 const list = buckets.get(k);120 if (list) list.push(t.id);121 else buckets.set(k, [t.id]);122 }123 }124 }125 d = { buckets, masks, occ: new Occupancy(geo.G, tiles) };126 this.derived.set(geo, d);127 }128 this.buckets = d.buckets;129 this.masks = d.masks;130 this.occ = d.occ;131 }132 if (this.theme !== theme) this.fillCache.clear();133 this.geo = geo;134 this.theme = theme;135 }136137 /** Tiles whose bounding box intersects a cell range. */138 tilesIn(x0: number, y0: number, x1: number, y1: number): Set<number> {139 const out = new Set<number>();140 for (141 let by = Math.floor(y0 / BUCKET);142 by <= Math.floor(y1 / BUCKET);143 by++144 ) {145 for (146 let bx = Math.floor(x0 / BUCKET);147 bx <= Math.floor(x1 / BUCKET);148 bx++149 ) {150 const list = this.buckets.get(`${bx}:${by}`);151 if (list) { for (const id of list) out.add(id); }152 }153 }154 return out;155 }156157 /** One opaque fill for a cell: the board tinted toward its owners' hues. */158 fill(hues: number[], status: TileStatus, known: boolean, wilt = 0): string {159 const key = `${hues.join(",")}|${status}|${known ? 1 : 0}|${wilt}`;160 let f = this.fillCache.get(key);161 if (!f) {162 f = hues.length163 ? cellFill(hues, status, known, this.theme, 0, WILT_CHROMA[wilt] ?? 1)164 : neutralFill(status, known, this.theme, 0);165 this.fillCache.set(key, f);166 }167 return f;168 }169}170171/** Per-state lookups the raster and the overlays read. */172type Frame = {173 s: State;174 tileHue: Int16Array; // per tile: agent hue or -1175 agentName: Map<string, string>;176 reached: Uint8Array; // per tile: 1 if some territory has reached it177 tileWilt: Uint8Array; // per tile: its agent's wilt level 0..3 (health)178 agentWilt: Map<string, number>;179 final: boolean; // the last revision of a finished game: never-reached tiles are shown180 lastCells: number[]; // cells the latest commit at this revision placed181 toMove: string | null; // the agent of the latest event182};183184/** `State.reached` once the fold provides it; every tile reached before. */185function reachedOf(s: State): Uint8Array {186 const r = (s as unknown as { reached?: Uint8Array }).reached;187 if (r && r.length === s.tiles.length) return r;188 return new Uint8Array(s.tiles.length).fill(1);189}190191function frameOf(192 s: State,193 hues: Map<string, number>,194 events: GameEvent[],195 final = false,196): Frame {197 const n = s.tiles.length;198 const tileHue = new Int16Array(n).fill(-1);199 // a solved tile is its solver's; a revealed one is its revealer's while200 // it is worked on201 const revealer = new Map<number, string>();202 for (const e of s.console) {203 if (e.op === "reveal" && e.tile !== undefined && e.agent) {204 revealer.set(e.tile, e.agent);205 }206 }207 for (let t = 0; t < n; t++) {208 const a = s.tileAgent[t] ?? revealer.get(t) ?? null;209 if (a !== null) {210 const h = hues.get(a);211 if (h !== undefined) tileHue[t] = h;212 }213 }214 const agentName = labelsOf(s.agents);215 const agentWilt = new Map<string, number>();216 for (const a of s.agents) {217 agentWilt.set(a.id, wiltOf(a.health));218 }219 const tileWilt = new Uint8Array(n);220 for (let t = 0; t < n; t++) {221 const a = s.tileAgent[t] ?? revealer.get(t) ?? null;222 if (a !== null) tileWilt[t] = agentWilt.get(a) ?? 0;223 }224 // the latest event at this revision: who is "to move", and the stones225 // it placed (the last-move marks)226 const ev = s.revision >= 0 ? events[s.revision] : undefined;227 let lastCells: number[] = [];228 let toMove: string | null = null;229 if (ev && ev.kind === "batch") {230 toMove = ev.agent ?? null;231 if (ev.op === "commit") lastCells = ev.accepted.map((c) => c.id);232 } else if (ev && ev.kind === "join") toMove = ev.agent;233 return {234 s,235 tileHue,236 agentName,237 reached: reachedOf(s),238 tileWilt,239 agentWilt,240 final,241 lastCells,242 toMove,243 };244}245246/** Wilt level from health: 0 healthy, 1-2 warning (rate rising toward247 * death), 3 dead. The hue desaturates with it. */248export /**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 */254function 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}275276function wiltOf(h: Health | undefined): number {277 if (!h) return 0;278 if (h.state === "dead") return 3;279 if (h.state === "warning") {280 const k = (h.rate - HEALTH_WARN) / (HEALTH_DEATH - HEALTH_WARN);281 return k < 0.5 ? 1 : 2;282 }283 return 0;284}285286const WILT_CHROMA = [1, 0.7, 0.45, 0.22];287288// ---------------------------------------------------------------- raster289290type Raster = { canvas: HTMLCanvasElement; ppc: number }; // pixels per cell291292/** Draw one stone at (cx, cy) with radius r. */293function stone(294 ctx: CanvasRenderingContext2D,295 cx: number,296 cy: number,297 r: number,298 v: 0 | 1,299 theme: Theme,300 given: boolean,301) {302 ctx.beginPath();303 ctx.arc(cx, cy, r, 0, Math.PI * 2);304 ctx.fillStyle = v === 1 ? theme.stone1 : theme.stone0;305 ctx.fill();306 if (v === 0 || theme.name === "dark") {307 ctx.strokeStyle = theme.line;308 ctx.lineWidth = Math.max(0.6, r * 0.12);309 ctx.stroke();310 }311 if (given) {312 // a given stone carries a small mark, like a numbered stone in print313 ctx.beginPath();314 ctx.arc(cx, cy, Math.max(0.8, r * 0.28), 0, Math.PI * 2);315 ctx.fillStyle = v === 1 ? theme.stone0 : theme.stone1;316 ctx.fill();317 }318}319320function renderMapTile(321 mt: MapTile,322 f: Frame,323 scene: Scene,324 dpr: number,325): Raster {326 const s = f.s, geo = s.geo!, G = s.G, theme = scene.theme;327 const level = mt.level;328 const { x0, y0, x1, y1, cpt } = tileCellRange(mt, G);329 // rasterise at no less than one pixel per cell; drawImage averages down330 const ppc = Math.max(1, mt.zoom) * dpr;331 const size = Math.round(cpt * ppc);332 const canvas = document.createElement("canvas");333 canvas.width = size;334 canvas.height = size;335 const ctx = canvas.getContext("2d")!;336 const cx0 = mt.tx * cpt, cy0 = mt.ty * cpt; // cell origin of the map tile337 const statusOfCell = (id: number): TileStatus | null => {338 const a = geo.ownerStart[id], b = geo.ownerStart[id + 1];339 if (a === b) return null;340 let best: TileStatus = "hidden";341 for (let i = a; i < b; i++) {342 const st = s.tiles[geo.ownerList[i]];343 if (RANK[st] > RANK[best]) best = st;344 }345 return best;346 };347 let cellWilt = 0;348 const huesOfCell = (id: number, out: number[]) => {349 out.length = 0;350 cellWilt = 0;351 for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {352 const t = geo.ownerList[i];353 const h = f.tileHue[t];354 if (h >= 0) {355 out.push(h);356 if (f.tileWilt[t] > cellWilt) cellWilt = f.tileWilt[t];357 }358 }359 if (out.length > 1) out.sort((p, q) => p - q);360 };361 // unreached: every owner is a hidden tile no territory has reached362 const unreachedCell = (id: number): boolean => {363 for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {364 const t = geo.ownerList[i];365 if (f.reached[t] === 1 || s.tiles[t] !== "hidden") return false;366 }367 return true;368 };369 const fog = level <= 1 && f.final ? fogFill(theme) : null;370 const hues: number[] = [];371 const fillOf = (id: number): string | null => {372 const st = statusOfCell(id);373 const c = s.cells[id];374 if (st === null) {375 if (!c) return null; // a hole: the page shows through376 return scene.fill([], "solved", true); // ownerless known cell (v2 filler)377 }378 if (st === "hidden" && !c && unreachedCell(id)) return fog; // fog: void, unless final379 huesOfCell(id, hues);380 return scene.fill(hues, st, !!c, cellWilt);381 };382383 if (ppc <= 2 * dpr) {384 // small cells: write pixels directly385 const img = ctx.createImageData(size, size);386 const d = img.data;387 const p = Math.round(ppc);388 for (let y = y0; y <= y1; y++) {389 for (let x = x0; x <= x1; x++) {390 const fill = fillOf(y * G + x);391 if (fill === null) continue;392 const [r, g, b] = rgbOf(fill);393 const px0 = Math.round((x - cx0) * ppc),394 py0 = Math.round((y - cy0) * ppc);395 for (let dy = 0; dy < p; dy++) {396 const row = (py0 + dy) * size;397 if (py0 + dy >= size) break;398 for (let dx = 0; dx < p; dx++) {399 if (px0 + dx >= size) break;400 const i = (row + px0 + dx) * 4;401 d[i] = r;402 d[i + 1] = g;403 d[i + 2] = b;404 d[i + 3] = 255;405 }406 }407 }408 }409 ctx.putImageData(img, 0, 0);410 } else {411 for (let y = y0; y <= y1; y++) {412 for (let x = x0; x <= x1; x++) {413 const fill = fillOf(y * G + x);414 if (fill === null) continue;415 ctx.fillStyle = fill;416 ctx.fillRect((x - cx0) * ppc, (y - cy0) * ppc, ppc + 0.5, ppc + 0.5);417 }418 }419 }420 if (level === 0) return { canvas, ppc };421422 // L1: shape outlines in ink, hairline. A revealed tile (the work in423 // progress) gets its agent's colour; a solved one a plain ink edge; a424 // hidden one none, except at the end of a finished game, when the425 // never-explored tiles show faintly.426 const tiles = scene.tilesIn(x0, y0, x1, y1);427 ctx.lineJoin = "miter";428 ctx.lineCap = "butt";429 for (const id of tiles) {430 const st = s.tiles[id];431 const t = s.manifest!.tiles[id];432 if (st === "hidden") {433 if (level >= 2 && f.reached[id] === 0 && f.final) {434 ctx.strokeStyle = theme.line;435 ctx.globalAlpha = 0.18;436 ctx.lineWidth = Math.max(0.6, Math.min(1, ppc * 0.03));437 traceOutline(ctx, t, scene.masks[id], ppc, -cx0 * ppc, -cy0 * ppc);438 }439 continue;440 }441 const h = f.tileHue[id];442 const revealed = st === "revealed";443 ctx.strokeStyle = revealed && h >= 0444 ? agentColour(h, theme, true, WILT_CHROMA[f.tileWilt[id]])445 : theme.line;446 ctx.globalAlpha = revealed ? 0.95 : 0.55;447 ctx.lineWidth = Math.max(448 0.7,449 Math.min(1.5, ppc * (revealed ? 0.07 : 0.04)),450 );451 traceOutline(ctx, t, scene.masks[id], ppc, -cx0 * ppc, -cy0 * ppc);452 }453 ctx.globalAlpha = 1;454 // counting marks: a small square in the owner's colour on every point455 // of a solved tile, as a counting diagram marks territory456 if (ppc >= 3) {457 const q = Math.max(1, ppc * 0.3);458 for (const id of tiles) {459 if (s.tiles[id] !== "solved") continue;460 const h = f.tileHue[id];461 if (h < 0) continue;462 ctx.fillStyle = agentColour(h, theme, true, WILT_CHROMA[f.tileWilt[id]]);463 const t = s.manifest!.tiles[id];464 const mask = scene.masks[id];465 for (let ly = 0; ly < t.h; ly++) {466 const gy = t.y0 + ly;467 if (gy < y0 || gy > y1) continue;468 for (let lx = 0; lx < t.w; lx++) {469 const gx = t.x0 + lx;470 if (gx < x0 || gx > x1 || !mask[ly * t.w + lx]) continue;471 ctx.fillRect(472 (gx - cx0 + 0.5) * ppc - q / 2,473 (gy - cy0 + 0.5) * ppc - q / 2,474 q,475 q,476 );477 }478 }479 }480 }481 ctx.globalAlpha = 1;482 if (level === 1) return { canvas, ppc };483484 // L3: the board lines of every revealed or solved tile, through the485 // cell centres along each of its lines, as a Go board is ruled486 if (level >= 3) {487 ctx.strokeStyle = theme.line;488 ctx.globalAlpha = 0.5;489 ctx.lineWidth = Math.max(0.6, ppc * 0.035);490 ctx.beginPath();491 for (const id of tiles) {492 if (s.tiles[id] === "hidden") continue;493 const t = s.manifest!.tiles[id];494 for (const run of tileLines(t, scene.masks[id])) {495 const a = run[0], b = run[run.length - 1];496 ctx.moveTo(497 (t.x0 + a % t.w + 0.5 - cx0) * ppc,498 (t.y0 + Math.floor(a / t.w) + 0.5 - cy0) * ppc,499 );500 ctx.lineTo(501 (t.x0 + b % t.w + 0.5 - cx0) * ppc,502 (t.y0 + Math.floor(b / t.w) + 0.5 - cy0) * ppc,503 );504 }505 }506 ctx.stroke();507 ctx.globalAlpha = 1;508 }509510 // L2: stones. A 1 is black, a 0 white; a given carries a small mark.511 const r = ppc * 0.44;512 for (let y = y0; y <= y1; y++) {513 for (let x = x0; x <= x1; x++) {514 const id = y * G + x;515 const c = s.cells[id];516 if (!c) continue;517 stone(518 ctx,519 (x - cx0 + 0.5) * ppc,520 (y - cy0 + 0.5) * ppc,521 r,522 c.v as 0 | 1,523 theme,524 c.kind !== "commit",525 );526 }527 }528 return { canvas, ppc };529}530531/** Outline of a tile: every edge between one of its cells and a cell that532 * is not its own, at `ppc` pixels per cell with a pixel offset. */533function traceOutline(534 ctx: CanvasRenderingContext2D,535 t: TileSpec,536 mask: Uint8Array,537 ppc: number,538 ox: number,539 oy: number,540) {541 const w = t.w, h = t.h;542 const inside = (x: number, y: number) =>543 x >= 0 && y >= 0 && x < w && y < h && mask[y * w + x] === 1;544 ctx.beginPath();545 for (let y = 0; y < h; y++) {546 for (let x = 0; x < w; x++) {547 if (!inside(x, y)) continue;548 const px = ox + (t.x0 + x) * ppc, py = oy + (t.y0 + y) * ppc;549 if (!inside(x, y - 1)) {550 ctx.moveTo(px, py);551 ctx.lineTo(px + ppc, py);552 }553 if (!inside(x, y + 1)) {554 ctx.moveTo(px, py + ppc);555 ctx.lineTo(px + ppc, py + ppc);556 }557 if (!inside(x - 1, y)) {558 ctx.moveTo(px, py);559 ctx.lineTo(px, py + ppc);560 }561 if (!inside(x + 1, y)) {562 ctx.moveTo(px + ppc, py);563 ctx.lineTo(px + ppc, py + ppc);564 }565 }566 }567 ctx.stroke();568}569570/** Lines of a tile as local cell-index runs, horizontal then vertical. */571function tileLines(t: TileSpec, mask: Uint8Array): number[][] {572 const out: number[][] = [];573 for (let y = 0; y < t.h; y++) {574 let run: number[] = [];575 for (let x = 0; x <= t.w; x++) {576 if (x < t.w && mask[y * t.w + x]) {577 run.push(y * t.w + x);578 continue;579 }580 if (run.length) out.push(run);581 run = [];582 }583 }584 for (let x = 0; x < t.w; x++) {585 let run: number[] = [];586 for (let y = 0; y <= t.h; y++) {587 if (y < t.h && mask[y * t.w + x]) {588 run.push(y * t.w + x);589 continue;590 }591 if (run.length) out.push(run);592 run = [];593 }594 }595 return out;596}597598/** A crown on each seat reserved for yesterday's winner (L1 and up), in ink. */599function drawHonours(600 ctx: CanvasRenderingContext2D,601 s: State,602 view: View,603 theme: Theme,604) {605 if (!s.honours.length || !s.manifest) return;606 const { scale, ox, oy } = view;607 for (const h of s.honours) {608 const t = s.manifest.tiles[h.seed_tile];609 if (!t) continue;610 const cx = ox + (t.x0 + t.w / 2) * scale;611 const cy = oy + (t.y0 + t.h / 2) * scale;612 const r = Math.max(6, Math.min(18, scale * 1.2));613 ctx.save();614 ctx.fillStyle = theme.ink;615 ctx.strokeStyle = theme.bg;616 ctx.lineWidth = 1;617 ctx.beginPath();618 ctx.moveTo(cx - r, cy + r * 0.6);619 ctx.lineTo(cx - r, cy - r * 0.3);620 ctx.lineTo(cx - r * 0.5, cy + r * 0.1);621 ctx.lineTo(cx, cy - r * 0.7);622 ctx.lineTo(cx + r * 0.5, cy + r * 0.1);623 ctx.lineTo(cx + r, cy - r * 0.3);624 ctx.lineTo(cx + r, cy + r * 0.6);625 ctx.closePath();626 ctx.fill();627 ctx.stroke();628 ctx.restore();629 }630}631632/** The last-move ring on the stones the latest commit placed (L2+), and633 * the placement snap: for SNAP_MS after a change the stones grow in. */634function drawLastMove(635 ctx: CanvasRenderingContext2D,636 f: Frame,637 view: View,638 theme: Theme,639 snap: number, // 0..1 progress of the placement motion, 1 when settled640) {641 const s = f.s, G = s.G;642 if (!f.lastCells.length) return;643 const { scale, ox, oy } = view;644 const r = scale * 0.44;645 const k = 0.55 + 0.45 * (1 - Math.pow(1 - snap, 3));646 ctx.save();647 for (const id of f.lastCells) {648 const c = s.cells[id];649 if (!c) continue;650 const x = id % G, y = Math.floor(id / G);651 const cx = ox + (x + 0.5) * scale, cy = oy + (y + 0.5) * scale;652 if (snap < 1) {653 // the board under it, then the stone at its current size654 ctx.beginPath();655 ctx.arc(cx, cy, r + 1, 0, Math.PI * 2);656 ctx.fillStyle = theme.panel;657 ctx.fill();658 stone(ctx, cx, cy, r * k, c.v as 0 | 1, theme, c.kind !== "commit");659 }660 // the ring, in the stone's contrast661 ctx.beginPath();662 ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2);663 ctx.strokeStyle = c.v === 1 ? theme.stone0 : theme.stone1;664 ctx.lineWidth = Math.max(1, r * 0.14);665 ctx.stroke();666 }667 ctx.restore();668}669670/** The tile under the viewport centre, if anyone has revealed it. */671function focusTile(672 f: Frame,673 view: View,674 W: number,675 H: number,676): number | null {677 const s = f.s, geo = s.geo!, G = s.G;678 const { scale, ox, oy } = view;679 const cxCell = Math.floor((W / 2 - ox) / scale);680 const cyCell = Math.floor((H / 2 - oy) / scale);681 if (cxCell < 0 || cyCell < 0 || cxCell >= G || cyCell >= G) return null;682 const id = cyCell * G + cxCell;683 const owners: number[] = [];684 for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {685 owners.push(geo.ownerList[i]);686 }687 if (!owners.length) return null;688 owners.sort((a, b) => RANK[s.tiles[b]] - RANK[s.tiles[a]]);689 const tid = owners.find((t) => s.tiles[t] === "revealed") ??690 owners.find((t) => s.tiles[t] === "solved");691 return tid ?? null;692}693694/** Per-frame: a dead agent's cross on its last solved tile, above the stones. */695function drawDeaths(696 ctx: CanvasRenderingContext2D,697 f: Frame,698 view: View,699 theme: Theme,700) {701 const s = f.s;702 const { scale, ox, oy } = view;703 for (const an of s.analysis.agents) {704 if (an.health.state !== "dead" || an.last_solved === null) continue;705 const t = s.manifest!.tiles[an.last_solved];706 if (!t) continue;707 const cx = ox + (t.x0 + t.w / 2) * scale,708 cy = oy + (t.y0 + t.h / 2) * scale;709 const r = Math.max(4, Math.min(t.w, t.h) * scale * 0.18);710 ctx.save();711 ctx.strokeStyle = theme.danger;712 ctx.globalAlpha = 0.95;713 ctx.lineWidth = Math.max(1.5, r * 0.22);714 ctx.beginPath();715 ctx.moveTo(cx - r, cy - r);716 ctx.lineTo(cx + r, cy + r);717 ctx.moveTo(cx + r, cy - r);718 ctx.lineTo(cx - r, cy + r);719 ctx.stroke();720 ctx.restore();721 }722}723724/** L3 overlay: the frame of the focus tile and its pins. */725function drawFocus(726 ctx: CanvasRenderingContext2D,727 f: Frame,728 scene: Scene,729 view: View,730 tid: number,731 theme: Theme,732) {733 const s = f.s, geo = s.geo!, G = s.G;734 const { scale, ox, oy } = view;735 const t = s.manifest!.tiles[tid];736 const mask = scene.masks[tid];737 ctx.save();738 // the frame: a hairline box around the tile with coordinates outside it,739 // letters along the top, numbers down the left, as a board is lettered740 ctx.strokeStyle = theme.line;741 ctx.globalAlpha = 0.9;742 ctx.lineWidth = 1;743 traceOutline(ctx, t, mask, scale, ox, oy);744 // no coordinates: they collide with the stones at this scale; the745 // frame and the pins are enough to say which tile this is746 // pins: the overlaps an alternative solution differs on, marked on747 // their shared cells with a small triangle748 ctx.fillStyle = theme.ink;749 ctx.globalAlpha = 0.85;750 for (const oi of t.pins ?? []) {751 const o = t.overlaps[oi];752 if (!o) continue;753 const other = new Set(geo.cellsOf[o.with]);754 for (const c of geo.cellsOf[tid]) {755 if (!other.has(c)) continue;756 if (s.cells[c]) continue; // a stone sits there already757 const x = c % G, y = Math.floor(c / G);758 const cx = ox + (x + 0.5) * scale, cy = oy + (y + 0.5) * scale;759 const r = scale * 0.16;760 ctx.beginPath();761 ctx.moveTo(cx, cy - r);762 ctx.lineTo(cx + r, cy + r * 0.8);763 ctx.lineTo(cx - r, cy + r * 0.8);764 ctx.closePath();765 ctx.fill();766 }767 }768 ctx.restore();769}770771// ---------------------------------------------------------------- compose772773type Perf = { frames: number[]; redraws: number[]; visible: number[] };774775function compose(776 canvas: HTMLCanvasElement,777 f: Frame,778 view: View,779 scene: Scene,780 cache: TileCache<Raster>,781 log: ChangeLog,782 theme: Theme,783 perf: Perf,784 snap: number,785): { redrawn: number; visible: number; level: Level; focus: number | null } {786 const ctx = canvas.getContext("2d")!;787 const dpr = globalThis.devicePixelRatio || 1;788 const W = canvas.clientWidth, H = canvas.clientHeight;789 if (790 canvas.width !== Math.round(W * dpr) ||791 canvas.height !== Math.round(H * dpr)792 ) {793 canvas.width = Math.round(W * dpr);794 canvas.height = Math.round(H * dpr);795 }796 const t0 = performance.now();797 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);798 ctx.fillStyle = theme.bg;799 ctx.fillRect(0, 0, W, H);800 const s = f.s;801 if (!s.manifest || !s.geo) {802 ctx.fillStyle = theme.muted;803 ctx.font = `13px "Noto Serif JP", serif`;804 ctx.fillText("waiting for a game", 16, 24);805 return { redrawn: 0, visible: 0, level: 0, focus: null };806 }807 scene.prepare(s.geo, s.manifest.tiles, theme);808 const tiles = occupiedTilesForView(s.G, view, W, H, scene.occ);809 let redrawn = 0;810 ctx.imageSmoothingEnabled = true;811 ctx.imageSmoothingQuality = "high";812 for (const mt of tiles) {813 let r = cache.get(mt.cacheKey, mt.key, s.revision, log);814 if (!r) {815 r = renderMapTile(mt, f, scene, dpr);816 cache.set(mt.cacheKey, r, s.revision);817 redrawn++;818 }819 const { x, y, size } = tileScreenRect(mt, view);820 // snap to whole device pixels so neighbouring map tiles abut exactly821 const xr = Math.round(x * dpr) / dpr, yr = Math.round(y * dpr) / dpr;822 const wr = Math.round((x + size) * dpr) / dpr - xr;823 const hr = Math.round((y + size) * dpr) / dpr - yr;824 ctx.drawImage(825 r.canvas,826 0,827 0,828 r.canvas.width,829 r.canvas.height,830 xr,831 yr,832 wr,833 hr,834 );835 }836 const level = levelFor(view.scale);837 if (level >= 1) drawHonours(ctx, f.s, view, theme);838 let focus: number | null = null;839 if (level >= 2) drawLastMove(ctx, f, view, theme, snap);840 if (level === 3) {841 focus = focusTile(f, view, W, H);842 drawDeaths(ctx, f, view, theme);843 if (focus !== null) drawFocus(ctx, f, scene, view, focus, theme);844 }845 const dt = performance.now() - t0;846 perf.frames.push(dt);847 perf.redraws.push(redrawn);848 perf.visible.push(tiles.length);849 if (perf.frames.length > 240) {850 perf.frames.shift();851 perf.redraws.shift();852 perf.visible.shift();853 }854 return { redrawn, visible: tiles.length, level, focus };855}856857/** Frame the generated tiles (the cluster), not the G x G field: a daily858 * field is G=1000 with a small cluster at the centre that grows. */859function fitView(s: State, W: number, H: number): View {860 const box = s.manifest ? boxOf(s.manifest.tiles) : null;861 if (!box) {862 const G = Math.max(1, s.G);863 const scale = (Math.min(W, H) * 0.94) / G;864 return { scale, ox: (W - G * scale) / 2, oy: (H - G * scale) / 2 };865 }866 return fitBox(box, W, H);867}868869// ---------------------------------------------------------------- clocks870871type Clock =872 | { kind: "main"; secs: number }873 | { kind: "byo"; periods: number; secs: number }874 | { kind: "timeout" };875876/** Health as a Go clock: main time while healthy (the distance to the877 * warning line), byo-yomi periods at warning (the distance to death),878 * timeout when dead. A reading of the rate, not a running timer. */879export function clockOf(h: Health): Clock {880 if (h.state === "dead") return { kind: "timeout" };881 if (h.state === "warning") {882 const k = Math.max(883 0,884 Math.min(1, (h.rate - HEALTH_WARN) / (HEALTH_DEATH - HEALTH_WARN)),885 );886 const left = 3 - k * 3; // periods left, fractional887 const periods = Math.max(1, Math.ceil(left));888 const frac = left - Math.floor(left);889 return {890 kind: "byo",891 periods,892 secs: Math.round((frac === 0 ? 1 : frac) * PERIOD),893 };894 }895 const k = Math.max(0, Math.min(1, h.rate / HEALTH_WARN));896 return { kind: "main", secs: Math.round(MAIN_TIME * (1 - k)) };897}898899function mmss(secs: number): string {900 const m = Math.floor(secs / 60), s = secs % 60;901 return `${m}:${String(s).padStart(2, "0")}`;902}903904// ---------------------------------------------------------------- timeline905906function Timeline(props: {907 events: GameEvent[];908 deaths: Death[];909 rev: number;910 max: number;911 live: boolean;912 onScrub: (rev: number) => void;913 theme: Theme;914}) {915 const ref = useRef<HTMLCanvasElement>(null);916 const marks = useMemo(917 () => markers(props.events, props.deaths),918 [props.events, props.deaths],919 );920 const dens = useMemo(() => density(props.events, 240), [props.events]);921 useEffect(() => {922 const c = ref.current;923 if (!c) return;924 const dpr = globalThis.devicePixelRatio || 1;925 const W = c.clientWidth, H = c.clientHeight;926 c.width = Math.round(W * dpr);927 c.height = Math.round(H * dpr);928 const ctx = c.getContext("2d")!;929 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);930 ctx.clearRect(0, 0, W, H);931 const th = props.theme;932 const n = Math.max(1, props.max);933 const xOf = (rev: number) => (rev / n) * (W - 2) + 1;934 // the rule, and activity as ticks below it935 ctx.fillStyle = th.line;936 ctx.globalAlpha = 0.9;937 ctx.fillRect(0, 14, W, 1);938 ctx.globalAlpha = 0.35;939 const bw = W / dens.length;940 for (let i = 0; i < dens.length; i++) {941 const h = dens[i] * (H - 18);942 if (h > 0) ctx.fillRect(i * bw, 16, Math.max(1, bw - 0.6), h);943 }944 ctx.globalAlpha = 1;945 // markers on the rule, in kifu notation: a stone for a join, a filled946 // mark for a tile solved, two stones for a meeting, a chevron where947 // the field grew, a cross for a death, a bar at the end948 for (const m of marks) {949 const x = xOf(m.rev);950 ctx.fillStyle = th.ink;951 ctx.strokeStyle = th.ink;952 ctx.lineWidth = 1;953 if (m.kind === "join") {954 ctx.beginPath();955 ctx.arc(x, 8, 3, 0, Math.PI * 2);956 ctx.fillStyle = th.bg;957 ctx.fill();958 ctx.stroke();959 } else if (m.kind === "solved") {960 ctx.fillRect(x - 0.5, 10, 1, 5);961 } else if (m.kind === "meet") {962 ctx.beginPath();963 ctx.arc(x - 2.5, 8, 2.5, 0, Math.PI * 2);964 ctx.fill();965 ctx.beginPath();966 ctx.arc(x + 2.5, 8, 2.5, 0, Math.PI * 2);967 ctx.fillStyle = th.bg;968 ctx.fill();969 ctx.stroke();970 } else if (m.kind === "death") {971 ctx.strokeStyle = th.danger;972 ctx.lineWidth = 1.5;973 ctx.beginPath();974 ctx.moveTo(x - 3.5, 4.5);975 ctx.lineTo(x + 3.5, 11.5);976 ctx.moveTo(x + 3.5, 4.5);977 ctx.lineTo(x - 3.5, 11.5);978 ctx.stroke();979 } else if (m.kind === "grow") {980 ctx.beginPath();981 ctx.moveTo(x - 4, 12);982 ctx.lineTo(x, 4);983 ctx.lineTo(x + 4, 12);984 ctx.stroke();985 } else if (m.kind === "guess-run") {986 ctx.fillStyle = th.danger;987 ctx.fillRect(x - 0.5, 4, 1, 7);988 } else {989 ctx.fillRect(x - 1, 2, 2, 12);990 }991 }992 // playhead993 const x = xOf(Math.max(0, props.rev));994 ctx.fillStyle = props.live ? th.danger : th.ink;995 ctx.fillRect(x - 1, 0, 2, H);996 }, [997 props.events,998 props.rev,999 props.max,1000 props.live,1001 props.theme,1002 marks,1003 dens,1004 ]);1005 const pick = (e: PointerEvent) => {1006 const c = ref.current;1007 if (!c || props.max <= 0) return;1008 const rect = c.getBoundingClientRect();1009 const f = Math.max(1010 0,1011 Math.min(1, (e.clientX - rect.left - 1) / (rect.width - 2)),1012 );1013 let rev = Math.round(f * props.max);1014 const px = rect.width / props.max;1015 for (const m of marks) {1016 if (Math.abs((m.rev - rev) * px) <= 4) {1017 rev = m.rev;1018 break;1019 }1020 }1021 props.onScrub(rev);1022 };1023 const dragging = useRef(false);1024 return (1025 <canvas1026 ref={ref}1027 class="rule-strip"1028 title="the day: click or drag to scrub; stones are joins, filled marks tiles solved, two stones a meeting, a chevron the field growing, a cross a death"1029 onPointerDown={(e) => {1030 dragging.current = true;1031 (e.currentTarget as HTMLCanvasElement).setPointerCapture(e.pointerId);1032 pick(e as unknown as PointerEvent);1033 }}1034 onPointerMove={(e) => {1035 if (dragging.current) pick(e as unknown as PointerEvent);1036 }}1037 onPointerUp={() => {1038 dragging.current = false;1039 }}1040 />1041 );1042}10431044// ---------------------------------------------------------------- styles10451046// The chrome is not selectable (a board, cards and controls are not text1047// to copy); the move list, the tile detail and the table are.1048const STYLE = `1049.match{display:grid;grid-template-columns:minmax(0,1fr) 360px;grid-template-rows:minmax(0,1fr) auto auto;height:100%;background:var(--bg);user-select:none;-webkit-user-select:none}1050.match .pane{user-select:text;-webkit-user-select:text}1051.board{position:relative;grid-column:1;grid-row:1;min-height:0}1052.board canvas.field{width:100%;height:100%;display:block;cursor:grab;touch-action:none}1053.chip{position:absolute;top:10px;right:12px;font-size:12px;color:var(--text);background:var(--bg);border:1px solid var(--rule);padding:2px 8px;pointer-events:none}1054.chip b{font-weight:600}1055.counting{grid-column:1;grid-row:2;display:flex;align-items:baseline;border-top:1px solid var(--rule);padding:6px 12px;font-size:13px;overflow-x:auto;white-space:nowrap;background:var(--chrome)}1056.counting .lbl{font-variant-caps:small-caps;letter-spacing:.06em;color:var(--muted);margin-right:14px}1057.counting .p{display:inline-flex;align-items:baseline;gap:6px;margin-right:18px}1058.counting .p b{font-weight:600}1059.counting .p.lead b{text-decoration:underline;text-underline-offset:3px}1060.st{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid var(--rule);vertical-align:-1px}1061.dock{grid-column:2;grid-row:1 / span 2;border-left:2px solid var(--rule);display:flex;flex-direction:column;min-height:0;background:var(--chrome)}1062.dock .head{display:flex;align-items:baseline;gap:10px;padding:10px 14px 6px;border-bottom:1px solid var(--rule)}1063.dock .head .day{font-size:15px;font-weight:600}1064.dock .head .mv{margin-left:auto;font-size:13px;color:var(--text-dim)}1065.players{display:flex;flex-direction:column;border-bottom:1px solid var(--rule)}1066.player{display:grid;grid-template-columns:22px minmax(0,1fr) auto;column-gap:10px;row-gap:1px;align-items:center;padding:8px 14px;border-bottom:1px solid var(--hair);position:relative}1067.player:last-child{border-bottom:0}1068.player .av{width:18px;height:18px;border-radius:50%;border:1px solid var(--rule);grid-row:1 / span 2}1069.player .name{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}1070.player .rank{font-size:11.5px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}1071.player .nums{text-align:right;font-size:12.5px;line-height:1.3;white-space:nowrap}1072.player .nums b{font-weight:600}1073.player .clock{grid-column:2 / span 2;display:flex;gap:8px;align-items:baseline;font-size:12px;color:var(--text-dim);margin-top:2px}1074.player .clock b{font-weight:600;color:var(--text)}1075.player .clock.byo b{color:var(--danger)}1076.player .periods i{display:inline-block;width:7px;height:7px;border:1px solid var(--rule);margin-right:3px;vertical-align:-1px}1077.player .periods i.left{background:var(--danger);border-color:var(--danger)}1078.player.tomove{background:var(--text);color:var(--bg)}1079.player.tomove .rank,.player.tomove .clock,.player.tomove .nums{color:var(--bg)}1080.player.tomove .clock b{color:var(--bg)}1081.player.tomove .av,.player.tomove .periods i{border-color:var(--bg)}1082.player .tag{position:absolute;right:14px;top:5px;font-size:10px;font-variant-caps:small-caps;letter-spacing:.08em}1083.player.dead .name{text-decoration:line-through}1084.tabs{display:flex;border-bottom:1px solid var(--rule)}1085.tabs button{flex:1;background:none;border:0;border-right:1px solid var(--hair);padding:6px 4px;font-size:12px;font-variant-caps:small-caps;letter-spacing:.06em;cursor:pointer;color:var(--text-dim);border-radius:0}1086.tabs button:last-child{border-right:0}1087.tabs button.on{color:var(--text);box-shadow:inset 0 -2px 0 var(--rule)}1088.pane{flex:1;min-height:0;overflow:auto;font-size:12.5px}1089.moves{display:grid;grid-template-columns:auto 14px minmax(0,1fr);column-gap:8px;padding:6px 14px;align-items:baseline}1090.moves .n{color:var(--muted);text-align:right}1091.moves .who{color:var(--text-dim);margin-right:5px}1092.moves .what{color:var(--text)}1093.moves .note{grid-column:1 / span 3;font-style:italic;color:var(--text-dim);padding:3px 0}1094.moves .note.danger{color:var(--danger)}1095.moves .cur{font-weight:600}1096.tile{padding:10px 14px;line-height:1.7}1097.tile .k{color:var(--muted);font-variant-caps:small-caps;letter-spacing:.06em;margin-right:8px}1098table.tt{border-collapse:collapse;width:100%;font-size:12px}1099table.tt th{font-weight:600;font-variant-caps:small-caps;letter-spacing:.06em;text-align:left;padding:6px 6px 4px;border-bottom:1px solid var(--rule);color:var(--text-dim)}1100table.tt td{padding:4px 6px;border-bottom:1px solid var(--hair);vertical-align:baseline}1101table.tt td.n,table.tt th.n{text-align:right}1102table.tt .cap{padding:10px 6px 4px;font-variant-caps:small-caps;letter-spacing:.06em;color:var(--muted);border:0}1103.result{margin:10px 14px 0;border:2px solid var(--rule);padding:10px 12px;background:var(--bg)}1104.result .r1{font-variant-caps:small-caps;letter-spacing:.1em;font-size:12px;color:var(--text-dim)}1105.result .r2{font-size:17px;font-weight:600;margin:2px 0 6px}1106.controls{display:flex;align-items:center;gap:8px;padding:6px 12px;border-top:2px solid var(--rule);background:var(--chrome);grid-column:1 / span 2;grid-row:3}1107.controls .rev{color:var(--muted);font-size:12px;min-width:64px;white-space:nowrap}1108.controls canvas.rule-strip{flex:1;height:34px;display:block;cursor:pointer;min-width:60px}1109.controls .perf{color:var(--muted);font-size:11px;white-space:nowrap}1110.sheet{display:none}1111@media (max-width:720px){1112 .match{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto auto}1113 .board{grid-row:2}1114 .counting{grid-row:3;padding:4px 10px;font-size:12px}1115 .dock{grid-column:1;grid-row:1;border-left:0;border-bottom:2px solid var(--rule)}1116 .dock .pane,.dock .tabs,.dock .head,.dock .result{display:none}1117 .dock .players{border-bottom:0;flex-direction:row;overflow-x:auto}1118 .player{flex:0 0 220px;padding:6px 10px;border-bottom:0;border-right:1px solid var(--hair)}1119 .sheet{position:fixed;left:0;right:0;bottom:0;z-index:5;background:var(--chrome);border-top:2px solid var(--rule);display:flex;flex-direction:column;height:46px;transition:height .25s ease}1120 .sheet.open{height:52vh}1121 .sheet .handle{display:flex;align-items:center;justify-content:center;height:16px;cursor:pointer;flex:none}1122 .sheet .handle i{width:36px;height:3px;background:var(--rule);display:block}1123 .controls{grid-row:4;padding-bottom:52px;gap:6px}1124 .controls select{max-width:104px}1125 .chip{top:6px;right:8px;font-size:11px}1126 .controls .perf{display:none}1127}1128`;11291130// ---------------------------------------------------------------- island11311132type Tab = "moves" | "tile" | "table";11331134export default function Viewer(1135 props: {1136 replay: string | null;1137 theme: "dark" | "light" | null;1138 /** follow this game live (the daily); null follows the newest game */1139 live?: string | null;1140 /** the day this game is (for the head and the result card) */1141 day?: string | null;1142 /** an initial zoom in pixels per cell, centred on the cluster (deep links) */1143 zoom?: number | null;1144 },1145) {1146 const canvasRef = useRef<HTMLCanvasElement>(null);1147 const paneRef = useRef<HTMLDivElement>(null);1148 const scene = useRef(new Scene());1149 // ~40 map tiles are visible at once; 160 keeps one level plus its1150 // neighbours warm without holding hundreds of megabytes of canvases1151 const cache = useRef(new TileCache<Raster>(160));1152 const log = useRef(new ChangeLog());1153 const perf = useRef<Perf>({ frames: [], redraws: [], visible: [] });1154 const [events, setEvents] = useState<GameEvent[]>([]);1155 const [rev, setRev] = useState(-1);1156 const [live, setLive] = useState(props.replay === null);1157 const [gameId, setGameId] = useState<string | null>(null);1158 const [view, setView] = useState<View | null>(null);1159 const [theme, setTheme] = useState<Theme>(LIGHT);1160 const [playing, setPlaying] = useState(false);1161 const [speed, setSpeed] = useState<Speed>(4);1162 const [follow, setFollow] = useState<string>("");1163 const [tab, setTab] = useState<Tab>("table");1164 const [sheet, setSheet] = useState(false);1165 const [drawInfo, setDrawInfo] = useState({1166 redrawn: 0,1167 visible: 0,1168 level: 0 as Level,1169 focus: null as number | null,1170 });11711172 useEffect(() => {1173 if (props.theme) {1174 document.documentElement.dataset.theme = props.theme;1175 setTheme(props.theme === "dark" ? DARK : LIGHT);1176 return;1177 }1178 const mq = globalThis.matchMedia?.("(prefers-color-scheme: dark)");1179 if (!mq) return;1180 const apply = () => setTheme(mq.matches ? DARK : LIGHT);1181 apply();1182 mq.addEventListener("change", apply);1183 return () => mq.removeEventListener("change", apply);1184 }, [props.theme]);1185 const eventsRef = useRef<GameEvent[]>([]);11861187 // event source1188 useEffect(() => {1189 if (props.replay) {1190 fetch(`/log/${props.replay}`).then((r) => r.text()).then((t) => {1191 const evs = parseLog(t);1192 setGameId(props.replay);1193 setEvents(evs);1194 setRev(evs.length - 1);1195 });1196 return;1197 }1198 const es = new EventSource(1199 props.live ? `/events/${props.live}` : "/events/live",1200 );1201 es.addEventListener("game", (m) => {1202 const { game_id } = JSON.parse((m as MessageEvent).data);1203 setGameId(game_id);1204 eventsRef.current = [];1205 setEvents([]);1206 setRev(-1);1207 setView(null);1208 setLive(true);1209 });1210 es.addEventListener("event", (m) => {1211 const ev = JSON.parse((m as MessageEvent).data) as GameEvent;1212 eventsRef.current = [...eventsRef.current, ev];1213 setEvents(eventsRef.current);1214 });1215 return () => es.close();1216 }, [props.replay, props.live]);12171218 // checkpointed fold: a state every K revisions, folded forward on demand1219 const cps = useRef(new Checkpoints());1220 cps.current.feed(events);1221 useEffect(() => {1222 if (live) setRev(events.length - 1);1223 }, [events, live]);12241225 const state: State = useMemo(1226 () => (events.length ? cps.current.at(rev < 0 ? 0 : rev) : emptyState()),1227 [events, rev],1228 );1229 const final: State = useMemo(1230 () => (events.length ? cps.current.at(events.length - 1) : state),1231 [events],1232 );1233 const deaths = useMemo(1234 () =>1235 final.agents.filter((a) => a.health.died_at !== null).map((a) => ({1236 rev: a.health.died_at!,1237 agent: a.id,1238 name: labelsOf(final.agents).get(a.id) ?? a.id,1239 })),1240 [final],1241 );12421243 // a hue, once shown, never changes1244 const huesRef = useRef(new Map<string, number>());1245 const hues = useMemo(() => {1246 for (const h of final.honours) {1247 const a = final.agents.find((x) =>1248 x.id === h.agent || x.id === h.user || x.name === h.name1249 );1250 if (a && !huesRef.current.has(a.id)) huesRef.current.set(a.id, h.hue);1251 }1252 const h = agentHues(final.agents, final.geo, 3, huesRef.current);1253 huesRef.current = h;1254 return h;1255 }, [final.agents, final.geo, final.honours]);1256 const ended = events.length > 0 &&1257 events[events.length - 1]?.kind === "end";1258 const atEnd = ended && state.revision === events.length - 1;1259 const frame = useMemo(1260 () => frameOf(state, hues, events, atEnd),1261 [state, hues, events, atEnd],1262 );12631264 // change log: which map tiles each event touched (fed once per event)1265 const fed = useRef(0);1266 const logKey = useRef<string>("");1267 useEffect(() => {1268 const geo = final.geo;1269 if (!geo || !final.manifest) return;1270 const key = `${final.manifest.id}|${geo.G}`;1271 if (logKey.current !== key || fed.current > events.length) {1272 logKey.current = key;1273 log.current = new ChangeLog();1274 cache.current.clear();1275 fed.current = 0;1276 }1277 for (let i = fed.current; i < events.length; i++) {1278 const ev = events[i];1279 const cells = cellsOfEvent(1280 ev as unknown as { kind: string; [k: string]: unknown },1281 geo,1282 );1283 if (cells.length) log.current.add(ev.rev, keysForCells(geo.G, cells));1284 }1285 fed.current = events.length;1286 }, [events, final.geo, final.manifest]);1287 useEffect(() => {1288 cache.current.clear();1289 }, [theme, hues]);12901291 // initial fit (or a deep-linked zoom about the cluster's centre)1292 useEffect(() => {1293 const c = canvasRef.current;1294 if (!c || !state.manifest || view) return;1295 const fit = fitView(final, c.clientWidth, c.clientHeight);1296 if (props.zoom && props.zoom > 0) {1297 const W = c.clientWidth, H = c.clientHeight;1298 const cx = (W / 2 - fit.ox) / fit.scale,1299 cy = (H / 2 - fit.oy) / fit.scale;1300 setView({1301 scale: props.zoom,1302 ox: W / 2 - cx * props.zoom,1303 oy: H / 2 - cy * props.zoom,1304 });1305 return;1306 }1307 setView(fit);1308 }, [state.manifest, view]);13091310 // the placement snap: when the revision changes, the newest stones grow1311 // in over SNAP_MS; the raster already holds them, the overlay redraws1312 // them scaled for those frames1313 const snapAt = useRef(0);1314 const lastRev = useRef(-2);1315 if (state.revision !== lastRev.current) {1316 lastRev.current = state.revision;1317 snapAt.current = performance.now();1318 }13191320 // draw1321 useEffect(() => {1322 const c = canvasRef.current;1323 if (!c) return;1324 let raf = 0;1325 const paint = () => {1326 const el = performance.now() - snapAt.current;1327 const snap = Math.max(0, Math.min(1, el / SNAP_MS));1328 const info = compose(1329 c,1330 frame,1331 view ?? fitView(final, c.clientWidth, c.clientHeight),1332 scene.current,1333 cache.current,1334 log.current,1335 theme,1336 perf.current,1337 snap,1338 );1339 setDrawInfo((d) =>1340 d.redrawn === info.redrawn && d.visible === info.visible &&1341 d.level === info.level && d.focus === info.focus1342 ? d1343 : info1344 );1345 (globalThis as unknown as { __mosaicPerf: Perf }).__mosaicPerf =1346 perf.current;1347 if (snap < 1) raf = requestAnimationFrame(paint);1348 };1349 raf = requestAnimationFrame(paint);1350 const ro = new ResizeObserver(() => requestAnimationFrame(paint));1351 ro.observe(c);1352 return () => {1353 cancelAnimationFrame(raf);1354 ro.disconnect();1355 };1356 }, [frame, view, theme]);13571358 // the move list follows the playhead1359 useEffect(() => {1360 const p = paneRef.current;1361 if (!p || tab !== "moves") return;1362 const cur = p.querySelector(".cur") as HTMLElement | null;1363 if (cur) cur.scrollIntoView({ block: "nearest" });1364 else if (live) p.scrollTop = p.scrollHeight;1365 }, [state.revision, live, tab]);13661367 // a hidden tab keeps nothing expensive1368 useEffect(() => {1369 const onVis = () => {1370 if (document.hidden) {1371 setPlaying(false);1372 cache.current.clear();1373 } else {1374 requestAnimationFrame(() => setRev((r) => r));1375 }1376 };1377 document.addEventListener("visibilitychange", onVis);1378 return () => document.removeEventListener("visibilitychange", onVis);1379 }, []);13801381 // playback1382 useEffect(() => {1383 if (!playing) return;1384 let raf = 0;1385 let last = performance.now();1386 let carry = 0;1387 const tick = (now: number) => {1388 const { steps, carry: c } = stepFor(speed, now - last, carry);1389 carry = c;1390 last = now;1391 if (steps > 0) {1392 setRev((r) => {1393 const max = events.length - 1;1394 const next = Math.min(max, r + steps);1395 if (next >= max) setPlaying(false);1396 return next;1397 });1398 }1399 raf = requestAnimationFrame(tick);1400 };1401 raf = requestAnimationFrame(tick);1402 return () => cancelAnimationFrame(raf);1403 }, [playing, speed, events.length]);14041405 // eased view changes (zoom level transitions, follow)1406 const targetRef = useRef<View | null>(null);1407 const animRef = useRef(0);1408 const viewRef = useRef<View | null>(null);1409 viewRef.current = view;1410 const easeTo = (target: View) => {1411 const from = viewRef.current;1412 if (!from) {1413 setView(target);1414 return;1415 }1416 targetRef.current = target;1417 cancelAnimationFrame(animRef.current);1418 const start = performance.now();1419 const step = (now: number) => {1420 const k = Math.min(1, (now - start) / EASE_MS);1421 const e = 1 - Math.pow(1 - k, 3);1422 const t = targetRef.current!;1423 const ls = Math.log(from.scale) +1424 (Math.log(t.scale) - Math.log(from.scale)) * e;1425 setView({1426 scale: Math.exp(ls),1427 ox: from.ox + (t.ox - from.ox) * e,1428 oy: from.oy + (t.oy - from.oy) * e,1429 });1430 if (k < 1) animRef.current = requestAnimationFrame(step);1431 };1432 animRef.current = requestAnimationFrame(step);1433 };14341435 // follow an agent: keep its last solved tile centred at L21436 useEffect(() => {1437 if (!follow) return;1438 const c = canvasRef.current;1439 const a = state.analysis.agents.find((x) => x.agent === follow);1440 if (!c || !a || a.last_solved === null || !state.manifest) return;1441 const t = state.manifest.tiles[a.last_solved];1442 const scale = 16;1443 const cx = t.x0 + t.w / 2, cy = t.y0 + t.h / 2;1444 easeTo({1445 scale,1446 ox: c.clientWidth / 2 - cx * scale,1447 oy: c.clientHeight / 2 - cy * scale,1448 });1449 }, [follow, state.revision]);14501451 // pan, wheel zoom, pinch zoom1452 const pointers = useRef(new Map<number, { x: number; y: number }>());1453 const dragRef = useRef<1454 { x: number; y: number; ox: number; oy: number } | null1455 >(null);1456 const pinchRef = useRef<1457 { d: number; scale: number; cx: number; cy: number } | null1458 >(null);1459 useEffect(() => {1460 const c = canvasRef.current;1461 if (!c) return;1462 const zoomAt = (v: View, mx: number, my: number, ns: number): View => {1463 const f = ns / v.scale;1464 return { scale: ns, ox: mx - (mx - v.ox) * f, oy: my - (my - v.oy) * f };1465 };1466 const bounds = () => {1467 const fit = fitView(final, c.clientWidth, c.clientHeight).scale;1468 return { minS: fit * 0.5, maxS: 64 };1469 };1470 const down = (e: PointerEvent) => {1471 const v = viewRef.current;1472 if (!v) return;1473 cancelAnimationFrame(animRef.current);1474 c.setPointerCapture(e.pointerId);1475 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });1476 if (pointers.current.size === 2) {1477 const [a, b] = [...pointers.current.values()];1478 const rect = c.getBoundingClientRect();1479 pinchRef.current = {1480 d: Math.hypot(a.x - b.x, a.y - b.y),1481 scale: v.scale,1482 cx: (a.x + b.x) / 2 - rect.left,1483 cy: (a.y + b.y) / 2 - rect.top,1484 };1485 dragRef.current = null;1486 } else if (e.button === 0) {1487 dragRef.current = { x: e.clientX, y: e.clientY, ox: v.ox, oy: v.oy };1488 }1489 e.preventDefault();1490 };1491 const move = (e: PointerEvent) => {1492 const v = viewRef.current;1493 if (!v) return;1494 if (pointers.current.has(e.pointerId)) {1495 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });1496 }1497 const p = pinchRef.current;1498 if (p && pointers.current.size >= 2) {1499 const [a, b] = [...pointers.current.values()];1500 const d = Math.hypot(a.x - b.x, a.y - b.y);1501 const { minS, maxS } = bounds();1502 const ns = Math.max(minS, Math.min(maxS, p.scale * (d / p.d)));1503 const rect = c.getBoundingClientRect();1504 const mx = (a.x + b.x) / 2 - rect.left,1505 my = (a.y + b.y) / 2 - rect.top;1506 const base = zoomAt(v, p.cx, p.cy, ns);1507 setView({1508 scale: ns,1509 ox: base.ox + (mx - p.cx),1510 oy: base.oy + (my - p.cy),1511 });1512 p.cx = mx;1513 p.cy = my;1514 p.scale = ns;1515 p.d = d;1516 return;1517 }1518 const d = dragRef.current;1519 if (!d) return;1520 setView({1521 scale: v.scale,1522 ox: d.ox + e.clientX - d.x,1523 oy: d.oy + e.clientY - d.y,1524 });1525 };1526 const up = (e: PointerEvent) => {1527 pointers.current.delete(e.pointerId);1528 if (pointers.current.size < 2) pinchRef.current = null;1529 dragRef.current = null;1530 };1531 const wheel = (e: WheelEvent) => {1532 const v = targetRef.current && animRef.current1533 ? targetRef.current1534 : viewRef.current;1535 if (!v || !state.manifest) return;1536 e.preventDefault();1537 const rect = c.getBoundingClientRect();1538 const mx = e.clientX - rect.left, my = e.clientY - rect.top;1539 const { minS, maxS } = bounds();1540 const k = Math.exp(-e.deltaY * 0.0015);1541 const ns = Math.max(minS, Math.min(maxS, v.scale * k));1542 const target = zoomAt(v, mx, my, ns);1543 if (levelFor(ns) !== levelFor(v.scale)) easeTo(target);1544 else {1545 cancelAnimationFrame(animRef.current);1546 animRef.current = 0;1547 setView(target);1548 }1549 };1550 c.addEventListener("pointerdown", down);1551 c.addEventListener("pointermove", move);1552 c.addEventListener("pointerup", up);1553 c.addEventListener("pointercancel", up);1554 c.addEventListener("wheel", wheel, { passive: false });1555 return () => {1556 c.removeEventListener("pointerdown", down);1557 c.removeEventListener("pointermove", move);1558 c.removeEventListener("pointerup", up);1559 c.removeEventListener("pointercancel", up);1560 c.removeEventListener("wheel", wheel);1561 };1562 }, [state.manifest, state.G]);15631564 const max = Math.max(0, events.length - 1);1565 const an = state.analysis;1566 const boards = useMemo(() => boardsOf(state), [state]);1567 const honoured = (a: AgentInfo) =>1568 final.honours.some((h) =>1569 h.agent === a.id || h.user === a.id || h.name === a.name1570 );1571 const labels = labelsOf(final.agents);1572 const players = final.agents.map((a: AgentInfo) => {1573 const s = an.agents.find((x) => x.agent === a.id);1574 const health = s?.health ?? a.health;1575 return {1576 a,1577 s,1578 label: labels.get(a.id) ?? a.id,1579 hue: hues.get(a.id) ?? 0,1580 crowned: honoured(a),1581 wilt: wiltOf(health),1582 health,1583 clock: clockOf(health),1584 toMove: frame.toMove === a.id,1585 };1586 }).sort((x, y) =>1587 Number(y.crowned) - Number(x.crowned) ||1588 (y.s?.cells ?? 0) - (x.s?.cells ?? 0)1589 );1590 const leader = players.reduce(1591 (best, p) => ((p.s?.cells ?? 0) > (best?.s?.cells ?? -1) ? p : best),1592 null as (typeof players)[number] | null,1593 );1594 const day = props.day ?? state.day ?? null;1595 const focusTileSpec = drawInfo.focus !== null && state.manifest1596 ? state.manifest.tiles[drawInfo.focus]1597 : null;1598 const solvedCount = state.tiles.filter((t) => t === "solved").length;15991600 const dockBody = (1601 <>1602 {atEnd && <ResultCard boards={boards} day={day} />}1603 <div class="tabs">1604 {(["table", "moves", "tile"] as Tab[]).map((t) => (1605 <button1606 key={t}1607 type="button"1608 class={tab === t ? "on" : ""}1609 onClick={() => setTab(t)}1610 >1611 {t}1612 </button>1613 ))}1614 </div>1615 <div class="pane" ref={paneRef}>1616 {tab === "moves" && (1617 <div class="moves">1618 {state.console.map((e) => {1619 const cur = e.rev === state.revision;1620 const name = e.agent1621 ? frame.agentName.get(e.agent) ?? e.agent1622 : "";1623 const h = e.agent ? hues.get(e.agent) : undefined;1624 if (1625 e.op === "join" || e.op === "grow" || e.op === "end" ||1626 e.op === "start"1627 ) {1628 return (1629 <div key={e.rev} class={`note${cur ? " cur" : ""}`}>1630 {e.rev} {noteLine(e, name)}1631 </div>1632 );1633 }1634 return [1635 <span key={`${e.rev}n`} class={`n${cur ? " cur" : ""}`}>1636 {e.rev}1637 </span>,1638 <span1639 key={`${e.rev}s`}1640 class="st"1641 style={h !== undefined1642 ? `background:${agentColour(h, theme, true)}`1643 : ""}1644 />,1645 <span key={`${e.rev}w`} class={`what${cur ? " cur" : ""}`}>1646 <span class="who">{name}</span>1647 {moveLine(e)}1648 </span>,1649 ];1650 })}1651 {deaths.filter((d) => d.rev <= state.revision).map((d) => (1652 <div key={`d${d.rev}`} class="note danger">1653 {d.rev} {d.name} resigns (timeout)1654 </div>1655 ))}1656 </div>1657 )}1658 {tab === "tile" && (1659 <div class="tile">1660 {focusTileSpec1661 ? (1662 <>1663 <div>1664 <span class="k">tile</span>#{focusTileSpec.id}{" "}1665 <span class="k">status</span>1666 {state.tiles[focusTileSpec.id]}1667 </div>1668 <div>1669 <span class="k">shape</span>1670 {focusTileSpec.w}x{focusTileSpec.h}, {focusTileSpec.cells}1671 {" "}1672 cells, {focusTileSpec.rects.length}{" "}1673 {focusTileSpec.rects.length === 1 ? "rect" : "rects"}1674 </div>1675 <div>1676 <span class="k">m</span>1677 {focusTileSpec.m} solutions alone1678 </div>1679 <div>1680 <span class="k">by</span>1681 {state.tileAgent[focusTileSpec.id]1682 ? frame.agentName.get(1683 state.tileAgent[focusTileSpec.id]!,1684 )1685 : "-"}1686 </div>1687 <div>1688 <span class="k">neighbours</span>1689 {focusTileSpec.overlaps.map((o) => `#${o.with}`).join(" ")}1690 </div>1691 <div>1692 <span class="k">pins</span>1693 {(focusTileSpec.pins ?? []).length1694 ? (focusTileSpec.pins ?? []).map((i) =>1695 `#${focusTileSpec.overlaps[i]?.with}`1696 ).join(" ")1697 : "-"}1698 </div>1699 </>1700 )1701 : (1702 <div style="color:var(--muted)">1703 zoom in until the board is lettered; the tile under the centre1704 is described here1705 </div>1706 )}1707 </div>1708 )}1709 {tab === "table" && <TournamentTable boards={boards} />}1710 </div>1711 </>1712 );17131714 return (1715 <div class="match">1716 <style>{STYLE}</style>1717 <div class="board">1718 <canvas ref={canvasRef} class="field" />1719 <div class="chip">1720 move <b>{state.revision < 0 ? "-" : state.revision}</b>1721 {!live && props.replay === null ? " (scrubbing)" : ""}1722 {props.replay ? " (replay)" : ""}1723 </div>1724 </div>1725 <div class="counting">1726 <span class="lbl">counting</span>1727 {players.length === 0 && (1728 <span style="color:var(--muted)">no players yet</span>1729 )}1730 {players.map((p) => (1731 <span1732 key={p.a.id}1733 class={`p${leader && leader.a.id === p.a.id ? " lead" : ""}`}1734 >1735 <span1736 class="st"1737 style={`background:${1738 agentColour(p.hue, theme, true, WILT_CHROMA[p.wilt])1739 }`}1740 />1741 <span class={p.health.state === "dead" ? "dead" : ""}>1742 {p.label}1743 </span>1744 <b>{p.s?.cells ?? 0}</b>1745 </span>1746 ))}1747 </div>1748 <div class="dock">1749 <div class="head">1750 <span class="day">{day ?? gameId?.slice(0, 8) ?? "mosaic"}</span>1751 <span class="sc" style="color:var(--text-dim);font-size:12px">1752 {ended ? "finished" : live ? "live" : "replay"}1753 </span>1754 <span class="mv">{solvedCount}/{state.tiles.length} tiles</span>1755 </div>1756 <div class="players">1757 {players.length === 0 && (1758 <div style="padding:10px 14px;color:var(--muted)">1759 waiting for players1760 </div>1761 )}1762 {players.map((p) => (1763 <div1764 key={p.a.id}1765 class={`player${p.toMove ? " tomove" : ""}${1766 p.health.state === "dead" ? " dead" : ""1767 }`}1768 >1769 <span1770 class="av"1771 style={`background:${1772 agentColour(p.hue, theme, true, WILT_CHROMA[p.wilt])1773 }`}1774 title={`health ${p.health.state}: ${1775 (p.health.rate * 100).toFixed(1)1776 }% wrong over ${p.health.committed} cells`}1777 />1778 <span class="name">1779 {p.crowned ? "♕ " : ""}1780 {p.label}1781 </span>1782 <span class="nums">1783 <b>{p.s?.tiles ?? 0}</b> tiles <b>{p.s?.cells ?? 0}</b> pts1784 </span>1785 <span class="rank">1786 {p.a.model ?? "model unreported"}1787 {p.a.harness ? ` — ${p.a.harness}` : ""}1788 </span>1789 <ClockView clock={p.clock} />1790 {p.toMove && <span class="tag">to move</span>}1791 </div>1792 ))}1793 </div>1794 {dockBody}1795 </div>1796 <div class="controls">1797 <button1798 type="button"1799 class="btn"1800 disabled={events.length === 0}1801 onClick={() => {1802 if (!playing && rev >= max) setRev(0);1803 setLive(false);1804 setPlaying(!playing);1805 }}1806 >1807 {playing ? "pause" : "play"}1808 </button>1809 <select1810 class="btn"1811 value={String(speed)}1812 onChange={(e) => {1813 const v = (e.target as HTMLSelectElement).value;1814 setSpeed(v === "max" ? "max" : Number(v) as Speed);1815 }}1816 >1817 {SPEEDS.map((sp) => (1818 <option key={String(sp)} value={String(sp)}>1819 {sp === "max" ? "max" : `${sp}x`}1820 </option>1821 ))}1822 </select>1823 <select1824 class="btn"1825 value={follow}1826 onChange={(e) => setFollow((e.target as HTMLSelectElement).value)}1827 >1828 <option value="">follow: none</option>1829 {final.agents.map((a) => (1830 <option key={a.id} value={a.id}>1831 follow: {labels.get(a.id) ?? a.id}1832 </option>1833 ))}1834 </select>1835 <span class="rev">{rev < 0 ? "-" : rev} / {max}</span>1836 <Timeline1837 events={events}1838 deaths={deaths}1839 rev={rev}1840 max={max}1841 live={live}1842 theme={theme}1843 onScrub={(v) => {1844 setPlaying(false);1845 setRev(v);1846 setLive(props.replay === null && v >= max);1847 }}1848 />1849 {props.replay === null && !live1850 ? (1851 <button1852 type="button"1853 class="btn"1854 onClick={() => {1855 setPlaying(false);1856 setLive(true);1857 setRev(max);1858 }}1859 >1860 live1861 </button>1862 )1863 : null}1864 <span1865 class="perf"1866 title="level; map tiles redrawn this frame / visible; raster cache size"1867 >1868 L{drawInfo.level} {drawInfo.redrawn}/{drawInfo.visible} c{cache1869 .current.size()}1870 </span>1871 </div>1872 {/* phone: the tabs and pane as a bottom sheet */}1873 <div class={`sheet${sheet ? " open" : ""}`}>1874 <div class="handle" onClick={() => setSheet(!sheet)}>1875 <i />1876 </div>1877 {dockBody}1878 </div>1879 </div>1880 );1881}18821883function ClockView(props: { clock: Clock }) {1884 const c = props.clock;1885 if (c.kind === "timeout") {1886 return (1887 <span class="clock byo">1888 <span class="sc">clock</span>1889 <b>timeout</b>1890 </span>1891 );1892 }1893 if (c.kind === "main") {1894 return (1895 <span class="clock">1896 <span class="sc">main time</span>1897 <b>{mmss(c.secs)}</b>1898 </span>1899 );1900 }1901 return (1902 <span class="clock byo">1903 <span class="sc">byo-yomi</span>1904 <span class="periods">1905 {[3, 2, 1].map((k) => (1906 <i1907 key={k}1908 class={k <= c.periods ? "left" : ""}1909 />1910 ))}1911 </span>1912 <b>{mmss(c.secs)}</b>1913 </span>1914 );1915}19161917function ResultCard(props: { boards: Boards; day: string | null }) {1918 const rows = props.boards.by_user.filter((r) => r.reward > 0);1919 const first = rows[0], second = rows[1];1920 let line = "no result";1921 if (first && second) {1922 line = `${first.key} wins by ${first.reward - second.reward} points`;1923 } else if (first) line = `${first.key} wins with ${first.reward} points`;1924 const top = props.boards.by_model[0];1925 return (1926 <div class="result">1927 <div class="r1">result {props.day ?? ""}</div>1928 <div class="r2">{line}</div>1929 {top ? <div class="r1">top model {top.key}</div> : null}1930 </div>1931 );1932}19331934function TournamentTable(props: { boards: Boards }) {1935 const rows = (rs: Row[], cap: string) => (1936 <>1937 <tr>1938 <td class="cap" colSpan={5}>{cap}</td>1939 </tr>1940 {rs.length === 0 && (1941 <tr>1942 <td colSpan={5} style="color:var(--muted)">nobody yet</td>1943 </tr>1944 )}1945 {rs.map((r, i) => (1946 <tr key={r.key}>1947 <td class="n">{i + 1}</td>1948 <td1949 class={r.dead > 0 && r.dead >= r.agents ? "dead" : ""}1950 style={r.verified === false ? "color:var(--muted)" : ""}1951 title={r.verified === false ? "unverified: no machine key" : ""}1952 >1953 {r.name ?? r.key}1954 {r.name1955 ? (1956 <span style="color:var(--muted);font-size:0.8em">1957 {` ${r.key}`}1958 </span>1959 )1960 : null}1961 {r.models.length > 11962 ? (1963 <span style="color:var(--muted)">1964 {` (${r.models.join(", ")})`}1965 </span>1966 )1967 : null}1968 {r.dead > 0 && r.dead >= r.agents1969 ? <span class="danger">&nbsp;(timeout)</span>1970 : r.dead > 01971 ? (1972 <span style="color:var(--muted)">1973 {` (${r.dead} of ${r.agents} timed out)`}1974 </span>1975 )1976 : null}1977 </td>1978 <td class="n">{r.tiles}</td>1979 <td class="n">{r.reward}</td>1980 <td class="n">{(r.wrong_rate * 100).toFixed(1)}%</td>1981 </tr>1982 ))}1983 </>1984 );1985 return (1986 <table class="tt">1987 <thead>1988 <tr>1989 <th class="n">#</th>1990 <th>player</th>1991 <th class="n">tiles</th>1992 <th class="n">points</th>1993 <th class="n">wrong</th>1994 </tr>1995 </thead>1996 <tbody>1997 {rows(props.boards.by_user, "by user")}1998 {rows(props.boards.by_model, "by model")}1999 </tbody>2000 </table>2001 );2002}20032004function noteLine(e: State["console"][number], name: string): string {2005 switch (e.op) {2006 case "start":2007 return "the field opens";2008 case "join":2009 return `${name} joins at #${e.tile}`;2010 case "grow":2011 return "the field grows";2012 case "end":2013 return "end of the day";2014 default:2015 return e.op;2016 }2017}20182019function moveLine(e: State["console"][number]): string {2020 switch (e.op) {2021 case "reveal":2022 return `reveals #${e.tile} (${e.revealed_cells} cells)`;2023 case "commit":2024 return `${e.accepted} stones${e.rejected ? `, ${e.rejected} wrong` : ""}${2025 e.solved.length2026 ? `, solves ${e.solved.map((i) => `#${i}`).join(" ")} +${e.reward}`2027 : ""2028 }${2029 e.meets?.length ? `, meets ${e.meets.map((m) => m.with).join(" ")}` : ""2030 }`;2031 case "retract":2032 return `retracts ${e.retracted}`;2033 default:2034 return e.op;2035 }2036}