import { ChessEngine, ChessRules, } from "/shared/chess-engine.js"; import { chooseComputerMove, } from "/js/ai.js"; /* ========================================================= PIECES ========================================================= */ const PIECE_GLYPHS = { w: { K: "♔", Q: "♕", R: "♖", B: "♗", N: "♘", P: "♙", }, b: { K: "♚", Q: "♛", R: "♜", B: "♝", N: "♞", P: "♟", }, }; /* ========================================================= STORAGE ========================================================= */ const STORAGE_KEYS = { name: "chess-party:name", partyIds: "chess-party:party-ids", }; /* ========================================================= ELEMENTS ========================================================= */ 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"), 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"), resignButton: document.querySelector("#resignButton"), drawButton: document.querySelector("#drawButton"), rematchButton: document.querySelector("#rematchButton"), drawRequest: document.querySelector("#drawRequest"), drawRequestName: document.querySelector("#drawRequestName"), acceptDrawButton: document.querySelector("#acceptDrawButton"), declineDrawButton: document.querySelector("#declineDrawButton"), chatPanel: document.querySelector("#chatPanel"), chatMessages: document.querySelector("#chatMessages"), chatForm: document.querySelector("#chatForm"), chatInput: document.querySelector("#chatInput"), promotionDialog: document.querySelector("#promotionDialog"), promotionOptions: document.querySelector("#promotionOptions"), toastHost: document.querySelector("#toastHost"), }; /* ========================================================= STATE ========================================================= */ const appState = { mode: "ai", engine: new ChessEngine(), orientation: "w", selectedSquare: null, aiLevel: "intermediate", playerColor: "w", aiTimeControl: "10+0", isAiThinking: false, pendingPromotionMoves: null, party: null, partySnapshot: null, partyStream: null, playerName: "Guest", gameOver: false, gameOverMessage: null, drawOfferPending: false, drawOfferFrom: null, localClocks: { white: 600, black: 600, running: false, lastTick: Date.now(), turn: "w", }, aiClockTimer: null, }; /* ========================================================= TIME CONTROLS ========================================================= */ 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, }; /* ========================================================= HELPERS ========================================================= */ 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 escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function formatClock(seconds) { const safe = Math.max( 0, Number(seconds) || 0 ); const total = Math.ceil(safe); const minutes = Math.floor(total / 60); const secs = total % 60; if (minutes >= 60) { const hours = Math.floor(minutes / 60); const mins = minutes % 60; return ( `${String(hours).padStart(2, "0")}:` + `${String(mins).padStart(2, "0")}:` + `${String(secs).padStart(2, "0")}` ); } return ( `${String(minutes).padStart(2, "0")}:` + `${String(secs).padStart(2, "0")}` ); } /* ========================================================= TOAST ========================================================= */ function showToast( message, type = "info" ) { if (!elements.toastHost) { alert(message); return; } const toast = document.createElement("div"); toast.className = `toast ${type}`; toast.textContent = message; elements.toastHost.appendChild( toast ); window.setTimeout( () => toast.remove(), 3200 ); } /* ========================================================= MODE ========================================================= */ function setMode(mode) { appState.mode = mode; elements.modeAiButton ?.classList.toggle( "active", mode === "ai" ); elements.modePartyButton ?.classList.toggle( "active", mode === "party" ); elements.aiControls ?.classList.toggle( "hidden", mode !== "ai" ); elements.partyControls ?.classList.toggle( "hidden", mode !== "party" ); elements.chatPanel ?.classList.toggle( "hidden", mode !== "party" ); if (elements.modeBadge) { elements.modeBadge.textContent = mode === "ai" ? "AI Arena" : "Party Lounge"; } render(); } /* ========================================================= URL ========================================================= */ 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 ); } /* ========================================================= BOARD ========================================================= */ 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 ); } } } /* ========================================================= STATUS ========================================================= */ 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" ); } /* ========================================================= GAME OVER ========================================================= */ function getGameOverMessage() { if (!appState.partySnapshot) { return ( appState.gameOverMessage || "Game over." ); } const reason = appState.partySnapshot .gameOverReason; const winner = appState.partySnapshot.winner; if ( reason === "agreement" ) { return "Draw agreed."; } if ( reason === "timeout" ) { return winner === "w" ? "White wins on time." : "Black wins on time."; } if ( reason === "resignation" ) { return winner === "w" ? "White wins — Black resigned." : "Black wins — White resigned."; } if ( reason === "checkmate" ) { return winner === "w" ? "White wins by checkmate." : "Black wins by checkmate."; } if ( reason === "draw" ) { return "Draw."; } return "Game over."; } /* ========================================================= ROSTER ========================================================= */ 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
  • "; return; } elements.spectatorList.innerHTML = spectators .map( spectator => `
  • ${escapeHtml( spectator.name )}${ spectator.connected ? "" : " · offline" }
  • ` ) .join(""); } /* ========================================================= PARTY SUMMARY ========================================================= */ 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 roleText = appState.party.role === "white" ? "playing as White" : appState.party.role === "black" ? "playing as Black" : "watching as a spectator"; const timeLabel = appState.partySnapshot ?.timeControl?.label || "Без часов"; elements.partySummary.textContent = `Party ${appState.party.code} · ${roleText}. ${timeLabel}.`; } /* ========================================================= CLOCKS ========================================================= */ function renderClocks() { let clocks; let timeControl; if ( appState.mode === "party" ) { clocks = appState.partySnapshot ?.clocks; timeControl = appState.partySnapshot ?.timeControl; } else { clocks = appState.localClocks; const seconds = TIME_CONTROLS[ appState.aiTimeControl ]; timeControl = { initial: seconds, label: elements.aiTimeControl ?.selectedOptions?.[0] ?.textContent || "Без часов", }; } const hasClock = Boolean( timeControl && timeControl.initial > 0 ); if (!hasClock) { if (elements.whiteClock) { elements.whiteClock.textContent = "∞"; } if (elements.blackClock) { elements.blackClock.textContent = "∞"; } return; } const white = clocks?.white ?? 0; const 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 ); } } /* ========================================================= BUTTONS ========================================================= */ function updateGameButtons() { const status = currentStatus(); const finished = appState.gameOver || status.phase === "checkmate" || status.phase === "draw"; const human = Boolean( currentHumanColor() ); if ( elements.resignButton ) { elements.resignButton.disabled = finished || !human; } if ( elements.drawButton ) { const show = appState.mode === "party" && human; elements.drawButton.classList.toggle( "hidden", !show ); elements.drawButton.disabled = finished || !human || appState.drawOfferPending; elements.drawButton.innerHTML = appState.drawOfferPending ? "🤝 Ничья предложена" : "🤝 Предложить ничью"; } if ( elements.rematchButton ) { const show = appState.mode === "party" && human; elements.rematchButton.classList.toggle( "hidden", !show ); elements.rematchButton.disabled = !show || !finished; } } /* ========================================================= RENDER ========================================================= */ function render() { renderBoard(); renderRoster(); renderPartySummary(); renderClocks(); updateGameButtons(); if ( elements.turnBadge ) { elements.turnBadge.textContent = formatStatus(); } if ( elements.statusMessage ) { elements.statusMessage.textContent = formatStatus(); } if ( elements.connectionBadge ) { if (appState.mode === "ai") { elements.connectionBadge.textContent = appState.isAiThinking ? "Computer thinking" : `AI: ${appState.aiLevel}`; } else if (appState.party) { const role = appState.party.role === "white" ? "White" : appState.party.role === "black" ? "Black" : "Spectator"; elements.connectionBadge.textContent = `Party ${appState.party.code} · ${role}`; } else { elements.connectionBadge.textContent = "Party idle"; } } elements.copyInviteButton ?.classList.toggle( "hidden", !appState.party ); elements.leavePartyButton ?.classList.toggle( "hidden", !appState.party ); renderDrawRequest(); } /* ========================================================= DRAW REQUEST ========================================================= */ function renderDrawRequest() { if ( !elements.drawRequest ) { return; } const offer = appState.partySnapshot ?.drawOffer; const isIncoming = Boolean( offer && offer.by !== appState.party?.clientId && !appState.gameOver ); elements.drawRequest.classList.toggle( "hidden", !isIncoming ); if ( isIncoming && elements.drawRequestName ) { elements.drawRequestName.textContent = `${offer.name} предлагает ничью`; } } /* ========================================================= SELECTION ========================================================= */ function clearSelection() { appState.selectedSquare = null; appState.pendingPromotionMoves = null; } /* ========================================================= PROMOTION ========================================================= */ 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"; button.textContent = PIECE_GLYPHS[ move.color ][ move.promotion.toUpperCase() ]; 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" ); } /* ========================================================= MOVE ========================================================= */ 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(); } /* ========================================================= BOARD CLICK ========================================================= */ function handleSquareClick( square ) { if ( appState.pendingPromotionMoves || appState.gameOver ) { return; } const piece = appState.engine.getPiece( square ); const humanColor = currentHumanColor(); 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; } } if (!isHumanTurn()) { clearSelection(); renderBoard(); return; } if ( piece && piece.color === humanColor && piece.color === appState.engine.state.turn ) { appState.selectedSquare = appState.selectedSquare === square ? null : square; renderBoard(); return; } clearSelection(); renderBoard(); } /* ========================================================= AI ========================================================= */ function scheduleAiTurn() { if ( appState.mode !== "ai" ) { return; } const status = currentStatus(); if ( !status || status.phase !== "playing" || appState.gameOver ) { appState.isAiThinking = false; render(); return; } if ( appState.engine.state.turn === appState.playerColor ) { appState.isAiThinking = false; render(); return; } appState.isAiThinking = true; render(); window.setTimeout( () => { if ( appState.mode !== "ai" || appState.gameOver ) { appState.isAiThinking = false; return; } const move = chooseComputerMove( appState.engine, appState.aiLevel ); appState.isAiThinking = false; if (!move) { render(); return; } submitMove(move); }, 400 ); } /* ========================================================= AI CLOCK ========================================================= */ function startAiClock() { stopAiClock(); const seconds = TIME_CONTROLS[ appState.aiTimeControl ] || 0; appState.localClocks = { white: seconds, black: seconds, running: seconds > 0, lastTick: Date.now(), turn: appState.engine.state.turn, }; render(); if (!seconds) { return; } appState.aiClockTimer = window.setInterval( () => { updateAiClock(); renderClocks(); if ( appState.gameOver ) { stopAiClock(); } }, 250 ); } function stopAiClock() { if ( appState.aiClockTimer ) { window.clearInterval( appState.aiClockTimer ); appState.aiClockTimer = null; } } function updateAiClock() { if ( appState.mode !== "ai" ) { return; } const clocks = appState.localClocks; if ( !clocks.running || !TIME_CONTROLS[ appState.aiTimeControl ] ) { return; } const now = Date.now(); const elapsed = (now - clocks.lastTick) / 1000; if (elapsed <= 0) { return; } const role = clocks.turn === "w" ? "white" : "black"; clocks[role] = Math.max( 0, clocks[role] - elapsed ); clocks.lastTick = now; if ( clocks[role] <= 0 ) { clocks.running = false; appState.gameOver = true; appState.gameOverMessage = clocks.turn === "w" ? "Black wins on time." : "White wins on time."; showToast( appState.gameOverMessage, "error" ); render(); } } function updateAiClockAfterMove() { const clocks = appState.localClocks; const increment = getIncrementForTimeControl( appState.aiTimeControl ); if ( !clocks.running || !TIME_CONTROLS[ appState.aiTimeControl ] ) { return; } updateAiClock(); const previousTurn = clocks.turn; const role = previousTurn === "w" ? "white" : "black"; clocks[role] += increment; clocks.turn = appState.engine.state.turn; clocks.lastTick = Date.now(); } function getIncrementForTimeControl( value ) { const parts = String(value).split("+"); return Number( parts[1] || 0 ); } /* ========================================================= LOCAL RESET ========================================================= */ function resetLocalGame() { stopAiClock(); closePromotionDialog(); clearSelection(); appState.engine = new ChessEngine(); appState.partySnapshot = null; appState.orientation = appState.playerColor; appState.isAiThinking = false; appState.gameOver = false; appState.gameOverMessage = null; appState.drawOfferPending = false; appState.drawOfferFrom = null; startAiClock(); render(); scheduleAiTurn(); } /* ========================================================= API ========================================================= */ 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.", }; } } /* ========================================================= PARTY STATE ========================================================= */ function hydratePartyState( partyPayload, announce = true ) { if (!partyPayload) { return; } appState.party = { code: partyPayload.code, clientId: partyPayload.you?.id || appState.party?.clientId || 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; appState.drawOfferPending = Boolean( partyPayload.drawOffer && partyPayload.drawOffer.by === appState.party.clientId ); appState.drawOfferFrom = partyPayload.drawOffer ? partyPayload.drawOffer.name : null; if ( appState.party.role === "white" ) { appState.orientation = "w"; } if ( appState.party.role === "black" ) { appState.orientation = "b"; } setMode("party"); updatePartyUrl( partyPayload.code ); render(); if ( partyPayload.chat ) { renderChat( partyPayload.chat ); } if ( announce && partyPayload.gameOverReason ) { showToast( getGameOverMessage(), "success" ); } } /* ========================================================= SSE ========================================================= */ function connectPartyStream() { if (!appState.party) { return; } if ( appState.partyStream ) { appState.partyStream.close(); } const url = `/api/party/events?partyCode=${ encodeURIComponent( appState.party.code ) }&clientId=${ encodeURIComponent( appState.party.clientId ) }`; const stream = new EventSource(url); stream.addEventListener( "party", event => { try { const party = JSON.parse( event.data ); hydratePartyState( party, false ); } catch (error) { console.error( error ); } } ); stream.addEventListener( "chat", event => { try { appendChatMessage( JSON.parse( event.data ) ); } catch (error) { console.error( error ); } } ); stream.onerror = () => { if ( appState.mode === "party" && elements.connectionBadge ) { elements.connectionBadge.textContent = `Party ${appState.party.code} · reconnecting`; } }; appState.partyStream = stream; } /* ========================================================= CREATE PARTY ========================================================= */ 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" ); } /* ========================================================= JOIN PARTY ========================================================= */ 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 storedId = storedIds[ normalizedCode ] || null; const response = await postJson( "/api/party/join", { name: appState.playerName, partyCode: normalizedCode, clientId: storedId, } ); 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" ); } /* ========================================================= LEAVE ========================================================= */ async function leaveParty( silent = false ) { if (!appState.party) { return; } const oldParty = appState.party; await postJson( "/api/party/leave", { partyCode: oldParty.code, clientId: oldParty.clientId, } ); if ( appState.partyStream ) { appState.partyStream.close(); appState.partyStream = null; } removeStoredPartyId( oldParty.code ); appState.party = null; appState.partySnapshot = null; updatePartyUrl(null); clearChat(); setMode("ai"); resetLocalGame(); if (!silent) { showToast( "Left the party room." ); } } /* ========================================================= NEW PARTY GAME ========================================================= */ async function startNewPartyGame() { if (!appState.party) { return; } const response = await postJson( "/api/party/new-game", { partyCode: appState.party.code, clientId: appState.party.clientId, } ); if (!response.ok) { showToast( response.error || "Unable to start a new game.", "error" ); return; } hydratePartyState( response.party, false ); showToast( "New game started.", "success" ); } /* ========================================================= RESIGN ========================================================= */ async function resignGame() { if (appState.gameOver) { return; } const color = currentHumanColor(); if (!color) { return; } if ( !window.confirm( "Вы действительно хотите сдаться?" ) ) { return; } if ( appState.mode === "party" && appState.party ) { const response = await postJson( "/api/party/resign", { partyCode: appState.party.code, clientId: appState.party.clientId, } ); if (!response.ok) { showToast( response.error || "Unable to resign.", "error" ); return; } hydratePartyState( response.party, false ); showToast( "Вы сдались.", "info" ); return; } appState.gameOver = true; appState.gameOverMessage = color === "w" ? "Black wins — White resigned." : "White wins — Black resigned."; stopAiClock(); showToast( appState.gameOverMessage, "success" ); render(); } /* ========================================================= DRAW ========================================================= */ async function offerDraw() { if ( appState.mode !== "party" || appState.gameOver || !appState.party ) { return; } if ( appState.drawOfferPending ) { showToast( "Вы уже предложили ничью." ); return; } const response = await postJson( "/api/party/draw-offer", { partyCode: appState.party.code, clientId: appState.party.clientId, } ); if (!response.ok) { showToast( response.error || "Unable to offer draw.", "error" ); return; } hydratePartyState( response.party, false ); showToast( "Предложение ничьей отправлено.", "success" ); } /* ========================================================= DRAW RESPONSE ========================================================= */ async function respondToDraw( accept ) { if ( !appState.party ) { return; } const response = await postJson( "/api/party/draw-response", { partyCode: appState.party.code, clientId: appState.party.clientId, accept: Boolean(accept), } ); if (!response.ok) { showToast( response.error || "Unable to respond.", "error" ); return; } hydratePartyState( response.party, false ); showToast( accept ? "Ничья согласована." : "Предложение отклонено.", accept ? "success" : "info" ); } /* ========================================================= REMATCH ========================================================= */ async function requestRematch() { if ( appState.mode !== "party" || !appState.party ) { return; } if (!appState.gameOver) { showToast( "Реванш доступен после окончания партии." ); return; } const response = await postJson( "/api/party/rematch", { partyCode: appState.party.code, clientId: appState.party.clientId, } ); if (!response.ok) { showToast( response.error || "Unable to request rematch.", "error" ); return; } hydratePartyState( response.party, false ); if ( response.party.rematch.white && response.party.rematch.black ) { showToast( "Реванш начинается!", "success" ); } else { showToast( "Вы выбрали реванш. Ждём второго игрока.", "success" ); } } /* ========================================================= INVITE ========================================================= */ 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}` ); } } /* ========================================================= CHAT ========================================================= */ function clearChat() { if ( elements.chatMessages ) { elements.chatMessages.innerHTML = `
    Chat becomes available in Party mode.
    `; } } function renderChat( messages ) { clearChat(); if ( !messages || !messages.length ) { showChatEmpty(); return; } elements.chatMessages.innerHTML = ""; for ( const message of messages ) { appendChatMessage( message, false ); } scrollChatToBottom(); } function showChatEmpty() { if ( elements.chatMessages ) { elements.chatMessages.innerHTML = `
    Пока сообщений нет.
    Начните разговор.
    `; } } function appendChatMessage( message, scroll = true ) { if ( !elements.chatMessages ) { return; } const empty = elements.chatMessages .querySelector( ".chat-empty" ); empty?.remove(); const wrapper = document.createElement( "div" ); wrapper.className = "chat-message"; const meta = document.createElement( "div" ); meta.className = "chat-meta"; const name = document.createElement( "span" ); name.className = "chat-name"; name.textContent = message.name || "Guest"; const time = document.createElement( "span" ); time.textContent = new Date( message.time || Date.now() ).toLocaleTimeString( [], { hour: "2-digit", minute: "2-digit", } ); meta.append( name, time ); const text = document.createElement( "div" ); text.className = "chat-text"; text.textContent = message.text || ""; wrapper.append( meta, text ); elements.chatMessages.appendChild( wrapper ); if (scroll) { scrollChatToBottom(); } } function scrollChatToBottom() { if ( elements.chatMessages ) { elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; } } async function sendChatMessage( event ) { event.preventDefault(); if ( !appState.party ) { return; } const message = elements.chatInput?.value.trim(); if (!message) { return; } const response = await postJson( "/api/party/chat", { partyCode: appState.party.code, clientId: appState.party.clientId, message, } ); if (!response.ok) { showToast( response.error || "Unable to send message.", "error" ); return; } if ( elements.chatInput ) { elements.chatInput.value = ""; } } /* ========================================================= STORAGE ========================================================= */ function getStoredPartyIds() { try { return JSON.parse( localStorage.getItem( STORAGE_KEYS.partyIds ) || "{}" ); } catch { return {}; } } function setStoredPartyId( code, clientId ) { const ids = getStoredPartyIds(); ids[code] = clientId; localStorage.setItem( STORAGE_KEYS.partyIds, JSON.stringify(ids) ); } function removeStoredPartyId( code ) { const ids = getStoredPartyIds(); delete ids[code]; localStorage.setItem( STORAGE_KEYS.partyIds, JSON.stringify(ids) ); } /* ========================================================= EVENTS ========================================================= */ 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); } else { setMode("ai"); resetLocalGame(); } } ); elements.modePartyButton ?.addEventListener( "click", () => { setMode("party"); } ); elements.startAiButton ?.addEventListener( "click", async () => { if (appState.party) { await leaveParty(true); } setMode("ai"); resetLocalGame(); showToast( "New AI game ready.", "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.resignButton ?.addEventListener( "click", resignGame ); elements.drawButton ?.addEventListener( "click", offerDraw ); elements.rematchButton ?.addEventListener( "click", requestRematch ); elements.acceptDrawButton ?.addEventListener( "click", () => respondToDraw(true) ); elements.declineDrawButton ?.addEventListener( "click", () => respondToDraw(false) ); elements.chatForm ?.addEventListener( "submit", sendChatMessage ); 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(); } } ); } /* ========================================================= INIT ========================================================= */ 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();