Gitea/workflows #1
@@ -46,13 +46,15 @@ const ChessAPI = {
|
||||
throw new Error(message || "Invalid move or not your turn.");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
// The move endpoint returns both the move result and the full resulting
|
||||
// board state, so the caller can render without a follow-up fetch.
|
||||
const data = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "Invalid move or not your turn.");
|
||||
if (!data.result.success) {
|
||||
throw new Error(data.result.message || "Invalid move or not your turn.");
|
||||
}
|
||||
|
||||
return result;
|
||||
return data;
|
||||
},
|
||||
|
||||
async handleMove(targetRow, targetCol) {
|
||||
@@ -85,16 +87,14 @@ const ChessAPI = {
|
||||
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
||||
|
||||
try {
|
||||
const moveResult = await this.makeMove(moveDto);
|
||||
const { result, state } = await this.makeMove(moveDto);
|
||||
|
||||
const updatedGame = await this.getGameState(GameState.currentGameId);
|
||||
ChessBoard.renderPieces(updatedGame.pieces);
|
||||
ChessBoard.renderState(state);
|
||||
|
||||
GameState.clearSelection();
|
||||
ChessInteractions.clearHighlights();
|
||||
|
||||
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
|
||||
this.alertGameStatusChange(moveResult);
|
||||
this.alertGameStatusChange(result);
|
||||
|
||||
} catch (error) {
|
||||
alert("❌ " + error.message);
|
||||
|
||||
@@ -1,4 +1,91 @@
|
||||
// Material value per piece type, indexed by PieceType (0=Pawn .. 5=King).
|
||||
const PIECE_VALUES = [1, 5, 3, 3, 9, 0];
|
||||
|
||||
const ChessBoard = {
|
||||
// Render from a full game-state payload (the shape returned by the move/state
|
||||
// endpoints and pushed over SignalR). Ignores state older than what's already
|
||||
// shown, so a slow initial fetch can't clobber a move that arrived first.
|
||||
renderState(state) {
|
||||
if (!state || !GameState.shouldApply(state.version)) return;
|
||||
this.renderPieces(state.pieces);
|
||||
this.renderCapturedTrays(state.pieces, state.capturedPieces);
|
||||
this.renderMoveList(state.sanHistory);
|
||||
this.renderStatus(state);
|
||||
GameState.setVersion(state.version);
|
||||
},
|
||||
|
||||
// Two-column numbered move list (white move, black move), latest highlighted.
|
||||
renderMoveList(sanHistory) {
|
||||
const list = document.getElementById("moveList");
|
||||
if (!list) return;
|
||||
|
||||
const san = sanHistory ?? [];
|
||||
|
||||
if (san.length === 0) {
|
||||
list.innerHTML = '<p class="movePlaceholder">Moves will appear here once a game begins.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = "";
|
||||
|
||||
for (let i = 0; i < san.length; i += 2) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "moveRow";
|
||||
|
||||
const num = document.createElement("span");
|
||||
num.className = "moveNum";
|
||||
num.textContent = `${i / 2 + 1}.`;
|
||||
row.appendChild(num);
|
||||
|
||||
row.appendChild(this.moveCell(san[i], i === san.length - 1));
|
||||
|
||||
if (i + 1 < san.length)
|
||||
row.appendChild(this.moveCell(san[i + 1], i + 1 === san.length - 1));
|
||||
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
list.scrollTop = list.scrollHeight;
|
||||
},
|
||||
|
||||
moveCell(san, isLatest) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = isLatest ? "moveSan latest" : "moveSan";
|
||||
cell.textContent = san;
|
||||
return cell;
|
||||
},
|
||||
|
||||
renderStatus(state) {
|
||||
let text;
|
||||
let alert = false;
|
||||
|
||||
if (state.isCheckmate) {
|
||||
const winner = state.currentPlayer === "White" ? "Black" : "White";
|
||||
text = `Checkmate — ${winner} wins`;
|
||||
alert = true;
|
||||
} else if (state.isStalemate) {
|
||||
text = "Draw — stalemate";
|
||||
alert = true;
|
||||
} else if (state.isThreefoldRepetition) {
|
||||
text = "Draw — threefold repetition";
|
||||
alert = true;
|
||||
} else {
|
||||
text = `${state.currentPlayer} to move`;
|
||||
if (state.isCheck) {
|
||||
text += " — check";
|
||||
alert = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror to both the desktop panel status and the mobile bar status.
|
||||
["statusLine", "mobileStatus"].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.toggle("alert", alert);
|
||||
});
|
||||
},
|
||||
|
||||
renderPieces(pieces) {
|
||||
this.clearAllSquares();
|
||||
this.renderCoordinateLabels();
|
||||
@@ -7,6 +94,48 @@ const ChessBoard = {
|
||||
this.highlightPreviousMove();
|
||||
},
|
||||
|
||||
// Show each side's captured pieces and the leading side's material advantage,
|
||||
// arranged so the current player's tray sits below the board.
|
||||
renderCapturedTrays(activePieces, capturedPieces) {
|
||||
const captured = capturedPieces ?? [];
|
||||
|
||||
// A captured piece's color is the side that lost it, so White's haul is the
|
||||
// captured Black pieces, and vice versa.
|
||||
const whiteCaptured = captured.filter(p => p.color === 1);
|
||||
const blackCaptured = captured.filter(p => p.color === 0);
|
||||
|
||||
// Net material from pieces still on the board, so promotions count correctly.
|
||||
const advantage = (activePieces ?? []).reduce(
|
||||
(sum, p) => sum + (p.color === 0 ? PIECE_VALUES[p.type] : -PIECE_VALUES[p.type]), 0);
|
||||
|
||||
const white = { captured: whiteCaptured, advantage: Math.max(advantage, 0) };
|
||||
const black = { captured: blackCaptured, advantage: Math.max(-advantage, 0) };
|
||||
|
||||
const isWhite = GameState.currentPlayerIsWhite !== false;
|
||||
this.fillCapturedTray("bottom", isWhite ? white : black);
|
||||
this.fillCapturedTray("top", isWhite ? black : white);
|
||||
},
|
||||
|
||||
fillCapturedTray(position, side) {
|
||||
const tray = document.getElementById(`captured-${position}`);
|
||||
const badge = document.getElementById(`advantage-${position}`);
|
||||
if (!tray || !badge) return;
|
||||
|
||||
tray.innerHTML = "";
|
||||
[...side.captured]
|
||||
.sort((a, b) => PIECE_VALUES[a.type] - PIECE_VALUES[b.type])
|
||||
.forEach(piece => {
|
||||
const img = document.createElement("img");
|
||||
img.src = ChessUtils.getPieceImageUrl(piece);
|
||||
img.alt = piece.type;
|
||||
img.className = "capturedPiece";
|
||||
img.draggable = false;
|
||||
tray.appendChild(img);
|
||||
});
|
||||
|
||||
badge.textContent = side.advantage > 0 ? `+${side.advantage}` : "";
|
||||
},
|
||||
|
||||
clearAllSquares() {
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const square = document.getElementById(`square-${i}`);
|
||||
|
||||
@@ -11,8 +11,8 @@ const ChessSignalR = {
|
||||
console.error("❌ SignalR connection closed:", err?.message);
|
||||
});
|
||||
|
||||
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => {
|
||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
||||
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => {
|
||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state);
|
||||
});
|
||||
|
||||
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
|
||||
@@ -28,12 +28,14 @@ const ChessSignalR = {
|
||||
}
|
||||
},
|
||||
|
||||
async handleMoveUpdate(gameId, moveDto, moveResultDto) {
|
||||
async handleMoveUpdate(gameId, moveDto, moveResultDto, state) {
|
||||
if (gameId !== GameState.currentGameId) return;
|
||||
|
||||
const gameState = await ChessAPI.getGameState(gameId);
|
||||
// Drop the echo of our own move and any out-of-order delivery.
|
||||
if (!GameState.shouldApply(state?.version)) return;
|
||||
|
||||
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(state);
|
||||
|
||||
ChessAPI.alertGameStatusChange(moveResultDto);
|
||||
},
|
||||
@@ -50,12 +52,6 @@ const ChessSignalR = {
|
||||
await this.leaveGame();
|
||||
},
|
||||
|
||||
async notifyMoveMade(moveDto, moveResult) {
|
||||
if (this.connection) {
|
||||
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
||||
}
|
||||
},
|
||||
|
||||
async leaveGame() {
|
||||
if (this.connection) {
|
||||
await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId);
|
||||
|
||||
@@ -6,6 +6,10 @@ const GameState = {
|
||||
legalMoves: [],
|
||||
previousMoveStart: null,
|
||||
previousMoveEnd: null,
|
||||
// Highest ply (move count) already rendered. Lets us ignore stale or
|
||||
// already-applied updates that arrive out of order, including the echo
|
||||
// of our own move.
|
||||
lastVersion: -1,
|
||||
|
||||
reset() {
|
||||
this.currentGameId = null;
|
||||
@@ -15,12 +19,22 @@ const GameState = {
|
||||
this.legalMoves = [];
|
||||
this.previousMoveStart = null;
|
||||
this.previousMoveEnd = null;
|
||||
this.lastVersion = -1;
|
||||
},
|
||||
|
||||
shouldApply(version) {
|
||||
return typeof version !== "number" || version > this.lastVersion;
|
||||
},
|
||||
|
||||
setVersion(version) {
|
||||
if (typeof version === "number") this.lastVersion = version;
|
||||
},
|
||||
|
||||
setGameInfo(gameId, playerId, isWhite) {
|
||||
this.currentGameId = gameId;
|
||||
this.currentPlayerId = playerId;
|
||||
this.currentPlayerIsWhite = isWhite;
|
||||
this.lastVersion = -1;
|
||||
},
|
||||
|
||||
setSelectedPiece(piece) {
|
||||
|
||||
@@ -11,8 +11,8 @@ const Spectate = {
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto) =>
|
||||
this.handleMoveUpdate(gameId, moveDto));
|
||||
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) =>
|
||||
this.handleMoveUpdate(gameId, moveDto, state));
|
||||
|
||||
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
|
||||
|
||||
@@ -117,7 +117,10 @@ const Spectate = {
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const state = await response.json();
|
||||
this.renderFromState(gameId, await response.json());
|
||||
},
|
||||
|
||||
renderFromState(gameId, state) {
|
||||
const result = this.resultTextFromState(state);
|
||||
const stored = this.games.get(gameId);
|
||||
|
||||
@@ -128,10 +131,13 @@ const Spectate = {
|
||||
this.setResult(gameId, result);
|
||||
},
|
||||
|
||||
async handleMoveUpdate(gameId, moveDto) {
|
||||
async handleMoveUpdate(gameId, moveDto, state) {
|
||||
if (!this.games.has(gameId)) return;
|
||||
|
||||
await this.renderGame(gameId);
|
||||
// Render from the pushed state; fall back to a fetch only if it's missing.
|
||||
if (state) this.renderFromState(gameId, state);
|
||||
else await this.renderGame(gameId);
|
||||
|
||||
this.highlightMove(gameId, moveDto);
|
||||
},
|
||||
|
||||
|
||||
@@ -30,6 +30,18 @@ function resetCopyPgn() {
|
||||
window.copyPgn = copyPgn;
|
||||
window.showCopyPgn = showCopyPgn;
|
||||
|
||||
// Mobile slide-up menu (move list + secondary actions).
|
||||
function toggleMenu() {
|
||||
document.body.classList.toggle("menu-open");
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
document.body.classList.remove("menu-open");
|
||||
}
|
||||
|
||||
window.toggleMenu = toggleMenu;
|
||||
window.closeMenu = closeMenu;
|
||||
|
||||
async function forfeitCurrentGame() {
|
||||
const gameId = ChessUtils.getCookie("chessGameId");
|
||||
const playerId = ChessUtils.getCookie("chessPlayerId");
|
||||
@@ -44,6 +56,7 @@ async function forfeitCurrentGame() {
|
||||
}
|
||||
|
||||
async function startNewGame() {
|
||||
closeMenu();
|
||||
await ChessSignalR.stopConnection();
|
||||
await forfeitCurrentGame();
|
||||
resetCopyPgn();
|
||||
@@ -63,7 +76,7 @@ async function startNewGame() {
|
||||
await ChessSignalR.setupConnection();
|
||||
|
||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to start new game:", error);
|
||||
@@ -71,6 +84,7 @@ async function startNewGame() {
|
||||
}
|
||||
|
||||
async function startCPUGame() {
|
||||
closeMenu();
|
||||
await ChessSignalR.stopConnection();
|
||||
await forfeitCurrentGame();
|
||||
resetCopyPgn();
|
||||
@@ -91,7 +105,7 @@ async function startCPUGame() {
|
||||
await ChessSignalR.setupConnection();
|
||||
|
||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to start CPU game:", error);
|
||||
@@ -111,7 +125,7 @@ async function resumeSavedGame() {
|
||||
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
||||
|
||||
await ChessSignalR.setupConnection();
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (err) {
|
||||
console.warn("Saved game not found or expired.", err);
|
||||
|
||||
Reference in New Issue
Block a user