// M0 · lección 04
El tablero: la legalidad la decide el navegador
La demo antes de que exista el modelo: un tablero que solo acepta jugadas legales, un rival de mentira, y la decisión de arquitectura que sostiene todo lo demás: el modelo propone y el navegador filtra. Con el estado del juego como funciones puras y el envoltorio de cm-chessboard.
Qué vas a construir
La pantalla donde jugarás contra tu propio modelo, que en este hito ya funciona sin modelo ninguno: tablero, lista de jugadas, deshacer, nueva partida, exportar PGN y un rival que hace siempre la primera jugada legal.
Tener la demo antes que el modelo obliga a tomar ya la decisión de arquitectura que sostiene todo lo demás: quién decide si una jugada es legal.
pnpm check, pnpm lint, pnpm test, pnpm build, pnpm e2e y pnpm lighthouse son la cadena
completa que también corre en la CI.
La decisión: el modelo propone, el navegador filtra
El modelo devolverá un logitLogitsPuntuación bruta que el modelo asigna a cada token del vocabulario antes de convertirla en probabilidades (con softmax). En Rukh hay un logit por jugada UCI posible; la demo pone a menos infinito los de las jugadas ilegales antes de muestrear, y la temperatura y el top-k actúan sobre ellos. por token: una puntuación bruta para cada
jugada de su vocabularioVocabularioLa lista de todos los tokens que el modelo conoce, cada uno con un id entero fijo. En Rukh el vocabulario UCI tiene 2 030 entradas: 8 tokens especiales, 54 tramos de Elo y las 1 968 jugadas posibles, enumeradas sin mirar datos. Su tamaño fija el de la capa de embeddings y el de la capa de salida. (unas 1 900 jugadas UCI posibles). chess.js
calcula cuáles son legales en la posición actual, y el navegador pone la probabilidad de las demás a
cero (su logit, a menos infinito, que tras el softmax da probabilidad cero).
Después el navegador muestreaMuestreoElegir el siguiente token sorteándolo según las probabilidades del modelo, en vez de coger siempre el más probable (argmax). Una jugada con probabilidad 0,6 sale seis de cada diez veces. En la demo de Rukh se muestrea solo entre las jugadas legales; la temperatura y el top-k deciden cuánto se aleja el sorteo de la favorita. entre las que quedan: sortea la jugada
según esas probabilidades, en vez de coger siempre la más probable, y así la demo no juega siempre
la misma partida. El modelo nunca hace una jugada ilegal en la demo, aunque la proponga.
Y como la propone, se puede medir. El modo «sin máscara» (a partir de M2) enseña la jugada cruda que el modelo quería hacer y la marca si es ilegal; esa tasa de legalidadTasa de legalidadPorcentaje de jugadas propuestas por el modelo que son legales cuando no se le enmascaran las ilegales. Es la primera métrica de la tabla única: mide si el modelo ha aprendido las reglas solo viendo partidas. En la demo, la legalidad la decide chess.js; el modelo solo propone. es la primera columna de la tabla única.
game.ts: el estado de la partida, sin efectos
Todo el ajedrez de la demo vive en un módulo de funciones puras, sin clases, sin objetos mutables y
sin window.
import { Chess, type Square } from 'chess.js';
export type Color = 'w' | 'b';export type Promotion = 'q' | 'r' | 'b' | 'n';
export interface Move { from: string; to: string; promotion?: Promotion;}
/** * Immutable snapshot of a game. `history` is the SAN move list played from `start`, the FEN * the game began at (the initial position unless the state was built with `fromFen`). */export interface GameState { start: string; fen: string; history: string[]; turn: Color; over: boolean; result: string | null;}
export const INITIAL_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';GameState guarda la posición inicial y el historial, y de ahí deriva todo lo
demás; el FENFENCadena de texto que describe una posición completa: piezas por fila, turno, derechos de enroque, casilla al paso y contadores de jugadas. Es la clave con la que se cruzan las posiciones de las partidas con las evaluaciones públicas de Stockfish. actual es un valor calculado. Así deshacer es quitar jugadas del final de
una lista, y exportar PGNPGNFormato de fichero de partidas: cabeceras entre corchetes ([White "..."], [Result "1-0"]) y las jugadas en SAN, con comentarios opcionales como %clk o %eval. La demo exporta PGN; los volcados de Lichess lo usan como origen. es posible, porque el PGN es el historial. Con solo
el FEN, deshacer necesitaría una pila de posiciones y el PGN no se podría reconstruir.
function fromHistory(start: string, history: string[]): Chess { const chess = new Chess(start); for (const san of history) chess.move(san); return chess;}
function fromState(state: GameState): Chess { return fromHistory(state.start, state.history);}
function resultOf(chess: Chess): string | null { if (chess.isCheckmate()) return chess.turn() === 'w' ? '0-1' : '1-0'; if (chess.isDraw()) return '1/2-1/2'; return null;}
function snapshot(chess: Chess, start: string): GameState { return { start, fen: chess.fen(), history: chess.history(), turn: chess.turn(), over: chess.isGameOver(), result: resultOf(chess), };}El patrón de todo el fichero está en estas funciones privadas: cada operación reconstruye un Chess
desde el historial, lo modifica y devuelve una instantánea nueva. El objeto mutable de chess.js
nunca sale del módulo.
Reconstruir la partida en cada llamada parece un derroche, pero son decenas de jugadas que chess.js
aplica en microsegundos. A cambio no hay estado compartido, y desaparece el bug más molesto de una
interfaz de ajedrez: el tablero y la lógica discrepando sobre de quién es el turno.
resultOf es fácil de invertir: si hay jaque mate, gana el que no tiene el turno.
chess.turn() === 'w' significa que las blancas están mateadas, y el resultado es 0-1.
export function newGame(): GameState { return snapshot(new Chess(), INITIAL_FEN);}
/** Builds a state from an arbitrary FEN (no history). Used by tests and future share links. */export function fromFen(fen: string): GameState { const chess = new Chess(fen); return snapshot(chess, chess.fen());}fromFen pasa chess.fen() como start y no el fen recibido: chess.js normaliza el FEN
(completa los campos que falten), y guardar la versión normalizada evita que start y fen difieran
en una partida sin jugadas.
/** Destination squares of every legal move from `from` (deduplicated across promotions). */export function legalTargets(state: GameState, from: string): string[] { const chess = fromState(state); const moves = chess.moves({ square: from as Square, verbose: true }); return [...new Set(moves.map((move) => move.to))];}
/** True when moving `from` to `to` is a legal pawn move that reaches the last rank. */export function needsPromotion(state: GameState, from: string, to: string): boolean { const chess = fromState(state); const moves = chess.moves({ square: from as Square, verbose: true }); return moves.some((move) => move.to === to && move.promotion !== undefined);}legalTargets dibuja los puntos del tablero cuando coges una pieza. El Set hace falta porque un
peón en séptima tiene cuatro jugadas legales a la misma casilla (una por pieza de promoción).
needsPromotion pregunta a las reglas si esa jugada concreta ofrece elección de pieza, y solo
entonces se abre el diálogo.
/** Applies a move and returns the new state, or `null` when the move is illegal. */export function applyMove(state: GameState, move: Move): GameState | null { const chess = fromState(state); try { chess.move({ from: move.from, to: move.to, promotion: move.promotion }); } catch { return null; } return snapshot(chess, state.start);}
/** True when the human has at least one ply of their own in `history` that undo can remove. */export function canUndo(state: GameState, human: Color): boolean { return state.history.length >= (human === 'b' ? 2 : 1);}applyMove es donde se decide la legalidad de la demo. chess.js lanza si la jugada no es
legal, y la función lo convierte en null: una jugada ilegal no es un error del programa sino un
caso previsto, y en M2 es lo que rechazará las jugadas ilegales del modelo.
canUndo pide dos medias jugadas con negras porque la partida empieza con una jugada del rival:
deshacerla sería empezar otra partida.
/** * Undoes moves until it is the human's turn again with at least one human move removed: * two plies when it is already the human's turn, one when the opponent is to move. * Returns `state` unchanged when the human has not moved yet (as black, the opponent's * opening ply stays on the board). */export function undoPair(state: GameState, human: Color): GameState { if (!canUndo(state, human)) return state; const plies = state.turn === human ? 2 : 1; const history = state.history.slice(0, Math.max(0, state.history.length - plies)); return snapshot(fromHistory(state.start, history), state.start);}Deshacer quita una o dos medias jugadasPly (media jugada)Una jugada de un solo bando. 1. e4 e5 son dos plies y una jugada completa. Los filtros del recorte y las longitudes de secuencia del modelo se cuentan en plies porque es lo que ve el modelo: un token por ply. según de quién sea el turno. Si es tu
turno, la última jugada fue del rival y hay que quitar las dos; si acabas de mover y el rival todavía
no ha contestado, basta una. Equivocarse aquí deja el turno al revés.
const DEFAULT_HEADERS: Record<string, string> = { Event: 'Rukh demo', Site: 'https://rukh.borjaglez.com',};
function today(): string { const now = new Date(); const pad = (value: number) => String(value).padStart(2, '0'); return `${now.getFullYear()}.${pad(now.getMonth() + 1)}.${pad(now.getDate())}`;}
/** PGN export with the demo headers merged with `headers` (caller wins). */export function toPgn(state: GameState, headers: Record<string, string>): string { const chess = fromState(state); const merged: Record<string, string> = { ...DEFAULT_HEADERS, Date: today(), ...headers, Result: headers.Result ?? state.result ?? '*', }; for (const [key, value] of Object.entries(merged)) chess.setHeader(key, value); return chess.pgn();}El PGN saca una partida de la demo hacia cualquier otro sitio (Lichess, un analizador,
python-chess). Las cabeceras se mezclan en orden: valores por defecto, fecha, lo que pase quien
llama (que gana) y el resultado al final, con * si la partida no ha terminado, como pide el
estándar. La fecha va con puntos (2026.09.18), también por el estándar.
El rival de mentira
import { Chess } from 'chess.js';import type { Move } from './game';
export interface Opponent { id: string; label: string; pick(fen: string): Promise<Move>;}
/** Mock opponent: the first legal move in chess.js `moves({ verbose: true })` order. */export const firstLegalMove: Opponent = { id: 'mock', label: 'Primera jugada legal', async pick(fen) { const chess = new Chess(fen); const [move] = chess.moves({ verbose: true }); if (!move) throw new Error('No legal moves in this position'); return { from: move.from, to: move.to, promotion: move.promotion as Move['promotion'], }; },};Lo que interesa es la interfaz: Opponent tiene un método, pick(fen), que devuelve una promesa.
Es la misma que en M2 implementará el jugador ONNX, con el modelo en un worker. Que sea async desde
el primer día, cuando el rival responde en microsegundos, evita reescribir la interfaz cuando tarde
40 ms y haya que enseñar un «pensando».
Este rival sirve para probar todo lo demás sin depender de un modelo que todavía no existe, y es el que usa la CI.
export type StageKind = 'mock';
export interface Stage { id: string; label: string; sizeMb: number; kind: StageKind;}
/** Model stages available in the demo. P0 only ships the mock opponent. */export const STAGES: Stage[] = [ { id: 'mock', label: 'Primera jugada legal', sizeMb: 0, kind: 'mock' },];
export function findStage(id: string): Stage | undefined { return STAGES.find((stage) => stage.id === id);}El registro de etapas, con una sola entrada. StageKind = 'mock' es una unión de un miembro que en
M2 pasa a ser 'mock' | 'onnx'; entonces TypeScript señalará todos los sitios donde falta tratar el
caso nuevo, cosa que un string no haría. sizeMb existe ya, a cero, porque la demo tendrá que
avisar de cuánto pesa lo que descarga.
import type { Color } from './game';import { findStage, STAGES } from './registry';
export interface Query { mock: boolean; stage: string; color: Color;}
/** * Parses the page query string. `mock` is forced by `?mock=1` and is also true when the * requested stage is unknown or is itself a mock; in P0 every stage is a mock, so `mock` * is always true. `?color=b` makes the human play black. */export function parseQuery(search: string): Query { const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); const requested = params.get('stage') ?? STAGES[0].id; const stage = findStage(requested); const mock = params.get('mock') === '1' || stage === undefined || stage.kind === 'mock'; return { mock, stage: stage?.id ?? STAGES[0].id, color: params.get('color') === 'b' ? 'b' : 'w', };}Los parámetros de la URL son la forma en que el curso enlaza a la demo con una configuración
concreta (?mock=1, y más adelante ?stage=dpo&elo=1500). La función nunca falla: una etapa
desconocida cae a la primera de STAGES y activa el modo de mentira, así que un enlace con un
parámetro obsoleto sigue funcionando. mock es cierto si lo pides, si la etapa no existe o si la
etapa es un mock; por lo último, en M0 siempre lo es.
App.tsx: el estado compartido y el turno del rival
import { computed, signal } from '@preact/signals';import { useEffect } from 'preact/hooks';import { applyMove, canUndo, newGame, toPgn, undoPair, type Color, type GameState, type Move,} from '../lib/game';import { firstLegalMove, type Opponent } from '../lib/opponent';import { parseQuery } from '../lib/query';import { STAGES } from '../lib/registry';import Board from './Board';import ModelPanel from './ModelPanel';import MoveList from './MoveList';
const HUMAN_LABEL = 'Humano';
/** Game state lives in module-level signals so the three zones share one source of truth. */const game = signal<GameState>(newGame());const human = signal<Color>('w');const stage = signal<string>(STAGES[0].id);/** True while the opponent is thinking. */const thinking = signal(false);/** True while the board animates a turn: colour changes wait for it. */const turning = signal(false);const busy = computed(() => thinking.value || turning.value);Las señales de Preact son valores observables: quien lee una se entera cuando cambia, sin que el componente se vuelva a pintar. Aquí están a nivel de módulo porque hay una sola partida por página y las tres zonas (tablero, panel, lista) leen la misma. El tablero, que envuelve una librería imperativa, necesita suscribirse a los cambios sin re-renderizar.
thinking es el rival calculando y turning el tablero girando. La interfaz se bloquea en los dos
casos (busy), pero las dos causas se distinguen en pantalla y en los tests.
function opponentFor(): Opponent { // P0: every stage is the mock opponent. return firstLegalMove;}
function opponentToMove(state: GameState): boolean { return !state.over && state.turn !== human.value;}
async function settle() { const current = game.value; if (!opponentToMove(current) || thinking.value) return; thinking.value = true; try { const move = await opponentFor().pick(current.fen); // The position may have been reset (new game, colour change, undo) while the opponent // was thinking: drop the stale reply; the `finally` below re-schedules if needed. if (game.value.fen !== current.fen) return; const next = applyMove(current, move); if (next) game.value = next; } finally { thinking.value = false; // Re-run only when a newer position still waits for the opponent (no loop otherwise). if (game.value.fen !== current.fen && opponentToMove(game.value)) void settle(); }}Todo el cuidado de settle() va a un solo problema: la respuesta que llega tarde. Mientras el
rival piensa, puedes darle a “nueva partida”, cambiar de color o deshacer. Cuando la promesa se
resuelve, su jugada corresponde a una posición que ya no está en pantalla, y aplicarla produce una
pieza que se mueve sola en una partida distinta.
La solución es corta. Se guarda el FEN de partida (current) y, al volver del await, si cambió, la
respuesta se tira. En el finally, si la posición nueva todavía espera al rival, se vuelve a llamar;
la condición doble (fen distinto y le toca al rival) impide que eso se convierta en un bucle. Con el
rival de mentira nunca se dispara; con un modelo en un worker, constantemente.
function playHuman(move: Move): boolean { const current = game.value; if (busy.value || current.over || current.turn !== human.value) return false; const next = applyMove(current, move); if (!next) return false; game.value = next; void settle(); return true;}
function startNewGame() { game.value = newGame(); void settle();}
function chooseColor(color: Color) { if (color === human.value || busy.value) return; human.value = color; startNewGame();}
function undo() { if (busy.value || !canUndo(game.value, human.value)) return; game.value = undoPair(game.value, human.value); // As black, undo leaves the opponent to move: let it reply. void settle();}
function stageLabel(): string { return STAGES.find((entry) => entry.id === stage.value)?.label ?? STAGES[0].label;}
function exportPgn(): string { const white = human.value === 'w' ? HUMAN_LABEL : stageLabel(); const black = human.value === 'b' ? HUMAN_LABEL : stageLabel(); return toPgn(game.value, { White: white, Black: black });}Las acciones que el usuario puede disparar empiezan comprobando busy: son los puntos de entrada, y
es ahí donde hay que comprobarlo. playHuman devuelve boolean porque el tablero necesita saber si
la jugada se aceptó para devolver la pieza si no.
Las acciones que cambian la posición terminan en void settle(), también startNewGame: con negras,
la partida nueva empieza con el rival moviendo. El void dice que la promesa se ignora a propósito,
como exige la regla no-floating-promises de ESLint, porque una promesa ignorada por descuido se come
los errores.
export default function App() { useEffect(() => { const query = parseQuery(window.location.search); stage.value = query.stage; if (query.color !== human.value) { human.value = query.color; } void settle(); }, []);
return ( <> <Board game={game} human={human} turning={turning} onMove={playHuman} /> <aside class="panel" data-testid="panel" aria-label="Modelo y jugadas"> <ModelPanel game={game} human={human} stage={stage} busy={busy} onStage={(id) => (stage.value = id)} onColor={chooseColor} /> <MoveList game={game} human={human} busy={busy} onUndo={undo} onNewGame={startNewGame} onExport={exportPgn} /> </aside> </> );}El componente es casi solo composición. La URL se lee en un useEffect porque window.location no
existe cuando Astro renderiza la página en el servidor.
Board.tsx: envolver una librería imperativa
cm-chessboard dibuja el tablero en SVG, es responsive, funciona con ratón y con el dedo, y es imperativa: se le dan órdenes, no se le pasa un estado. Envolverla en un componente reactivo es el trabajo de este fichero, el más difícil de la demo. Es un intérprete entre dos personas que hablan distinto: el resto de la demo dice «la partida ahora es esta», y el componente lo traduce a las órdenes sueltas que la librería entiende («mueve esta pieza», «gira el tablero»), en el orden y al ritmo que ella acepta.
import { computed, effect, type Signal } from '@preact/signals';import { useEffect, useRef } from 'preact/hooks';import { legalTargets, needsPromotion, type Color, type GameState, type Move } from '../lib/game';
interface Props { game: Signal<GameState>; human: Signal<Color>; /** Set while the board animates an orientation change. */ turning: Signal<boolean>; /** Applies the human move; returns false when it is rejected. */ onMove: (move: Move) => boolean;}
/** * cm-chessboard wrapper. The library is loaded on the client only (it touches the DOM at * import time), the sprites are served from /pieces and /extensions (copied by * scripts/copy-assets.mjs) and the theme lives in src/styles/board.css. */export default function Board({ game, human, turning, onMove }: Props) { const container = useRef<HTMLDivElement>(null);rukh-web/src/islands/Board.tsx
Las props son señales, no valores: el componente se monta una vez y se suscribe. Pasar GameState
por valor obligaría a un re-render por jugada, y cada re-render reconstruiría el tablero.
useEffect(() => { const element = container.current; if (!element) return; let disposed = false; let dispose: (() => void) | undefined;
(async () => { const [ { Chessboard, COLOR, INPUT_EVENT_TYPE }, { Markers, MARKER_TYPE }, { PromotionDialog }, { Accessibility }, ] = await Promise.all([ import('cm-chessboard/src/Chessboard.js'), import('cm-chessboard/src/extensions/markers/Markers.js'), import('cm-chessboard/src/extensions/promotion-dialog/PromotionDialog.js'), import('cm-chessboard/src/extensions/accessibility/Accessibility.js'), ]); if (disposed) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const board = new Chessboard(element, { position: game.value.fen, orientation: human.value === 'w' ? COLOR.white : COLOR.black, assetsUrl: '/', style: { cssClass: 'rukh', showCoordinates: true, borderType: 'none', pieces: { file: 'pieces/standard.svg' }, animationDuration: reduced ? 0 : 150, }, extensions: [ { class: Markers, props: { autoMarkers: MARKER_TYPE.frame } }, { class: PromotionDialog }, { class: Accessibility, props: { brailleNotationInAlt: true, movePieceForm: false, boardAsTable: false, piecesAsList: false, visuallyHidden: true, }, }, ], });rukh-web/src/islands/Board.tsx
Los import() son dinámicos porque cm-chessboard toca el DOM al importarse, y un import estático
rompería el renderizado en el servidor de Astro. Promise.all los baja en paralelo. if (disposed) return evita la fuga clásica de un efecto asíncrono: si el componente se desmonta mientras los
módulos bajan, no se construye un tablero sobre un elemento que ya no está.
animationDuration respeta prefers-reduced-motion. La extensión de accesibilidad va casi toda
apagada, con solo la notación braille en el alt: sus formularios y tablas alternativas duplicarían
la interfaz.
const clearTargets = () => board.removeMarkers(MARKER_TYPE.dot);
const handler = (event: Parameters<Parameters<typeof board.enableMoveInput>[0]>[0]) => { const state = game.value; switch (event.type) { case INPUT_EVENT_TYPE.moveInputStarted: { if (state.over || state.turn !== human.value) return false; const targets = legalTargets(state, event.squareFrom); if (targets.length === 0) return false; clearTargets(); for (const square of targets) board.addMarker(MARKER_TYPE.dot, square); return true; } case INPUT_EVENT_TYPE.validateMoveInput: { const from = event.squareFrom; const to = event.squareTo ?? ''; if (needsPromotion(state, from, to)) { const color = state.turn === 'w' ? COLOR.white : COLOR.black; board.showPromotionDialog(to, color, (result) => { clearTargets(); const piece = result.piece?.charAt(1) as Move['promotion'] | undefined; if (result.type === 'pieceSelected' && piece) { if (!onMove({ from, to, promotion: piece })) void board.setPosition(game.value.fen, false); } else { void board.setPosition(game.value.fen, false); } }); return true; } return onMove({ from, to }); } case INPUT_EVENT_TYPE.moveInputCanceled: case INPUT_EVENT_TYPE.moveInputFinished: clearTargets(); return true; default: return true; } };rukh-web/src/islands/Board.tsx
Aquí la legalidad llega a la pantalla. moveInputStarted se dispara al coger una pieza: si la
partida terminó, no es tu turno o la pieza no tiene jugadas legales, devuelve false y la pieza no
se levanta; si no, pinta como puntos las casillas de legalTargets. validateMoveInput se dispara
al soltar, y devolver false hace que la pieza vuelva a su casilla.
La promoción es el caso feo: el diálogo es asíncrono y la librería espera una respuesta síncrona. Se
devuelve true y el diálogo corrige después: si eliges pieza y onMove la acepta, el efecto de
posición redibuja; si cancelas o se rechaza, setPosition al FEN actual devuelve el peón a su sitio.
Parameters<Parameters<typeof board.enableMoveInput>[0]>[0] es “el primer parámetro del primer
parámetro de enableMoveInput”: el tipo del evento, extraído de la firma en vez de copiado a mano.
Feo, pero si la librería lo cambia, TypeScript avisa.
La máscara ya está en pantalla. El resto del fichero es concurrencia contra una librería imperativa, oficio de frontend y no de modelos: puedes saltarlo en la primera lectura.
// FontaneríaBoard.tsx, segunda mitad: sincronizar la librería con la partida sin carreras (effect, queueMicrotask, data-busy, limpieza)
// Keep the board in sync with the game: opponent replies, undo, new game and // side effects of the human move (castling rook, en passant capture, promotion). // `data-busy` is true while position animations are pending: cm-chessboard ignores new // input until the previous move animation has finished, and the E2E tests wait for it. let pending = 0; const setBusy = (delta: number) => { pending += delta; element.parentElement?.setAttribute('data-busy', String(pending > 0)); };
// Deferred a microtask: when the human moves, cm-chessboard moves the piece itself right // after the validate callback, and setPosition then only animates the remaining diff. const stopPosition = effect(() => { const fen = game.value.fen; setBusy(1); queueMicrotask(() => { if (disposed) return; board.setPosition(fen, true).finally(() => setTimeout(() => setBusy(-1), 0)); }); });
// Accessible name for the board image (the Accessibility extension keeps the braille // notation in `alt`; `aria-label` is what assistive tech reads for role="img"). element.querySelector('svg.cm-chessboard')?.setAttribute('aria-label', 'Tablero de ajedrez');rukh-web/src/islands/Board.tsx
El effect de signals sustituye al re-render: cada vez que cambia game.value.fen, llama a
setPosition. Eso cubre de una vez la respuesta del rival, el deshacer, la partida nueva y los
efectos colaterales de tu jugada (la torre del enroque, el peón capturado al paso, la pieza de
promoción).
El queueMicrotask existe porque, cuando tú mueves, cm-chessboard mueve la pieza él mismo justo
después del callback de validación. Llamado de inmediato, setPosition animaría la jugada dos veces;
diferido, solo anima la diferencia que queda.
data-busy es la señal que esperan los tests de extremo a extremo: sin ella, un test que pulsa dos
jugadas seguidas pierde la segunda, porque la librería ignora la entrada mientras anima. Sacar al DOM
un estado interno de la librería convierte un test intermitente en uno determinista.
// cm-chessboard rejects a second setOrientation while one is queued: chain them and, // once a turn finishes, re-apply the latest colour if it changed meanwhile. const orientationOf = (color: Color) => (color === 'w' ? COLOR.white : COLOR.black); let turn: Promise<void> = Promise.resolve(); const orient = () => { turn = turn.then(async () => { if (disposed) return; const wanted = orientationOf(human.value); if (board.getOrientation() === wanted) return; turning.value = true; try { await board.setOrientation(wanted, false); } finally { if (disposed || board.getOrientation() === orientationOf(human.value)) { turning.value = false; } else { orient(); } } }); };
// Input is toggled only when the game ends or the human changes colour: `computed` // keeps the effect from re-running (and interrupting cm-chessboard) on every move. const over = computed(() => game.value.over); const stopInput = effect(() => { const finished = over.value; const color = human.value; if (board.isMoveInputEnabled()) board.disableMoveInput(); clearTargets(); orient(); if (!finished) board.enableMoveInput(handler, orientationOf(color)); });rukh-web/src/islands/Board.tsx
Aquí hay dos problemas de concurrencia contra la misma librería, y sus soluciones aparecen cada vez que se envuelve algo imperativo.
cm-chessboard rechaza un setOrientation mientras otro está en la cola: si pulsas “negras” y
“blancas” rápido, el segundo se pierde y el tablero queda del revés. Una cadena de promesas
(turn = turn.then(...)) serializa las peticiones y, al final, si el color deseado cambió mientras
giraba, vuelve a llamar.
Un efecto de signals se re-ejecuta cuando cambia cualquier señal que lea. Si leyera game.value.over
directamente, se re-ejecutaría en cada jugada y cortaría animaciones con disableMoveInput.
computed(() => game.value.over) crea una señal derivada que solo notifica cuando el booleano
cambia: el efecto corre dos veces por partida en vez de sesenta. Ese computed como filtro de
igualdad es lo que hay que recordar de este fichero.
dispose = () => { stopPosition(); stopInput(); turning.value = false; board.destroy(); }; })();
return () => { disposed = true; dispose?.(); }; }, [game, human, turning, onMove]);
return ( <div class="board" data-testid="board" data-busy="true"> <div class="board__surface" ref={container} /> </div> );}rukh-web/src/islands/Board.tsx
La limpieza va en dos niveles: la función que devuelve el useEffect marca disposed siempre,
aunque los módulos no hayan terminado de bajar, y llama a dispose si ya existe. El data-busy="true"
inicial hace que los tests no toquen un tablero a medio construir.
// Ejercicio 01Gana en cuatro jugadas al rival de mentira
Abre la demo con ?mock=1 y juega. El rival hace siempre la primera jugada legal en el orden que
devuelve chess.js. ¿Puedes darle mate en cuatro jugadas? Exporta el PGN y mira las cabeceras.
// SoluciónVer la solución
chess.js enumera las jugadas legales recorriendo el tablero desde a8 hacia h1, así que la primera
pieza negra que encuentra con jugada es el caballo de b8: tras 1. e4 responde Nc6, y después
mueve la torre de a8 adelante y atrás (Rb8, Ra8) mientras nadie se lo impida. Con
1. e4 Nc6 2. Bc4 Rb8 3. Qh5 Ra8 4. Qxf7# hay mate en cuatro (comprobado con chess.js). El PGN
lleva [White "Humano"], [Black "Primera jugada legal"] y el resultado 1-0, que sale de
state.result en toPgn.
En M2 este rival se sustituye por el decoder entrenado, y la partida deja de ser tan fácil.
// Ejercicio 02Rompe la máscara a mano
La lógica del juego no está expuesta en la consola del navegador, así que pruébala con un test.
Añade un caso a tests/game.test.ts, que llega con el clon (la lección no lo muestra): parte de newGame(), pide applyMove con una jugada ilegal
(por ejemplo, e2e5, un peón que avanza tres casillas) y mira qué devuelve. Después haz lo mismo
con una jugada legal y compara el history de los dos estados con el del estado de partida.
Ejecútalo con pnpm test tests/game.test.ts.
// SoluciónVer la solución
applyMove devuelve null para la ilegal y un GameState nuevo para la legal, con la jugada en
history en notación SAN. El estado original no cambia en ninguno de los dos casos:
es lo que significa que la función sea pura, y la razón por la que playHuman puede comprobar el
resultado antes de asignarlo a la señal.
En M2 ese null es la rama que se cuenta: cada vez que el modelo propone algo que applyMove
rechaza, es una jugada ilegal que la tasa de legalidad sin máscara registra.
Qué has aprendido
La demo funciona antes de que exista el modelo, y por el camino se ha tomado la decisión que más
consecuencias tiene en el resto del curso: el modelo propone y el navegador filtra. La legalidad
la decide chess.js en el cliente, el modelo solo ordena candidatos, y las dos cosas se miden por
separado.
También has visto tres patrones de envolver algo imperativo en algo reactivo: tirar la respuesta que
llega tarde, serializar órdenes con una cola de promesas y filtrar un efecto con computed. La
respuesta tardía te la volverás a encontrar en cualquier interfaz que hable con un LLM en streaming:
el usuario cambia de conversación y la respuesta anterior sigue llegando.
Cómo se mide: pnpm test en verde (estado del juego y rival), pnpm e2e en verde en los tres
tamaños de pantalla (jugar una partida completa, deshacer, exportar PGN, axe sin violaciones), y la
demo con ?mock=1 permite jugar y ganar en ordenador y en móvil.
Con esto se cierra M0, y el club está montado. El aprendiz todavía no existe, pero ya tiene todo lo
que necesita para aprender y para que se le pueda medir: un local donde cualquiera reproduce el
entorno con un comando, un árbitro calibrado, un cuaderno que apunta cada ejecución con su código y
su configuración, una biblioteca con su ficha de catálogo y un tablero que no admite jugadas
ilegales. El repositorio está en el tag p0 y la descarga de datos está corriendo.
En M1 empieza a aprender el alfabeto: cómo se escribe una jugada para que una red la pueda leer, y qué cuenta como un token.
La cheatsheet del módulo, diez preguntas con su respuesta corta, está justo debajo.
// el recorrido · lo conseguido
- M0 El tallerEntorno reproducible(hecho)
- M1 Datos y tokensDatos y tokenización(por delante)
- M2 El decoderPreentrenar un GPT(por delante)
- M3 El encoderEmbeddings y clasificar(por delante)
- M4 Fine-tuningSFT y LoRA(por delante)
- M5 AlineamientoPreferencias: DPO, GRPO(por delante)
- M6 Evaluar y publicarMedir y desplegar(por delante)
- FASE 2 El entrenadorAgentes, RAG, MCP
lo que tienes ahora
Un repositorio que se instala con un comando, Stockfish respondiendo, MLflow anotando cada ejecución, dos meses de partidas de Lichess en disco y un tablero web que solo acepta jugadas legales.
siguiente parada · M1 Datos y tokens
Le enseñas el alfabeto: qué es una jugada escrita. Y ordenas la biblioteca para que pueda leer millones de partidas sin esperar.
// cheatsheet M0
Diez preguntas para llevarte
- 01¿Qué es un modelo de lenguaje de ajedrez?
- Una red que predice la siguiente jugada dada la secuencia de jugadas anteriores. Se entrena con la misma pérdida que un LLM de texto (siguiente token), pero cada token es una jugada, así que las reglas y la estrategia emergen de predecir bien.
- 02¿Por qué el ajedrez es un buen dominio para aprender IA generativa?
- Es cerrado y verificable: un motor decide si una jugada es legal y cuánto vale, así que las recompensas son gratis y objetivas. Los datos son abiertos y CC0 (Lichess), los modelos son pequeños (decenas de millones de parámetros) y la demo se entiende en cinco segundos.
- 03¿Qué diferencia hay entre SAN, UCI, FEN y PGN?
- SAN escribe una jugada como en los libros (Nf3) y depende del tablero; UCI escribe origen y destino (g1f3) y no depende de nada; FEN describe una posición completa en una línea; PGN es el fichero de una partida entera con cabeceras y jugadas en SAN.
- 04¿Qué papel tiene Stockfish en este curso?
- Es el juez, no el jugador. Con UCI_LimitStrength y UCI_Elo (hacen falta los dos) se convierte en un rival calibrado para estimar Elo; a poca profundidad da recompensas baratas para DPO y GRPO; y sus evaluaciones públicas etiquetan posiciones para el encoder.
- 05¿Por qué las configuraciones usan extra="forbid"?
- Para que una clave mal escrita en un YAML falle al cargar, antes de tocar la red o la GPU. Con el comportamiento por defecto de pydantic se ignoraría, el valor por defecto se usaría en silencio y el resultado mediría otra cosa.
- 06¿Qué filtros aplica el recorte de datos y por qué?
- Ambos Elo ≥ 1800 (calidad), base de tiempo ≥ 180 s (excluye bullet, donde se juega a ciegas contra el reloj), terminación Normal o Time forfeit (excluye abandonos y trampas) y sin variantes. El mínimo de 20 plies no se aplica aquí sino en M1, tras convertir a UCI, y el manifiesto lo guarda como min_plies_deferred.
- 07¿Para qué sirven el --dry-run y el manifiesto de la descarga?
- El --dry-run imprime el mismo plan (la SQL con los filtros) que la descarga ejecutaría, porque los dos salen del mismo código. El manifiesto, escrito al final, guarda los filtros aplicados de verdad, las filas por mes y un sha256 por fichero: es la prueba de qué entró en los datos.
- 08¿Por qué la legalidad la decide el navegador y no el modelo?
- Porque el modelo solo propone una distribución sobre tokens; chess.js conoce las reglas y enmascara las jugadas ilegales antes de muestrear, es decir, de sortear la jugada según sus probabilidades (decodificación restringida, la misma técnica que fuerza JSON válido en un LLM). Medir cuántas propone legales sin máscara es, además, una métrica de cuánto ha aprendido las reglas.
- 09¿Para qué sirve MLflow si ya tengo los logs?
- Para que cada entrenamiento quede registrado con su configuración completa, el SHA de git del código, sus métricas por paso y sus artefactos, comparable con los demás. Las model cards se generan desde MLflow para que ningún número de la tabla se escriba a mano.
- 10¿Cómo se mide el progreso en cada módulo?
- Siempre con la misma tabla: tasa de jugadas legales sin máscara, exactitud top-1 y top-3 en validación, puzles resueltos por dificultad y Elo estimado contra Stockfish limitado con intervalo de confianza. Los criterios de aceptación se escriben antes de medir.