"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();