import { ChessEngine, ChessRules } from "/shared/chess-engine.js"; import { chooseComputerMove } from "/js/ai.js"; const PIECE_GLYPHS = { w: { K: "♔", Q: "♕", R: "♖", B: "♗", N: "♘", P: "♙" }, b: { K: "♚", Q: "♛", R: "♜", B: "♝", N: "♞", P: "♟" }, }; const STORAGE_KEYS = { name: "chess-party:name", partyIds: "chess-party:party-ids" }; const TIME_CONTROLS = { none: 0, "1+0": 60, "3+0": 180, "3+2": 180, "5+0": 300, "5+3": 300, "10+0": 600, "15+10": 900, "30+0": 1800, "30+20": 1800 }; const elements = { modeBadge: document.querySelector("#modeBadge"), connectionBadge: document.querySelector("#connectionBadge"), board: document.querySelector("#board"), statusMessage: document.querySelector("#statusMessage"), turnBadge: document.querySelector("#turnBadge"), modeAiButton: document.querySelector("#modeAiButton"), modePartyButton: document.querySelector("#modePartyButton"), playerNameInput: document.querySelector("#playerNameInput"), aiControls: document.querySelector("#aiControls"), partyControls: document.querySelector("#partyControls"), aiLevelSelect: document.querySelector("#aiLevelSelect"), playerColorSelect: document.querySelector("#playerColorSelect"), aiTimeControl: document.querySelector("#aiClockSelect"), partyTimeControl: document.querySelector("#partyClockSelect"), startAiButton: document.querySelector("#startAiButton"), newGameToolbarButton: document.querySelector("#newGameToolbarButton"), createPartyButton: document.querySelector("#createPartyButton"), partyCodeInput: document.querySelector("#partyCodeInput"), joinPartyButton: document.querySelector("#joinPartyButton"), leavePartyButton: document.querySelector("#leavePartyButton"), partySummary: document.querySelector("#partySummary"), copyInviteButton: document.querySelector("#copyInviteButton"), flipBoardButton: document.querySelector("#flipBoardButton"), whitePlayerLabel: document.querySelector("#whitePlayerLabel"), blackPlayerLabel: document.querySelector("#blackPlayerLabel"), rosterWhite: document.querySelector("#rosterWhite"), rosterBlack: document.querySelector("#rosterBlack"), spectatorList: document.querySelector("#spectatorList"), spectatorCount: document.querySelector("#spectatorCount"), whiteClock: document.querySelector("#whiteClock"), blackClock: document.querySelector("#blackClock"), promotionDialog: document.querySelector("#promotionDialog"), promotionOptions: document.querySelector("#promotionOptions"), toastHost: document.querySelector("#toastHost"), }; const appState = { mode: "ai", engine: new ChessEngine(), orientation: "w", selectedSquare: null, aiLevel: "intermediate", playerColor: "w", aiTimeControl: "10+0", isAiThinking: false, aiMoveTimer: null, aiClockTimer: null, pendingPromotionMoves: null, playerName: "Guest", party: null, partySnapshot: null, partyStream: null, gameOver: false, gameOverMessage: null, localClocks: { white: 0, black: 0, running: false, turn: "w", lastTick: 0 }, }; function currentStatus() { return appState.engine.state.status || ChessEngine.evaluateStatus(appState.engine.state); } function currentHumanColor() { if (appState.mode === "ai") return appState.playerColor; if (!appState.party) return null; if (appState.party.role === "white") return "w"; if (appState.party.role === "black") return "b"; return null; } function isHumanTurn() { const color = currentHumanColor(); const status = currentStatus(); return Boolean(color && status.phase === "playing" && appState.engine.state.turn === color && !appState.isAiThinking && !appState.gameOver); } function getLegalMovesForSelected() { if (!appState.selectedSquare) return []; return appState.engine.getMovesFrom(appState.selectedSquare); } function clearSelection() { appState.selectedSquare = null; appState.pendingPromotionMoves = null; } function escapeHtml(value) { return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } function formatClock(seconds) { const total = Math.max(0, Math.ceil(Number(seconds) || 0)); const minutes = Math.floor(total / 60); const secs = total % 60; if (minutes >= 60) { const hours = Math.floor(minutes / 60); return `${String(hours).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; } return `${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; } function getIncrementForTimeControl(value) { return Number(String(value || "").split("+")[1] || 0); } function getTimeControlSeconds(value) { return TIME_CONTROLS[value] || 0; } function showToast(message, type = "info") { if (!elements.toastHost) return console.log(message); const toast = document.createElement("div"); toast.className = `toast ${type}`; toast.textContent = message; elements.toastHost.appendChild(toast); window.setTimeout(() => toast.remove(), 3200); } function setMode(mode) { appState.mode = mode; const isAi = mode === "ai"; const isParty = mode === "party"; elements.modeAiButton?.classList.toggle("active", isAi); elements.modePartyButton?.classList.toggle("active", isParty); elements.aiControls?.classList.toggle("hidden", !isAi); elements.partyControls?.classList.toggle("hidden", !isParty); if (elements.modeBadge) elements.modeBadge.textContent = isAi ? "AI Arena" : "Party Lounge"; } function updatePartyUrl(code = null) { const url = new URL(window.location.href); if (code) url.searchParams.set("party", code); else url.searchParams.delete("party"); window.history.replaceState({}, "", url); } function renderBoard() { if (!elements.board) return; elements.board.innerHTML = ""; const selectedMoves = getLegalMovesForSelected(); const moveTargets = new Map(selectedMoves.map(move => [move.to, move])); const orientation = appState.orientation; const xOrder = orientation === "w" ? [0, 1, 2, 3, 4, 5, 6, 7] : [7, 6, 5, 4, 3, 2, 1, 0]; const yOrder = orientation === "w" ? [7, 6, 5, 4, 3, 2, 1, 0] : [0, 1, 2, 3, 4, 5, 6, 7]; const status = currentStatus(); const checkColor = status.check ? appState.engine.state.turn : null; const lastMove = appState.engine.state.lastMove; for (const y of yOrder) { for (const x of xOrder) { const squareName = ChessRules.toSquare(x, y); const piece = appState.engine.state.board[y][x]; const square = document.createElement("button"); square.type = "button"; square.className = `square ${(x + y) % 2 === 0 ? "dark" : "light"}`; square.dataset.square = squareName; if (appState.selectedSquare === squareName) square.classList.add("selected"); const move = moveTargets.get(squareName); if (move) square.classList.add(move.capture ? "capture-target" : "legal-target"); if (lastMove && (lastMove.from === squareName || lastMove.to === squareName)) square.classList.add("last-move"); if (piece && piece.type === "K" && piece.color === checkColor) square.classList.add("check-square"); if (piece) { const pieceNode = document.createElement("span"); pieceNode.className = `piece ${piece.color === "w" ? "white" : "black"}`; pieceNode.textContent = PIECE_GLYPHS[piece.color][piece.type]; square.appendChild(pieceNode); } square.addEventListener("click", () => handleSquareClick(squareName)); elements.board.appendChild(square); } } } function formatStatus() { if (appState.gameOver) return getGameOverMessage(); const status = currentStatus(); if (status.phase === "checkmate") return "Checkmate"; if (status.phase === "draw") return "Draw"; if (status.check) return appState.engine.state.turn === "w" ? "White is in check" : "Black is in check"; return appState.engine.state.turn === "w" ? "White to move" : "Black to move"; } function getGameOverMessage() { const snapshot = appState.partySnapshot; if (!snapshot) return appState.gameOverMessage || "Game over."; const { gameOverReason: reason, winner } = snapshot; switch (reason) { case "timeout": return winner === "w" ? "White wins on time." : "Black wins on time."; case "checkmate": return winner === "w" ? "White wins by checkmate." : "Black wins by checkmate."; case "draw": return "Draw."; default: return appState.gameOverMessage || "Game over."; } } function renderRoster() { if (appState.mode === "ai") { const human = appState.playerName || "You"; const whiteName = appState.playerColor === "w" ? human : `Computer (${appState.aiLevel})`; const blackName = appState.playerColor === "b" ? human : `Computer (${appState.aiLevel})`; if (elements.whitePlayerLabel) elements.whitePlayerLabel.textContent = whiteName; if (elements.blackPlayerLabel) elements.blackPlayerLabel.textContent = blackName; if (elements.rosterWhite) elements.rosterWhite.textContent = whiteName; if (elements.rosterBlack) elements.rosterBlack.textContent = blackName; if (elements.spectatorList) elements.spectatorList.innerHTML = "
  • None in AI mode
  • "; if (elements.spectatorCount) elements.spectatorCount.textContent = "0 / 20"; return; } const white = appState.partySnapshot?.players?.white; const black = appState.partySnapshot?.players?.black; const whiteName = white ? `${white.name}${white.connected ? "" : " · offline"}` : "Open seat"; const blackName = black ? `${black.name}${black.connected ? "" : " · offline"}` : "Open seat"; if (elements.whitePlayerLabel) elements.whitePlayerLabel.textContent = whiteName; if (elements.blackPlayerLabel) elements.blackPlayerLabel.textContent = blackName; if (elements.rosterWhite) elements.rosterWhite.textContent = whiteName; if (elements.rosterBlack) elements.rosterBlack.textContent = blackName; const spectators = appState.partySnapshot?.spectators || []; if (elements.spectatorCount) elements.spectatorCount.textContent = `${spectators.length} / 20`; if (!elements.spectatorList) return; if (!spectators.length) elements.spectatorList.innerHTML = "
  • None yet
  • "; else elements.spectatorList.innerHTML = spectators.map(s => `
  • ${escapeHtml(s.name)}${s.connected ? "" : " · offline"}
  • `).join(""); } function renderPartySummary() { if (!elements.partySummary) return; if (!appState.party) { elements.partySummary.textContent = "Create a room to become White. The next player joins as Black. Up to 20 spectators can watch live."; return; } const role = appState.party.role; const roleText = role === "white" ? "playing as White" : role === "black" ? "playing as Black" : "watching as a spectator"; const timeLabel = appState.partySnapshot?.timeControl?.label || "Без часов"; elements.partySummary.textContent = `Party ${appState.party.code} · ${roleText}. ${timeLabel}.`; } function renderClocks() { let clocks, timeControl; if (appState.mode === "party") { clocks = appState.partySnapshot?.clocks; timeControl = appState.partySnapshot?.timeControl; } else { clocks = appState.localClocks; timeControl = { initial: getTimeControlSeconds(appState.aiTimeControl), label: elements.aiTimeControl?.selectedOptions?.[0]?.textContent || "Без часов" }; } const enabled = Boolean(timeControl?.initial > 0); if (!enabled) { if (elements.whiteClock) { elements.whiteClock.textContent = "∞"; elements.whiteClock.classList.remove("active", "low"); } if (elements.blackClock) { elements.blackClock.textContent = "∞"; elements.blackClock.classList.remove("active", "low"); } return; } const white = clocks?.white ?? 0, black = clocks?.black ?? 0; if (elements.whiteClock) { elements.whiteClock.textContent = formatClock(white); elements.whiteClock.classList.toggle("active", clocks?.turn === "w" && clocks?.running); elements.whiteClock.classList.toggle("low", white <= 10); } if (elements.blackClock) { elements.blackClock.textContent = formatClock(black); elements.blackClock.classList.toggle("active", clocks?.turn === "b" && clocks?.running); elements.blackClock.classList.toggle("low", black <= 10); } } function renderConnectionBadge() { if (!elements.connectionBadge) return; if (appState.mode === "ai") { elements.connectionBadge.textContent = appState.isAiThinking ? "Computer thinking" : `AI: ${appState.aiLevel}`; return; } if (!appState.party) { elements.connectionBadge.textContent = "Party idle"; return; } const role = appState.party.role === "white" ? "White" : appState.party.role === "black" ? "Black" : "Spectator"; elements.connectionBadge.textContent = `Party ${appState.party.code} · ${role}`; } function render() { renderBoard(); renderRoster(); renderPartySummary(); renderClocks(); renderConnectionBadge(); const status = formatStatus(); if (elements.turnBadge) elements.turnBadge.textContent = status; if (elements.statusMessage) elements.statusMessage.textContent = status; elements.copyInviteButton?.classList.toggle("hidden", !appState.party); elements.leavePartyButton?.classList.toggle("hidden", !appState.party); } function openPromotionDialog(moves) { appState.pendingPromotionMoves = moves; if (!elements.promotionOptions) return; elements.promotionOptions.innerHTML = ""; for (const move of moves) { const button = document.createElement("button"); button.type = "button"; button.className = "promotion-button"; const promotion = String(move.promotion || "Q").toUpperCase(); button.textContent = PIECE_GLYPHS[move.color][promotion]; button.addEventListener("click", () => { closePromotionDialog(); submitMove(move); }); elements.promotionOptions.appendChild(button); } elements.promotionDialog?.classList.remove("hidden"); } function closePromotionDialog() { appState.pendingPromotionMoves = null; elements.promotionDialog?.classList.add("hidden"); } async function submitMove(move) { if (appState.gameOver) return; clearSelection(); if (appState.mode === "party" && appState.party) { const response = await postJson("/api/party/move", { partyCode: appState.party.code, clientId: appState.party.clientId, from: move.from, to: move.to, promotion: move.promotion || null, }); if (!response.ok) { if (response.party) hydratePartyState(response.party, false); showToast(response.error || "Move rejected.", "error"); return; } hydratePartyState(response.party, false); return; } const result = appState.engine.makeMove({ from: move.from, to: move.to, promotion: move.promotion || null }); if (!result.ok) { showToast(result.error || "Illegal move.", "error"); return; } updateAiClockAfterMove(); render(); scheduleAiTurn(); } function handleSquareClick(square) { if (appState.pendingPromotionMoves || appState.gameOver) return; if (!isHumanTurn()) { clearSelection(); renderBoard(); return; } const piece = appState.engine.getPiece(square); if (appState.selectedSquare) { const matchingMoves = getLegalMovesForSelected().filter(move => move.to === square); if (matchingMoves.length === 1) { submitMove(matchingMoves[0]); return; } if (matchingMoves.length > 1) { openPromotionDialog(matchingMoves); return; } } const humanColor = currentHumanColor(); if (piece && piece.color === humanColor && piece.color === appState.engine.state.turn) { appState.selectedSquare = appState.selectedSquare === square ? null : square; renderBoard(); return; } clearSelection(); renderBoard(); } function scheduleAiTurn() { stopAiMoveTimer(); if (appState.mode !== "ai") return; const status = currentStatus(); if (!status || status.phase !== "playing" || appState.gameOver || appState.engine.state.turn === appState.playerColor) { appState.isAiThinking = false; render(); return; } appState.isAiThinking = true; render(); appState.aiMoveTimer = window.setTimeout(() => { appState.aiMoveTimer = null; if (appState.mode !== "ai" || appState.gameOver) { appState.isAiThinking = false; render(); return; } const move = chooseComputerMove(appState.engine, appState.aiLevel); appState.isAiThinking = false; if (!move) { render(); return; } submitMove(move); }, 400); } function stopAiMoveTimer() { if (appState.aiMoveTimer) { window.clearTimeout(appState.aiMoveTimer); appState.aiMoveTimer = null; } appState.isAiThinking = false; } function startAiClock() { stopAiClock(); const seconds = getTimeControlSeconds(appState.aiTimeControl); appState.localClocks = { white: seconds, black: seconds, running: seconds > 0, turn: appState.engine.state.turn, lastTick: Date.now() }; render(); if (!seconds) return; appState.aiClockTimer = window.setInterval(() => { updateAiClock(); renderClocks(); }, 250); } function stopAiClock() { if (appState.aiClockTimer) { window.clearInterval(appState.aiClockTimer); appState.aiClockTimer = null; } } function updateAiClock() { if (appState.mode !== "ai" || appState.gameOver || !appState.localClocks.running) return; const clocks = appState.localClocks; const now = Date.now(); const elapsed = Math.max(0, (now - clocks.lastTick) / 1000); if (!elapsed) return; clocks.lastTick = now; const key = clocks.turn === "w" ? "white" : "black"; clocks[key] = Math.max(0, clocks[key] - elapsed); if (clocks[key] <= 0) { clocks.running = false; appState.gameOver = true; appState.gameOverMessage = clocks.turn === "w" ? "Black wins on time." : "White wins on time."; stopAiClock(); showToast(appState.gameOverMessage, "error"); render(); } } function updateAiClockAfterMove() { const clocks = appState.localClocks; if (!clocks.running) return; updateAiClock(); if (appState.gameOver) return; const key = clocks.turn === "w" ? "white" : "black"; clocks[key] += getIncrementForTimeControl(appState.aiTimeControl); clocks.turn = appState.engine.state.turn; clocks.lastTick = Date.now(); } function resetLocalGame() { stopAiMoveTimer(); stopAiClock(); closePromotionDialog(); appState.engine = new ChessEngine(); appState.partySnapshot = null; appState.orientation = appState.playerColor; appState.gameOver = false; appState.gameOverMessage = null; startAiClock(); scheduleAiTurn(); render(); } async function postJson(url, body) { try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const contentType = response.headers.get("content-type") || ""; if (!contentType.includes("application/json")) return { ok: false, error: `Server returned ${response.status} instead of JSON.` }; return await response.json(); } catch (error) { return { ok: false, error: error instanceof Error ? error.message : "Network error." }; } } function hydratePartyState(partyPayload, announce = true) { if (!partyPayload) return; const previousCode = appState.party?.code; const previousClientId = appState.party?.clientId; appState.party = { code: partyPayload.code, clientId: partyPayload.you?.id || previousClientId || null, role: partyPayload.you?.role || appState.party?.role || "spectator", }; appState.partySnapshot = partyPayload; appState.engine = new ChessEngine(partyPayload.game); appState.gameOver = Boolean(partyPayload.gameOverReason); appState.gameOverMessage = appState.gameOver ? getGameOverMessage() : null; if (appState.party.role === "white") appState.orientation = "w"; else if (appState.party.role === "black") appState.orientation = "b"; setMode("party"); updatePartyUrl(partyPayload.code); render(); if (announce && partyPayload.gameOverReason) showToast(getGameOverMessage(), "success"); } function connectPartyStream() { if (!appState.party) return; closePartyStream(); const { code, clientId } = appState.party; const url = `/api/party/events?partyCode=${encodeURIComponent(code)}&clientId=${encodeURIComponent(clientId)}`; const stream = new EventSource(url); stream.addEventListener("party", event => { try { hydratePartyState(JSON.parse(event.data), false); } catch (error) { console.error("Party SSE error:", error); } }); stream.onerror = () => { if (appState.mode === "party" && elements.connectionBadge) elements.connectionBadge.textContent = `Party ${code} · reconnecting`; }; appState.partyStream = stream; } function closePartyStream() { if (appState.partyStream) { appState.partyStream.close(); appState.partyStream = null; } } async function createParty() { if (appState.party) await leaveParty(true); const timeControl = elements.partyTimeControl?.value || "10+0"; const response = await postJson("/api/party/create", { name: appState.playerName, timeControl }); if (!response.ok) { showToast(response.error || "Unable to create party.", "error"); return; } setStoredPartyId(response.party.code, response.clientId); hydratePartyState(response.party, false); connectPartyStream(); showToast(`Party ${response.party.code} created.`, "success"); } async function joinParty(code) { const normalizedCode = String(code || "").trim().toUpperCase(); if (!normalizedCode) { showToast("Enter a party code first.", "error"); return; } if (appState.party?.code === normalizedCode) { showToast(`You are already in party ${normalizedCode}.`); return; } if (appState.party) await leaveParty(true); const storedIds = getStoredPartyIds(); const response = await postJson("/api/party/join", { name: appState.playerName, partyCode: normalizedCode, clientId: storedIds[normalizedCode] || null }); if (!response.ok) { showToast(response.error || "Unable to join party.", "error"); return; } setStoredPartyId(response.party.code, response.clientId); hydratePartyState(response.party, false); connectPartyStream(); showToast(`Joined party ${response.party.code}.`, "success"); } async function leaveParty(silent = false) { if (!appState.party) return; const party = appState.party; closePartyStream(); await postJson("/api/party/leave", { partyCode: party.code, clientId: party.clientId }); removeStoredPartyId(party.code); appState.party = null; appState.partySnapshot = null; updatePartyUrl(null); setMode("ai"); resetLocalGame(); if (!silent) showToast("Left the party room."); } async function copyInviteLink() { if (!appState.party) return; const inviteUrl = `${window.location.origin}${window.location.pathname}?party=${encodeURIComponent(appState.party.code)}`; try { await navigator.clipboard.writeText(inviteUrl); showToast("Invite link copied.", "success"); } catch { showToast(`Party code: ${appState.party.code}`); } } function getStoredPartyIds() { try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.partyIds) || "{}"); } catch { return {}; } } function setStoredPartyId(code, clientId) { if (!code || !clientId) return; const ids = getStoredPartyIds(); ids[code] = clientId; localStorage.setItem(STORAGE_KEYS.partyIds, JSON.stringify(ids)); } function removeStoredPartyId(code) { if (!code) return; const ids = getStoredPartyIds(); delete ids[code]; localStorage.setItem(STORAGE_KEYS.partyIds, JSON.stringify(ids)); } function bindEvents() { elements.playerNameInput?.addEventListener("input", event => { appState.playerName = event.target.value.trim() || "Guest"; localStorage.setItem(STORAGE_KEYS.name, appState.playerName); renderRoster(); }); elements.aiLevelSelect?.addEventListener("change", event => { appState.aiLevel = event.target.value; render(); }); elements.playerColorSelect?.addEventListener("change", event => { appState.playerColor = event.target.value; if (appState.mode === "ai") resetLocalGame(); }); elements.aiTimeControl?.addEventListener("change", event => { appState.aiTimeControl = event.target.value; if (appState.mode === "ai") resetLocalGame(); }); elements.modeAiButton?.addEventListener("click", async () => { if (appState.party) await leaveParty(true); setMode("ai"); resetLocalGame(); }); elements.modePartyButton?.addEventListener("click", () => { setMode("party"); render(); }); elements.startAiButton?.addEventListener("click", async () => { if (appState.party) await leaveParty(true); setMode("ai"); resetLocalGame(); showToast("New AI game ready.", "success"); }); // Binding the top toolbar New Game button elements.newGameToolbarButton?.addEventListener("click", async () => { if (appState.party) await leaveParty(true); setMode("ai"); resetLocalGame(); showToast("Новая игра", "success"); }); elements.createPartyButton?.addEventListener("click", createParty); elements.joinPartyButton?.addEventListener("click", () => joinParty(elements.partyCodeInput?.value)); elements.leavePartyButton?.addEventListener("click", () => leaveParty(false)); elements.copyInviteButton?.addEventListener("click", copyInviteLink); elements.flipBoardButton?.addEventListener("click", () => { appState.orientation = appState.orientation === "w" ? "b" : "w"; renderBoard(); }); elements.partyCodeInput?.addEventListener("input", event => event.target.value = event.target.value.toUpperCase().replace(/[^A-Z0-9]/g, "")); elements.partyCodeInput?.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); joinParty(elements.partyCodeInput.value); }}); elements.promotionDialog?.addEventListener("click", event => { if (event.target === elements.promotionDialog) closePromotionDialog(); }); window.addEventListener("beforeunload", () => { stopAiMoveTimer(); stopAiClock(); closePartyStream(); }); } async function init() { bindEvents(); const savedName = localStorage.getItem(STORAGE_KEYS.name); if (savedName) { appState.playerName = savedName; if (elements.playerNameInput) elements.playerNameInput.value = savedName; } const params = new URLSearchParams(window.location.search); const partyCode = params.get("party"); if (partyCode) { setMode("party"); if (elements.partyCodeInput) elements.partyCodeInput.value = partyCode.toUpperCase(); await joinParty(partyCode); return; } setMode("ai"); resetLocalGame(); } init();