Commit 95acecec authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(chess): live games, clocks, refresh, puzzles; Night Arcade board

Live games lost their move history (each opponent move arrived as a bare
FEN), so threefold repetition never triggered and the PGN broke; moves now
replay from the server's list. The mating move was sent after the game
closed and never reached the opponent. A refreshed tab overwrote the
server's move list with a short one; it now rebuilds from the server.
The opponent got the increment twice, Black's clock started before the
first move, and a flag is now confirmed with the server. Refreshing a bot
game lost it; the bot accepted half of all draw offers at random and froze
on an illegal reply. Promotion has a cancellable picker. The result card
no longer invents rating changes for bot games, rematch after a live game
searches at the same clock, and back to table no longer stacks a home.
Analysis kept the evaluation sign and orientation for Black; review runs
one engine call per position and caches. Puzzles accept an alternative
mate and offer retry / show answer.

Board: flat lavender in an ink frame, pieces on a lip, tokens only.
tests/play/chess.mjs: 183/183 (bot, online with two accounts, puzzles,
three phone sizes), zero console errors.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent e4ccd12e
This diff is collapsed.
// The promotion picker: a small white card dealt onto the board with the four
// pieces the pawn can become, in the player's own colour. Resolves to
// 'q' | 'r' | 'b' | 'n', or null when the player changes their mind.
import { t } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js';
import * as audio from '../../../core/audio.js';
import { haptic } from '../../../core/feel.js';
import { pieceHTML } from '../canvas/board.js';
const PIECES = ['q', 'r', 'b', 'n'];
/**
* @param {HTMLElement} dialog the host element (kept: #promo-dialog in the game)
* @param {'w'|'b'} color
*/
export function pickPromotion(dialog, color) {
return new Promise(resolve => {
dialog.classList.add('cg-promo');
dialog.classList.toggle('cg-promo-black', color === 'b');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-label', t('chess.promote_to'));
dialog.innerHTML = `
<div class="cg-promo-title">${t('chess.promote_to')}</div>
<div class="cg-promo-row">
${PIECES.map(p => `
<button type="button" class="cg-promo-pick" data-piece="${p}" aria-label="${t('chess.piece_' + p)}">
${pieceHTML(color === 'w' ? p.toUpperCase() : p, 46)}
</button>`).join('')}
</div>
<button type="button" class="cg-promo-cancel" aria-label="${t('common.cancel')}">${icon('x', 18)}</button>
`;
dialog.hidden = false;
dialog.style.display = '';
audio.play('flip');
haptic('medium');
const done = (val) => {
dialog.hidden = true;
dialog.innerHTML = '';
resolve(val);
};
dialog.querySelectorAll('[data-piece]').forEach(btn => {
btn.addEventListener('click', () => done(btn.dataset.piece), { once: true });
});
dialog.querySelector('.cg-promo-cancel').addEventListener('click', () => done(null), { once: true });
});
}
export const PROMO_STYLE = `
.cg-promo {
position: absolute;
inset: 0;
margin: auto;
width: max-content;
height: max-content;
z-index: 40;
padding: 16px 14px 14px;
border-radius: var(--r-card);
background: var(--card);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
animation: deal var(--dur-normal) var(--ease-pop) both;
}
.cg-promo[hidden] { display: none !important; }
.cg-promo-title { font-family: var(--font-display); font-size: var(--fs-md); color: var(--ink); }
.cg-promo-row { display: flex; gap: 8px; }
.cg-promo-pick {
width: 58px; height: 62px;
display: flex; align-items: center; justify-content: center;
border: none;
border-radius: 16px;
background: var(--card-2);
box-shadow: 0 4px 0 var(--card-edge);
cursor: pointer;
padding: 0;
}
.cg-promo-black .cg-promo-pick { background: var(--card-3); }
.cg-promo-pick.is-pressed { translate: 0 3px; box-shadow: 0 1px 0 var(--card-edge); }
.cg-promo-cancel {
position: absolute;
top: -14px; inset-inline-end: -14px;
width: 44px; height: 44px;
border-radius: 14px;
border: none;
background: var(--btn-red-2);
box-shadow: 0 3px 0 var(--btn-red-edge);
color: var(--text-primary);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
padding: 0;
}
.cg-promo-cancel .i { stroke-width: 3; }
`;
......@@ -8,9 +8,9 @@ export function renderRatingGraph(container, history, options = {}) {
const {
width = 340,
height = 140,
color = getColor('gold', '#F6BB3C'),
color = getColor('gold', '#FFC531'),
bgColor = null, // null: let the ground show through
gridColor = getColor('back_line', 'rgba(255, 210, 140, 0.14)'),
gridColor = getColor('back_line', 'rgba(255, 255, 255, 0.07)'),
} = options;
container.innerHTML = '';
......@@ -19,12 +19,12 @@ export function renderRatingGraph(container, history, options = {}) {
return;
}
const muted = getColor('text_muted', '#CFAC80');
const primary = getColor('text_primary', '#F3F0E8');
const ground = getColor('back_900', '#221008');
const up = getColor('win', '#3DBE7E');
const down = getColor('red', '#EF4D55');
const font = getColor('font_ar', 'Alexandria, system-ui, sans-serif');
const muted = getColor('text_muted', '#8E889E');
const primary = getColor('text_primary', '#FFFFFF');
const ground = getColor('back_900', '#14121A');
const up = getColor('win', '#2ED47A');
const down = getColor('red', '#FF4D5E');
const font = getColor('font_ar', 'Rubik, system-ui, sans-serif');
const { canvas, ctx } = createCanvas(container, width, height);
canvas.style.width = '100%';
......
// Move Classification Engine
// Classifies moves as: brilliant, great, best, good, book, inaccuracy, mistake, blunder
import { emoji } from '../../../core/theme.js';
import { t } from '../../../core/i18n.js';
const THRESHOLDS = {
......@@ -19,7 +18,7 @@ export function classifyMove(evalBefore, evalAfter, bestEval, isPlayerWhite, mov
const lossFromBest = (bestEval - evalAfter) * sign;
// Book moves (first 2 full moves = 4 half-moves)
if (moveNum <= 2) return { class: 'book', symbol: emoji('book', '📖', 14), color: 'var(--text-secondary)' };
if (moveNum <= 2) return { class: 'book', symbol: '', color: 'var(--text-secondary)' };
// If eval is mate-level, special handling
if (Math.abs(evalAfter) > 900) {
......@@ -42,7 +41,7 @@ export function classifyMove(evalBefore, evalAfter, bestEval, isPlayerWhite, mov
return { class: 'brilliant', symbol: '!!', color: 'var(--cyan)' };
}
if (lossFromBest <= THRESHOLDS.great) {
return { class: 'best', symbol: emoji('best_move_star', '★', 14), color: 'var(--emerald)' };
return { class: 'best', symbol: '', color: 'var(--emerald)' };
}
return { class: 'great', symbol: '!', color: 'var(--blue)' };
}
......
// Two chess clocks. Time is kept in milliseconds; the running side loses the
// wall-clock time that passes. A move "presses" the clock: the mover is credited
// the increment (as on the server, api/game.php) and the other side starts.
export class ChessClock {
constructor(timeMs, incrementMs = 0) {
this.initial = timeMs;
......@@ -13,33 +16,48 @@ export class ChessClock {
start(color) {
this.stop();
if (color !== 'w' && color !== 'b') return;
this.running = color;
this.lastTick = performance.now();
const tick = () => {
const now = performance.now();
const elapsed = now - this.lastTick;
this.lastTick = now;
if (this.running === 'w') {
this.white -= elapsed;
if (this.white <= 0) { this.white = 0; this.stop(); this.onFlag?.('w'); return; }
} else {
this.black -= elapsed;
if (this.black <= 0) { this.black = 0; this.stop(); this.onFlag?.('b'); return; }
const key = this.running === 'w' ? 'white' : 'black';
this[key] -= elapsed;
if (this[key] <= 0) {
this[key] = 0;
const flagged = this.running;
this.stop();
this.onTick?.(this.white, this.black);
this.onFlag?.(flagged);
return;
}
this.onTick?.(this.white, this.black);
};
this.interval = setInterval(tick, 100);
}
/** `color` just moved: credit its increment and start the other side. */
press(color) {
if (color === 'w') this.white += this.increment;
else if (color === 'b') this.black += this.increment;
this.start(color === 'w' ? 'b' : 'w');
this.onTick?.(this.white, this.black);
}
/** Kept for old callers: the running side presses. */
switch() {
if (!this.running) return;
if (this.running === 'w') {
this.white += this.increment;
this.start('b');
} else {
this.black += this.increment;
this.start('w');
}
this.press(this.running);
}
/** Adopt times from the server (it is the authority in live games). */
set(whiteMs, blackMs) {
if (Number.isFinite(whiteMs)) this.white = Math.max(0, whiteMs);
if (Number.isFinite(blackMs)) this.black = Math.max(0, blackMs);
this.lastTick = performance.now();
this.onTick?.(this.white, this.black);
}
stop() {
......@@ -54,8 +72,13 @@ export class ChessClock {
return color === 'w' ? this.white : this.black;
}
/** m:ss, h:mm:ss for long games, and tenths in the last ten seconds. */
format(ms) {
if (ms <= 0) return '0:00';
if (ms < 10000) {
const tenths = Math.floor(ms / 100);
return `0:0${Math.floor(tenths / 10)}.${tenths % 10}`;
}
const totalSec = Math.ceil(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
......@@ -78,9 +101,9 @@ export class ChessClock {
}
export function parseTimeControl(key) {
const match = key.match(/(\w+)_(\d+)_(\d+)/);
const match = String(key || '').match(/(\w+)_(\d+)_(\d+)/);
if (!match) return { time: 600000, increment: 0 };
const minutes = parseInt(match[2]);
const inc = parseInt(match[3]);
const minutes = parseInt(match[2], 10);
const inc = parseInt(match[3], 10);
return { time: minutes * 60 * 1000, increment: inc * 1000 };
}
// A thin wrapper round chess.js (the global `Chess`, 0.10 API, with the 1.x
// method names accepted too). One game at a time: the scene that is open.
let game = null;
export function create(fen) {
game = new Chess(fen);
game = fen ? new Chess(fen) : new Chess();
return game;
}
export function get() { return game; }
/** A legal move, or null. Never throws: chess.js 1.x throws on illegal moves. */
export function move(from, to, promotion) {
const m = game.move({ from, to, promotion: promotion || undefined });
return m;
try {
return game.move({ from, to, promotion: promotion || undefined }) || null;
} catch (e) {
return null;
}
}
export function moveUci(uci) {
const from = uci.substring(0, 2);
const to = uci.substring(2, 4);
const promotion = uci.length > 4 ? uci[4] : undefined;
return move(from, to, promotion);
if (!uci || uci.length < 4) return null;
return move(uci.substring(0, 2), uci.substring(2, 4), uci.length > 4 ? uci[4] : undefined);
}
export function undo() { return game.undo(); }
export function fen() { return game.fen(); }
export function turn() { return game.turn(); }
export function isGameOver() { return game.game_over ? game.game_over() : game.isGameOver(); }
......@@ -49,7 +54,40 @@ export function reset() {
}
export function load(fen) {
game.load(fen);
return game.load(fen);
}
/**
* Rebuild the game from the start by playing `moves` ({from, to, promotion}).
* Keeping the real history (not just a FEN) is what lets threefold repetition
* be detected and the PGN be complete. Returns how many moves applied.
*/
export function replay(moves, startFen) {
game = startFen ? new Chess(startFen) : new Chess();
let n = 0;
for (const m of moves || []) {
if (!m || !m.from || !m.to) break;
if (!move(m.from, m.to, m.promotion)) break;
n++;
}
return n;
}
/** The board part of a FEN, for comparing positions without the counters. */
export function placement(fenStr) {
return String(fenStr || '').split(' ').slice(0, 2).join(' ');
}
/** The square of `color`'s king, or null. */
export function kingSquare(color) {
const b = game.board();
for (let r = 0; r < 8; r++) {
for (let c = 0; c < 8; c++) {
const p = b[r][c];
if (p && p.type === 'k' && p.color === color) return String.fromCharCode(97 + c) + String(8 - r);
}
}
return null;
}
export function getResult(playerColor) {
......@@ -59,3 +97,13 @@ export function getResult(playerColor) {
}
return 'draw';
}
/** Why the position on the board is final, in the server's vocabulary. */
export function endReason() {
if (isCheckmate()) return 'checkmate';
if (isStalemate()) return 'stalemate';
if (isThreefoldRepetition()) return 'threefold';
if (isInsufficientMaterial()) return 'insufficient';
if (isDraw()) return 'fifty_moves';
return 'draw';
}
// ECO Opening Database — maps move sequences to opening names
const OPENINGS = {
'1.e4': { name: "King's Pawn Opening", eco: 'B00' },
'1.e4 e5': { name: "Open Game", eco: 'C20' },
'1.e4 e5 2.Nf3': { name: "King's Knight Opening", eco: 'C40' },
'1.e4 e5 2.Nf3 Nc6': { name: "Four Knights Game", eco: 'C44' },
'1.e4': { name: "King's Pawn Opening", nameAr: "افتتاحية بيدق الملك", eco: 'B00' },
'1.e4 e5': { name: "Open Game", nameAr: "اللعبة المفتوحة", eco: 'C20' },
'1.e4 e5 2.Nf3': { name: "King's Knight Opening", nameAr: "افتتاحية حصان الملك", eco: 'C40' },
'1.e4 e5 2.Nf3 Nc6': { name: "King's Knight Opening", nameAr: "افتتاحية حصان الملك", eco: 'C44' },
'1.e4 e5 2.Nf3 Nc6 3.Nc3 Nf6': { name: "Four Knights Game", nameAr: "الأحصنة الأربعة", eco: 'C47' },
'1.e4 e5 2.Nf3 Nc6 3.Bb5': { name: "Ruy Lopez", nameAr: "الإسبانية", eco: 'C60' },
'1.e4 e5 2.Nf3 Nc6 3.Bc4': { name: "Italian Game", nameAr: "الإيطالية", eco: 'C50' },
'1.e4 e5 2.Nf3 Nc6 3.d4': { name: "Scotch Game", nameAr: "الاسكتلندية", eco: 'C45' },
'1.e4 e5 2.Nf3 Nf6': { name: "Petrov's Defense", nameAr: "دفاع بتروف", eco: 'C42' },
'1.e4 e5 2.f4': { name: "King's Gambit", nameAr: "غامبيت الملك", eco: 'C30' },
'1.e4 c5': { name: "Sicilian Defense", nameAr: "الصقلية", eco: 'B20' },
'1.e4 c5 2.Nf3 d6': { name: "Sicilian: Najdorf", nameAr: "الصقلية: نايدورف", eco: 'B90' },
'1.e4 c5 2.Nf3 Nc6': { name: "Sicilian: Classical", nameAr: "الصقلية: الكلاسيكية", eco: 'B30' },
'1.e4 c5 2.Nf3 e6': { name: "Sicilian: French", nameAr: "الصقلية: الفرنسية", eco: 'B40' },
'1.e4 c5 2.Nf3 d6': { name: "Sicilian Defense", nameAr: "الصقلية", eco: 'B50' },
'1.e4 c5 2.Nf3 Nc6': { name: "Sicilian Defense", nameAr: "الصقلية", eco: 'B30' },
'1.e4 c5 2.Nf3 e6': { name: "Sicilian: French Variation", nameAr: "الصقلية: المتغير الفرنسي", eco: 'B40' },
'1.e4 e6': { name: "French Defense", nameAr: "الدفاع الفرنسي", eco: 'C00' },
'1.e4 e6 2.d4 d5': { name: "French: Classical", nameAr: "الفرنسي: الكلاسيكي", eco: 'C01' },
'1.e4 e6 2.d4 d5': { name: "French Defense", nameAr: "الدفاع الفرنسي", eco: 'C00' },
'1.e4 c6': { name: "Caro-Kann Defense", nameAr: "كارو-كان", eco: 'B10' },
'1.e4 c6 2.d4 d5': { name: "Caro-Kann: Main Line", nameAr: "كارو-كان: الخط الرئيسي", eco: 'B12' },
'1.e4 d5': { name: "Scandinavian Defense", nameAr: "الإسكندنافي", eco: 'B01' },
'1.e4 d6': { name: "Pirc Defense", nameAr: "دفاع بيرك", eco: 'B07' },
'1.e4 g6': { name: "Modern Defense", nameAr: "الدفاع الحديث", eco: 'B06' },
'1.e4 Nf6': { name: "Alekhine's Defense", nameAr: "دفاع أليخين", eco: 'B02' },
'1.d4': { name: "Queen's Pawn Opening", eco: 'D00' },
'1.d4 d5': { name: "Closed Game", eco: 'D00' },
'1.d4': { name: "Queen's Pawn Opening", nameAr: "افتتاحية بيدق الوزير", eco: 'A40' },
'1.d4 d5': { name: "Closed Game", nameAr: "اللعبة المغلقة", eco: 'D00' },
'1.d4 d5 2.c4': { name: "Queen's Gambit", nameAr: "غامبيت الملكة", eco: 'D06' },
'1.d4 d5 2.c4 e6': { name: "QGD: Classical", nameAr: "غامبيت الملكة المرفوض", eco: 'D30' },
'1.d4 d5 2.c4 dxc4': { name: "QGA: Accepted", nameAr: "غامبيت الملكة المقبول", eco: 'D20' },
'1.d4 d5 2.c4 e6': { name: "Queen's Gambit Declined", nameAr: "غامبيت الملكة المرفوض", eco: 'D30' },
'1.d4 d5 2.c4 dxc4': { name: "Queen's Gambit Accepted", nameAr: "غامبيت الملكة المقبول", eco: 'D20' },
'1.d4 d5 2.c4 c6': { name: "Slav Defense", nameAr: "الدفاع السلافي", eco: 'D10' },
'1.d4 Nf6': { name: "Indian Defense", eco: 'A45' },
'1.d4 Nf6': { name: "Indian Defense", nameAr: "الدفاع الهندي", eco: 'A45' },
'1.d4 Nf6 2.c4 g6': { name: "King's Indian Defense", nameAr: "هندي الملك", eco: 'E60' },
'1.d4 Nf6 2.c4 e6': { name: "Nimzo/Queen's Indian", nameAr: "نيمزو-هندية", eco: 'E00' },
'1.d4 Nf6 2.c4 e6': { name: "Indian Defense", nameAr: "الدفاع الهندي", eco: 'E00' },
'1.d4 Nf6 2.c4 e6 3.Nc3 Bb4': { name: "Nimzo-Indian Defense", nameAr: "نيمزو-هندية", eco: 'E20' },
'1.d4 Nf6 2.c4 c5': { name: "Benoni Defense", nameAr: "دفاع بنوني", eco: 'A56' },
'1.d4 f5': { name: "Dutch Defense", nameAr: "الدفاع الهولندي", eco: 'A80' },
......
import * as scene from '../../core/scene.js';
import { mountGame, unmountGame } from './scenes/game.js';
import { mountResult } from './scenes/result.js';
import { mountAnalysis } from './scenes/analysis.js';
import { mountResult, unmountResult } from './scenes/result.js';
import { mountAnalysis, unmountAnalysis } from './scenes/analysis.js';
import { mountHistory } from './scenes/history.js';
import { mountReview } from './scenes/review.js';
import { mountReview, unmountReview } from './scenes/review.js';
import { mountSpectate } from './scenes/spectate.js';
scene.register('chess-game', mountGame, unmountGame);
scene.register('chess-result', mountResult);
scene.register('chess-analysis', mountAnalysis);
scene.register('chess-result', mountResult, unmountResult);
scene.register('chess-analysis', mountAnalysis, unmountAnalysis);
scene.register('chess-history', mountHistory);
scene.register('chess-review', mountReview);
scene.register('chess-review', mountReview, unmountReview);
scene.register('chess-spectate', mountSpectate);
......@@ -12,11 +12,20 @@ import { icon } from '../../../core/icons.js';
import { screenHead, wireBack, esc } from '../../../core/ui.js';
let board, analysisData, currentMoveIdx;
let requestSeq = 0; // only the newest analysis may paint
let analyzeTimer = 0;
export function unmountAnalysis() {
requestSeq++;
clearTimeout(analyzeTimer);
if (board) { board.destroy(); board = null; }
}
export function mountAnalysis(el, params) {
const { pgn, moveHistory = [], finalFen } = params;
const { pgn, moveHistory = [], finalFen, playerColor = 'w' } = params;
currentMoveIdx = 0;
analysisData = null;
requestSeq++;
engine.create();
......@@ -41,7 +50,11 @@ export function mountAnalysis(el, params) {
<!-- Timeline scrubber -->
<div class="an-timeline">
<div id="move-display" class="an-move num">${t('analysis.start')}</div>
<div class="an-step">
<button type="button" class="icon-btn" id="an-prev" aria-label="${t('analysis.prev')}">${icon('chevron-start', 22, { weight: 3 })}</button>
<div id="move-display" class="an-move num">${t('analysis.start')}</div>
<button type="button" class="icon-btn" id="an-next" aria-label="${t('analysis.next')}">${icon('chevron-end', 22, { weight: 3 })}</button>
</div>
<div id="scrubber-track" class="an-track" role="slider" aria-label="${t('chess.timeline')}">
<div id="scrubber-fill" class="an-track-fill"></div>
<div id="scrubber-thumb" class="an-thumb"></div>
......@@ -56,7 +69,7 @@ export function mountAnalysis(el, params) {
`;
const boardContainer = el.querySelector('#analysis-board');
board = new ChessBoard(boardContainer, { interactive: false });
board = new ChessBoard(boardContainer, { interactive: false, flipped: playerColor === 'b' });
board.setPosition(engine.fen());
// Build move list
......@@ -95,6 +108,9 @@ export function mountAnalysis(el, params) {
track.addEventListener('pointerup', () => { isDragging = false; });
track.addEventListener('pointercancel', () => { isDragging = false; });
el.querySelector('#an-prev').addEventListener('click', () => { if (currentMoveIdx > 0) goToMove(el, moves, currentMoveIdx - 1, true); });
el.querySelector('#an-next').addEventListener('click', () => { if (currentMoveIdx < moves.length) goToMove(el, moves, currentMoveIdx + 1, true); });
// Go to final position
goToMove(el, moves, moves.length);
}
......@@ -120,20 +136,26 @@ function renderMoveChips(el, moves) {
});
}
function goToMove(el, moves, idx) {
function goToMove(el, moves, idx, step = false) {
if (!board) return;
const prevIdx = currentMoveIdx;
currentMoveIdx = idx;
engine.create();
let last = null;
for (let i = 0; i < idx; i++) {
const m = moves[i];
if (m) engine.move(m.from, m.to, m.promotion);
if (m) last = engine.move(m.from, m.to, m.promotion) || last;
}
board.setPosition(engine.fen());
if (idx > 0) {
const lastMove = moves[idx - 1];
board.setLastMove(lastMove.from, lastMove.to);
// One step forward slides the piece; any other jump sets the position.
if (step && idx === prevIdx + 1 && last) {
board.playMove(last, engine.fen(), { onLand: () => audio.play(last.captured ? 'capture' : 'move', 'game') });
} else {
board.setPosition(engine.fen());
board.setLastMove(last ? last.from : null, last ? last.to : null);
}
board.setCheck(engine.isCheck() ? engine.kingSquare(engine.turn()) : null);
// Update display
const display = el.querySelector('#move-display');
......@@ -162,13 +184,19 @@ function goToMove(el, moves, idx) {
activeChip.scrollIntoView({ inline: 'center', behavior: 'smooth' });
}
// Request analysis for this position
analyzePosition(el, engine.fen());
// Request analysis for this position, once the player stops scrubbing.
const fen = engine.fen();
clearTimeout(analyzeTimer);
analyzeTimer = setTimeout(() => analyzePosition(el, fen), 220);
}
async function analyzePosition(el, fen) {
const seq = ++requestSeq;
const linesContainer = el.querySelector('#analysis-lines');
if (!linesContainer) return;
linesContainer.innerHTML = `<div class="an-quiet">${t('analysis.analyzing')}</div>`;
// The engine scores from the side to move; the bar is always White's view.
const pov = fen.split(' ')[1] === 'b' ? -1 : 1;
// Show opening explorer if data exists
const explorerData = lookup(fen);
......@@ -178,12 +206,15 @@ async function analyzePosition(el, fen) {
}
try {
const data = await net.post('analysis.php', { fen, depth: 18, lines: 3 });
const data = await net.post('analysis.php', { fen, depth: 16, lines: 3 });
if (seq !== requestSeq) return;
if (data.lines && data.lines.length > 0) {
renderAnalysisLines(el, data.lines, fen, explorerData);
updateEvalBar(el, data.lines[0].evaluation);
const lines = data.lines.map(l => ({ ...l, evaluation: (Number(l.evaluation) || 0) * pov }));
renderAnalysisLines(el, lines, fen, explorerData);
updateEvalBar(el, lines[0].evaluation);
}
} catch (e) {
if (seq !== requestSeq) return;
if (!explorerData) {
linesContainer.innerHTML = `<div class="an-quiet an-error">${icon('alert', 14)}${t('analysis.failed')}</div>`;
}
......@@ -226,6 +257,7 @@ function renderAnalysisLines(el, lines, fen, explorerData) {
function updateEvalBar(el, evaluation) {
const fill = el.querySelector('#eval-fill');
const text = el.querySelector('#eval-text');
if (!fill || !text) return;
const clamped = Math.max(-10, Math.min(10, evaluation));
const percent = 50 + (clamped / 10) * 50;
fill.style.transform = `scaleX(${percent / 100})`;
......@@ -250,10 +282,12 @@ const ANALYSIS_STYLE = `
width: 100%;
max-width: 560px;
margin-inline: auto;
/* Clear the play button that stands out of the tab bar. */
padding-bottom: 30px;
}
.an-head { padding: var(--s-2) var(--gutter) 0; }
.an-head .screen-head { margin-bottom: var(--s-2); }
.an-board { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: var(--s-1) var(--s-2); }
.an-board { flex: 1; min-height: 200px; display: flex; align-items: center; justify-content: center; padding: var(--s-1) var(--s-2); }
.an-eval { display: flex; align-items: center; gap: var(--s-3); padding: var(--s-3) var(--gutter) var(--s-2); }
.an-eval-text {
......@@ -303,7 +337,8 @@ const ANALYSIS_STYLE = `
.an-book-games { font-size: var(--fs-xs); color: var(--text-muted); }
.an-timeline { padding: var(--s-2) var(--gutter) 0; border-top: 1px solid var(--back-line); }
.an-move { text-align: center; font-size: var(--fs-sm); font-weight: 700; color: var(--text-primary); margin-bottom: var(--s-2); }
.an-step { display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); }
.an-move { flex: 1; text-align: center; font-family: var(--font-display); font-size: var(--fs-lg); color: var(--text-primary); }
.an-track {
position: relative;
height: 44px;
......
This source diff could not be displayed because it is too large. You can view the blob instead.
This diff is collapsed.
......@@ -12,6 +12,10 @@ import { icon } from '../../../core/icons.js';
import { screenHead, wireBack, esc } from '../../../core/ui.js';
let reviewData = null;
let reviewSeq = 0; // a review left half-way stops asking the engine
let reviewCache = null; // { key, results, applied } of the last finished review
export function unmountReview() { reviewSeq++; }
export async function mountReview(el, params) {
const { moveHistory = [], playerColor = 'w', botId, result } = params;
......@@ -37,72 +41,87 @@ export async function mountReview(el, params) {
wireBack(el);
// Start analyzing
await analyzeAllMoves(el, moveHistory, playerColor);
}
/**
* Score every position once (the eval after move i is the eval before move
* i+1, so n moves need n+1 engine calls, not 2n), a few at a time. Scores
* come from the side to move and are turned to White's point of view.
*/
async function analyzeAllMoves(el, moves, playerColor) {
const results = [];
const seq = ++reviewSeq;
// Coming back from the analysis re-mounts this screen: reuse the last review.
const key = moves.map(m => m.from + m.to + (m.promotion || '')).join(' ');
if (reviewCache && reviewCache.key === key) {
reviewData = reviewCache.results;
renderReview(el, reviewCache.results, reviewCache.applied, playerColor);
return;
}
const chess = new Chess();
let prevEval = 0.2; // Slight white advantage at start
const fens = [chess.fen()];
const applied = [];
for (const move of moves) {
const m = chess.move({ from: move.from, to: move.to, promotion: move.promotion });
if (!m) break;
applied.push(move);
fens.push(chess.fen());
}
const finalMate = chess.in_checkmate ? chess.in_checkmate() : chess.isCheckmate();
const evals = new Array(fens.length).fill(null);
const best = new Array(fens.length).fill(null);
const progressBar = el.querySelector('#progress-bar');
const progressText = el.querySelector('#progress-text');
for (let i = 0; i < moves.length; i++) {
const fen = chess.fen();
const isWhiteMove = chess.turn() === 'w';
const move = moves[i];
// Apply move
chess.move({ from: move.from, to: move.to, promotion: move.promotion });
// Get engine eval for position AFTER the move
let evalAfter = prevEval;
let bestEval = prevEval;
let bestMove = null;
try {
const analysis = await net.post('analysis.php', { fen: chess.fen(), depth: 12, lines: 1 });
if (analysis?.lines?.[0]) {
// Eval is from side-to-move perspective, flip it to white's perspective
evalAfter = analysis.lines[0].evaluation * (chess.turn() === 'w' ? 1 : -1);
}
} catch (e) {}
try {
const bestAnalysis = await net.post('analysis.php', { fen, depth: 12, lines: 1 });
if (bestAnalysis?.lines?.[0]) {
bestEval = bestAnalysis.lines[0].evaluation * (isWhiteMove ? 1 : -1);
bestMove = bestAnalysis.lines[0].move;
}
} catch (e) {
bestEval = evalAfter;
let done = 0;
const work = async (i) => {
if (i === fens.length - 1 && finalMate) {
evals[i] = fens[i].split(' ')[1] === 'w' ? -999 : 999;
} else {
try {
const a = await net.post('analysis.php', { fen: fens[i], depth: 12, lines: 1 });
const line = a?.lines?.[0];
if (line) {
const pov = fens[i].split(' ')[1] === 'w' ? 1 : -1;
evals[i] = (Number(line.evaluation) || 0) * pov;
best[i] = line.move || null;
}
} catch (e) {}
}
done++;
if (seq !== reviewSeq) return;
const shown = Math.min(applied.length, done);
if (progressBar) progressBar.style.transform = `scaleX(${fens.length ? done / fens.length : 1})`;
if (progressText) progressText.textContent = `${shown}/${applied.length}`;
};
let next = 0;
await Promise.all(Array.from({ length: Math.min(4, fens.length) }, async () => {
while (next < fens.length && seq === reviewSeq) await work(next++);
}));
if (seq !== reviewSeq) return;
if (evals[0] == null) evals[0] = 0.2; // a slight White edge at the start
for (let i = 1; i < evals.length; i++) if (evals[i] == null) evals[i] = evals[i - 1];
const results = applied.map((move, i) => {
const isWhiteMove = i % 2 === 0;
const moveNum = Math.floor(i / 2) + 1;
const classification = classifyMove(prevEval, evalAfter, bestEval, isWhiteMove, moveNum);
results.push({
return {
san: move.san,
evalBefore: prevEval,
evalAfter,
bestEval,
bestMove,
classification,
isWhite: isWhiteMove
});
prevEval = evalAfter;
// Update progress
const pct = Math.round(((i + 1) / moves.length) * 100);
progressBar.style.transform = `scaleX(${pct / 100})`;
progressText.textContent = `${i + 1}/${moves.length}`;
}
evalBefore: evals[i],
evalAfter: evals[i + 1],
bestEval: evals[i],
bestMove: best[i],
classification: classifyMove(evals[i], evals[i + 1], evals[i], isWhiteMove, moveNum),
isWhite: isWhiteMove,
};
});
reviewData = results;
renderReview(el, results, moves, playerColor);
reviewCache = { key, results, applied };
renderReview(el, results, applied, playerColor);
}
function renderReview(el, results, moves, playerColor) {
......@@ -220,11 +239,11 @@ function renderEvalGraph(container, results) {
canvas.style.height = height + 'px';
canvas.style.display = 'block';
const ink = getColor('ink', '#3A2112');
const ink3 = getColor('ink_3', '#6F6858');
const paper = getColor('card_2', '#F7E6C4');
const card = getColor('card', '#FFF6E2');
const red = getColor('red_ink', '#C8262C');
const ink = getColor('ink', '#1A1722');
const ink3 = getColor('ink_3', '#7D768C');
const paper = getColor('card_2', '#F4F2F8');
const card = getColor('card', '#FFFFFF');
const red = getColor('red_ink', '#F0384B');
const padding = 6;
const graphW = width - padding * 2;
......
......@@ -20,14 +20,19 @@ export const THEMES = [
{ key: 'endgame', name: 'نهايات', nameEn: 'Endgame', icon: 'flag' },
{ key: 'promotion', name: 'ترقية', nameEn: 'Promotion', icon: 'chess-queen' },
{ key: 'trapped', name: 'قطعة محاصرة', nameEn: 'Trapped Piece', icon: 'lock' },
{ key: 'zugzwang', name: 'تسوغزوانغ', nameEn: 'Zugzwang', icon: 'hourglass' },
{ key: 'zugzwang', name: 'زوغزوانغ', nameEn: 'Zugzwang', icon: 'hourglass' },
{ key: 'tactics', name: 'تكتيك', nameEn: 'Tactics', icon: 'puzzle' },
{ key: 'capture', name: 'أخذ', nameEn: 'Capture', icon: 'circle-x' },
{ key: 'center', name: 'المركز', nameEn: 'Center', icon: 'grid' },
{ key: 'pawn', name: 'بيادق', nameEn: 'Pawns', icon: 'chess-pawn' },
{ key: 'en-passant', name: 'أخذ في المرور', nameEn: 'En Passant', icon: 'chess-pawn' },
{ key: 'en-passant', name: 'الأخذ بالمرور', nameEn: 'En Passant', icon: 'chess-pawn' },
{ key: 'rook', name: 'طابية', nameEn: 'Rook', icon: 'chess-rook' },
{ key: 'stalemate', name: 'بات', nameEn: 'Stalemate', icon: 'ban' },
{ key: 'opening', name: 'افتتاحية', nameEn: 'Opening', icon: 'book' },
{ key: 'italian', name: 'الإيطالية', nameEn: 'Italian', icon: 'book' },
{ key: 'advance', name: 'تقدّم', nameEn: 'Advance', icon: 'trending-up' },
{ key: 'check', name: 'كش', nameEn: 'Check', icon: 'chess-king' },
{ key: 'queen', name: 'الوزير', nameEn: 'Queen', icon: 'chess-queen' },
];
export function getThemeName(key, lang = 'ar') {
......
import * as scene from '../../core/scene.js';
import { mountPuzzle } from './scenes/puzzle.js';
import { mountPuzzle, unmountPuzzle } from './scenes/puzzle.js';
scene.register('puzzles', mountPuzzle);
scene.register('puzzles', mountPuzzle, unmountPuzzle);
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment