665 lines
27 KiB
JavaScript
665 lines
27 KiB
JavaScript
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 = "<li>None in AI mode</li>";
|
|
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 = "<li>None yet</li>";
|
|
else elements.spectatorList.innerHTML = spectators.map(s => `<li>${escapeHtml(s.name)}${s.connected ? "" : " · offline"}</li>`).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();
|
|
|