clone

minesweeper as an mcp server: agents start games, play moves in batches and watch the board

main
mcpsweeper / src / view.ts
view.ts 42.8K
1import { App } from "@modelcontextprotocol/ext-apps";2import "98.css";3import "./view.css";45type Status = "playing" | "won" | "lost";6type Coordinate = { row: number; col: number };7type Observation = {8 game_id: string;9 revision: number;10 status: Status;11 width: number;12 height: number;13 mine_count: number;14 revealed_count: number;15 flagged_count: number;16 remaining_safe_cells: number;17 certified_no_guess: boolean;18 rows: string[];19};20type MoveEvent = {21 revision: number;22 intent: string;23 submittedCount: number;24 revealed: Coordinate[];25 revealedValues?: Array<Coordinate & { value: string }>;26 flagged: Coordinate[];27 unflagged: Coordinate[];28 exploded: Coordinate | null;29 status: Status;30 createdAt: string;31};32type StructuredResult = {33 game_id?: string;34 preview_url?: unknown;35 share_url?: unknown;36 observation?: unknown;37 initial_observation?: unknown;38 events?: unknown[];39 event?: unknown;40};4142type BakedReplay = {43 start: Observation;44 events: MoveEvent[];45 meta: { slug?: string; expires_at?: string; kind?: string };46};47type ReplayTurn = [48 revision: number,49 intent: string,50 submittedCount: number,51 revealedValues: Array<[cell: number, value: string]>,52 flaggedCells: number[],53 unflaggedCells: number[],54 explodedCell: number | null,55 status: Status,56];57type ReplayLinkPayload = {58 v: 1;59 s: Observation;60 t: ReplayTurn[];61};62type Connection = "preview" | "connecting" | "live" | "replay" | "error";6364let hostApp: App | null = null;6566function requiredElement<T extends Element>(selector: string): T {67 const element = document.querySelector<T>(selector);68 if (element === null) throw new Error(`Missing required element: ${selector}`);69 return element;70}7172const canvas = requiredElement<HTMLCanvasElement>("#game");73const boardFrame = requiredElement<HTMLDivElement>(".board-frame");74const pauseButton = requiredElement<HTMLButtonElement>("#pause-btn");75const speedButton = requiredElement<HTMLButtonElement>("#speed-btn");76const fitButton = requiredElement<HTMLButtonElement>("#fit-btn");77const compareButton = requiredElement<HTMLButtonElement>("#compare-btn");78const replayLink = requiredElement<HTMLAnchorElement>("#replay-link");79const faceButton = requiredElement<HTMLButtonElement>("#face-btn");80const faceIcon = requiredElement<HTMLSpanElement>("#face-icon");81const mineCounter = requiredElement<HTMLOutputElement>("#mine-counter");82const batchCounter = requiredElement<HTMLOutputElement>("#batch-counter");83const statusText = requiredElement<HTMLParagraphElement>("#status-text");84const connectionText = requiredElement<HTMLParagraphElement>("#connection-text");85const replaySlider = requiredElement<HTMLInputElement>("#replay-slider");86const replayPosition = requiredElement<HTMLOutputElement>("#replay-position");87const intentLog = requiredElement<HTMLOListElement>("#intent-log");88const zoomSlider = requiredElement<HTMLInputElement>("#zoom-slider");89const comparisonPhase = requiredElement<HTMLOutputElement>("#comparison-phase");90const maybeContext = canvas.getContext("2d");91if (maybeContext === null) throw new Error("Canvas 2D rendering is unavailable.");92const context: CanvasRenderingContext2D = maybeContext;93const MAX_CANVAS_BACKING_SIZE = 4096;94let canvasCssSize = 768;95let canvasRenderScale = 1;96let lastBoardPaintKey = "";9798const state: {99 observation: Observation | null;100 initialObservation: Observation | null;101 events: MoveEvent[];102 latestIntent: string;103 previewUrl: string;104 shareUrl: string;105 connection: Connection;106 error: string;107 displayRevision: number;108 followingLive: boolean;109 replaying: boolean;110 replayAccumulatorMs: number;111 speed: number;112 elapsedMs: number;113 zoom: number;114 centeredGameId: string;115 comparisonEnabled: boolean;116 comparisonAfter: boolean;117 comparisonAccumulatorMs: number;118} = {119 observation: null,120 initialObservation: null,121 events: [],122 latestIntent: "Waiting for the agent to begin…",123 previewUrl: "",124 shareUrl: "",125 connection: window.top === window ? "preview" : "connecting",126 error: "",127 displayRevision: 0,128 followingLive: true,129 replaying: false,130 replayAccumulatorMs: 0,131 speed: 1,132 elapsedMs: 0,133 zoom: 3,134 centeredGameId: "",135 comparisonEnabled: true,136 comparisonAfter: true,137 comparisonAccumulatorMs: 0,138};139140const flashUntil = new Map<string, number>();141const numberColors = ["#000000", "#0000ff", "#008000", "#ff0000", "#000080", "#800000", "#008080", "#000000", "#808080"];142const clueGlyphs: Record<string, string[]> = {143 "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],144 "2": ["11111", "00001", "00001", "11111", "10000", "10000", "11111"],145 "3": ["11111", "00001", "00001", "11111", "00001", "00001", "11111"],146 "4": ["10001", "10001", "10001", "11111", "00001", "00001", "00001"],147 "5": ["11111", "10000", "10000", "11111", "00001", "00001", "11111"],148 "6": ["11111", "10000", "10000", "11111", "10001", "10001", "11111"],149 "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],150 "8": ["11111", "10001", "10001", "11111", "10001", "10001", "11111"],151};152153function structured(value: unknown): StructuredResult | null {154 return typeof value === "object" && value !== null ? (value as StructuredResult) : null;155}156157function isCoordinate(value: unknown): value is Coordinate {158 if (typeof value !== "object" || value === null) return false;159 const coordinate = value as Partial<Coordinate>;160 return Number.isInteger(coordinate.row) && Number.isInteger(coordinate.col);161}162163function isVisualObservation(value: unknown): value is Observation {164 if (typeof value !== "object" || value === null) return false;165 const observation = value as Partial<Observation>;166 return (167 typeof observation.game_id === "string" &&168 Number.isInteger(observation.revision) &&169 Number.isInteger(observation.width) &&170 Number.isInteger(observation.height) &&171 Array.isArray(observation.rows) &&172 observation.rows.length === observation.height &&173 observation.rows.every((row) => typeof row === "string" && row.length === observation.width)174 );175}176177function normalizeEvent(value: unknown): MoveEvent | null {178 if (typeof value !== "object" || value === null) return null;179 const event = value as Partial<MoveEvent>;180 if (!Number.isInteger(event.revision)) return null;181 const status: Status = event.status === "won" || event.status === "lost" ? event.status : "playing";182 const coordinates = (items: unknown): Coordinate[] => (Array.isArray(items) ? items.filter(isCoordinate) : []);183 const exploded = isCoordinate(event.exploded) ? event.exploded : null;184 const revealedValues = Array.isArray(event.revealedValues)185 ? event.revealedValues.filter(186 (cell): cell is Coordinate & { value: string } =>187 isCoordinate(cell) && typeof (cell as { value?: unknown }).value === "string",188 )189 : [];190 const revealed = coordinates(event.revealed);191 const flagged = coordinates(event.flagged);192 const unflagged = coordinates(event.unflagged);193 return {194 revision: event.revision as number,195 intent: typeof event.intent === "string" ? event.intent : "",196 submittedCount:197 Number.isInteger(event.submittedCount) && (event.submittedCount as number) >= 0198 ? (event.submittedCount as number)199 : revealed.length + flagged.length + unflagged.length + (exploded === null ? 0 : 1),200 revealed,201 revealedValues,202 flagged,203 unflagged,204 exploded,205 status,206 createdAt: typeof event.createdAt === "string" ? event.createdAt : "",207 };208}209210function coordinateToCell(width: number, coordinate: Coordinate): number {211 return coordinate.row * width + coordinate.col;212}213214function cellToCoordinate(width: number, cell: number): Coordinate {215 return { row: Math.floor(cell / width), col: cell % width };216}217218function eventToReplayTurn(event: MoveEvent, width: number): ReplayTurn {219 return [220 event.revision,221 event.intent,222 event.submittedCount,223 (event.revealedValues ?? []).map((cell) => [coordinateToCell(width, cell), cell.value]),224 event.flagged.map((cell) => coordinateToCell(width, cell)),225 event.unflagged.map((cell) => coordinateToCell(width, cell)),226 event.exploded === null ? null : coordinateToCell(width, event.exploded),227 event.status,228 ];229}230231function replayTurnToEvent(value: unknown, width: number, height: number): MoveEvent | null {232 if (!Array.isArray(value) || value.length !== 8) return null;233 const [revision, intent, submittedCount, revealedValues, flaggedCells, unflaggedCells, explodedCell, status] = value;234 const totalCells = width * height;235 const validCell = (cell: unknown): cell is number => Number.isInteger(cell) && (cell as number) >= 0 && (cell as number) < totalCells;236 if (237 !Number.isInteger(revision) ||238 typeof intent !== "string" ||239 !Number.isInteger(submittedCount) ||240 (submittedCount as number) < 0 ||241 !Array.isArray(revealedValues) ||242 !Array.isArray(flaggedCells) ||243 !Array.isArray(unflaggedCells) ||244 (explodedCell !== null && !validCell(explodedCell)) ||245 (status !== "playing" && status !== "won" && status !== "lost")246 ) {247 return null;248 }249 const revealed = revealedValues250 .filter(251 (cell): cell is [number, string] =>252 Array.isArray(cell) && cell.length === 2 && validCell(cell[0]) && typeof cell[1] === "string",253 )254 .map(([cell, clue]) => ({ ...cellToCoordinate(width, cell), value: clue }));255 if (revealed.length !== revealedValues.length || !flaggedCells.every(validCell) || !unflaggedCells.every(validCell)) {256 return null;257 }258 return {259 revision: revision as number,260 intent,261 submittedCount: submittedCount as number,262 revealed: revealed.map(({ row, col }) => ({ row, col })),263 revealedValues: revealed,264 flagged: flaggedCells.map((cell) => cellToCoordinate(width, cell)),265 unflagged: unflaggedCells.map((cell) => cellToCoordinate(width, cell)),266 exploded: explodedCell === null ? null : cellToCoordinate(width, explodedCell),267 status,268 createdAt: "",269 };270}271272function bytesToBase64Url(bytes: Uint8Array): string {273 let binary = "";274 for (let offset = 0; offset < bytes.length; offset += 8192) {275 binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192));276 }277 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");278}279280function base64UrlToBytes(value: string): Uint8Array {281 const base64 = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");282 const binary = atob(base64);283 return Uint8Array.from(binary, (character) => character.charCodeAt(0));284}285286async function encodeReplayLink(payload: ReplayLinkPayload): Promise<string> {287 const bytes = new TextEncoder().encode(JSON.stringify(payload));288 if (typeof CompressionStream === "undefined") return `j.${bytesToBase64Url(bytes)}`;289 const stream = new Blob([bytes.buffer as ArrayBuffer]).stream().pipeThrough(new CompressionStream("gzip"));290 return `g.${bytesToBase64Url(new Uint8Array(await new Response(stream).arrayBuffer()))}`;291}292293async function readReplayLink(): Promise<{ start: Observation; turns: MoveEvent[] } | null> {294 const encoded = new URLSearchParams(location.hash.slice(1)).get("replay");295 if (encoded === null) return null;296 try {297 const separator = encoded.indexOf(".");298 const format = encoded.slice(0, separator);299 const bytes = base64UrlToBytes(encoded.slice(separator + 1));300 const json =301 format === "g"302 ? await new Response(303 new Blob([bytes.buffer as ArrayBuffer]).stream().pipeThrough(new DecompressionStream("gzip")),304 ).text()305 : format === "j"306 ? new TextDecoder().decode(bytes)307 : "";308 const payload = JSON.parse(json) as Partial<ReplayLinkPayload>;309 if (payload.v !== 1 || !isVisualObservation(payload.s) || !Array.isArray(payload.t)) return null;310 const turns = payload.t.map((turn) => replayTurnToEvent(turn, payload.s!.width, payload.s!.height));311 if (turns.some((turn) => turn === null)) return null;312 return { start: payload.s, turns: turns as MoveEvent[] };313 } catch {314 return null;315 }316}317318function readBakedReplay(): BakedReplay | null {319 const element = document.getElementById("baked-replay");320 if (element === null || element.textContent === null) return null;321 try {322 const data = JSON.parse(element.textContent) as unknown;323 if (typeof data !== "object" || data === null) return null;324 const payload = data as { v?: unknown; start?: unknown; events?: unknown; meta?: unknown };325 if (payload.v !== 1 || !isVisualObservation(payload.start) || !Array.isArray(payload.events)) return null;326 const events: MoveEvent[] = [];327 for (const value of payload.events) {328 const event = normalizeEvent(value);329 if (event === null) return null;330 events.push(event);331 }332 const meta = typeof payload.meta === "object" && payload.meta !== null ? (payload.meta as BakedReplay["meta"]) : {};333 return { start: payload.start, events, meta };334 } catch {335 return null;336 }337}338339const expiryNote = requiredElement<HTMLParagraphElement>("#expiry-note");340341function applyBakedChrome(meta: BakedReplay["meta"]): void {342 if (meta.kind === "file") {343 replayLink.hidden = true;344 return;345 }346 if (typeof meta.slug === "string") {347 state.shareUrl = new URL(`/r/${meta.slug}`, location.href).toString();348 replayLink.href = new URL(`/r/${meta.slug}.html`, location.href).toString();349 replayLink.removeAttribute("target");350 replayLink.textContent = "Download replay file";351 replayLink.setAttribute("aria-disabled", "false");352 }353 if (typeof meta.expires_at === "string") {354 const expires = new Date(meta.expires_at);355 if (!Number.isNaN(expires.getTime())) {356 expiryNote.hidden = false;357 expiryNote.textContent = `Server copy expires ~${expires.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;358 }359 }360}361362function beginLinkedReplay(start: Observation, turns: MoveEvent[]): void {363 state.initialObservation = start;364 state.events = turns.sort((left, right) => left.revision - right.revision);365 const lastTurn = state.events.at(-1);366 const revision = lastTurn?.revision ?? start.revision;367 state.observation = { ...start, revision, status: lastTurn?.status ?? start.status };368 state.latestIntent = lastTurn?.intent || "Replay loaded";369 state.connection = "replay";370 state.displayRevision = revision;371 state.followingLive = false;372 state.centeredGameId = start.game_id;373 const previewUrl = new URL(location.href);374 previewUrl.hash = "";375 previewUrl.search = "";376 state.previewUrl = previewUrl.toString();377 state.observation = observationAt(revision) ?? state.observation;378}379380function consumeResult(value: unknown): void {381 const result = structured(value);382 if (result === null) return;383 if (typeof result.preview_url === "string") state.previewUrl = result.preview_url;384 if (typeof result.share_url === "string") state.shareUrl = result.share_url;385 if (result.game_id !== undefined && state.observation === null) {386 state.latestIntent = `Field ${result.game_id.slice(0, 8)} initialized`;387 }388 if (isVisualObservation(result.initial_observation)) state.initialObservation = result.initial_observation;389 if (isVisualObservation(result.observation)) {390 if (state.initialObservation === null && result.observation.revision === 0) {391 state.initialObservation = structuredClone(result.observation);392 }393 state.observation = result.observation;394 if (state.centeredGameId !== result.observation.game_id) {395 state.centeredGameId = result.observation.game_id;396 requestAnimationFrame(() => {397 boardFrame.scrollLeft = (boardFrame.scrollWidth - boardFrame.clientWidth) / 2;398 boardFrame.scrollTop = (boardFrame.scrollHeight - boardFrame.clientHeight) / 2;399 });400 }401 }402 const incoming = result.events ?? (result.event === undefined ? [] : [result.event]);403 let acceptedEvents = 0;404 for (const value of incoming) {405 const event = normalizeEvent(value);406 if (event === null) continue;407 if (state.events.some((current) => current.revision === event.revision)) continue;408 acceptedEvents += 1;409 state.events.push(event);410 state.latestIntent = event.intent || `Batch ${event.revision}`;411 for (const coordinate of [...event.revealed, ...event.flagged, ...event.unflagged]) {412 flashUntil.set(`${coordinate.row}:${coordinate.col}`, state.elapsedMs + 1700 / state.speed);413 }414 if (event.exploded !== null) flashUntil.set(`${event.exploded.row}:${event.exploded.col}`, Number.POSITIVE_INFINITY);415 }416 state.events.sort((left, right) => left.revision - right.revision);417 if (state.followingLive && state.observation !== null) state.displayRevision = state.observation.revision;418 if (acceptedEvents > 0) {419 state.comparisonAfter = false;420 state.comparisonAccumulatorMs = 0;421 }422 state.connection = "live";423 state.error = "";424 void refreshReplayLink();425}426427function drawRaisedCell(x: number, y: number, size: number): void {428 context.fillStyle = "#c0c0c0";429 context.fillRect(x, y, size, size);430 const edge = Math.max(1, Math.floor(size * 0.13));431 context.fillStyle = "#ffffff";432 context.fillRect(x, y, size, edge);433 context.fillRect(x, y, edge, size);434 context.fillStyle = "#808080";435 context.fillRect(x, y + size - edge, size, edge);436 context.fillRect(x + size - edge, y, edge, size);437 context.fillStyle = "#000000";438 context.fillRect(x + edge, y + size - edge, size - edge, Math.max(1, edge / 2));439 context.fillRect(x + size - edge, y + edge, Math.max(1, edge / 2), size - edge);440}441442function drawRevealedCell(x: number, y: number, size: number): void {443 context.fillStyle = "#c0c0c0";444 context.fillRect(x, y, size, size);445 context.strokeStyle = "#808080";446 context.lineWidth = 1;447 context.strokeRect(x + 0.5, y + 0.5, size - 1, size - 1);448}449450function drawFlag(x: number, y: number, size: number): void {451 context.fillStyle = "#000000";452 context.fillRect(x + size * 0.45, y + size * 0.25, Math.max(1, size * 0.1), size * 0.48);453 context.fillRect(x + size * 0.28, y + size * 0.7, size * 0.46, Math.max(1, size * 0.1));454 context.fillStyle = "#ff0000";455 context.beginPath();456 context.moveTo(x + size * 0.48, y + size * 0.22);457 context.lineTo(x + size * 0.22, y + size * 0.38);458 context.lineTo(x + size * 0.48, y + size * 0.53);459 context.closePath();460 context.fill();461}462463function drawMine(x: number, y: number, size: number, exploded: boolean): void {464 if (exploded) {465 context.fillStyle = "#ff0000";466 context.fillRect(x, y, size, size);467 }468 context.fillStyle = "#000000";469 context.beginPath();470 context.arc(x + size / 2, y + size / 2, size * 0.24, 0, Math.PI * 2);471 context.fill();472 context.fillRect(x + size * 0.16, y + size * 0.46, size * 0.68, Math.max(1, size * 0.08));473 context.fillRect(x + size * 0.46, y + size * 0.16, Math.max(1, size * 0.08), size * 0.68);474}475476function drawClue(value: string, x: number, y: number, size: number): void {477 const glyph = clueGlyphs[value];478 if (glyph === undefined) return;479 const pixel = Math.max(1, Math.floor((size - 1) / 7));480 const width = 5 * pixel;481 const height = 7 * pixel;482 const left = Math.round(x + (size - width) / 2);483 const top = Math.round(y + (size - height) / 2);484 context.fillStyle = numberColors[Number(value)] ?? "#000000";485 for (let row = 0; row < glyph.length; row += 1) {486 const pixels = glyph[row] ?? "";487 for (let col = 0; col < pixels.length; col += 1) {488 if (pixels[col] === "1") context.fillRect(left + col * pixel, top + row * pixel, pixel, pixel);489 }490 }491}492493type BoardDetail = "overview" | "compact" | "detailed";494495function detailForCellSize(cellSize: number): BoardDetail {496 if (cellSize < 4) return "overview";497 if (cellSize < 7) return "compact";498 return "detailed";499}500501function drawOverviewCell(value: string, x: number, y: number, size: number): void {502 if (value === "X") context.fillStyle = "#ff0000";503 else if (value === "*") context.fillStyle = "#000000";504 else if (value === "#" || value === "F") context.fillStyle = "#a8a8a8";505 else context.fillStyle = "#d0d0d0";506 context.fillRect(x, y, size + 0.05, size + 0.05);507508 if (value === "F") {509 context.fillStyle = "#ff0000";510 const marker = Math.max(1 / canvasRenderScale, size * 0.5);511 context.fillRect(x + (size - marker) / 2, y + (size - marker) / 2, marker, marker);512 } else if (value >= "1" && value <= "8") {513 context.fillStyle = numberColors[Number(value)] ?? "#000000";514 const marker = Math.max(1 / canvasRenderScale, size * 0.34);515 context.fillRect(x + (size - marker) / 2, y + (size - marker) / 2, marker, marker);516 }517}518519function drawCompactMarker(value: string, x: number, y: number, size: number): void {520 if (value === "F") {521 context.fillStyle = "#ff0000";522 const marker = Math.max(1, size * 0.42);523 context.fillRect(x + (size - marker) / 2, y + (size - marker) / 2, marker, marker);524 } else if (value === "X" || value === "*") {525 drawMine(x, y, size, value === "X");526 } else if (value >= "1" && value <= "8") {527 context.fillStyle = numberColors[Number(value)] ?? "#000000";528 const marker = Math.max(1, size * 0.3);529 context.fillRect(x + (size - marker) / 2, y + (size - marker) / 2, marker, marker);530 }531}532533function activeFlashCount(): number {534 let count = 0;535 for (const expiresAt of flashUntil.values()) {536 if (expiresAt > state.elapsedMs) count += 1;537 }538 return count;539}540541function configureCanvas(cssSize: number): void {542 canvasCssSize = Math.max(1, Math.round(cssSize));543 const deviceScale = Math.max(1, window.devicePixelRatio || 1);544 canvasRenderScale = Math.min(deviceScale, MAX_CANVAS_BACKING_SIZE / canvasCssSize);545 const backingSize = Math.max(1, Math.round(canvasCssSize * canvasRenderScale));546 canvas.style.width = `${canvasCssSize}px`;547 canvas.style.height = `${canvasCssSize}px`;548 if (canvas.width !== backingSize || canvas.height !== backingSize) {549 canvas.width = backingSize;550 canvas.height = backingSize;551 }552 canvasRenderScale = backingSize / canvasCssSize;553 context.setTransform(canvasRenderScale, 0, 0, canvasRenderScale, 0, 0);554 context.imageSmoothingEnabled = false;555 lastBoardPaintKey = "";556}557558function renderBoard(observation: Observation): void {559 const cellSize = canvasCssSize / Math.max(observation.width, observation.height);560 const detail = detailForCellSize(cellSize);561 const flashCount = activeFlashCount();562 const paintKey = `${observation.game_id}:${observation.revision}:${canvas.width}:${canvasCssSize}:${detail}:${flashCount}`;563 if (paintKey === lastBoardPaintKey) return;564 lastBoardPaintKey = paintKey;565566 context.fillStyle = "#808080";567 context.fillRect(0, 0, canvasCssSize, canvasCssSize);568 for (let row = 0; row < observation.height; row += 1) {569 const rowValue = observation.rows[row] ?? "";570 for (let col = 0; col < observation.width; col += 1) {571 const value = rowValue[col] ?? "#";572 const x = col * cellSize;573 const y = row * cellSize;574 const flashing = (flashUntil.get(`${row}:${col}`) ?? 0) > state.elapsedMs;575 if (detail === "overview") drawOverviewCell(value, x, y, cellSize);576 else {577 if (value === "#" || value === "F") drawRaisedCell(x, y, cellSize);578 else drawRevealedCell(x, y, cellSize);579 }580581 if (flashing && value !== "X") {582 context.strokeStyle = "#ffff00";583 context.lineWidth = Math.max(1 / canvasRenderScale, cellSize * 0.12);584 const inset = Math.min(1, cellSize * 0.2);585 context.strokeRect(x + inset, y + inset, cellSize - inset * 2, cellSize - inset * 2);586 }587 if (detail === "detailed") {588 if (value === "F") drawFlag(x, y, cellSize);589 else if (value === "X" || value === "*") drawMine(x, y, cellSize, value === "X");590 else if (value !== "#" && value !== "0") drawClue(value, x, y, cellSize);591 } else if (detail === "compact") {592 drawCompactMarker(value, x, y, cellSize);593 }594 }595 }596}597598function renderWaiting(): void {599 context.fillStyle = "#c0c0c0";600 context.fillRect(0, 0, canvasCssSize, canvasCssSize);601 context.fillStyle = "#000000";602 context.textAlign = "center";603 context.textBaseline = "middle";604 context.font = "bold 28px 'MS Sans Serif', Arial, sans-serif";605 context.fillText("Waiting for agent…", canvasCssSize / 2, canvasCssSize / 2);606}607608function counterDigits(value: number, minimumDigits = 3): string {609 return Math.max(0, value).toString().padStart(minimumDigits, "0");610}611612let historicalObservationCache: { key: string; observation: Observation } | null = null;613function observationAt(revision: number): Observation | null {614 const live = state.observation;615 if (live === null) return null;616 if ((revision >= live.revision && state.connection !== "replay") || state.initialObservation === null) return live;617 const cacheKey = `${live.game_id}:${live.revision}:${state.events.length}:${state.initialObservation.revision}:${revision}`;618 if (historicalObservationCache?.key === cacheKey) return historicalObservationCache.observation;619620 const rows = state.initialObservation.rows.map((row) => row.split(""));621 let status: Status = state.initialObservation.status;622 for (const event of state.events) {623 if (event.revision > revision) break;624 for (const coordinate of event.unflagged) {625 const row = rows[coordinate.row];626 if (row?.[coordinate.col] === "F") row[coordinate.col] = "#";627 }628 for (const coordinate of event.flagged) {629 const row = rows[coordinate.row];630 if (row?.[coordinate.col] === "#") row[coordinate.col] = "F";631 }632 for (const cell of event.revealedValues ?? []) {633 const row = rows[cell.row];634 if (row !== undefined) row[cell.col] = cell.value;635 }636 if (event.exploded !== null) {637 const row = rows[event.exploded.row];638 if (row !== undefined) row[event.exploded.col] = "X";639 }640 status = event.status;641 }642 const renderedRows = rows.map((row) => row.join(""));643 let revealedCount = 0;644 let flaggedCount = 0;645 for (const row of renderedRows) {646 for (const cell of row) {647 if (cell === "F") flaggedCount += 1;648 else if (cell !== "#" && cell !== "*" && cell !== "X") revealedCount += 1;649 }650 }651 const observation = {652 ...state.initialObservation,653 revision,654 status,655 rows: renderedRows,656 revealed_count: revealedCount,657 flagged_count: flaggedCount,658 remaining_safe_cells: live.width * live.height - live.mine_count - revealedCount,659 };660 historicalObservationCache = { key: cacheKey, observation };661 return observation;662}663664function selectRevision(revision: number): void {665 const maximum = state.observation?.revision ?? 0;666 state.displayRevision = Math.max(0, Math.min(maximum, revision));667 state.followingLive = state.displayRevision === maximum && state.observation?.status === "playing";668 state.replaying = false;669 state.replayAccumulatorMs = 0;670 state.comparisonAfter = false;671 state.comparisonAccumulatorMs = 0;672}673674let lastIntentLogKey = "";675function renderReplayControls(): void {676 const maximum = state.observation?.revision ?? 0;677 replaySlider.max = String(maximum);678 replaySlider.value = String(Math.min(maximum, state.displayRevision));679 replayPosition.textContent = `${counterDigits(state.displayRevision)} / ${counterDigits(maximum)}`;680 pauseButton.textContent = state.replaying ? "Pause" : state.displayRevision < maximum ? "Resume" : "Replay";681682 const key = `${state.events.length}:${state.displayRevision}:${maximum}`;683 if (key === lastIntentLogKey) return;684 lastIntentLogKey = key;685 intentLog.replaceChildren();686 const entries = [687 { revision: 0, updates: 0, intent: "Initial opening" },688 ...state.events.map((event) => ({689 revision: event.revision,690 updates: event.submittedCount,691 intent: event.intent || `Batch ${event.revision}`,692 })),693 ];694 for (const entry of entries) {695 const item = document.createElement("li");696 const button = document.createElement("button");697 button.type = "button";698 const revision = document.createElement("span");699 revision.textContent = counterDigits(entry.revision);700 const updates = document.createElement("span");701 updates.textContent = entry.updates.toLocaleString();702 const intent = document.createElement("span");703 intent.textContent = entry.intent;704 button.append(revision, updates, intent);705 if (entry.revision === state.displayRevision) button.setAttribute("aria-current", "step");706 button.addEventListener("click", () => selectRevision(entry.revision));707 item.append(button);708 intentLog.append(item);709 }710 intentLog.querySelector<HTMLElement>("[aria-current='step']")?.scrollIntoView({ block: "nearest" });711}712713function visibleRevision(): number {714 return (715 state.comparisonEnabled && !state.replaying && state.displayRevision > 0 && !state.comparisonAfter716 ? state.displayRevision - 1717 : state.displayRevision718 );719}720721function render(): void {722 const comparedRevision = visibleRevision();723 const observation = observationAt(comparedRevision);724 if (observation === null) renderWaiting();725 else renderBoard(observation);726727 const remainingMines = observation === null ? 0 : observation.mine_count - observation.flagged_count;728 mineCounter.textContent = counterDigits(remainingMines, remainingMines > 999 ? 5 : 3);729 mineCounter.classList.toggle("wide-counter", remainingMines > 999);730 batchCounter.textContent = counterDigits(observation?.revision ?? 0);731 const selectedEvent = [...state.events].reverse().find((event) => event.revision <= state.displayRevision);732 statusText.textContent = state.error || selectedEvent?.intent || (state.displayRevision === 0 ? "Initial opening" : state.latestIntent);733 connectionText.textContent = state.connection.toUpperCase();734 comparisonPhase.textContent = state.displayRevision === 0 ? "OPEN" : comparedRevision < state.displayRevision ? "BEFORE" : "AFTER";735 faceIcon.className = `face-glyph ${736 observation?.status === "won"737 ? "face-won"738 : observation?.status === "lost"739 ? "face-lost"740 : !state.replaying && state.displayRevision < (state.observation?.revision ?? 0)741 ? "face-paused"742 : "face-playing"743 }`;744 renderReplayControls();745}746747function update(deltaMs: number): void {748 state.elapsedMs += deltaMs * state.speed;749 if (state.comparisonEnabled && !state.replaying && state.displayRevision > 0) {750 state.comparisonAccumulatorMs += deltaMs;751 if (state.comparisonAccumulatorMs >= 750) {752 state.comparisonAccumulatorMs = 0;753 state.comparisonAfter = !state.comparisonAfter;754 }755 }756 if (!state.replaying || state.observation === null) return;757 state.replayAccumulatorMs += deltaMs * state.speed;758 if (state.replayAccumulatorMs < 650) return;759 state.replayAccumulatorMs = 0;760 state.displayRevision += 1;761 if (state.displayRevision >= state.observation.revision) {762 state.displayRevision = state.observation.revision;763 state.replaying = false;764 }765}766767function makePreview(): Observation {768 const sizeParameter = new URLSearchParams(window.location.search).get("size");769 const requestedSize = sizeParameter === null ? Number.NaN : Number(sizeParameter);770 const size = Number.isInteger(requestedSize) ? Math.max(16, Math.min(224, requestedSize)) : 64;771 const center = (size - 1) / 2;772 const rows: string[] = [];773 let revealed = 0;774 let flagged = 0;775 for (let row = 0; row < size; row += 1) {776 let line = "";777 for (let col = 0; col < size; col += 1) {778 const distance = Math.hypot(row - center, col - center);779 if ((row * 17 + col * 31) % 47 === 0 && distance < size * 0.43) {780 line += "F";781 flagged += 1;782 } else if (distance < size * 0.4 || (row + col) % 13 === 0) {783 line += String((row * 7 + col * 3) % 4);784 revealed += 1;785 } else line += "#";786 }787 rows.push(line);788 }789 return {790 game_id: "preview-field",791 revision: 7,792 status: "playing",793 width: size,794 height: size,795 mine_count: Math.round(size * size * 0.1),796 revealed_count: revealed,797 flagged_count: flagged,798 remaining_safe_cells: size * size - Math.round(size * size * 0.1) - revealed,799 certified_no_guess: true,800 rows,801 };802}803804function beginPreview(): void {805 const observation = makePreview();806 state.observation = observation;807 state.previewUrl = new URL("/preview", location.href).toString();808 const intents = [809 "Expand the zero frontier from the central opening.",810 "Resolve the northwest edge as a complete constraint.",811 "Mark the forced mines bordering the open basin.",812 "Sweep the newly safe cells along the eastern seam.",813 "Propagate the one-two pattern through the lower field.",814 "Close the remaining isolated frontier pockets.",815 "The northern frontier resolves as one constraint wave.",816 ];817 const previewBandWidth = Math.min(14, observation.width - 2);818 const previewBandStart = Math.max(1, Math.floor(observation.width * 0.3));819 state.latestIntent = intents.at(-1) ?? "Preview complete";820 const revealed = Array.from({ length: 84 }, (_, index) => ({821 row: Math.min(observation.height - 2, previewBandStart + Math.floor(index / previewBandWidth)),822 col: Math.min(observation.width - 2, previewBandStart + (index % previewBandWidth)),823 }));824 const flagged: Coordinate[] = [];825 for (let row = 0; row < observation.height; row += 1) {826 for (let col = 0; col < observation.width; col += 1) {827 if (observation.rows[row]?.[col] === "F") flagged.push({ row, col });828 }829 }830 state.events = intents.map((intent, index) => {831 const batchRevealed = revealed.slice(index * 12, (index + 1) * 12);832 const flagStart = Math.floor((index * flagged.length) / intents.length);833 const flagEnd = Math.floor(((index + 1) * flagged.length) / intents.length);834 return {835 revision: index + 1,836 intent,837 submittedCount: batchRevealed.length + flagEnd - flagStart,838 revealed: batchRevealed,839 revealedValues: batchRevealed.map((coordinate) => ({840 ...coordinate,841 value: observation.rows[coordinate.row]?.[coordinate.col] ?? "0",842 })),843 flagged: flagged.slice(flagStart, flagEnd),844 unflagged: [],845 exploded: null,846 status: "playing",847 createdAt: new Date().toISOString(),848 } satisfies MoveEvent;849 });850 const initialRows = observation.rows.map((row) => row.split(""));851 for (const coordinate of [...revealed, ...flagged]) {852 const row = initialRows[coordinate.row];853 if (row !== undefined) row[coordinate.col] = "#";854 }855 const initialRenderedRows = initialRows.map((row) => row.join(""));856 const initialRevealedCount = initialRenderedRows.reduce(857 (total, row) => total + [...row].filter((cell) => cell !== "#" && cell !== "F").length,858 0,859 );860 state.initialObservation = {861 ...observation,862 revision: 0,863 status: "playing",864 rows: initialRenderedRows,865 revealed_count: initialRevealedCount,866 flagged_count: 0,867 remaining_safe_cells: observation.width * observation.height - observation.mine_count - initialRevealedCount,868 };869 state.displayRevision = observation.revision;870 for (const coordinate of revealed) flashUntil.set(`${coordinate.row}:${coordinate.col}`, 1800);871 void refreshReplayLink();872}873874let replayLinkKey = "";875let replayLinkGeneration = 0;876async function refreshReplayLink(): Promise<void> {877 if (state.shareUrl !== "") {878 if (replayLink.href !== state.shareUrl && state.connection !== "replay") {879 replayLink.href = state.shareUrl;880 replayLink.textContent = "Open share link";881 replayLink.setAttribute("aria-disabled", "false");882 }883 return;884 }885 const start = state.initialObservation;886 const live = state.observation;887 if (start === null || live === null || state.previewUrl === "") {888 replayLink.setAttribute("aria-disabled", "true");889 replayLink.textContent = "Preparing replay link…";890 return;891 }892 const key = `${live.game_id}:${live.revision}:${state.events.length}:${state.previewUrl}`;893 if (key === replayLinkKey) return;894 replayLinkKey = key;895 const generation = ++replayLinkGeneration;896 replayLink.setAttribute("aria-disabled", "true");897 replayLink.textContent = "Encoding replay…";898 const payload: ReplayLinkPayload = {899 v: 1,900 s: start,901 t: state.events.map((event) => eventToReplayTurn(event, start.width)),902 };903 const encoded = await encodeReplayLink(payload);904 if (generation !== replayLinkGeneration) return;905 const target = new URL(state.previewUrl);906 target.search = "";907 target.hash = `replay=${encoded}`;908 replayLink.href = target.toString();909 replayLink.title = `Open encoded replay link (${Math.ceil(target.toString().length / 1024).toLocaleString()} KB)`;910 replayLink.textContent = "Open replay link";911 replayLink.setAttribute("aria-disabled", "false");912}913914function toggleReplay(): void {915 const maximum = state.observation?.revision ?? 0;916 if (maximum === 0) return;917 if (state.replaying) {918 state.replaying = false;919 return;920 }921 if (state.displayRevision >= maximum) state.displayRevision = 0;922 state.followingLive = false;923 state.replaying = true;924 state.replayAccumulatorMs = 0;925}926927pauseButton.addEventListener("click", toggleReplay);928faceButton.addEventListener("click", toggleReplay);929930replaySlider.addEventListener("input", () => selectRevision(Number(replaySlider.value)));931932function setZoom(zoom: number): void {933 const horizontalCenter = (boardFrame.scrollLeft + boardFrame.clientWidth / 2) / Math.max(1, boardFrame.scrollWidth);934 const verticalCenter = (boardFrame.scrollTop + boardFrame.clientHeight / 2) / Math.max(1, boardFrame.scrollHeight);935 state.zoom = Math.max(1, Math.min(4, zoom));936 const fittedSize = Math.max(1, Math.min(boardFrame.clientWidth, boardFrame.clientHeight));937 configureCanvas(fittedSize * state.zoom);938 const zoomLabel = `${Math.round(state.zoom * 100)}%`;939 zoomSlider.title = `Board zoom: ${zoomLabel}`;940 zoomSlider.setAttribute("aria-valuetext", zoomLabel);941 requestAnimationFrame(() => {942 boardFrame.scrollLeft = horizontalCenter * boardFrame.scrollWidth - boardFrame.clientWidth / 2;943 boardFrame.scrollTop = verticalCenter * boardFrame.scrollHeight - boardFrame.clientHeight / 2;944 });945}946947zoomSlider.addEventListener("input", () => setZoom(Number(zoomSlider.value)));948requestAnimationFrame(() => setZoom(state.zoom));949let pendingResizeFrame = 0;950function scheduleCanvasResize(): void {951 cancelAnimationFrame(pendingResizeFrame);952 pendingResizeFrame = requestAnimationFrame(() => setZoom(state.zoom));953}954window.addEventListener("resize", scheduleCanvasResize);955new ResizeObserver(scheduleCanvasResize).observe(boardFrame);956957const speeds = [0.5, 1, 2, 4];958speedButton.addEventListener("click", () => {959 const currentIndex = speeds.indexOf(state.speed);960 state.speed = speeds[(currentIndex + 1) % speeds.length] ?? 1;961 speedButton.textContent = `Speed ${state.speed}×`;962});963964fitButton.addEventListener("click", () => {965 zoomSlider.value = "1";966 setZoom(1);967});968969compareButton.addEventListener("click", () => {970 state.comparisonEnabled = !state.comparisonEnabled;971 state.comparisonAfter = true;972 state.comparisonAccumulatorMs = 0;973 compareButton.textContent = `Compare: ${state.comparisonEnabled ? "On" : "Off"}`;974});975976replayLink.addEventListener("click", (event) => {977 if (replayLink.getAttribute("aria-disabled") === "true") {978 event.preventDefault();979 return;980 }981 if (hostApp?.getHostCapabilities()?.openLinks !== undefined) {982 event.preventDefault();983 void hostApp.openLink({ url: replayLink.href });984 }985});986987let previousTimestamp = performance.now();988function frame(timestamp: number): void {989 update(Math.min(50, timestamp - previousTimestamp));990 previousTimestamp = timestamp;991 render();992 requestAnimationFrame(frame);993}994995window.render_game_to_text = () => {996 const observation = observationAt(visibleRevision());997 const cellSize = observation === null ? 0 : canvasCssSize / Math.max(observation.width, observation.height);998 return JSON.stringify({999 mode: state.connection,1000 replaying: state.replaying,1001 display_revision: state.displayRevision,1002 live_revision: state.observation?.revision ?? 0,1003 speed: state.speed,1004 zoom: state.zoom,1005 comparison: {1006 enabled: state.comparisonEnabled,1007 phase: comparisonPhase.textContent,1008 },1009 board_render: {1010 css_size: canvasCssSize,1011 backing_size: canvas.width,1012 render_scale: Number(canvasRenderScale.toFixed(2)),1013 cell_size: Number(cellSize.toFixed(2)),1014 detail: observation === null ? "waiting" : detailForCellSize(cellSize),1015 },1016 coordinate_system: "row 0 / col 0 is top-left; rows increase down and columns right",1017 observation:1018 observation === null1019 ? null1020 : {1021 game_id: observation.game_id,1022 status: observation.status,1023 revision: observation.revision,1024 width: observation.width,1025 height: observation.height,1026 mines: observation.mine_count,1027 flags: observation.flagged_count,1028 revealed: observation.revealed_count,1029 remaining_safe_cells: observation.remaining_safe_cells,1030 },1031 latest_intent: state.latestIntent,1032 recent_batches: state.events.slice(-5).map((event) => ({1033 revision: event.revision,1034 revealed: event.revealed.length,1035 flagged: event.flagged.length,1036 status: event.status,1037 })),1038 error: state.error,1039 });1040};10411042window.advanceTime = (milliseconds: number) => {1043 const frames = Math.max(1, Math.round(milliseconds / (1000 / 60)));1044 for (let index = 0; index < frames; index += 1) update(1000 / 60);1045 render();1046};10471048declare global {1049 interface Window {1050 render_game_to_text: () => string;1051 advanceTime: (milliseconds: number) => void;1052 }1053}10541055async function initialize(): Promise<void> {1056 const baked = readBakedReplay();1057 if (baked !== null) {1058 beginLinkedReplay(baked.start, baked.events);1059 applyBakedChrome(baked.meta);1060 return;1061 }1062 const linkedReplay = await readReplayLink();1063 if (linkedReplay !== null) {1064 beginLinkedReplay(linkedReplay.start, linkedReplay.turns);1065 void refreshReplayLink();1066 return;1067 }1068 if (window.top === window) {1069 beginPreview();1070 return;1071 }10721073 const app = new App({ name: "MCPSweeper spectator", version: "0.1.0" }, {}, { autoResize: false, strict: true });1074 hostApp = app;1075 let gameId = "";1076 let lastRevision = 0;1077 app.ontoolresult = (result) => {1078 const value = structured(result.structuredContent);1079 if (value?.game_id !== undefined) gameId = value.game_id;1080 if (isVisualObservation(value?.observation)) gameId = value.observation.game_id;1081 consumeResult(result.structuredContent);1082 };1083 void app1084 .connect()1085 .then(async () => {1086 await app.sendSizeChanged({ height: 640 });1087 state.connection = "live";1088 while (true) {1089 await new Promise((resolve) => setTimeout(resolve, 650));1090 if (gameId === "") continue;1091 try {1092 const result = await app.callServerTool({1093 name: "watch_game",1094 arguments: { game_id: gameId, since_revision: lastRevision },1095 });1096 const value = structured(result.structuredContent);1097 consumeResult(result.structuredContent);1098 if (isVisualObservation(value?.observation)) lastRevision = value.observation.revision;1099 } catch (error) {1100 state.connection = "error";1101 state.error = error instanceof Error ? error.message : "Polling failed";1102 }1103 }1104 })1105 .catch((error: unknown) => {1106 state.connection = "error";1107 state.error = error instanceof Error ? error.message : "Unable to connect to host";1108 });1109}11101111void initialize();1112requestAnimationFrame(frame);