import { ChessEngine, VALUES } from "/shared/chess-engine.js";
const PIECES = {
w: { k:"♔", q:"♕", r:"♖", b:"♗", n:"♘", p:"♙" },
b: { k:"♚", q:"♛", r:"♜", b:"♝", n:"♞", p:"♟" }
};
const $ = (id) => document.getElementById(id);
const state = {
mode: null,
engine: new ChessEngine(),
orientation: "w",
selected: null,
legal: [],
party: null,
clientId: localStorage.getItem("chess-client-id") || crypto.randomUUID(),
eventSource: null,
pendingPromotion: null,
aiBusy: false,
localGameOver: false
};
localStorage.setItem("chess-client-id", state.clientId);
function show(view) {
$("homeView").classList.toggle("hidden", view !== "home");
$("gameView").classList.toggle("hidden", view !== "game");
}
function toast(text) {
const el = $("toast");
el.textContent = text;
el.classList.add("show");
clearTimeout(toast.timer);
toast.timer = setTimeout(() => el.classList.remove("show"), 2200);
}
function resetSelection() {
state.selected = null;
state.legal = [];
}
function boardOrder() {
const files = "abcdefgh".split("");
const ranks = [8,7,6,5,4,3,2,1];
if (state.orientation === "b") {
files.reverse();
ranks.reverse();
}
return { files, ranks };
}
function snapshot() {
return state.mode === "party" ? state.party?.game : state.engine.getSnapshot();
}
function renderBoard() {
const snap = snapshot();
if (!snap) return;
const board = $("board");
board.innerHTML = "";
const { files, ranks } = boardOrder();
const legalMap = new Map(state.legal.map(m => [m.to, m]));
const last = snap.lastMove;
for (const rank of ranks) {
for (const file of files) {
const name = `${file}${rank}`;
const f = "abcdefgh".indexOf(file);
const r = rank - 1;
const el = document.createElement("button");
el.type = "button";
el.className = `square ${((f+r)%2===0) ? "light" : "dark"}`;
el.dataset.square = name;
if (state.selected === name) el.classList.add("selected");
if (state.selected && state.legal.some(m => m.to === name)) {
el.classList.add(snap.board[name] ? "capture" : "legal");
}
if (last?.from === name) el.classList.add("last-from");
if (last?.to === name) el.classList.add("last-to");
if (state.selected && state.legal.some(m => m.to === name)) el.classList.add("trajectory");
const piece = snap.board[name];
if (piece) {
const span = document.createElement("span");
span.className = "piece";
span.textContent = PIECES[piece.color][piece.type];
el.appendChild(span);
}
const fileLabel = document.createElement("span");
fileLabel.className = "coord file";
fileLabel.textContent = file;
el.appendChild(fileLabel);
const rankLabel = document.createElement("span");
rankLabel.className = "coord rank";
rankLabel.textContent = rank;
el.appendChild(rankLabel);
el.addEventListener("click", () => handleSquare(name));
board.appendChild(el);
}
}
}
function canUserMove() {
if (state.mode === "ai") return state.engine.state.turn === "w" && !state.aiBusy && !state.localGameOver;
if (state.mode === "party") {
return state.party?.you?.role === (state.party.game.turn === "w" ? "white" : "black")
&& !state.party.gameOverReason;
}
return false;
}
function handleSquare(name) {
if (!canUserMove()) return;
const snap = snapshot();
const piece = snap.board[name];
if (state.selected) {
const candidate = state.legal.filter(m => m.to === name);
if (candidate.length) {
if (candidate.some(m => m.promotion)) {
state.pendingPromotion = { from: state.selected, to: name };
$("promotionModal").classList.remove("hidden");
} else {
playMove(state.selected, name, candidate[0].promotion || null);
}
return;
}
}
if (piece && piece.color === snap.turn) {
state.selected = name;
if (state.mode === "ai") state.legal = state.engine.movesFrom(name);
else state.legal = state.partyEngineMoves(name);
} else {
resetSelection();
}
renderBoard();
updateStatus();
}
function partyEngineMoves(from) {
// The server is authoritative. The snapshot contains enough board state to
// calculate normal movement hints locally; the server validates the final move.
const temp = new ChessEngine();
temp.state = JSON.parse(JSON.stringify({
board: state.party.game.board,
turn: state.party.game.turn,
castling: state.party.game.castling,
ep: state.party.game.ep,
halfmove: state.party.game.halfmove,
fullmove: state.party.game.fullmove,
lastMove: state.party.game.lastMove
}));
return temp.movesFrom(from);
}
async function playMove(from, to, promotion = null) {
resetSelection();
$("promotionModal").classList.add("hidden");
if (state.mode === "ai") {
const result = state.engine.makeMove({ from, to, promotion: promotion || "q" });
if (!result.ok) {
toast(result.error);
renderBoard();
return;
}
renderAll();
if (!state.engine.getStatus().phase.match(/checkmate|draw/)) {
state.aiBusy = true;
renderAll();
setTimeout(aiMove, 220);
}
return;
}
try {
const response = await fetch("/api/party/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
partyCode: state.party.code,
clientId: state.clientId,
from, to, promotion: promotion || "q"
})
});
const data = await response.json();
if (!data.ok) toast(data.error || "Ход не выполнен.");
if (data.party) applyParty(data.party);
} catch {
toast("Нет соединения с сервером.");
}
}
function aiMove() {
const moves = state.engine.legalMoves("b");
if (!moves.length) {
state.aiBusy = false;
renderAll();
return;
}
// Small deterministic evaluation: captures, promotion, checks, center.
let best = moves[0];
let bestScore = -Infinity;
for (const move of moves) {
const piece = state.engine.state.board[move.from];
const captured = state.engine.state.board[move.to];
let score = Math.random() * 0.15;
if (captured) score += VALUES[captured.type] * 2.5;
if (move.promotion) score += 9;
if (["d4","e4","d5","e5"].includes(move.to)) score += .5;
if (piece?.type === "p" && Math.abs(Number(move.to[1]) - Number(move.from[1])) === 2) score += .1;
if (score > bestScore) { bestScore = score; best = move; }
}
state.engine.makeMove({
from: best.from,
to: best.to,
promotion: best.promotion || "q"
});
state.aiBusy = false;
renderAll();
}
function renderMoves(snap) {
const list = $("movesList");
list.innerHTML = "";
const moves = snap.moves || [];
for (let i = 0; i < moves.length; i += 2) {
const row = document.createElement("div");
row.className = "move";
row.innerHTML = `
${Math.floor(i/2)+1}.
${escapeHtml(moves[i]?.san || "")}
${escapeHtml(moves[i+1]?.san || "")}
`;
list.appendChild(row);
}
$("moveCount").textContent = moves.length;
list.scrollTop = list.scrollHeight;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&":"&","<":"<",">":">",'"':""","'":"'"
}[c]));
}
function renderMaterial(snap) {
const mat = snap.material || { captured:{w:[],b:[]},score:{w:0,b:0} };
const icon = (color, type) => PIECES[color][type];
$("material").innerHTML = `
${mat.captured.w.map(p => icon("w",p)).join("") || "—"}
Δ ${mat.score.w - mat.score.b}
${mat.captured.b.map(p => icon("b",p)).join("") || "—"}
`;
}
function updateStatus() {
const snap = snapshot();
if (!snap) return;
const status = snap.status || { phase:"playing", turn:snap.turn };
let text = state.selected ? "Выберите клетку назначения." : "Выберите фигуру.";
if (status.phase === "check") text = "Шах.";
if (status.phase === "checkmate") text = "Мат.";
if (status.phase === "draw") text = `Ничья: ${status.reason || "правило"}.`;
if (state.mode === "ai" && state.aiBusy) text = "ИИ думает…";
if (state.mode === "ai" && state.localGameOver) text = "Партия завершена сдачей.";
if (state.mode === "party" && state.party?.gameOverReason === "resign") text = "Партия завершена сдачей.";
if (state.mode === "party" && state.party?.gameOverReason === "timeout") text = "Время закончилось.";
if (state.mode === "party" && state.party?.drawOffer) text = `${state.party.drawOffer.name} предложил(а) ничью.`;
$("gameStatus").textContent = text;
$("turnBadge").textContent = snap.turn === "w" ? "Ход белых" : "Ход чёрных";
}
function renderPlayers() {
if (state.mode === "ai") {
$("whiteName").textContent = "Вы";
$("blackName").textContent = "ИИ";
$("whiteMeta").textContent = "Белые";
$("blackMeta").textContent = "Чёрные";
$("whiteClock").textContent = "—";
$("blackClock").textContent = "—";
return;
}
const p = state.party;
$("whiteName").textContent = p.players.white?.name || "Ожидание…";
$("blackName").textContent = p.players.black?.name || "Ожидание…";
$("whiteMeta").textContent = p.players.white?.connected ? "Белые • онлайн" : "Белые • офлайн";
$("blackMeta").textContent = p.players.black?.connected ? "Чёрные • онлайн" : "Чёрные • офлайн";
$("whiteClock").textContent = formatClock(p.clocks.white, p.timeControl.initial);
$("blackClock").textContent = formatClock(p.clocks.black, p.timeControl.initial);
}
function formatClock(seconds, enabled) {
if (!enabled) return "—";
const total = Math.max(0, Math.ceil(seconds));
const m = Math.floor(total / 60);
const s = total % 60;
return `${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`;
}
function renderAll() {
renderBoard();
renderMoves(snapshot());
renderMaterial(snapshot());
renderPlayers();
updateStatus();
}
function startAI() {
closeParty();
state.mode = "ai";
state.engine = new ChessEngine();
state.orientation = "w";
state.localGameOver = false;
resetSelection();
$("chatPanel").classList.add("hidden");
show("game");
renderAll();
}
async function createParty() {
const name = $("nameInput").value.trim() || "Guest";
const timeControl = $("timeControl").value;
await enterParty("/api/party/create", { name, timeControl });
}
async function joinParty() {
const name = $("nameInput").value.trim() || "Guest";
const partyCode = $("partyCodeInput").value.trim().toUpperCase();
if (!partyCode) {
$("setupError").textContent = "Введите код комнаты.";
return;
}
await enterParty("/api/party/join", { name, partyCode, clientId: state.clientId });
}
async function enterParty(url, body) {
$("setupError").textContent = "";
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const data = await response.json();
if (!data.ok) {
$("setupError").textContent = data.error || "Ошибка.";
return;
}
state.mode = "party";
state.party = data.party;
state.orientation = state.party.you?.role === "black" ? "b" : "w";
resetSelection();
$("chatPanel").classList.remove("hidden");
show("game");
connectEvents();
renderAll();
} catch {
$("setupError").textContent = "Сервер недоступен.";
}
}
function connectEvents() {
if (state.eventSource) state.eventSource.close();
state.eventSource = new EventSource(
`/api/party/events?partyCode=${encodeURIComponent(state.party.code)}&clientId=${encodeURIComponent(state.clientId)}`
);
state.eventSource.addEventListener("party", e => {
try { applyParty(JSON.parse(e.data)); } catch {}
});
state.eventSource.onerror = () => {};
}
function applyParty(party) {
state.party = party;
// Important: orientation is never changed by a received move.
// It only changes when the user presses "Повернуть".
renderAll();
renderChat();
}
function renderChat() {
if (state.mode !== "party") return;
const box = $("chatMessages");
box.innerHTML = "";
for (const msg of state.party.chat || []) {
const row = document.createElement("div");
row.className = "chat-message";
row.innerHTML = `${escapeHtml(msg.name)} ${escapeHtml(msg.text)}`;
box.appendChild(row);
}
box.scrollTop = box.scrollHeight;
}
async function partyAction(endpoint) {
if (!state.party) return;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ partyCode: state.party.code, clientId: state.clientId })
});
const data = await response.json();
if (!data.ok) toast(data.error || "Операция не выполнена.");
if (data.party) applyParty(data.party);
} catch {
toast("Нет соединения с сервером.");
}
}
function newGame() {
if (state.mode === "ai") {
state.engine = new ChessEngine();
state.aiBusy = false;
state.localGameOver = false;
state.orientation = "w";
resetSelection();
renderAll();
return;
}
if (state.mode === "party") {
partyAction("/api/party/rematch");
return;
}
$("partySetup").classList.add("hidden");
show("home");
}
function closeParty() {
state.eventSource?.close();
state.eventSource = null;
state.party = null;
}
function backHome() {
if (state.mode === "party") {
partyAction("/api/party/leave");
closeParty();
}
state.mode = null;
state.engine = new ChessEngine();
resetSelection();
$("partySetup").classList.add("hidden");
show("home");
}
$("newGameBtn").addEventListener("click", newGame);
$("flipBtn").addEventListener("click", () => {
state.orientation = state.orientation === "w" ? "b" : "w";
renderBoard();
});
$("backHomeBtn").addEventListener("click", backHome);
$("resignBtn").addEventListener("click", () => {
if (state.mode === "party") partyAction("/api/party/resign");
else {
const snap = state.engine.getSnapshot();
if (!snap.status.phase.match(/checkmate|draw/)) {
state.localGameOver = true;
state.aiBusy = false;
resetSelection();
renderAll();
toast("Вы сдались.");
}
}
});
$("drawBtn").addEventListener("click", () => {
if (state.mode === "party") partyAction("/api/party/draw");
else toast("Предложение ничьей доступно в Party.");
});
$("rematchBtn").addEventListener("click", newGame);
document.querySelectorAll(".mode-card").forEach(btn => {
btn.addEventListener("click", () => {
if (btn.dataset.mode === "ai") startAI();
else $("partySetup").classList.remove("hidden");
});
});
$("createPartyBtn").addEventListener("click", createParty);
$("joinPartyBtn").addEventListener("click", joinParty);
$("partyCodeInput").addEventListener("input", e => {
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g,"");
});
$("chatForm").addEventListener("submit", async e => {
e.preventDefault();
if (!state.party) return;
const input = $("chatInput");
const text = input.value.trim();
if (!text) return;
input.value = "";
try {
await fetch("/api/party/chat", {
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
partyCode:state.party.code,
clientId:state.clientId,
text
})
});
} catch {
toast("Не удалось отправить сообщение.");
}
});
document.querySelectorAll("[data-promotion]").forEach(btn => {
btn.addEventListener("click", () => {
const p = state.pendingPromotion;
if (!p) return;
state.pendingPromotion = null;
playMove(p.from, p.to, btn.dataset.promotion);
});
});
show("home");