Files
server/doc/marketplace-scripts/lichess-tv-chess/source.js

554 lines
16 KiB
JavaScript

const COMMAND = "!chess";
const FEED_URL = "https://lichess.org/api/tv/feed";
const HEADER_HEIGHT = 40;
const PADDING = 16;
const BOARD_MARGIN = 12;
const BORDER_RADIUS = 14;
const FOOTER_HEIGHT = 30;
const LIGHT_SQUARE = "#f0d9b5";
const DARK_SQUARE = "#b58863";
const BOARD_OUTLINE = "#111827";
const PANEL_BG = "rgba(15, 23, 42, 0.72)";
const PANEL_BORDER = "rgba(255, 255, 255, 0.1)";
const TEXT_PRIMARY = "#f8fafc";
const TEXT_MUTED = "#cbd5f5";
const PIECE_ASSETS = {
P: "Chess_plt45.png",
R: "Chess_rlt45.png",
N: "Chess_nlt45.png",
B: "Chess_blt45.png",
Q: "Chess_qlt45.png",
K: "Chess_klt45.png",
p: "Chess_pdt45.png",
r: "Chess_rdt45.png",
n: "Chess_ndt45.png",
b: "Chess_bdt45.png",
q: "Chess_qdt45.png",
k: "Chess_kdt45.png",
};
const ACTIVE_STATUSES = new Set(["started", "playing", "created"]);
function normalizeMessage(message) {
return (message || "").trim().toLowerCase();
}
function isCommandMatch(message) {
const normalized = normalizeMessage(message);
if (!normalized.startsWith(COMMAND)) {
return false;
}
return normalized.length === COMMAND.length || normalized.startsWith(`${COMMAND} `);
}
function parseFen(fen) {
if (!fen || typeof fen !== "string") {
return null;
}
const parts = fen.trim().split(" ");
if (parts.length < 2) {
return null;
}
const rows = parts[0].split("/");
if (rows.length !== 8) {
return null;
}
const board = rows.map((row) => {
const squares = [];
for (const char of row) {
if (Number.isInteger(Number(char))) {
const emptyCount = Number(char);
for (let i = 0; i < emptyCount; i += 1) {
squares.push(null);
}
} else {
squares.push(char);
}
}
return squares.length === 8 ? squares : null;
});
if (board.some((row) => !row || row.length !== 8)) {
return null;
}
return {
board,
turn: parts[1],
};
}
function parseUciMove(move) {
if (!move || typeof move !== "string" || move.length < 4) {
return null;
}
const files = "abcdefgh";
const fromFile = files.indexOf(move[0]);
const fromRank = Number(move[1]);
const toFile = files.indexOf(move[2]);
const toRank = Number(move[3]);
if (fromFile < 0 || toFile < 0 || !fromRank || !toRank) {
return null;
}
return {
from: { row: 8 - fromRank, col: fromFile },
to: { row: 8 - toRank, col: toFile },
};
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function ensurePieceCache(state) {
if (!state.pieceCache) {
state.pieceCache = new Map();
}
return state.pieceCache;
}
function resolvePieceAsset(pieceKey, assets) {
const assetName = PIECE_ASSETS[pieceKey];
if (!assetName || !Array.isArray(assets)) {
return null;
}
return assets.find((asset) => asset?.name === assetName) || null;
}
function loadPieceBitmap(pieceKey, assets, state) {
const cache = ensurePieceCache(state);
const existing = cache.get(pieceKey);
if (existing?.bitmap) {
return existing.bitmap;
}
if (existing?.loading) {
return null;
}
const asset = resolvePieceAsset(pieceKey, assets);
if (!asset?.url && !asset?.blob) {
return null;
}
const entry = { loading: true, bitmap: null };
cache.set(pieceKey, entry);
const blobPromise = asset.blob
? Promise.resolve(asset.blob)
: fetch(asset.url)
.then((response) => {
if (!response.ok) {
throw new Error("Failed to load piece asset");
}
return response.blob();
});
blobPromise
.then((blob) => createImageBitmap(blob))
.then((bitmap) => {
entry.bitmap = bitmap;
entry.loading = false;
})
.catch(() => {
cache.delete(pieceKey);
});
return null;
}
function drawRoundedRect(ctx, x, y, width, height, radius) {
const r = clamp(radius, 0, Math.min(width, height) / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + width, y, x + width, y + height, r);
ctx.arcTo(x + width, y + height, x, y + height, r);
ctx.arcTo(x, y + height, x, y, r);
ctx.arcTo(x, y, x + width, y, r);
ctx.closePath();
}
function resolvePayload(event) {
if (!event || typeof event !== "object") {
return {};
}
if (event.d && typeof event.d === "object") {
return event.d;
}
return event;
}
function resolveGameId(payload) {
return payload?.id || payload?.gameId || payload?.game?.id || payload?.game?.gameId || null;
}
function resolveStatus(payload) {
return payload?.status || payload?.game?.status || payload?.status?.name || null;
}
function resolvePlayerFromList(players, color) {
if (!Array.isArray(players)) {
return null;
}
return players.find((player) => player?.color === color) || null;
}
function resolvePlayers(payload) {
const list = payload?.players || payload?.game?.players;
const white =
resolvePlayerFromList(list, "white") ||
payload?.players?.white ||
payload?.white ||
payload?.game?.players?.white ||
payload?.game?.white;
const black =
resolvePlayerFromList(list, "black") ||
payload?.players?.black ||
payload?.black ||
payload?.game?.players?.black ||
payload?.game?.black;
const whiteName = white?.name || white?.user?.name || white?.username || "White";
const blackName = black?.name || black?.user?.name || black?.username || "Black";
return { white, black, whiteName, blackName };
}
function resolveRating(player) {
if (!player) {
return null;
}
return player?.rating || player?.ratingDiff || player?.user?.rating || null;
}
function resolveClockValue(payload, color) {
const fromList = resolvePlayerFromList(payload?.players || payload?.game?.players, color);
if (Number.isFinite(fromList?.seconds)) {
return fromList.seconds;
}
const direct = payload?.clocks?.[color];
if (Number.isFinite(direct)) {
return direct;
}
const nested = payload?.game?.clocks?.[color];
if (Number.isFinite(nested)) {
return nested;
}
return null;
}
function formatClock(value) {
if (!Number.isFinite(value)) {
return "--:--";
}
const seconds = value > 1000 ? Math.floor(value / 100) : Math.floor(value);
const minutes = Math.floor(seconds / 60);
const remaining = Math.max(seconds % 60, 0);
return `${minutes}:${String(remaining).padStart(2, "0")}`;
}
function updateFromEvent(event, state) {
const payload = resolvePayload(event);
const gameId = resolveGameId(payload);
if (gameId && !state.gameId) {
state.gameId = gameId;
}
if (gameId && state.gameId && gameId !== state.gameId) {
state.shouldStop = true;
return;
}
const fen = payload?.fen || payload?.game?.fen || payload?.position?.fen;
const parsedFen = parseFen(fen);
if (parsedFen) {
state.fen = fen;
state.board = parsedFen.board;
state.turn = parsedFen.turn;
state.boardVisible = true;
}
const lastMove = payload?.lm || payload?.lastMove || payload?.move || payload?.moves?.split(" ")?.slice(-1)[0];
if (lastMove) {
state.lastMove = lastMove;
}
const status = resolveStatus(payload);
if (status) {
state.status = status;
}
const players = resolvePlayers(payload);
if (players.whiteName || players.blackName) {
state.whiteName = players.whiteName;
state.blackName = players.blackName;
}
const whiteRating = resolveRating(players.white);
const blackRating = resolveRating(players.black);
if (whiteRating) {
state.whiteRating = whiteRating;
}
if (blackRating) {
state.blackRating = blackRating;
}
const whiteClock = resolveClockValue(payload, "white");
const blackClock = resolveClockValue(payload, "black");
if (whiteClock !== null) {
state.whiteClock = whiteClock;
}
if (blackClock !== null) {
state.blackClock = blackClock;
}
if (status && !ACTIVE_STATUSES.has(String(status).toLowerCase())) {
state.gameOver = true;
}
}
async function streamFeed(state) {
state.feedActive = true;
state.error = null;
state.shouldStop = false;
state.gameOver = false;
state.gameId = null;
state.status = "connecting";
state.lastMove = null;
state.boardVisible = false;
state.fen = null;
state.whiteClock = null;
state.blackClock = null;
const controller = new AbortController();
state.abortController = controller;
try {
const response = await fetch(FEED_URL, {
method: "GET",
headers: {
Accept: "application/x-ndjson",
},
signal: controller.signal,
});
if (!response.ok || !response.body) {
throw new Error("Unable to load Lichess TV feed");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
if (state.shouldStop) {
break;
}
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
lines.forEach((line) => {
if (!line.trim()) {
return;
}
try {
const event = JSON.parse(line);
updateFromEvent(event, state);
} catch (_error) {
// Ignore malformed events.
}
});
if (state.gameOver) {
state.shouldStop = true;
}
}
} catch (error) {
if (error?.name !== "AbortError") {
state.error = error?.message || String(error);
}
} finally {
if (state.abortController === controller) {
state.abortController = null;
}
state.feedActive = false;
if (state.shouldStop) {
state.boardVisible = false;
state.board = null;
state.fen = null;
state.status = null;
state.gameId = null;
state.lastMove = null;
state.turn = null;
state.gameOver = false;
state.whiteClock = null;
state.blackClock = null;
state.needsClear = true;
}
}
}
function stopFeed(state) {
state.shouldStop = true;
if (state.abortController) {
state.abortController.abort();
}
state.needsClear = true;
}
function processChatCommands(chatMessages, state) {
if (!Array.isArray(chatMessages) || chatMessages.length === 0) {
return;
}
const lastSeen = state.lastChatTimestamp ?? 0;
let latestSeen = lastSeen;
let shouldStart = false;
chatMessages.forEach((message) => {
const timestamp = message?.timestamp ?? 0;
if (timestamp <= lastSeen) {
return;
}
latestSeen = Math.max(latestSeen, timestamp);
if (!shouldStart && isCommandMatch(message?.message)) {
shouldStart = true;
}
});
state.lastChatTimestamp = latestSeen;
if (shouldStart && !state.feedActive && !state.boardVisible) {
streamFeed(state);
}
}
function drawBoard(context, state) {
const { ctx, width, height, assets } = context;
const board = state.board;
if (!ctx || !board) {
return;
}
ctx.clearRect(0, 0, width, height);
const maxBoardSize = Math.min(width, height) * 0.55;
const boardSize = clamp(maxBoardSize, 180, Math.min(width, height));
if (boardSize <= 0) {
return;
}
const panelWidth = boardSize + BOARD_MARGIN * 2;
const panelHeight = boardSize + HEADER_HEIGHT + FOOTER_HEIGHT + BOARD_MARGIN;
const panelX = clamp(width - panelWidth - PADDING, PADDING, width - panelWidth);
const panelY = clamp(height - panelHeight - PADDING, PADDING, height - panelHeight);
ctx.fillStyle = PANEL_BG;
drawRoundedRect(ctx, panelX, panelY, panelWidth, panelHeight, BORDER_RADIUS);
ctx.fill();
ctx.strokeStyle = PANEL_BORDER;
ctx.lineWidth = 2;
ctx.stroke();
const headerX = panelX + BOARD_MARGIN;
const headerY = panelY + BOARD_MARGIN * 0.6;
ctx.fillStyle = TEXT_PRIMARY;
ctx.font = "600 20px 'Inter', 'Segoe UI', sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "top";
const whiteLabel = state.whiteRating ? `${state.whiteName} (${state.whiteRating})` : state.whiteName || "White";
const blackLabel = state.blackRating ? `${state.blackName} (${state.blackRating})` : state.blackName || "Black";
const whiteClock = formatClock(state.whiteClock);
const blackClock = formatClock(state.blackClock);
ctx.fillText(blackLabel, headerX, headerY);
ctx.textAlign = "right";
ctx.fillText(blackClock, panelX + panelWidth - BOARD_MARGIN, headerY);
ctx.textAlign = "left";
const statusText = state.status ? String(state.status).toUpperCase() : "LIVE";
ctx.fillStyle = TEXT_MUTED;
ctx.font = "600 13px 'Inter', 'Segoe UI', sans-serif";
ctx.fillText(statusText, headerX, headerY + 22);
const boardX = panelX + BOARD_MARGIN;
const boardY = panelY + HEADER_HEIGHT;
const squareSize = boardSize / 8;
ctx.strokeStyle = BOARD_OUTLINE;
ctx.lineWidth = 3;
ctx.strokeRect(boardX - 1.5, boardY - 1.5, boardSize + 3, boardSize + 3);
const lastMove = parseUciMove(state.lastMove);
for (let row = 0; row < 8; row += 1) {
for (let col = 0; col < 8; col += 1) {
const isLight = (row + col) % 2 === 0;
ctx.fillStyle = isLight ? LIGHT_SQUARE : DARK_SQUARE;
ctx.fillRect(boardX + col * squareSize, boardY + row * squareSize, squareSize, squareSize);
if (
lastMove &&
((lastMove.from.row === row && lastMove.from.col === col) ||
(lastMove.to.row === row && lastMove.to.col === col))
) {
ctx.fillStyle = "rgba(59, 130, 246, 0.35)";
ctx.fillRect(boardX + col * squareSize, boardY + row * squareSize, squareSize, squareSize);
}
}
}
for (let row = 0; row < 8; row += 1) {
for (let col = 0; col < 8; col += 1) {
const piece = board[row]?.[col];
if (!piece) {
continue;
}
const bitmap = loadPieceBitmap(piece, assets, state);
if (!bitmap) {
continue;
}
const size = squareSize * 0.85;
const offset = (squareSize - size) / 2;
ctx.drawImage(
bitmap,
boardX + col * squareSize + offset,
boardY + row * squareSize + offset,
size,
size
);
}
}
const turnIndicator = state.turn === "b" ? "Black to move" : "White to move";
ctx.fillStyle = TEXT_MUTED;
ctx.font = "500 13px 'Inter', 'Segoe UI', sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "bottom";
ctx.fillText(whiteLabel, panelX + BOARD_MARGIN, panelY + panelHeight - BOARD_MARGIN / 2);
ctx.textAlign = "right";
ctx.fillText(whiteClock, panelX + panelWidth - BOARD_MARGIN, panelY + panelHeight - BOARD_MARGIN / 2);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.font = "500 12px 'Inter', 'Segoe UI', sans-serif";
ctx.fillText(turnIndicator, panelX + BOARD_MARGIN, panelY + panelHeight - FOOTER_HEIGHT + 12);
}
function init() { }
function tick(context, state) {
const { ctx, width, height, chatMessages } = context;
if (!ctx) {
return;
}
processChatCommands(chatMessages, state);
if (state.needsClear) {
ctx.clearRect(0, 0, width, height);
state.needsClear = false;
}
if (!state.boardVisible || !state.board) {
return;
}
drawBoard(context, state);
if (state.shouldStop) {
stopFeed(state);
}
}