"use strict"; const OPPONENT = "Columbina"; // the computer's display name const THINK_MS = 450; const SQUARE_NAMES = ["top left", "top middle", "top right", "middle left", "center", "middle right", "bottom left", "bottom middle", "bottom right"]; // pause before the computer's move shows, so it's easy to follow const $ = id => document.getElementById(id); const board = createBoard($("board"), cell => request({ cell })); const EMPTY = { board: Array(9).fill(null), history: [[], []], turn: 0, winner: null, winLine: null, doomed: null }; let state = EMPTY; let human = 0; // the computer plays the other side let requestId = 0; // bumped whenever the situation changes, so stale replies are dropped let thinkTimer = null; let lastComputerMove = null; // {cell, how} from the server, shown under "Spoilers" let past = []; // earlier {state, lastComputerMove}, oldest first, for undo // Sends the position to the server: with `cell` to play it, without to get a computer move. async function request(extra) { const id = ++requestId; clearTimeout(thinkTimer); board.render(state, false); // no second click while this move is on its way try { const res = await fetch("/api/play", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ history: state.history, turn: state.turn, accuracy: accuracy(), ...extra }), }); const body = await res.json(); if (id !== requestId) return; if (!res.ok) throw new Error(body.error || "Something went wrong"); past.push({ state, lastComputerMove }); state = body; if (body.computerMove) lastComputerMove = body.computerMove; $("meta").textContent = ""; } catch (e) { if (id === requestId) $("meta").textContent = e.message; } if (id === requestId) update(); } // Renders, then lets the computer move if it's its turn. function update() { const computerTurn = state.winner === null && state.turn !== human; render(computerTurn); clearTimeout(thinkTimer); if (computerTurn) thinkTimer = setTimeout(() => request({}), THINK_MS); } function render(computerTurn) { board.render(state, !computerTurn && state.winner === null); for (const i of [0, 1]) { const el = $("player-" + i); el.querySelector(".who").textContent = i === human ? "You" : OPPONENT; el.classList.toggle("active", state.winner === null ? state.turn === i : state.winner === i); el.setAttribute("aria-pressed", String(i === human)); } $("status").textContent = state.winner !== null ? (state.winner === human ? "You win!" : `${OPPONENT} wins`) : computerTurn ? `${OPPONENT} is thinking…` : "Your turn"; $("new-game").textContent = state.winner === null ? "New game" : "Play again"; $("spoiler-text").innerHTML = spoilerText(); $("undo").disabled = undoTarget() < 0; } // Index in `past` of the latest earlier position where it was your turn, or -1. function undoTarget() { let i = past.length - 1; while (i >= 0 && past[i].state.turn !== human) i--; return i; } // Back to your previous turn, taking back Columbina's reply (or her pending move) too. function undo() { const i = undoTarget(); if (i < 0) return; ({ state, lastComputerMove } = past[i]); past.length = i; requestId++; $("meta").textContent = ""; update(); } function spoilerText() { if (!lastComputerMove) return `${OPPONENT} hasn't moved yet.`; const where = `${OPPONENT}'s last move (${SQUARE_NAMES[lastComputerMove.cell]})`; return { perfect: `${where} was perfect.`, lucky: `${where} was random, but happened to be a perfect move anyway.`, random: `${where} was random.`, }[lastComputerMove.how]; } function accuracy() { return Number($("accuracy").value) / 100; } for (const i of [0, 1]) { $("player-" + i).addEventListener("click", () => { if (human === i) return; human = i; requestId++; // drop any computer move in flight for the side you just took over update(); }); } $("accuracy").addEventListener("input", () => { $("accuracy-value").textContent = $("accuracy").value + "%"; }); $("new-game").addEventListener("click", () => { requestId++; state = EMPTY; lastComputerMove = null; past = []; board.reset(); $("meta").textContent = ""; update(); }); $("undo").addEventListener("click", undo); $("freshness").addEventListener("change", e => { $("board").classList.toggle("freshness", e.target.checked); }); $("board").classList.toggle("freshness", $("freshness").checked); document.addEventListener("keydown", e => { if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "z") { e.preventDefault(); undo(); } }); $("accuracy-value").textContent = $("accuracy").value + "%"; update();