| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "use strict"; |
|
|
| import { animated, flyTiles, initSpeed, scaled, sleep } from "../ui/animate.js"; |
| import { createBoard, createMiddle } from "../ui/board.js"; |
| import { confirmOn } from "../ui/confirm.js"; |
| import { |
| CENTER, |
| COLORS, |
| CUM_PENALTY, |
| FLOOR, |
| markerChip, |
| node, |
| poolCount, |
| tileEl, |
| } from "../ui/dom.js"; |
| import { bindHistoryKeys, createHistory } from "../ui/history.js"; |
| import { renderLog } from "../ui/log.js"; |
| import { clearPops, popScore } from "../ui/popups.js"; |
| import { clearScoring, renderFinalPanel, renderRoundPanel } from "../ui/scoring.js"; |
| import { buildRecord, copyRecord, downloadRecord } from "./record.js"; |
| import { setSharing, shareOnLeave, shareRecord, sharingOn, warmCollector } from "./upload.js"; |
| import { createSettings } from "../ui/settings.js"; |
| import { createStatus } from "../ui/status.js"; |
| import { analyticsOn, statsUrl, track } from "./analytics.js"; |
| import { AzulState, Rng } from "./engine.js"; |
| import { GameSession } from "./game.js"; |
| import { BACKENDS } from "./net.js"; |
| import { describeAction } from "./report.js"; |
|
|
| const el = (id) => document.getElementById(id); |
| const ui = { |
| matchup: el("matchup"), setup: el("setup"), seed: el("seed"), first: el("first"), |
| bot: el("bot"), botField: el("bot-field"), botChip: el("bot-chip"), |
| think: el("think"), deal: el("deal"), engineBar: el("engine-bar"), |
| engineText: el("engine-text"), aboutMeta: el("about-meta"), middle: el("middle"), |
| prompt: el("prompt"), hint: el("hint"), cancel: el("cancel"), fly: el("fly"), |
| scoring: el("scoring"), log: el("log"), coach: el("coach"), |
| coachField: el("coach-field"), coachLegend: el("coach-legend"), counts: el("counts"), |
| record: el("record"), recordNote: el("record-note"), |
| recordSave: el("record-save"), recordCopy: el("record-copy"), |
| settings: el("settings"), nav: el("nav"), |
| hand: el("hand"), handTiles: el("hand-tiles"), pops: el("pops"), |
| confirm: el("confirm"), confirmBar: el("confirm-bar"), |
| confirmDetail: el("confirm-detail"), confirmCancel: el("confirm-cancel"), |
| }; |
|
|
| let session = null; |
| let S = null; |
| let sel = null; |
| let suggestion = null; |
| let busy = false; |
| let meta = null; |
| |
| |
| |
| let bots = [{ id: "cobalt", name: "Cobalt", dir: "model" }]; |
| let bot = null; |
| |
| |
| |
| |
| |
| |
| |
| let adviserState = "none"; |
| let backend = null; |
| let engineReady = false; |
| let liveSims = 0; |
| let coachOn = false; |
| let notice = ""; |
| let noticeTimer = null; |
| let proposal = null; |
| let committing = null; |
| let analysis = null; |
| let navToken = 0; |
| let forked = null; |
| let branchCache = { forSession: null, ply: -1, legal: null }; |
|
|
| initSpeed(); |
| const status = createStatus(el("status")); |
| const settings = createSettings(ui.settings, { popups: true, confirm: true, boards: true }); |
| |
| settings.panel.append(node("i", "settings-gap"), el("think-field")); |
|
|
| |
| |
| |
| |
| settings.panel.append( |
| node("i", "settings-gap"), |
| node("span", "settings-label", "Share played games") |
| ); |
| const shareGroup = node("div", "speeds"); |
| shareGroup.setAttribute("role", "group"); |
| shareGroup.setAttribute("aria-label", "Share played games"); |
| const shareButtons = [true, false].map((value) => { |
| const b = node("button", "flag", value ? "On" : "Off"); |
| b.type = "button"; |
| b.title = value |
| ? "When a game ends it is sent, anonymously, to the public training pile: " + |
| "the moves, the tiles that were dealt, which net played, and the score. Nothing else." |
| : "Nothing is sent. Games stay in this tab unless you save them yourself."; |
| b.addEventListener("click", () => { |
| setSharing(value); |
| syncShare(); |
| say(value ? "sharing on: played games become training data" : "sharing off: nothing is sent"); |
| }); |
| shareGroup.appendChild(b); |
| return b; |
| }); |
| settings.panel.append(shareGroup); |
| function syncShare() { |
| shareButtons.forEach((b, i) => { |
| b.setAttribute("aria-pressed", String((i === 0) === sharingOn())); |
| }); |
| } |
| syncShare(); |
| const middle = createMiddle(ui.middle, { onPick: pick }); |
| const boards = { |
| human: createBoard(el("board-human"), { seat: 0, interactive: true, onPlay: route }), |
| ai: createBoard(el("board-ai"), { seat: 1 }), |
| }; |
| const nav = createHistory(ui.nav, { |
| log: () => (S && S.log) || [], |
| enabled: () => !busy, |
| onChange: onNavChange, |
| }); |
|
|
| |
| |
| |
| const COACH_THINK_S = 2; |
| const COACH_MAX_THINK_S = 3; |
| const coachLegend = () => |
| "Coach mode rates your move with " + strongestBot().name + "'s search (our strongest " + |
| "net, whoever you play against): 0.00 = the move it would have played, −1 ≈ a whole " + |
| "win thrown away. The coach reads the position while you think, so the verdict " + |
| "usually lands with your move."; |
|
|
| |
| |
| function say(message) { |
| notice = message || ""; |
| if (noticeTimer) clearTimeout(noticeTimer); |
| if (notice) { |
| noticeTimer = setTimeout(() => { |
| notice = ""; |
| if (S) renderStatus(nav.frame()); |
| }, 6000); |
| } |
| if (S) renderStatus(nav.frame()); |
| } |
|
|
| function setBusy(on) { |
| const changed = busy !== on; |
| busy = on; |
| document.body.classList.toggle("locked", on); |
| ui.deal.disabled = on || !engineReady; |
| ui.hint.disabled = on || !engineReady || !S || !S.your_turn; |
| nav.draw(); |
| |
| |
| if (changed && S) render(); |
| } |
|
|
| |
| |
| |
| const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" }); |
| let nextId = 1; |
| const pending = new Map(); |
|
|
| worker.onmessage = (event) => { |
| const msg = event.data; |
| const entry = pending.get(msg.id); |
| if (!entry) return; |
| if (msg.type === "loading" || msg.type === "progress") { |
| if (entry.onProgress) entry.onProgress(msg); |
| return; |
| } |
| pending.delete(msg.id); |
| if (msg.type === "error") entry.reject(new Error(msg.message)); |
| else entry.resolve(msg); |
| }; |
|
|
| worker.onerror = (event) => { |
| engineFailed(event.message || "the worker crashed"); |
| }; |
|
|
| |
| |
| |
| |
| |
| let workerChain = Promise.resolve(); |
|
|
| function ask(message, onProgress) { |
| const send = () => |
| new Promise((resolve, reject) => { |
| const id = nextId++; |
| pending.set(id, { resolve, reject, onProgress }); |
| worker.postMessage({ ...message, id }); |
| }); |
| const result = workerChain.then(send, send); |
| workerChain = result.catch(() => {}); |
| return result; |
| } |
|
|
| |
| function cancelSearches() { |
| worker.postMessage({ type: "cancel" }); |
| } |
|
|
| function engineFailed(reason) { |
| engineReady = false; |
| ui.bot.disabled = false; |
| ui.engineBar.classList.remove("ready"); |
| ui.engineBar.classList.add("failed"); |
| ui.engineText.textContent = "The net could not be loaded: " + reason; |
| ui.deal.disabled = true; |
| ui.hint.disabled = true; |
| status.set({ |
| headline: "The net could not be loaded", |
| detail: reason + ". Reload the page to try again.", |
| tone: "end", |
| }); |
| } |
|
|
| |
| async function bootEngine() { |
| try { |
| const manifest = await (await fetch("model/bots.json", { cache: "no-cache" })).json(); |
| if (Array.isArray(manifest.bots) && manifest.bots.length) bots = manifest.bots; |
| } catch (err) { |
| |
| } |
| await Promise.all( |
| bots.map(async (b) => { |
| try { |
| b.meta = await (await fetch(b.dir + "/model_meta.json", { cache: "no-cache" })).json(); |
| } catch (err) { |
| b.meta = null; |
| } |
| }) |
| ); |
| let storedId = null; |
| try { |
| storedId = localStorage.getItem("faience.bot"); |
| } catch (err) { |
| |
| } |
| |
| |
| bot = bots.find((b) => b.id === storedId) || strongestBot(); |
| if (bots.length > 1) { |
| ui.botField.hidden = false; |
| ui.bot.innerHTML = ""; |
| for (const b of bots) { |
| const option = document.createElement("option"); |
| option.value = b.id; |
| const elo = b.meta && typeof b.meta.elo === "number" ? ` · ${Math.round(b.meta.elo)} Elo` : ""; |
| option.textContent = b.name + elo; |
| ui.bot.appendChild(option); |
| } |
| ui.bot.value = bot.id; |
| syncBotChip(); |
| } |
| await loadBot(bot); |
| } |
|
|
| |
| async function loadBot(b) { |
| bot = b; |
| syncBotChip(); |
| try { |
| localStorage.setItem("faience.bot", b.id); |
| } catch (err) { |
| |
| } |
| engineReady = false; |
| adviserState = "none"; |
| ui.deal.disabled = true; |
| ui.bot.disabled = true; |
| ui.engineBar.classList.remove("ready"); |
| status.set({ headline: "Loading the net", detail: "it runs in this tab, so it downloads once", tone: "idle" }); |
| meta = b.meta; |
| describeModel(); |
| const sizeMB = meta && meta.onnx_bytes ? (meta.onnx_bytes / 1e6).toFixed(1) : "13"; |
| ui.engineText.textContent = `Downloading the net (${sizeMB} MB, cached after the first visit)…`; |
| let ready = null; |
| try { |
| ready = await ask( |
| { |
| type: "init", |
| |
| |
| |
| backends: ["webgpu", "wasm"].map((name) => ({ |
| name, |
| ep: BACKENDS[name].ep, |
| module: new URL(BACKENDS[name].module, import.meta.url).href, |
| wasm: new URL(BACKENDS[name].wasm, import.meta.url).href, |
| })), |
| modelUrl: new URL("../" + b.dir + "/model.onnx", import.meta.url).href, |
| }, |
| (msg) => { |
| if (msg.type !== "loading") return; |
| |
| |
| |
| const total = (meta && meta.onnx_bytes) || msg.total; |
| if (!total) return; |
| const pct = Math.min(100, Math.round((msg.received / total) * 100)); |
| ui.engineText.textContent = `Downloading the net: ${pct}% of ${(total / 1e6).toFixed(1)} MB`; |
| status.set({ |
| headline: `Downloading the net: ${pct}%`, |
| detail: "nothing is sent anywhere; the net plays from your own machine", |
| tone: "idle", |
| }); |
| } |
| ); |
| } catch (err) { |
| engineFailed(err.message); |
| return; |
| } |
| engineReady = true; |
| backend = { name: ready.backend, batch: ready.batch, margin: !!ready.margin, rate: null }; |
| ui.engineBar.classList.add("ready"); |
| ui.engineText.textContent = engineLine(); |
| describeModel(); |
| ui.deal.disabled = false; |
| ui.bot.disabled = false; |
| newGame(); |
| } |
|
|
| |
| function backendLabel() { |
| if (!backend) return "your CPU"; |
| return backend.name === "webgpu" ? "your GPU (WebGPU)" : "your CPU (WebAssembly)"; |
| } |
|
|
| function engineLine() { |
| const where = `searching on ${backendLabel()}`; |
| if (!meta) return `Net ready: ${where}.`; |
| const elo = typeof meta.elo === "number" ? `${Math.round(meta.elo)} Elo` : "unrated"; |
| const params = meta.num_params ? `${(meta.num_params / 1e6).toFixed(1)}M parameters` : ""; |
| const rate = |
| backend && backend.rate ? ` · ${Math.round(backend.rate).toLocaleString()} positions/s` : ""; |
| |
| |
| return `${botName()} · ${elo} on our internal ladder · ${params} · ${where}${rate}`; |
| } |
|
|
| function describeModel() { |
| if (!meta) { |
| ui.aboutMeta.textContent = ""; |
| return; |
| } |
| const bits = [ |
| `${meta.run}/${meta.checkpoint}`, |
| meta.games ? `${Number(meta.games).toLocaleString()} self-play games` : null, |
| meta.num_params ? `${meta.num_params.toLocaleString()} parameters` : null, |
| meta.onnx_bytes ? `${(meta.onnx_bytes / 1e6).toFixed(1)} MB ONNX` : null, |
| meta.exported_at ? `exported ${meta.exported_at.slice(0, 10)}` : null, |
| |
| |
| |
| backend |
| ? `${backend.name === "webgpu" ? "WebGPU" : "WebAssembly"}, ${backend.batch} positions per pass` |
| : null, |
| backend && backend.rate ? `${Math.round(backend.rate).toLocaleString()} positions/s here` : null, |
| backend && backend.margin ? "margin head: decisive play" : null, |
| ].filter(Boolean); |
| ui.aboutMeta.textContent = bits.join(" · "); |
| } |
|
|
| |
| |
| function strongestBot() { |
| return bots.reduce((a, b) => |
| ((b.meta && b.meta.elo) || 0) > ((a.meta && a.meta.elo) || 0) ? b : a |
| ); |
| } |
|
|
| |
| async function ensureAdviser() { |
| const top = strongestBot(); |
| if (!bot || bot.id === top.id) return true; |
| if (adviserState === "ready") return true; |
| if (adviserState === "loading") return false; |
| adviserState = "loading"; |
| say("downloading " + top.name + ", the advice net (13 MB, cached after once)"); |
| try { |
| await ask({ type: "coach", modelUrl: new URL("../" + top.dir + "/model.onnx", import.meta.url).href }); |
| adviserState = "ready"; |
| say(top.name + " is ready to advise"); |
| return true; |
| } catch (err) { |
| adviserState = "none"; |
| say("the advice net could not be loaded: " + err.message); |
| return false; |
| } |
| } |
|
|
| |
| function syncBotChip() { |
| ui.botChip.style.background = (bot && bot.swatch) || "transparent"; |
| } |
|
|
| |
| function botName() { |
| return (bot && bot.name) || (S && S.agent_name) || "the AI"; |
| } |
|
|
| function aiLabel() { |
| return S && S.agent_name ? S.agent_name : botName(); |
| } |
|
|
| |
| function seatBoards() { |
| boards.human = createBoard(el("board-human"), { |
| seat: S.human_seat, interactive: true, onPlay: route, |
| }); |
| boards.ai = createBoard(el("board-ai"), { seat: S.ai_seat }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function render() { |
| if (!S) return; |
| const frame = nav.frame(); |
| const live = !frame; |
| if (!live && proposal) proposal = null; |
| const placed = live && proposal ? proposal.move : null; |
| const heldInfo = placed || !live ? null : sel ? pickInfo(sel.source, sel.color) : committing; |
| const base = live ? S.state : frame.state; |
| const st = placed ? placedView(placed) : base; |
| const mid = placed ? st : heldInfo ? heldView(heldInfo) : base; |
| renderStatus(frame); |
| syncBanner(); |
| |
| |
| |
| const branch = !live && !placed ? branchLegal(frame) : null; |
| middle.render({ |
| state: mid, |
| legalActions: live && !placed ? S.human_legal_actions : branch || [], |
| canPick: live ? S.your_turn && !base.is_terminal && !busy && !placed : !!branch, |
| selection: null, |
| }); |
| renderHand(heldInfo); |
| boards.human.render({ |
| state: st, |
| legalActions: live && !placed ? S.human_legal_actions : [], |
| selection: live && !placed ? sel : null, |
| title: "You", |
| toMove: live && !base.is_terminal && base.current_player === S.human_seat, |
| highlightRow: live && suggestion ? suggestion.dest : undefined, |
| |
| openOnly: live && suggestion ? suggestion.dest : undefined, |
| }); |
| if (placed) glowPlacement(placed); |
| boards.ai.render({ |
| state: st, |
| title: aiLabel(), |
| chip: bot && bot.swatch, |
| toMove: live && !base.is_terminal && base.current_player === S.ai_seat, |
| }); |
| renderCounts(st); |
| renderLog(ui.log, (live ? S.log : frame.log) || []); |
| ui.cancel.classList.toggle("hidden", !live || !sel || !!placed); |
| ui.hint.disabled = busy || !engineReady || !live || !S.your_turn || !!placed; |
| syncCoach(); |
| if (live && !placed && suggestion) { |
| const dish = middle.sourceEl(suggestion.source); |
| if (dish) { |
| dish.classList.add("picked"); |
| setTimeout(() => dish.classList.remove("picked"), 1600); |
| } |
| } |
| startAnalysis(); |
| } |
|
|
| |
| function drawPosition(st) { |
| if (!st) return; |
| middle.render({ state: st, legalActions: [], canPick: false, selection: null }); |
| boards.human.render({ state: st, title: "You", toMove: false }); |
| boards.ai.render({ state: st, title: aiLabel(), toMove: false }); |
| renderCounts(st); |
| } |
|
|
| function renderStatus(frame) { |
| const st = frame ? frame.state : S.state; |
| const info = S.opponent_info || {}; |
| const rating = typeof info.elo === "number" ? " (" + Math.round(info.elo) + " Elo)" : ""; |
| ui.matchup.textContent = "you versus " + S.agent_name + rating + " · seed " + S.seed; |
| status.setScore(st.scores[S.human_seat], st.scores[S.ai_seat], "You", botName()); |
|
|
| if (frame) { |
| status.set({ |
| headline: "Viewing move " + frame.ply + " of " + frame.of, |
| detail: "← and → step through the game · End, or Latest, returns to play", |
| tone: "history", |
| }); |
| ui.prompt.textContent = branchableFrame(frame) |
| ? "A recorded position, yours to move. Pick tiles to play it differently from here." |
| : "This is a recorded position. The live game is untouched."; |
| return; |
| } |
|
|
| if (st.is_terminal) { |
| const mine = st.scores[S.human_seat]; |
| const theirs = st.scores[S.ai_seat]; |
| const verb = mine > theirs ? "You won " : theirs > mine ? botName() + " won " : "A draw, "; |
| status.set({ |
| headline: verb + mine + "–" + theirs, |
| detail: "The final scoring is below; the board stays exactly as it ended.", |
| tone: "end", |
| }); |
| ui.prompt.textContent = "Deal again for another game."; |
| return; |
| } |
| if (S.your_turn) { |
| if (proposal) { |
| status.set({ |
| headline: "Your move is placed", |
| detail: "This is the position it would leave. Validate or cancel below.", |
| tone: "you", |
| }); |
| ui.prompt.textContent = ""; |
| return; |
| } |
| status.set({ |
| headline: sel |
| ? "Your turn: pick a row for your " + COLORS[sel.color] + " tiles" |
| : "Your turn: pick a colour", |
| detail: turnDetail(), |
| tone: "you", |
| }); |
| ui.prompt.textContent = sel |
| ? "Or press Escape to put them back." |
| : "Take every tile of one colour from a factory, or from the middle."; |
| } else { |
| status.set({ headline: botName() + " is choosing a move", detail: turnDetail(), tone: "ai" }); |
| ui.prompt.textContent = ""; |
| } |
| } |
|
|
| |
| function turnDetail() { |
| const st = S.state; |
| const bits = ["Round " + (st.round + 1), st.tiles_left + " tiles left on the table"]; |
| const last = S.last_ai_move; |
| if (last) { |
| bits.push(botName() + " " + last.text); |
| if (last.search_text) bits.push(last.search_text); |
| } |
| if (notice) bits.push(notice); |
| return bits.join(" · "); |
| } |
|
|
| |
| |
| function renderCounts(st) { |
| const sum = (counts) => counts.reduce((a, b) => a + b, 0); |
| ui.counts.innerHTML = ""; |
| ui.counts.appendChild(node("span", "count", "Bag " + sum(st.bag))); |
| const lid = node("span", "count", "Lid " + sum(st.lid)); |
| lid.id = "lid-row"; |
| ui.counts.appendChild(lid); |
| ui.counts.appendChild(node("span", "count", st.tiles_left + " on the table")); |
| } |
|
|
| |
| |
| function currentThinkTime() { |
| const seconds = Number(ui.think.value); |
| return Number.isFinite(seconds) && seconds > 0 ? seconds : 0; |
| } |
|
|
| |
| function coachBudget() { |
| return Math.min(currentThinkTime() || COACH_THINK_S, COACH_MAX_THINK_S); |
| } |
|
|
| |
| async function think(state, onThinking) { |
| const budgetS = session ? session.thinkTimeS : 0; |
| liveSims = 0; |
| const reply = await ask( |
| { type: budgetS > 0 ? "search" : "policy", setup: state.toSetup(), budgetS }, |
| (msg) => { |
| if (msg.type !== "progress") return; |
| liveSims = msg.sims; |
| if (onThinking) onThinking(msg); |
| } |
| ); |
| |
| |
| if (backend && reply.search && reply.search.rate && !backend.rate) { |
| backend.rate = reply.search.rate; |
| ui.engineText.textContent = engineLine(); |
| describeModel(); |
| } |
| return { action: reply.action, search: reply.search }; |
| } |
|
|
| function newGame(event) { |
| if (event) event.preventDefault(); |
| if (!engineReady) { |
| say("the net is still loading"); |
| return; |
| } |
| let seed = Math.floor(Math.random() * (1 << 30)); |
| const typed = ui.seed.value.trim(); |
| if (typed) { |
| if (!/^-?\d+$/.test(typed)) { |
| say("the seed must be a whole number"); |
| return; |
| } |
| seed = Number(typed) >>> 0; |
| } |
| clearScoring(ui.scoring); |
| hideRecord(); |
| forked = null; |
| |
| |
| if (session && !session.state.isTerminal) shareRecord(currentRecord()); |
| session = new GameSession({ |
| seed, |
| humanPlaysFirst: ui.first.checked, |
| agentName: botName(), |
| opponentInfo: meta ? { checkpoint: meta.checkpoint, elo: meta.elo, run: meta.run, name: botName() } : { name: botName() }, |
| thinkTimeS: currentThinkTime(), |
| think, |
| }); |
| adopt({ reseat: true }); |
| say("new tiles dealt"); |
| track("game-start"); |
| |
| if (session.aiTurn) resumeIfPending(); |
| } |
|
|
| function adopt(options) { |
| const first = !S || (options && options.reseat); |
| S = session.snapshot(); |
| sel = null; |
| suggestion = null; |
| proposal = null; |
| committing = null; |
| if (first) { |
| seatBoards(); |
| nav.reset(); |
| } |
| noteFrames((options && options.moves) || []); |
| render(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function noteFrames(moves) { |
| moves.forEach((m) => { |
| if (m && m.state_before && typeof m.ply === "number") nav.note(m.ply - 1, m.state_before); |
| }); |
| nav.note(S.ply, S.state); |
| } |
|
|
| function pick(source, color) { |
| if (busy || !S || proposal) return; |
| if (nav.browsing()) { |
| if (!forkToBrowsed()) return; |
| |
| } |
| if (!S.your_turn) return; |
| suggestion = null; |
| if (sel && sel.source === source && sel.color === color) { |
| sel = null; |
| render(); |
| return; |
| } |
| |
| const takenRects = middle.sourceTiles(source, color).map((t) => t.getBoundingClientRect()); |
| const chip = |
| source === CENTER && S.state.marker_in_center |
| ? middle.centerEl().querySelector(".marker") |
| : null; |
| const markerRect = chip ? chip.getBoundingClientRect() : null; |
| const restRects = {}; |
| if (source !== CENTER) { |
| middle.remainderTiles(source, color).forEach((t) => { |
| (restRects[t.dataset.color] = restRects[t.dataset.color] || []).push( |
| t.getBoundingClientRect() |
| ); |
| }); |
| } |
| sel = { source, color }; |
| flyInto(() => { |
| const plan = []; |
| const handTiles = [].slice.call(ui.handTiles.querySelectorAll(".tile")); |
| takenRects.forEach((r, i) => plan.push([r, handTiles[i], color])); |
| if (markerRect) plan.push([markerRect, ui.handTiles.querySelector(".marker"), "marker"]); |
| Object.keys(restRects).forEach((c) => { |
| const now = middle.centerEl().querySelectorAll('.tile[data-color="' + c + '"]'); |
| const olds = restRects[c]; |
| olds.forEach((r, i) => plan.push([r, now[now.length - olds.length + i], Number(c)])); |
| }); |
| return plan; |
| }); |
| } |
|
|
| |
| |
| function pickInfo(source, color) { |
| return { |
| source, |
| color, |
| count: poolCount(S.state, source, color), |
| took_marker: source === CENTER && S.state.marker_in_center, |
| }; |
| } |
|
|
| |
| |
| function heldView(info) { |
| const st = structuredClone(S.state); |
| if (info.source === CENTER) { |
| st.center[info.color] = 0; |
| if (info.took_marker) st.marker_in_center = false; |
| } else { |
| const dish = st.factories[info.source]; |
| dish[info.color] = 0; |
| for (let c = 0; c < COLORS.length; c++) { |
| st.center[c] += dish[c]; |
| dish[c] = 0; |
| } |
| } |
| return st; |
| } |
|
|
| |
| function placedView(move) { |
| const st = heldView(move); |
| const me = st.players[S.human_seat]; |
| if (move.dest !== FLOOR && move.placed) { |
| const line = me.pattern_lines[move.dest]; |
| line.color = move.color; |
| line.count += move.placed; |
| } |
| if (move.to_floor) me.floor[move.color] += move.to_floor; |
| if (move.to_lid) st.lid[move.color] += move.to_lid; |
| if (move.took_marker) me.floor_marker = true; |
| const occupied = |
| me.floor.reduce((a, b) => a + b, 0) + (me.floor_marker ? 1 : 0); |
| me.floor_penalty = CUM_PENALTY[Math.min(7, occupied)]; |
| st.tiles_left -= move.count; |
| return st; |
| } |
|
|
| |
| function renderHand(info) { |
| ui.handTiles.innerHTML = ""; |
| if (!info || !info.count) { |
| ui.hand.hidden = true; |
| return; |
| } |
| for (let k = 0; k < info.count; k++) ui.handTiles.appendChild(tileEl(info.color)); |
| if (info.took_marker) ui.handTiles.appendChild(markerChip(true)); |
| ui.hand.hidden = false; |
| } |
|
|
| |
| function glowPlacement(move) { |
| placedTiles(move).forEach((t) => t.classList.add("proposed")); |
| } |
|
|
| |
| function placedTiles(move) { |
| const board = boards.human; |
| const out = []; |
| if (move.dest !== FLOOR && move.placed) { |
| out.push.apply(out, board.lineTiles(move.dest).slice(-move.placed)); |
| } |
| if (move.to_floor) { |
| const mine = board.floorTiles().filter((t) => Number(t.dataset.color) === move.color); |
| out.push.apply(out, mine.slice(-move.to_floor)); |
| } |
| if (move.took_marker) { |
| const floor = board.floorEl(); |
| const chip = floor && floor.querySelector(".marker"); |
| if (chip) out.push(chip); |
| } |
| return out; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function flyInto(find) { |
| render(); |
| if (!animated()) return; |
| const plan = find() || []; |
| const flights = []; |
| const covered = []; |
| plan.forEach(([rect, target, color]) => { |
| if (!rect || !target) return; |
| if (target.style) { |
| target.style.visibility = "hidden"; |
| covered.push(target); |
| } |
| flights.push({ from: rect, to: target, color, hide: false }); |
| }); |
| await flyTiles(flights, { layer: ui.fly }); |
| covered.forEach((elm) => { |
| elm.style.visibility = ""; |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function route(id) { |
| if (busy || !session || !S || !S.your_turn || nav.browsing() || proposal) return; |
| if (!confirmOn()) { |
| play(id); |
| return; |
| } |
| propose(id); |
| } |
|
|
| |
| |
| function syncBanner() { |
| const on = !!proposal && !busy; |
| ui.confirmBar.hidden = !on; |
| ui.confirmBar.parentElement.classList.toggle("placing", on); |
| if (on) { |
| ui.confirmDetail.textContent = |
| "You " + proposal.move.text + ". Nothing is final until you play it."; |
| } |
| } |
|
|
| |
| function propose(id) { |
| const move = describeAction(session.state, id); |
| |
| const rects = [].slice |
| .call(ui.handTiles.querySelectorAll(".tile")) |
| .map((t) => t.getBoundingClientRect()); |
| const chip = ui.handTiles.querySelector(".marker"); |
| const markerRect = chip ? chip.getBoundingClientRect() : null; |
| proposal = { id, move }; |
| sel = null; |
| flyInto(() => { |
| const landed = placedTiles(move); |
| const lidRect = |
| move.to_lid > 0 ? lidTarget().getBoundingClientRect() : null; |
| const plan = []; |
| rects.forEach((r, i) => { |
| |
| const target = landed[i] || lidRect; |
| plan.push([r, target, move.color]); |
| }); |
| if (markerRect) { |
| const floor = boards.human.floorEl(); |
| plan.push([markerRect, floor && floor.querySelector(".marker"), "marker"]); |
| } |
| return plan; |
| }); |
| } |
|
|
| |
| function cancelMove() { |
| proposal = null; |
| sel = null; |
| suggestion = null; |
| render(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function play(id) { |
| if (busy || !session || !S || !S.your_turn || nav.browsing()) return; |
| const move = describeAction(session.state, id); |
| const committed = !!(proposal && proposal.id === id); |
| |
| const fromHand = !committed && !ui.hand.hidden; |
| const handRects = fromHand |
| ? [].slice.call(ui.handTiles.querySelectorAll(".tile")).map((t) => t.getBoundingClientRect()) |
| : null; |
| const handChip = fromHand ? ui.handTiles.querySelector(".marker") : null; |
| const markerRect = handChip ? handChip.getBoundingClientRect() : null; |
| if (!committed) committing = move; |
| sel = null; |
| suggestion = null; |
| setBusy(true); |
| clearScoring(ui.scoring); |
| try { |
| const canCoach = coachOn && (!bot || bot.id === strongestBot().id || adviserState === "ready"); |
| const setupBefore = canCoach ? session.state.toSetup() : null; |
| |
| |
| |
| |
| |
| const reading = |
| analysis && analysis.forSession === session && analysis.ply === S.ply |
| ? analysis |
| : null; |
| cancelSearches(); |
| const headStart = canCoach ? reading : null; |
| const { move: applied, reports } = session.playHuman(id); |
| forked = null; |
| const entry = session.log[applied.log_n]; |
| if (setupBefore && entry) { |
| if (headStart) { |
| entry.coach = { pending: true }; |
| finishFromAnalysis(headStart, id, entry, setupBefore); |
| } else { |
| queueRating(setupBefore, id, entry); |
| } |
| } |
| const takeoff = committed |
| ? { skip: true } |
| : handRects |
| ? { fromRects: handRects, markerRect } |
| : null; |
| await settle([applied], boards.human, reports, "human", takeoff); |
| if (session.aiTurn) await runAiTurn(); |
| } catch (err) { |
| status.stopClock(); |
| say(err.message); |
| adopt(); |
| } finally { |
| committing = null; |
| setBusy(false); |
| resumeIfPending(); |
| flushRatings(); |
| } |
| } |
|
|
| |
| async function resumeIfPending() { |
| if (busy || !session || !session.aiTurn) return; |
| setBusy(true); |
| try { |
| await runAiTurn(); |
| } catch (err) { |
| status.stopClock(); |
| say(err.message); |
| adopt(); |
| } finally { |
| setBusy(false); |
| } |
| } |
|
|
| |
| async function runAiTurn() { |
| const firstReport = session.roundReports.length; |
| const budget = session.thinkTimeS; |
| liveSims = 0; |
| const thinking = session.aiReplies(); |
| status.set({ headline: botName() + " is thinking", detail: turnDetail(), tone: "ai", keepClock: true }); |
| status.startClock({ |
| budget, |
| label: (spent, cap) => { |
| |
| const counted = liveSims ? " · " + liveSims.toLocaleString() + " positions" : ""; |
| return cap |
| ? botName() + " is thinking: " + spent.toFixed(1) + "s of " + cap + "s" + counted |
| : botName() + " is picking a move"; |
| }, |
| }); |
| let moves; |
| try { |
| moves = await thinking; |
| } finally { |
| status.stopClock(); |
| } |
| await settle(moves, boards.ai, session.reportsSince(firstReport), "ai"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function playMoves(moves, board, reports, mover, takeoff) { |
| let taken = 0; |
| for (let i = 0; i < moves.length; i++) { |
| const move = moves[i]; |
| if (i > 0 && move.state_before) { |
| drawPosition(move.state_before); |
| await sleep(420); |
| } |
| if (mover === "ai") { |
| status.set({ |
| headline: botName() + " " + move.text, |
| detail: move.search_text || turnDetail(), |
| tone: "ai", |
| }); |
| } |
| await animateTake(move, board, middle, i === 0 ? takeoff : null); |
| |
| |
| document.dispatchEvent( |
| new CustomEvent("azul:animated", { detail: { ply: move.ply, side: move.side } }) |
| ); |
| if (move.ended_round && reports[taken]) { |
| const report = reports[taken++]; |
| status.set({ |
| headline: "Round " + (report.round + 1) + " scoring", |
| detail: "full lines move to the wall, the floor line goes to the lid", |
| tone: "scoring", |
| }); |
| await animateTiling(report); |
| } |
| } |
| } |
|
|
| |
| async function settle(moves, board, reports, mover, takeoff) { |
| await playMoves(moves, board, reports, mover, takeoff); |
| if (reports.length) clearPops(ui.pops); |
| adopt({ moves }); |
| if (reports.length) { |
| const last = reports[reports.length - 1]; |
| flashWall(last); |
| if (!last.game_over) renderRoundPanel(ui.scoring, last, sides()); |
| } |
| if (S.state.is_terminal && S.final) { |
| renderFinalPanel(ui.scoring, S.final, sides()); |
| showRecord(); |
| |
| |
| shareRecord(currentRecord()); |
| |
| |
| const model = meta ? meta.run + "-" + meta.checkpoint : "unknown"; |
| const result = |
| S.final.winner_side === "human" |
| ? "human-wins" |
| : S.final.winner_side === "ai" |
| ? "net-wins" |
| : "draw"; |
| track("game-end/" + model + "/" + result, { |
| title: |
| S.state.scores[S.human_seat] + "–" + S.state.scores[S.ai_seat] + |
| " in " + S.final.rounds_played + " rounds", |
| }); |
| } else if (mover === "ai" && S.last_ai_move) { |
| |
| status.set({ headline: botName() + " " + S.last_ai_move.text, detail: turnDetail(), tone: "ai" }); |
| await sleep(500); |
| } |
| render(); |
| } |
|
|
| function sides() { |
| return [[S.human_seat, "You"], [S.ai_seat, aiLabel()]]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const ratings = []; |
| let ratingNow = false; |
|
|
| function queueRating(setup, actionId, entry) { |
| if (!entry) return; |
| entry.coach = { pending: true }; |
| ratings.push({ setup, actionId, entry, forSession: session }); |
| } |
|
|
| |
| async function flushRatings() { |
| if (ratingNow) return; |
| ratingNow = true; |
| try { |
| while (ratings.length) { |
| const job = ratings.shift(); |
| let verdict; |
| try { |
| const reply = await ask({ |
| type: "rate", |
| setup: job.setup, |
| actionId: job.actionId, |
| budgetS: coachBudget(), |
| }); |
| verdict = reply.coach; |
| } catch (err) { |
| verdict = { unrated: true, reason: err.message }; |
| } |
| job.entry.coach = verdict; |
| |
| |
| if (job.forSession === session && S && !busy) render(); |
| } |
| } finally { |
| ratingNow = false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const ANALYSIS_MIN_SIMS = 400; |
|
|
| function startAnalysis() { |
| if (!coachOn || !engineReady || busy || !session || !S || !S.your_turn) return; |
| if (bot && bot.id !== strongestBot().id && adviserState !== "ready") return; |
| if (analysis && analysis.forSession === session && analysis.ply === S.ply) return; |
| const job = { forSession: session, ply: S.ply, budgetS: coachBudget() }; |
| job.promise = ask({ type: "analyze", setup: session.state.toSetup(), budgetS: job.budgetS }) |
| .then((reply) => reply.analysis) |
| .catch(() => null); |
| analysis = job; |
| } |
|
|
| |
| function verdictFrom(a, budgetS, actionId) { |
| const base = { budgetS, legal: a.legal, sims: a.sims, elapsedS: a.elapsedS }; |
| if (a.forced) return { ...base, delta: 0, forced: true }; |
| const explored = a.children || []; |
| if (!explored.length) { |
| return { ...base, unrated: true, reason: "the search had no time to explore this position" }; |
| } |
| const best = explored.reduce((x, y) => (y.q > x.q ? y : x)); |
| const mine = explored.find((c) => c.action === actionId); |
| if (!mine) { |
| return { ...base, unrated: true, reason: "the search never explored this move" }; |
| } |
| return { |
| ...base, |
| delta: Math.min(0, mine.q - best.q), |
| your_q: mine.q, |
| best_q: best.q, |
| visits: mine.visits, |
| best_visits: best.visits, |
| best_text: a.best_text, |
| explored: explored.length, |
| }; |
| } |
|
|
| |
| async function finishFromAnalysis(job, actionId, entry, setup) { |
| const a = await job.promise; |
| if (!a || (!a.forced && a.sims < ANALYSIS_MIN_SIMS)) { |
| |
| queueRating(setup, actionId, entry); |
| flushRatings(); |
| return; |
| } |
| entry.coach = verdictFrom(a, job.budgetS, actionId); |
| if (job.forSession === session && S && !busy) render(); |
| } |
|
|
| function syncCoach() { |
| ui.coach.checked = coachOn; |
| ui.coach.disabled = !engineReady; |
| ui.coachField.classList.toggle("off", !coachOn); |
| ui.coachLegend.textContent = coachLegend(); |
| ui.coachLegend.hidden = !coachOn; |
| } |
|
|
| |
| |
| async function askHint() { |
| if (!session || !S || !S.your_turn || busy || nav.browsing() || proposal) return; |
| setBusy(true); |
| try { |
| if (!(await ensureAdviser())) return; |
| const reply = await ask({ type: "policy", setup: session.state.toSetup() }); |
| const move = describeAction(session.state, reply.action); |
| suggestion = { source: move.source, color: move.color, dest: move.dest }; |
| sel = { source: move.source, color: move.color }; |
| render(); |
| say(strongestBot().name + " would " + move.text); |
| } catch (err) { |
| say(err.message); |
| } finally { |
| setBusy(false); |
| } |
| } |
|
|
| |
| function lidTarget() { |
| return document.getElementById("lid-row") || ui.counts; |
| } |
|
|
| |
| function travelTargets(board, move) { |
| const targets = []; |
| if (move.dest !== FLOOR && move.placed) { |
| const slots = board.lineSlots(move.dest); |
| targets.push.apply(targets, slots.slice(Math.max(0, slots.length - move.placed))); |
| } |
| const floorSlots = board.floorSlots(); |
| let taken = move.took_marker ? 1 : 0; |
| while (targets.length < move.count && taken < floorSlots.length) { |
| targets.push(floorSlots[taken++]); |
| } |
| const lid = lidTarget(); |
| while (targets.length < move.count) targets.push(lid); |
| return targets; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function animateTake(move, board, table, takeoff) { |
| if (takeoff && takeoff.skip) { |
| |
| |
| popFloorCost(move, board); |
| return; |
| } |
| const dish = table.sourceEl(move.source); |
| const row = board.lineRow(move.dest); |
| if (dish) dish.classList.add("picked"); |
| if (row) row.classList.add("incoming"); |
|
|
| const targets = travelTargets(board, move); |
| let flights; |
| if (takeoff && takeoff.fromRects) { |
| |
| |
| ui.hand.hidden = true; |
| ui.handTiles.innerHTML = ""; |
| flights = takeoff.fromRects.map((r, i) => ({ from: r, to: targets[i], color: move.color })); |
| if (move.took_marker && takeoff.markerRect) { |
| const slot = board.floorSlots()[0]; |
| if (slot) flights.push({ from: takeoff.markerRect, to: slot, color: "marker" }); |
| } |
| } else { |
| |
| const taken = table.sourceTiles(move.source, move.color, move.count); |
| flights = taken.map((from, i) => ({ from, to: targets[i], color: move.color })); |
| const centreEl = table.centerEl(); |
| table.remainderTiles(move.source, move.color).forEach((from) => { |
| flights.push({ from, to: centreEl, color: Number(from.dataset.color) }); |
| }); |
| if (move.took_marker) { |
| const chip = centreEl.querySelector(".marker"); |
| const slot = board.floorSlots()[0]; |
| if (chip && slot) flights.push({ from: chip, to: slot, color: "marker" }); |
| } |
| } |
|
|
| await flyTiles(flights, { layer: ui.fly }); |
| popFloorCost(move, board); |
| if (dish) dish.classList.remove("picked"); |
| if (row) row.classList.remove("incoming"); |
| } |
|
|
| |
| function popFloorCost(move, board) { |
| const dropped = (move.to_floor || 0) + (move.took_marker ? 1 : 0); |
| if (!dropped || !move.state_before) return; |
| const me = move.state_before.players[move.player]; |
| if (!me) return; |
| const occupied = me.floor.reduce((a, b) => a + b, 0) + (me.floor_marker ? 1 : 0); |
| const cost = |
| CUM_PENALTY[Math.min(7, occupied + dropped)] - CUM_PENALTY[Math.min(7, occupied)]; |
| if (!cost) return; |
| popScore(ui.pops, board.floorEl(), String(cost).replace("-", "−"), "loss"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function onNavChange(change) { |
| const token = ++navToken; |
| const plan = change ? planNavStep(change) : null; |
| render(); |
| if (plan) flyNavStep(plan, token); |
| } |
|
|
| function planNavStep(change) { |
| if (!animated() || !S) return null; |
| const delta = change.to - change.from; |
| if (Math.abs(delta) !== 1) return null; |
| const ply = Math.max(change.from, change.to); |
| const entry = (S.log || []).find((e) => e.kind === "move" && e.ply === ply); |
| if (!entry || typeof entry.color !== "number") return null; |
| const a = nav.stateAt(change.from); |
| const b = nav.stateAt(change.to) || (change.to === nav.latest() ? S.state : null); |
| if (!a || !b || a.round !== b.round) return null; |
| const board = entry.side === "human" ? boards.human : boards.ai; |
| const placed = Math.max(0, entry.count - (entry.overflow || 0)); |
| const floorCount = entry.dest === FLOOR ? entry.count : entry.overflow || 0; |
| let from; |
| if (delta > 0) { |
| |
| from = middle.sourceTiles(entry.source, entry.color, entry.count); |
| } else { |
| |
| const rowTiles = entry.dest !== FLOOR ? board.lineTiles(entry.dest).slice(-placed) : []; |
| from = rowTiles.concat(floorCount ? board.floorTiles().slice(-floorCount) : []); |
| } |
| const rects = from.map((elm) => elm.getBoundingClientRect()); |
| return { dir: delta, entry, board, placed, floorCount, rects }; |
| } |
|
|
| |
| async function flyNavStep(plan, token) { |
| if (token !== navToken) return; |
| const { entry, board } = plan; |
| let dests; |
| if (plan.dir > 0) { |
| const rowTiles = |
| entry.dest !== FLOOR ? board.lineTiles(entry.dest).slice(-plan.placed) : []; |
| dests = rowTiles.concat( |
| plan.floorCount ? board.floorTiles().slice(-plan.floorCount) : [] |
| ); |
| } else { |
| dests = middle.sourceTiles(entry.source, entry.color, entry.count); |
| } |
| const flights = []; |
| const covered = []; |
| dests.forEach((elm, i) => { |
| const from = plan.rects[i]; |
| if (!from || !elm) return; |
| elm.style.visibility = "hidden"; |
| covered.push(elm); |
| flights.push({ from, to: elm, color: entry.color, hide: false }); |
| }); |
| await flyTiles(flights, { layer: ui.fly, duration: 300, stagger: 35 }); |
| covered.forEach((elm) => { |
| elm.style.visibility = ""; |
| }); |
| } |
|
|
| |
| |
| |
| |
| async function animateTiling(report) { |
| if (!report) return; |
| const lidEl = lidTarget(); |
| |
| |
| let beatOffset = 0; |
| for (const [seat, board] of [[S.human_seat, boards.human], [S.ai_seat, boards.ai]]) { |
| const player = report.players[seat]; |
| if (!player) continue; |
| const wall = []; |
| const pops = []; |
| player.tiles.forEach((t) => { |
| const tiles = board.lineTiles(t.row); |
| const from = tiles[tiles.length - 1] || board.lineRow(t.row); |
| const to = board.wallCell(t.row, t.col); |
| if (from && to) { |
| wall.push({ from, to, color: t.color, hide: false }); |
| |
| |
| |
| pops.push({ row: t.row, col: t.col, text: "+" + t.points }); |
| } |
| }); |
| const lid = board.floorTiles().map((from) => ({ |
| from, to: lidEl, color: Number(from.dataset.color), |
| })); |
| |
| |
| const land = scaled(520); |
| const beat = scaled(70); |
| pops.forEach((p, i) => { |
| setTimeout( |
| () => popScore(ui.pops, board.wallCell(p.row, p.col), p.text), |
| land ? land + i * beat : beatOffset + i * 240 |
| ); |
| }); |
| beatOffset += pops.length * 240; |
| await flyTiles(wall, { layer: ui.fly, duration: 520, stagger: 70 }); |
| if (player.floor.penalty) { |
| popScore(ui.pops, board.floorEl(), String(player.floor.penalty).replace("-", "−"), "loss"); |
| } |
| if (lid.length) { |
| lidEl.classList.add("receiving"); |
| await flyTiles(lid, { layer: ui.fly, duration: 420, stagger: 40 }); |
| setTimeout(() => lidEl.classList.remove("receiving"), 400); |
| } |
| |
| |
| |
| if (typeof player.delta === "number" && player.delta !== 0) { |
| board.scoreCount(player.score_before, player.delta, player.score_after, { |
| hold: scaled(1300) || 600, |
| fade: scaled(1100) || 500, |
| }); |
| await sleep(700); |
| } |
| } |
| } |
|
|
| |
| function flashWall(report) { |
| [[S.human_seat, boards.human], [S.ai_seat, boards.ai]].forEach(([seat, board]) => { |
| const player = report.players[seat]; |
| if (!player) return; |
| player.tiles.forEach((t, i) => { |
| const cell = board.wallTile(t.row, t.col); |
| if (!cell) return; |
| cell.style.animationDelay = i * 70 + "ms"; |
| cell.classList.add("landing"); |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| function branchLegal(frame) { |
| if (!branchableFrame(frame)) return null; |
| if (branchCache.forSession === session && branchCache.ply === frame.ply) return branchCache.legal; |
| let legal = null; |
| try { |
| const replay = AzulState.newGame(session.seed, new Rng(session.seed)); |
| for (const e of session.log) { |
| if (e.kind === "move" && e.ply <= frame.ply) replay.apply(e.action_id); |
| } |
| legal = replay.legalActions(); |
| } catch (err) { |
| legal = null; |
| } |
| branchCache = { forSession: session, ply: frame.ply, legal }; |
| return legal; |
| } |
|
|
| function branchableFrame(frame) { |
| if (!frame || !session || !S || busy || !engineReady) return false; |
| const st = frame.state; |
| return !!st && !st.is_terminal && st.current_player === S.human_seat; |
| } |
|
|
| function forkToBrowsed() { |
| const frame = nav.frame(); |
| if (!branchableFrame(frame)) return false; |
| return forkTo(frame.ply); |
| } |
|
|
| function forkTo(ply) { |
| const prior = session; |
| const prefix = prior.log.filter((e) => e.kind === "move" && e.ply <= ply); |
| const thinks = new Map( |
| prior.log.filter((e) => e.kind === "think" && e.ply <= ply).map((e) => [e.ply, e]) |
| ); |
| const fresh = new GameSession({ |
| seed: prior.seed, |
| humanPlaysFirst: prior.humanPlaysFirst, |
| agentName: prior.agentName, |
| opponentInfo: prior.opponentInfo, |
| thinkTimeS: prior.thinkTimeS, |
| think, |
| }); |
| const replayed = []; |
| try { |
| for (const entry of prefix) { |
| replayed.push(fresh._apply(entry.action_id)); |
| const t = thinks.get(entry.ply); |
| if (t) { |
| |
| |
| fresh._logEntry("think", t.text, { |
| ply: t.ply, |
| ...(Number.isFinite(t.sims) ? { sims: t.sims } : {}), |
| ...(Number.isFinite(t.value) ? { value: t.value } : {}), |
| }); |
| } |
| } |
| } catch (err) { |
| say("this position cannot be branched: " + err.message); |
| return false; |
| } |
| if (fresh.ply !== ply) { |
| say("this position cannot be branched"); |
| return false; |
| } |
| |
| if (!prior.state.isTerminal) { |
| shareRecord(buildRecord(prior, { net: meta || {}, backend: backend ? backend.name : null })); |
| } |
| forked = prior; |
| session = fresh; |
| clearScoring(ui.scoring); |
| hideRecord(); |
| adopt({ reseat: true }); |
| noteFrames(replayed); |
| say("branched at move " + ply + ". Escape goes back to the old line."); |
| return true; |
| } |
|
|
| |
| function unfork() { |
| if (!forked || busy) return false; |
| session = forked; |
| forked = null; |
| sel = null; |
| suggestion = null; |
| adopt({ reseat: true }); |
| say("back on the original line"); |
| return true; |
| } |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| function currentRecord() { |
| if (!session) return null; |
| return buildRecord(session, { net: meta || {}, backend: backend ? backend.name : null }); |
| } |
|
|
| |
| function recordInvite() { |
| return sharingOn() |
| ? "Finished, and shared: the moves, the tiles that were dealt, and which " + |
| "net you played are on their way to the public training pile. Thank " + |
| "you. You can keep a copy too, or turn sharing off in Settings." |
| : "Finished. Sharing is off, so nothing was sent. If you would like this " + |
| "game to help train the net, you can save it and send it to me, or " + |
| "turn sharing on in Settings."; |
| } |
|
|
| function showRecord() { |
| ui.recordNote.textContent = recordInvite(); |
| ui.record.classList.remove("done"); |
| ui.record.hidden = false; |
| } |
|
|
| function hideRecord() { |
| ui.record.hidden = true; |
| ui.record.classList.remove("done"); |
| } |
|
|
| |
| function recordSaid(message) { |
| ui.recordNote.textContent = message; |
| ui.record.classList.add("done"); |
| } |
|
|
| |
| |
| window.faience = { record: currentRecord, log: () => (session ? session.log : []) }; |
|
|
| |
| ui.recordSave.addEventListener("click", () => { |
| const record = currentRecord(); |
| if (!record) return; |
| recordSaid( |
| downloadRecord(record) |
| ? "Saved. A copy of the game is yours to keep, or to send to me if sharing is off." |
| : "This browser would not save the file. Try Copy as text instead." |
| ); |
| }); |
| ui.recordCopy.addEventListener("click", async () => { |
| const record = currentRecord(); |
| if (!record) return; |
| const ok = await copyRecord(record); |
| recordSaid( |
| ok |
| ? "Copied. Paste it anywhere; a GitHub issue on the repo still reaches me." |
| : "This browser would not let the page copy. Use Save this game instead." |
| ); |
| }); |
|
|
| |
| |
| |
| |
| window.addEventListener("pagehide", () => { |
| if (session && !session.state.isTerminal) shareOnLeave(currentRecord()); |
| }); |
|
|
| |
| |
| warmCollector(); |
|
|
| ui.setup.addEventListener("submit", newGame); |
| ui.bot.addEventListener("change", () => { |
| const chosen = bots.find((b) => b.id === ui.bot.value); |
| if (!chosen || (bot && chosen.id === bot.id)) return; |
| if (busy) { |
| |
| ui.bot.value = bot ? bot.id : ""; |
| return; |
| } |
| loadBot(chosen); |
| }); |
| ui.think.addEventListener("change", () => { |
| if (session) session.thinkTimeS = currentThinkTime(); |
| if (S) render(); |
| }); |
| ui.hint.addEventListener("click", askHint); |
| ui.confirm.addEventListener("click", () => { |
| if (!proposal || busy) return; |
| play(proposal.id); |
| |
| }); |
| ui.confirmCancel.addEventListener("click", cancelMove); |
| ui.cancel.addEventListener("click", () => { |
| sel = null; |
| suggestion = null; |
| render(); |
| }); |
| ui.coach.addEventListener("change", async () => { |
| coachOn = ui.coach.checked; |
| syncCoach(); |
| if (coachOn) { |
| await ensureAdviser(); |
| startAnalysis(); |
| say("coach mode on: your moves are scored by " + strongestBot().name + "'s own search"); |
| } else { |
| say("coach mode off"); |
| } |
| }); |
| document.addEventListener("keydown", (event) => { |
| if (event.key !== "Escape") return; |
| if (nav.browsing()) { |
| nav.toLatest(); |
| return; |
| } |
| if (proposal && !busy) { |
| cancelMove(); |
| return; |
| } |
| if (sel) { |
| sel = null; |
| suggestion = null; |
| render(); |
| return; |
| } |
| if (forked && !busy) { |
| unfork(); |
| render(); |
| } |
| }); |
| |
| bindHistoryKeys(nav, { enabled: () => !busy }); |
|
|
| |
| |
| |
| el("corner-about").addEventListener("click", () => { |
| const about = el("about"); |
| about.scrollIntoView({ behavior: "smooth", block: "start" }); |
| about.classList.add("lit"); |
| setTimeout(() => about.classList.remove("lit"), 1600); |
| }); |
| if (analyticsOn()) { |
| el("corner-stats").href = statsUrl(); |
| track("pageview"); |
| } else { |
| el("corner-stats").remove(); |
| const note = el("tally-note"); |
| if (note) note.remove(); |
| } |
|
|
| |
| |
| |
| const NEWS_VERSION = "2026-08-21b"; |
| |
| const NEWS_LINES = [ |
| "Choose your opponent's strength in the top bar.", |
| "Go back with ← and play any of your past moves differently.", |
| ]; |
| (() => { |
| let seen = null; |
| try { |
| seen = localStorage.getItem("faience.news"); |
| } catch (err) { |
| |
| } |
| if (seen === NEWS_VERSION) return; |
| const news = el("news"); |
| NEWS_LINES.forEach((line) => el("news-text").appendChild(node("span", "news-line", line))); |
| news.hidden = false; |
| el("news-close").addEventListener("click", () => { |
| news.hidden = true; |
| try { |
| localStorage.setItem("faience.news", NEWS_VERSION); |
| } catch (err) { |
| |
| } |
| }); |
| })(); |
|
|
| syncCoach(); |
| bootEngine(); |
|
|