import { Game } from "./game/state"; import { Board } from "./board/board"; import { Engine } from "./game/engine"; import { getSet } from "./board/pieces"; import { Clock, formatTime } from "./game/clock"; import { threats, PIECE_NAME } from "./game/analysis"; import { explainPosition, explainMistake, blunderCheck } from "./game/coach"; import { sound } from "./util/sound"; import { loadPrefs, savePrefs, saveGame, clearGame } from "./util/storage"; import { goto } from "./util/url"; const app = document.querySelector("#app"); let prefs = loadPrefs(); let engine = null; // Entry point for the game screen. Loaded on demand so the home page stays tiny. export async function startGame(human, personality, tc, text) { prefs = loadPrefs(); const initial = parseInitial(text); if (initial === "invalid") return "That is not a valid FEN or PGN."; await new PlayController(human, personality, tc, initial).mount(); return null; } function parseInitial(text) { const t = text.trim(); if (!t) return null; const probe = new Game(); if (probe.load(t)) return { fen: t }; if (probe.loadPgn(t)) return { pgn: t }; return "invalid"; } class PlayController { constructor(human, personality, tc, initial) { this.human = human; this.personality = personality; this.thinking = false; this.timeLoss = null; this.threatsOn = prefs.threats; this.blunderOn = prefs.blunder; this.beforeHuman = null; this.afterHuman = null; this.humanMove = null; this.pendingBlunder = null; this.onTick = (w, b) => { this.setClock("w", w); this.setClock("b", b); }; this.onFlag = (side) => { this.timeLoss = side; this.clock.stop(); if (prefs.sound) sound.end(); this.update(); }; this.game = new Game(initial?.fen); if (initial?.pgn) this.game.loadPgn(initial.pgn); this.view = human; this.clock = new Clock(tc, this.onTick, this.onFlag); if (!engine) engine = new Engine(); } async mount() { const opp = this.human === "w" ? "b" : "w"; const clocksHtml = this.clock.enabled ? `
opponent--
you--
` : ""; app.innerHTML = `

${this.personality.face} ${this.personality.name}

`; app.querySelector("[data-back]").onclick = () => { this.clock.stop(); clearGame(); goto("home"); }; this.board = new Board(app.querySelector("#board"), { orientation: this.view, set: getSet(prefs.pieceSet), coords: prefs.coords, legalTargets: (sq) => (this.canMove() ? this.game.legalTargets(sq) : []), canMove: () => this.canMove(), onMove: (from, to, promo) => this.onHumanMove(from, to, promo), }); this.board.setPosition(this.game.board()); this.wireControls(); this.refreshCheck(); this.update(); if (engine) await engine.configure(this.personality); if (this.clock.enabled) this.clock.start(this.game.turn); if (this.game.turn !== this.human) this.aiMove(); } wireControls() { app.querySelector("#undo").onclick = () => this.undo(); app.querySelector("#flip").onclick = () => { this.view = this.view === "w" ? "b" : "w"; this.board.setOrientation(this.view); this.board.setPosition(this.game.board()); this.refreshCheck(); this.refreshThreats(); }; app.querySelector("#pgn").onclick = (e) => { navigator.clipboard?.writeText(this.game.pgn()); flash(e.target, "copied", "copy pgn"); }; app.querySelector("#new").onclick = () => { this.clock.stop(); clearGame(); goto("play"); }; const threatsBtn = app.querySelector("#t-threats"); threatsBtn.onclick = () => { this.threatsOn = !this.threatsOn; prefs = { ...prefs, threats: this.threatsOn }; savePrefs(prefs); threatsBtn.setAttribute("aria-pressed", String(this.threatsOn)); this.refreshThreats(); }; const blunderBtn = app.querySelector("#t-blunder"); blunderBtn.onclick = () => { this.blunderOn = !this.blunderOn; prefs = { ...prefs, blunder: this.blunderOn }; savePrefs(prefs); blunderBtn.setAttribute("aria-pressed", String(this.blunderOn)); }; app.querySelector("#explain").onclick = () => this.explain(); app.querySelector("#why").onclick = () => this.why(); if (this.clock.enabled) app.querySelector("#undo").disabled = true; } canMove() { return (!this.thinking && !this.pendingBlunder && !this.timeLoss && this.game.turn === this.human && !this.game.outcome().over); } onHumanMove(from, to, promo) { if (this.blunderOn) { const risk = blunderCheck(this.game, from, to, promo, this.human); if (risk) { this.pendingBlunder = { from, to, promo }; this.askBlunder(PIECE_NAME[risk.type], risk.square); return; } } this.commitHuman(from, to, promo); } commitHuman(from, to, promo) { this.beforeHuman = this.game.clone(); const move = this.game.play(from, to, promo); if (!move) return; this.humanMove = move; this.afterHuman = this.game.clone(); this.setCoach(""); this.afterMove(move); if (!this.game.outcome().over && !this.timeLoss) this.aiMove(); } async aiMove() { if (!engine || this.game.outcome().over || this.timeLoss) return; this.thinking = true; this.showThinking(); const uci = await engine.chooseMove(this.game.fen, this.personality, this.game.legalUci()); this.thinking = false; if (!uci || this.timeLoss) return this.update(); const move = this.game.playUci(uci); if (move) this.afterMove(move); else this.update(); } afterMove(move) { this.board.applyMove(move); if (this.clock.enabled) this.clock.press(move.color); this.playSound(move); this.showReactions(move); this.refreshCheck(); this.refreshThreats(); this.update(); this.persist(); if (this.game.outcome().over) { this.clock.stop(); if (prefs.sound) sound.end(); } } undo() { if (this.thinking || this.clock.enabled) return; this.game.undo(); if (this.game.turn !== this.human) this.game.undo(); this.pendingBlunder = null; this.setCoach(""); this.board.setPosition(this.game.board()); this.refreshCheck(); this.refreshThreats(); this.update(); this.persist(); } playSound(move) { if (!prefs.sound) return; if (move.captured) sound.capture(); else sound.move(); if (this.game.inCheck()) sound.check(); } showReactions(move) { if (!prefs.reactions) return; if (move.flags.includes("p")) this.board.react(move.to, "✨"); if (this.game.inCheck()) { const king = this.game.kingSquare(this.game.turn); if (king) this.board.react(king, "❗"); } } refreshCheck() { this.board.setCheck(this.game.inCheck() ? this.game.kingSquare(this.game.turn) : null); } refreshThreats() { if (this.threatsOn) this.board.setThreats(threats(this.game, this.human).map((t) => t.square)); else this.board.setThreats([]); } async explain() { if (!engine) return; this.setCoach("thinking…"); const { cp, mate } = await engine.evaluate(this.game.fen); const forHuman = this.game.turn === this.human ? 1 : -1; const cpH = cp === null ? null : cp * forHuman; const mateH = mate === null ? null : mate * forHuman; this.setCoach(explainPosition(this.game, this.human, cpH, mateH)); } why() { if (!this.beforeHuman || !this.afterHuman || !this.humanMove) { this.setCoach("Make a move first, then ask."); return; } const reason = explainMistake(this.beforeHuman, this.afterHuman, this.humanMove, this.human); this.setCoach(reason ?? "That move was fine — nothing was hanging."); } askBlunder(name, square) { this.setCoach(""); const coach = app.querySelector("#coach"); coach.innerHTML = `This move leaves your ${name} on ${square} for the taking.
`; coach.querySelector("#playanyway").onclick = () => { const p = this.pendingBlunder; this.pendingBlunder = null; coach.textContent = ""; this.commitHuman(p.from, p.to, p.promo); }; coach.querySelector("#takeback").onclick = () => { this.pendingBlunder = null; coach.textContent = ""; }; } setCoach(text) { const coach = app.querySelector("#coach"); if (coach) coach.textContent = text; } setClock(c, ms) { const el = document.getElementById("clk-" + c); if (!el) return; el.textContent = formatTime(ms); const box = el.parentElement; box.classList.toggle("running", this.game.turn === c && this.canRun()); box.classList.toggle("low", ms < 10000 && ms > 0); box.classList.toggle("flag", this.timeLoss === c); } canRun() { return !this.timeLoss && !this.game.outcome().over; } showThinking() { const status = app.querySelector("#status"); if (status) status.innerHTML = `${this.personality.face} is thinking…`; } update() { this.renderStatus(); this.renderMoves(); const undo = app.querySelector("#undo"); if (undo && !this.clock.enabled) undo.disabled = this.game.history().length === 0; } renderStatus() { const status = app.querySelector("#status"); if (!status) return; if (this.timeLoss) { status.textContent = this.timeLoss === this.human ? "You lost on time." : "Your opponent lost on time. You win."; return; } const outcome = this.game.outcome(); if (outcome.over) { status.textContent = describeOutcome(outcome, this.human); return; } const dot = ``; status.innerHTML = dot + (this.game.turn === this.human ? "Your move" : "Waiting…"); } renderMoves() { const list = app.querySelector("#movelist"); if (!list) return; const history = this.game.history(); let html = ""; for (let i = 0; i < history.length; i += 2) { const no = i / 2 + 1; const white = history[i]?.san ?? ""; const black = history[i + 1]?.san ?? ""; const wHere = i === history.length - 1 ? " here" : ""; const bHere = i + 1 === history.length - 1 ? " here" : ""; html += `
  • ${no}.${white}${black}
  • `; } list.innerHTML = html; list.parentElement.scrollTop = list.parentElement.scrollHeight; } persist() { if (this.game.outcome().over || this.timeLoss) { clearGame(); return; } saveGame({ pgn: this.game.pgn(), side: this.human, personality: this.personality.id, savedAt: Date.now(), }); } } function flash(btn, on, off) { btn.textContent = on; setTimeout(() => (btn.textContent = off), 1200); } function describeOutcome(outcome, human) { if (outcome.reason === "checkmate") { return outcome.winner === human ? "Checkmate. You win." : "Checkmate. You lose."; } const draws = { stalemate: "Stalemate. It's a draw.", insufficient: "Draw — not enough material to mate.", repetition: "Draw by repetition.", fifty: "Draw by the fifty-move rule.", draw: "The game is a draw.", }; return draws[outcome.reason] ?? "The game is over."; }