/**
* tablebase.js - /probe/stream SSE client and results state
*
* Owns the SSE client for /probe/stream and all state for the four ranked
* move tables (DTZ / DTC / DTM / DTM50):
* - setResultHandler(fn) - callback after each successful probe, used for
* auto-play.
* - setNoteHandler(fn) - callback with the message of a warning or info
* dot tapped with touch or pen input.
* - setViewStateHandler(fn) - callback with a plain object describing
* everything the results panel needs to render (loading/progress/error,
* the Root row, and the ranked move rows). Rendering itself is the
* caller's responsibility; this module never touches the DOM to draw
* the table.
* - attachTableEvents(tbody) - wires click-to-play, hover pairing, and
* hover-triggered prefetch onto the actual rendered
. Returns a
* cleanup function.
* - Prefetch on hover: warms the cache for the child position of a
* hovered move.
* - The Root row: the current position's own score for each metric,
* included in the view state ahead of the ranked child moves when the
* "Show Root Row" setting is on (see setShowRootRow/getShowRootRow).
* - A coverage indicator: distinct state when error_code === "missing_table".
* - An info flag on draws by insufficient material, and a warning flag on
* a cursed win / blessed loss outcome - both carry a tooltip string for
* the caller to render. The warning tooltip has a second line when the
* DTZ score is rounded.
* - CSV export of the current move table.
* - A result cache keyed by (variant, FEN), so revisiting an already-
* probed position reuses the cached result instead of issuing a new
* /probe/stream call.
*/
import { Board, VARIANTS } from './board.js';
import * as Storage from './storage.js';
// ── Bounded LRU cache ────────────────────────────────────────────────────
const _MAX_CACHE_ENTRIES = 500;
class _BoundedCache {
constructor(maxSize) {
this._maxSize = maxSize;
this._map = new Map();
}
has(key) { return this._map.has(key); }
get(key) {
if (!this._map.has(key)) return undefined;
const value = this._map.get(key);
this._map.delete(key);
this._map.set(key, value); // refresh recency
return value;
}
set(key, value) {
this._map.delete(key);
this._map.set(key, value);
if (this._map.size > this._maxSize) {
this._map.delete(this._map.keys().next().value); // evict oldest
}
}
delete(key) { this._map.delete(key); }
}
const Tablebase = (() => {
let _currentController = null;
let _probeSeq = 0;
let _loadingTimer = null;
let _pendingProgress = null;
let _onMoveSelect = null;
let _onNote = null;
let _onResult = null;
let _onViewState = null;
let _lastData = null;
let _lastFen = null; // FEN of the currently-applied results
let _lastVariant = null; // variant the currently-applied results were probed under
let _prefetchCache = new _BoundedCache(_MAX_CACHE_ENTRIES); // (variant,child_fen) → probe result data
let _resultCache = new _BoundedCache(_MAX_CACHE_ENTRIES); // (variant,fen) → last probe result
// Hover-prefetch state - module-scoped so probe() can see and cancel an
// in-flight prefetch for the exact position it's about to probe itself.
let _prefetchTimer = null;
let _prefetchController = null; // in-flight prefetch fetch, if any
let _prefetchKey = null; // cache key for the timer/fetch above
let _prefetchRun = null; // identity token for the current pending/in-flight run
let _prefetchPromise = null; // current run's eventual { ok, data|error, aborted } outcome
let _prefetchAttached = false; // true while a probe() call is awaiting _prefetchPromise
function _cancelPrefetch() {
clearTimeout(_prefetchTimer);
_prefetchTimer = null;
if (_prefetchAttached) return;
if (_prefetchController) { _prefetchController.abort(); _prefetchController = null; }
_prefetchKey = null;
_prefetchRun = null;
_prefetchPromise = null;
}
function _cacheKey(fen, variant) { return `${variant || VARIANTS.STANDARD}\u0000${fen}`; }
// ── Show Root Row setting ────────────────────────────────────────────
const _LS_SHOW_ROOT_ROW = 'chesstb_show_root_row';
function _readShowRootRowPref() { return Storage.read(_LS_SHOW_ROOT_ROW) === 'true'; }
let _showRootRow = _readShowRootRowPref();
const _ROOT_LABEL = 'Root';
// ── WDL / formatting helpers ─────────────────────────────────────────
function _wdlToOutcome(wdl) {
return { 2:'win', 1:'cursed_win', 0:'draw', '-1':'blessed_loss', '-2':'loss' }[wdl] ?? 'unknown';
}
function _plural(n) { return Math.abs(n) === 1 ? 'ply' : 'plies'; }
function _pliesText(n) {
const a = Math.abs(n);
return `${a} ${_plural(a)}`;
}
function _dtcRootRaw(data) {
return {
san: _ROOT_LABEL,
plies: data.dtc_available ? data.dtc[2] : 0,
order: data.dtc_available ? data.dtc[1] : 0,
outcome: data.dtc_available ? _wdlToOutcome(data.dtc[0]) : 'not_available',
available: data.dtc_available,
};
}
function _csvPlies(entry) { return entry.available === false ? 'N/A' : entry.plies; }
function _isPawnless(fen) {
if (!fen) return false;
return !/[Pp]/.test(fen.split(' ')[0]);
}
function _dtcDisplayEntry(entry, ownFen) {
if (!entry) return null;
if (!entry.available) return { ...entry, orderText: 'N/A' };
return _isPawnless(ownFen)
? { ...entry, orderText: '-' }
: { ...entry, orderText: String(entry.order) };
}
function _dtcScoreText(entry) {
const n = Math.abs(entry.plies);
return `${n} ${_plural(n)}/${entry.orderText}`;
}
const _ROUNDED_NOTE = 'Rounded Score — this could be off by 1 ply.';
function _warningText(entry) {
const text = entry.outcome === 'cursed_win'
? 'Cursed Win — a win in principle, but a draw under the 50-move rule.'
: entry.outcome === 'blessed_loss'
? 'Blessed Loss — a loss in principle, but a draw under the 50-move rule.'
: null;
return text && entry.rounded ? `${text}\n${_ROUNDED_NOTE}` : text;
}
// ── Cell descriptors ──────────────────────────────────────────────────
// Same shape/formatting rules as a rendered pair, as plain data;
// the caller renders these into cells (see ResultsPanel). `metric` and
// `groupClass` drive the CSS grouping; `interactive` gates whether the
// cell is clickable/prefetchable (false for the Root row).
function _createMoveCells(entry, groupClass, interactive = true, dtcFormat = false) {
const metric = groupClass.slice('group-'.length);
const headers = interactive
? { move: `th-${metric}-grp th-${metric}-move`, score: `th-${metric}-grp th-${metric}-score` }
: null;
if (!entry) {
return [
{ kind: 'move', className: `${groupClass} col-move is-empty-cell`, headers: headers?.move ?? null, text: '—', title: null, interactive: false },
{ kind: 'score', className: `${groupClass} col-score is-empty-cell`, headers: headers?.score ?? null, text: '—', title: null, interactive: false },
];
}
let scoreText;
if (entry.is_mate) scoreText = 'Checkmate';
else if (entry.draw_reason === 'stalemate') scoreText = 'Stalemate';
else if (entry.outcome === 'draw') scoreText = 'Draw';
else if (entry.outcome === 'unknown') scoreText = 'Unknown';
else if (entry.outcome === 'not_available') scoreText = 'Not Available';
else if (dtcFormat) scoreText = _dtcScoreText(entry);
else scoreText = _pliesText(entry.plies);
const outcomeLabel = {
win: 'Win', loss: 'Loss', cursed_win: 'Cursed win', blessed_loss: 'Blessed loss',
}[entry.outcome];
const scoreLabel = outcomeLabel && !entry.is_mate
? `${outcomeLabel}: ${scoreText}` : scoreText;
const moveCell = {
kind: 'move',
className: `${groupClass} col-move${interactive ? '' : ' is-root'}`,
headers: headers?.move ?? null,
text: entry.san,
title: entry.san,
interactive,
dataSan: interactive ? entry.san : null,
dataChildFen: interactive ? (entry.child_fen || '') : null,
};
const scoreCell = {
kind: 'score',
className: `${groupClass} col-score${entry.is_mate ? ' is-mate' : ''}`,
headers: headers?.score ?? null,
text: scoreText,
title: scoreLabel,
interactive,
dataSan: interactive ? entry.san : null,
dataChildFen: interactive ? (entry.child_fen || '') : null,
outcome: entry.outcome,
warning: _warningText(entry),
info: entry.draw_reason === 'insufficient_material' ? 'Draw by Insufficient Material' : null,
};
return [moveCell, scoreCell];
}
// ── Root row entries ──────────────────────────────────────────────────
function _rootEntries(data, fen) {
const maxRows = Math.max(
(data.moves_dtz || []).length,
(data.moves_dtc || []).length,
(data.moves_dtm || []).length,
(data.moves_dtm50 || []).length,
);
const rootIsMate = maxRows === 0 && data.wdl !== 0;
const dtzEntry = {
san: _ROOT_LABEL, plies: data.dtz_available ? data.dtz : 0,
outcome: data.dtz_available ? _wdlToOutcome(data.wdl) : 'not_available',
available: data.dtz_available,
rounded: data.dtz_rounded === true,
is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
};
const dtcRaw = {
..._dtcRootRaw(data),
is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
};
const dtcEntry = _dtcDisplayEntry(dtcRaw, fen);
const dtmEntry = {
san: _ROOT_LABEL, plies: data.dtm_available ? data.dtm : 0,
outcome: data.dtm_available ? _wdlToOutcome(data.wdl) : 'not_available',
available: data.dtm_available,
is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
};
const dtm50Entry = {
san: _ROOT_LABEL, plies: data.dtm50_available ? data.dtm50[1] : 0,
outcome: data.dtm50_available ? _wdlToOutcome(data.dtm50[0]) : 'not_available',
available: data.dtm50_available,
is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
};
return { dtzEntry, dtcEntry, dtmEntry, dtm50Entry };
}
// ── View state ────────────────────────────────────────────────────────
// Single object describing everything the results panel renders.
// Rebuilt on every meaningful change and pushed to _onViewState.
let _viewState = {
pending: false,
loading: false,
progress: { visible: false, pct: 0, label: '' },
error: null, // { message, isCoverage } | null
moveCount: '',
showRootRow: _showRootRow,
rootRow: null, // { cells: [...] } | null
rows: [], // [{ rank, cells: [...] }]
emptyState: true,
emptyMessage: 'Choose a position to see its best moves.',
};
function _publish(patch) {
_viewState = { ..._viewState, ...patch };
if (_onViewState) _onViewState(_viewState);
}
function _beginLoading(mySeq) {
clearTimeout(_loadingTimer);
_pendingProgress = { visible: true, pct: null, label: 'Checking position…' };
// Fast and cached responses should not flash through an empty table.
// Disable old move actions immediately, then show loading if needed.
_publish({ pending: true, error: null, ...(_viewState.loading ? { progress: _pendingProgress } : {}) });
_loadingTimer = setTimeout(() => {
if (mySeq !== _probeSeq) return;
_blankResults();
_publish({ loading: true, progress: _pendingProgress });
}, 180);
}
function _rootRowState(data, fen) {
if (!_showRootRow) return null;
if (!data) {
const cells = ['group-dtz', 'group-dtc', 'group-dtm', 'group-dtm50']
.flatMap(g => _createMoveCells(null, g));
return { cells };
}
const { dtzEntry, dtcEntry, dtmEntry, dtm50Entry } = _rootEntries(data, fen);
const cells = [
..._createMoveCells(dtzEntry, 'group-dtz', false),
..._createMoveCells(dtcEntry, 'group-dtc', false, true),
..._createMoveCells(dtmEntry, 'group-dtm', false),
..._createMoveCells(dtm50Entry, 'group-dtm50', false),
];
return { cells };
}
function _rowsState(movesDtz, movesDtc, movesDtm, movesDtm50) {
const maxRows = Math.max(movesDtz.length, movesDtc.length, movesDtm.length, movesDtm50.length);
const rows = [];
for (let i = 0; i < maxRows; i++) {
const dtcEntry = _dtcDisplayEntry(movesDtc[i] || null, (movesDtc[i] || {}).child_fen);
const cells = [
..._createMoveCells(movesDtz[i] || null, 'group-dtz'),
..._createMoveCells(dtcEntry, 'group-dtc', true, true),
..._createMoveCells(movesDtm[i] || null, 'group-dtm'),
..._createMoveCells(movesDtm50[i] || null, 'group-dtm50'),
];
rows.push({ rank: i + 1, cells });
}
return rows;
}
function _blankResults() {
_publish({
rootRow: _rootRowState(null, _lastFen),
rows: [],
emptyState: false,
moveCount: _viewState.moveCount, // set client-side from the FEN - untouched here
});
}
function _isComplete(data) {
return !(data && data.summary && data.summary.unknown > 0);
}
function _applyResults(data, fen, variant) {
clearTimeout(_loadingTimer);
_lastData = data;
_lastFen = fen;
_lastVariant = variant;
if (_isComplete(data)) _resultCache.set(_cacheKey(fen, variant), data);
const movesDtz = data.moves_dtz || [];
const movesDtc = data.moves_dtc || [];
const movesDtm = data.moves_dtm || [];
const movesDtm50 = data.moves_dtm50 || [];
const rows = _rowsState(movesDtz, movesDtc, movesDtm, movesDtm50);
const maxRows = rows.length;
_publish({
pending: false,
loading: false,
progress: { visible: false, pct: 0, label: '' },
error: null,
rootRow: _rootRowState(data, fen),
rows,
emptyState: maxRows === 0,
emptyMessage: data.draw_reason === 'insufficient_material'
? 'Draw by insufficient material.'
: data.draw_reason === 'stalemate'
? 'Stalemate. No legal moves.'
: data.wdl === -2 && maxRows === 0
? 'Checkmate. No legal moves.'
: data.wdl === 0
? 'Draw.'
: 'No ranked moves for this position.',
moveCount: maxRows > 0 ? `${maxRows} legal move${maxRows === 1 ? '' : 's'}` : '',
});
if (_onResult) _onResult(data);
}
function _applyCachedResult(data, fen, variant, mySeq) {
clearTimeout(_loadingTimer);
Promise.resolve().then(() => {
if (mySeq !== _probeSeq) return;
_applyResults(data, fen, variant);
});
}
function _reset() {
clearTimeout(_loadingTimer);
_publish({ pending: false, loading: false, progress: { visible: false, pct: 0, label: '' }, rootRow: _rootRowState(null, _lastFen), rows: [], emptyState: false, error: null });
_lastData = null;
_lastFen = null;
_lastVariant = null;
}
function _showCoverageError(errData) {
const pc = errData.piece_count;
const note = pc ? ` (position has ${pc} pieces)` : '';
_publish({ error: { message: `Not in tablebase${note} — load a tablebase with more piece coverage.`, isCoverage: true } });
}
function _handleError(errData, onError) {
const msg = errData.error || 'Unknown error.';
const isCoverageError = errData.error_code === 'missing_table';
if (isCoverageError) {
_showCoverageError(errData);
} else {
_publish({ error: { message: msg, isCoverage: false } });
}
if (onError) onError(msg, isCoverageError);
}
// ── SSE streaming client ──────────────────────────────────────────────
async function* _sseEvents(response) {
if (!response.body) throw { error: 'Network error.' };
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split('\n\n');
buffer = events.pop() || '';
for (const rawEvent of events) {
const line = rawEvent.trim();
if (!line.startsWith('data: ')) continue;
try { yield JSON.parse(line.slice(6)); } catch { /* skip malformed frame */ }
}
}
}
async function _probeStream(fen, variant, signal, mySeq) {
const response = await fetch('/probe/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fen, variant }),
signal,
});
if (!response.ok) {
const err = await response.json().catch(() => ({ error: 'Network error.' }));
throw err;
}
for await (const evtData of _sseEvents(response)) {
if (signal.aborted || mySeq !== _probeSeq) throw new DOMException('Cancelled', 'AbortError');
if (evtData.status === 'probing') {
const total = evtData.total;
_pendingProgress = {
visible: true,
pct: total > 0 ? Math.round((evtData.completed / total) * 100) : null,
label: total > 0 ? `Evaluating moves · ${evtData.completed}/${total}` : 'Preparing result…',
};
if (_viewState.loading) _publish({ progress: _pendingProgress });
} else if (evtData.status === 'done') {
return evtData;
} else if (evtData.status === 'error') {
throw evtData;
}
}
throw { error: 'Stream ended unexpectedly.' };
}
function probe(fen, variant, onError) {
const mySeq = ++_probeSeq;
clearTimeout(_loadingTimer);
if (_currentController) { _currentController.abort(); _currentController = null; }
const key = _cacheKey(fen, variant);
if (_prefetchKey === key) {
if (_prefetchController && _prefetchPromise) {
_prefetchAttached = true;
const waitingOn = _prefetchPromise;
_beginLoading(mySeq);
waitingOn.then(outcome => {
_prefetchAttached = false;
if (mySeq !== _probeSeq) return;
if (outcome.ok) {
_applyResults(outcome.data, fen, variant);
} else {
_reset();
if (!outcome.aborted) _handleError(outcome.error && outcome.error.error ? outcome.error : { error: 'Cannot reach backend.' }, onError);
}
});
return;
}
_cancelPrefetch();
}
if (_prefetchCache.has(key)) {
const cached = _prefetchCache.get(key);
_prefetchCache.delete(key);
_applyCachedResult(cached, fen, variant, mySeq);
return;
}
if (_resultCache.has(key)) {
_applyCachedResult(_resultCache.get(key), fen, variant, mySeq);
return;
}
_currentController = new AbortController();
const myController = _currentController;
const signal = myController.signal;
_beginLoading(mySeq);
_probeStream(fen, variant, signal, mySeq)
.then(data => {
if (mySeq !== _probeSeq) return;
if (_currentController === myController) _currentController = null;
if (data.error) {
_reset();
_handleError(data, onError);
} else {
_applyResults(data, fen, variant);
}
})
.catch(err => {
if (err && err.name === 'AbortError') return;
if (mySeq !== _probeSeq) return;
if (_currentController === myController) _currentController = null;
_reset();
_handleError(err && err.error ? err : { error: 'Cannot reach backend.' }, onError);
console.error('[Tablebase]', err);
});
}
// ── CSV export ────────────────────────────────────────────────────────
function _csvField(value) {
const s = String(value);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
function exportCsv() {
if (!_lastData) return;
const dtz = _lastData.moves_dtz || [];
const dtc = _lastData.moves_dtc || [];
const dtm = _lastData.moves_dtm || [];
const dtm50 = _lastData.moves_dtm50 || [];
const rows = Math.max(dtz.length, dtc.length, dtm.length, dtm50.length);
const lines = [];
if (_lastFen) lines.push(`[FEN "${_lastFen}"]`);
lines.push('Rank,DTZ Move,DTZ Plies,DTZ Outcome,DTC Move,DTC Plies,DTC Order,DTC Outcome,DTM Move,DTM Plies,DTM Outcome,DTM50 Move,DTM50 Plies,DTM50 Outcome');
if (_showRootRow) {
const { dtzEntry, dtcEntry, dtmEntry, dtm50Entry } = _rootEntries(_lastData, _lastFen);
lines.push([
'',
_ROOT_LABEL, _csvPlies(dtzEntry), dtzEntry.outcome,
_ROOT_LABEL, _csvPlies(dtcEntry), dtcEntry.orderText, dtcEntry.outcome,
_ROOT_LABEL, _csvPlies(dtmEntry), dtmEntry.outcome,
_ROOT_LABEL, _csvPlies(dtm50Entry), dtm50Entry.outcome,
].map(_csvField).join(','));
}
for (let i = 0; i < rows; i++) {
const d0 = dtz[i], d2 = dtm[i], d3 = dtm50[i];
const d1 = _dtcDisplayEntry(dtc[i] || null, (dtc[i] || {}).child_fen);
lines.push([
i + 1,
d0 ? d0.san : '', d0 ? _csvPlies(d0) : '', d0 ? d0.outcome : '',
d1 ? d1.san : '', d1 ? _csvPlies(d1) : '', d1 ? d1.orderText : '', d1 ? d1.outcome : '',
d2 ? d2.san : '', d2 ? _csvPlies(d2) : '', d2 ? d2.outcome : '',
d3 ? d3.san : '', d3 ? _csvPlies(d3) : '', d3 ? d3.outcome : '',
].map(_csvField).join(','));
}
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'chesstb_moves.csv';
a.click();
URL.revokeObjectURL(a.href);
}
// ── Public API ────────────────────────────────────────────────────────
function setMoveSelectHandler(fn) { _onMoveSelect = fn; }
function setNoteHandler(fn) { _onNote = fn; }
function setResultHandler(fn) { _onResult = fn; }
function setViewStateHandler(fn) { _onViewState = fn; if (fn) fn(_viewState); }
function getViewState() { return _viewState; }
function getLastData() { return _lastData; }
function getLastFen() { return _lastFen; }
function getLastVariant() { return _lastVariant; }
function setShowRootRow(show) {
_showRootRow = !!show;
Storage.write(_LS_SHOW_ROOT_ROW, _showRootRow);
_publish({ showRootRow: _showRootRow, rootRow: _rootRowState(_lastData, _lastFen) });
}
function getShowRootRow() { return _showRootRow; }
function clearErrorLine() { _publish({ error: null }); }
// Sets the legal-move count text ahead of any probe result - the count
// is computed client-side from the FEN, not from probe data.
function setMoveCount(text) { _publish({ moveCount: text }); }
// ── Table DOM wiring (hover pairing + prefetch + click-to-play) ───────
// Attached to the rendered |