diff --git a/chess/public/js/app.js b/chess/public/js/app.js
new file mode 100644
index 0000000..218276c
--- /dev/null
+++ b/chess/public/js/app.js
@@ -0,0 +1,3129 @@
+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();