Spaces:
Runtime error
Runtime error
| /** | |
| * ChessEcon Simulation Engine | |
| * ───────────────────────────────────────────────────────────────────────── | |
| * Generates realistic self-play game events in real time to drive the | |
| * live dashboard. In production this would be replaced by a WebSocket | |
| * connection to the actual ChessEcon backend. | |
| * | |
| * Design: Quantitative Finance Dark — Bloomberg-inspired terminal | |
| */ | |
| export type AgentColor = "white" | "black"; | |
| export type EventType = | |
| | "game_start" | |
| | "move" | |
| | "coaching_request" | |
| | "coaching_response" | |
| | "game_end" | |
| | "training_step" | |
| | "wallet_update"; | |
| export interface GameEvent { | |
| id: string; | |
| timestamp: number; | |
| type: EventType; | |
| agent?: AgentColor; | |
| move?: string; | |
| san?: string; | |
| fen?: string; | |
| complexity?: number; | |
| complexityLabel?: "SIMPLE" | "MODERATE" | "COMPLEX" | "CRITICAL"; | |
| walletWhite?: number; | |
| walletBlack?: number; | |
| coachingFee?: number; | |
| result?: "1-0" | "0-1" | "1/2-1/2"; | |
| reward?: number; | |
| economicReward?: number; | |
| combinedReward?: number; | |
| trainingLoss?: number; | |
| trainingStep?: number; | |
| message?: string; | |
| prizePool?: number; | |
| } | |
| export interface GameState { | |
| gameId: number; | |
| moveNumber: number; | |
| turn: AgentColor; | |
| fen: string; | |
| board: (string | null)[][]; | |
| moves: string[]; | |
| walletWhite: number; | |
| walletBlack: number; | |
| isOver: boolean; | |
| result?: "1-0" | "0-1" | "1/2-1/2"; | |
| coachingCallsWhite: number; | |
| coachingCallsBlack: number; | |
| } | |
| export interface TrainingMetrics { | |
| step: number; | |
| loss: number[]; | |
| reward: number[]; | |
| winRate: number[]; | |
| avgProfit: number[]; | |
| coachingRate: number[]; | |
| kl: number[]; | |
| steps: number[]; | |
| } | |
| // ── Chess piece unicode map ────────────────────────────────────────────── | |
| export const PIECES: Record<string, string> = { | |
| K: "♔", Q: "♕", R: "♖", B: "♗", N: "♘", P: "♙", | |
| k: "♚", q: "♛", r: "♜", b: "♝", n: "♞", p: "♟", | |
| }; | |
| // ── Starting FEN ───────────────────────────────────────────────────────── | |
| const STARTING_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"; | |
| export function fenToBoard(fen: string): (string | null)[][] { | |
| const rows = fen.split(" ")[0].split("/"); | |
| return rows.map((row) => { | |
| const cells: (string | null)[] = []; | |
| for (const ch of row) { | |
| if (/\d/.test(ch)) { | |
| for (let i = 0; i < parseInt(ch); i++) cells.push(null); | |
| } else { | |
| cells.push(ch); | |
| } | |
| } | |
| return cells; | |
| }); | |
| } | |
| // ── Realistic full game move sequences (opening + middlegame + endgame) ───── | |
| const GAME_SCRIPTS = [ | |
| // Ruy Lopez — full game ~50 moves | |
| ["e2e4","e7e5","g1f3","b8c6","f1b5","a7a6","b5a4","g8f6","e1g1","f8e7","f1e1","b7b5","a4b3","d7d6","c2c3","e8g8","h2h3","c6a5","b3c2","c7c5","d2d4","d8c7","b1d2","c5d4","c3d4","a5c6","d2f1","c6b4","c2b1","d6d5","e4d5","f6d5","f1e3","d5e3","f2e3","c8e6","d1d3","f8d8","d4d5","e6f7","b1c3","b4d3","e1d1","d3c5","d5d6","c7b6","c3d5","b6d6","d1d6","d8d6","d5f4","d6d2","f4e6","f7e6","a1d1","d2d1","c1d1","e6d5","d1d5","c5e4"], | |
| // Sicilian Najdorf — full game ~48 moves | |
| ["e2e4","c7c5","g1f3","d7d6","d2d4","c5d4","f3d4","g8f6","b1c3","a7a6","c1g5","e7e6","d1d2","f8e7","e1c1","e8g8","f2f4","h7h6","g5h4","b8d7","f1e2","d8a5","e2f3","b7b5","h4f6","d7f6","d4e6","f7e6","e4e5","d6e5","f4e5","f6d7","f3e4","d7e5","c3b1","a5c7","b1d2","c8b7","e4b7","c7b7","d2e4","e5c4","d2c4","b5c4","d1d8","f8d8","h1d1","d8d1","c1d1","b7c6","d1c1","a8d8","e4f2","c4c3","b2c3","c6c3","c1b1","c3e1","b1a2","d8d2"], | |
| // Queen's Gambit Declined — full game ~52 moves | |
| ["d2d4","d7d5","c2c4","e7e6","b1c3","g8f6","c1g5","f8e7","e2e3","e8g8","g1f3","h7h6","g5h4","b7b6","c4d5","e6d5","f1d3","c8b7","e1g1","b8d7","d1c2","c7c5","a1d1","c5d4","e3d4","d8c7","h4g3","f8e8","f1e1","a7a6","c3e2","e7d6","g3d6","c7d6","e2f4","d7f8","f4d5","f6d5","d3g6","f7g6","d1d5","d6e6","e1e6","f8e6","d5d1","e8f8","c2d3","b7c6","d3g6","h8g8","g6h5","g8g2","h1g1","g2g1","d1g1","e6f4","g1g7","f8g7","h5f3","g7f6"], | |
| // King's Indian Defence — full game ~55 moves | |
| ["d2d4","g8f6","c2c4","g7g6","b1c3","f8g7","e2e4","d7d6","g1f3","e8g8","f1e2","e7e5","e1g1","b8c6","d4d5","c6e7","f3e1","f6d7","e1d3","f7f5","f2f3","f5f4","c1d2","g6g5","c4c5","d6c5","d3c5","d7c5","d1c2","c5d3","c2d3","c7c6","d5c6","b7c6","a1c1","c8e6","c3b1","d8d7","b1d2","a8c8","d2c4","e6c4","d3c4","d7d2","c4c6","d2d4","c6c8","f8c8","c1c8","g8f7","c8h8","d4e3","h1f1","e3f2","f1f2","g7h6","f2f1","h6d2","h8h7","f7g6","h7h1","d2f4","h1g1","f4e3","g1g3","e3f4","g3g1"], | |
| ]; | |
| let scriptIndex = 0; | |
| let moveIndex = 0; | |
| function getNextMove(gameScript: string[]): string | null { | |
| if (moveIndex < gameScript.length) { | |
| return gameScript[moveIndex++]; | |
| } | |
| return null; | |
| } | |
| // ── Apply move to FEN (simplified) ─────────────────────────────────────── | |
| function applyMoveToBoard( | |
| board: (string | null)[][], | |
| move: string | |
| ): (string | null)[][] { | |
| const newBoard = board.map((row) => [...row]); | |
| const files = "abcdefgh"; | |
| const fromFile = files.indexOf(move[0]); | |
| const fromRank = 8 - parseInt(move[1]); | |
| const toFile = files.indexOf(move[2]); | |
| const toRank = 8 - parseInt(move[3]); | |
| if (fromFile < 0 || fromRank < 0 || toFile < 0 || toRank < 0) return newBoard; | |
| const piece = newBoard[fromRank]?.[fromFile]; | |
| if (!piece) return newBoard; | |
| newBoard[toRank][toFile] = piece; | |
| newBoard[fromRank][fromFile] = null; | |
| // Castling | |
| if (piece === "K" && move === "e1g1") { | |
| newBoard[7][5] = "R"; newBoard[7][7] = null; | |
| } else if (piece === "K" && move === "e1c1") { | |
| newBoard[7][3] = "R"; newBoard[7][0] = null; | |
| } else if (piece === "k" && move === "e8g8") { | |
| newBoard[0][5] = "r"; newBoard[0][7] = null; | |
| } else if (piece === "k" && move === "e8c8") { | |
| newBoard[0][3] = "r"; newBoard[0][0] = null; | |
| } | |
| return newBoard; | |
| } | |
| function boardToFen(board: (string | null)[][]): string { | |
| return board.map((row) => { | |
| let s = ""; | |
| let empty = 0; | |
| for (const cell of row) { | |
| if (!cell) { empty++; } | |
| else { if (empty) { s += empty; empty = 0; } s += cell; } | |
| } | |
| if (empty) s += empty; | |
| return s; | |
| }).join("/"); | |
| } | |
| function moveToSan(move: string, piece: string | null): string { | |
| if (!piece) return move; | |
| const files = "abcdefgh"; | |
| const toFile = files[files.indexOf(move[2])]; | |
| const toRank = move[3]; | |
| const isCapture = false; // simplified | |
| if (move === "e1g1" || move === "e8g8") return "O-O"; | |
| if (move === "e1c1" || move === "e8c8") return "O-O-O"; | |
| const pieceLetter = piece.toUpperCase(); | |
| if (pieceLetter === "P") return `${toFile}${toRank}`; | |
| return `${pieceLetter}${isCapture ? "x" : ""}${toFile}${toRank}`; | |
| } | |
| function complexityScore(moveNum: number): number { | |
| // Peaks in middlegame (moves 15-35) | |
| const base = 0.15 + 0.5 * Math.exp(-Math.pow(moveNum - 25, 2) / (2 * 12 * 12)); | |
| return Math.min(1, Math.max(0, base + (Math.random() - 0.5) * 0.15)); | |
| } | |
| function complexityLabel(score: number): "SIMPLE" | "MODERATE" | "COMPLEX" | "CRITICAL" { | |
| if (score < 0.20) return "SIMPLE"; | |
| if (score < 0.45) return "MODERATE"; | |
| if (score < 0.70) return "COMPLEX"; | |
| return "CRITICAL"; | |
| } | |
| let uid = 0; | |
| function nextId() { return `evt-${++uid}`; } | |
| // ── Main simulation class ───────────────────────────────────────────────── | |
| export class ChessEconSimulation { | |
| private listeners: ((event: GameEvent) => void)[] = []; | |
| private stateListeners: ((state: GameState) => void)[] = []; | |
| private metricsListeners: ((metrics: TrainingMetrics) => void)[] = []; | |
| private timer: ReturnType<typeof setTimeout> | null = null; | |
| private running = false; | |
| public state: GameState = this.freshState(); | |
| public metrics: TrainingMetrics = { | |
| step: 0, | |
| loss: [], | |
| reward: [], | |
| winRate: [], | |
| avgProfit: [], | |
| coachingRate: [], | |
| kl: [], | |
| steps: [], | |
| }; | |
| public events: GameEvent[] = []; | |
| public gameCount = 0; | |
| public totalGames = 0; | |
| private freshState(): GameState { | |
| return { | |
| gameId: 0, | |
| moveNumber: 0, | |
| turn: "white", | |
| fen: STARTING_FEN, | |
| board: fenToBoard(STARTING_FEN), | |
| moves: [], | |
| walletWhite: 100, | |
| walletBlack: 100, | |
| isOver: false, | |
| coachingCallsWhite: 0, | |
| coachingCallsBlack: 0, | |
| }; | |
| } | |
| on(listener: (event: GameEvent) => void) { | |
| this.listeners.push(listener); | |
| return () => { this.listeners = this.listeners.filter(l => l !== listener); }; | |
| } | |
| onState(listener: (state: GameState) => void) { | |
| this.stateListeners.push(listener); | |
| return () => { this.stateListeners = this.stateListeners.filter(l => l !== listener); }; | |
| } | |
| onMetrics(listener: (metrics: TrainingMetrics) => void) { | |
| this.metricsListeners.push(listener); | |
| return () => { this.metricsListeners = this.metricsListeners.filter(l => l !== listener); }; | |
| } | |
| private emit(event: GameEvent) { | |
| this.events = [event, ...this.events].slice(0, 200); | |
| this.listeners.forEach(l => l(event)); | |
| } | |
| private emitState() { | |
| this.stateListeners.forEach(l => l({ ...this.state })); | |
| } | |
| private emitMetrics() { | |
| this.metricsListeners.forEach(l => l({ ...this.metrics })); | |
| } | |
| start() { | |
| if (this.running) return; | |
| this.running = true; | |
| this.startGame(); | |
| } | |
| stop() { | |
| this.running = false; | |
| if (this.timer) clearTimeout(this.timer); | |
| } | |
| private schedule(fn: () => void, delay: number) { | |
| if (!this.running) return; | |
| this.timer = setTimeout(fn, delay); | |
| } | |
| private startGame() { | |
| this.gameCount++; | |
| this.totalGames++; | |
| const script = GAME_SCRIPTS[scriptIndex % GAME_SCRIPTS.length]; | |
| scriptIndex++; | |
| moveIndex = 0; | |
| const entryFee = 10; | |
| this.state = { | |
| ...this.freshState(), | |
| gameId: this.gameCount, | |
| walletWhite: this.state.walletWhite - entryFee, | |
| walletBlack: this.state.walletBlack - entryFee, | |
| }; | |
| this.emit({ | |
| id: nextId(), | |
| timestamp: Date.now(), | |
| type: "game_start", | |
| walletWhite: this.state.walletWhite, | |
| walletBlack: this.state.walletBlack, | |
| prizePool: entryFee * 2 * 0.9, | |
| message: `Game #${this.gameCount} started — Prize pool: ${(entryFee * 2 * 0.9).toFixed(1)} units`, | |
| }); | |
| this.emitState(); | |
| this.schedule(() => this.playMove(script), 600); | |
| } | |
| private playMove(script: string[]) { | |
| if (!this.running) return; | |
| if (this.state.isOver) return; | |
| const move = getNextMove(script); | |
| const agent = this.state.turn; | |
| const progress = this.gameCount / Math.max(1, this.totalGames); | |
| // End game after 70-90 moves or when script ends after move 40 | |
| const maxMoves = 70 + Math.floor(Math.random() * 20); | |
| if (this.state.moveNumber >= maxMoves) { | |
| this.endGame(); | |
| return; | |
| } | |
| // If script is exhausted, generate a plausible continuation move | |
| const effectiveMove = move ?? this.generateContinuationMove(); | |
| if (!effectiveMove) { | |
| this.endGame(); | |
| return; | |
| } | |
| const complexity = complexityScore(this.state.moveNumber); | |
| const label = complexityLabel(complexity); | |
| const canAffordCoaching = (agent === "white" ? this.state.walletWhite : this.state.walletBlack) >= 15; | |
| const wantsCoaching = (label === "COMPLEX" || label === "CRITICAL") && canAffordCoaching && Math.random() < (0.35 - 0.25 * progress); | |
| // Apply move | |
| const newBoard = applyMoveToBoard(this.state.board, effectiveMove); | |
| const piece = this.state.board[8 - parseInt(effectiveMove[1])]?.["abcdefgh".indexOf(effectiveMove[0])] ?? null; | |
| const san = moveToSan(effectiveMove, piece); | |
| const newFen = boardToFen(newBoard); | |
| this.state = { | |
| ...this.state, | |
| board: newBoard, | |
| fen: newFen, | |
| moveNumber: this.state.moveNumber + 1, | |
| turn: agent === "white" ? "black" : "white", | |
| moves: [...this.state.moves, san], | |
| }; | |
| if (wantsCoaching) { | |
| const fee = 5; | |
| if (agent === "white") { | |
| this.state.walletWhite -= fee; | |
| this.state.coachingCallsWhite++; | |
| } else { | |
| this.state.walletBlack -= fee; | |
| this.state.coachingCallsBlack++; | |
| } | |
| this.emit({ | |
| id: nextId(), | |
| timestamp: Date.now(), | |
| type: "coaching_request", | |
| agent, | |
| complexity, | |
| complexityLabel: label, | |
| coachingFee: fee, | |
| walletWhite: this.state.walletWhite, | |
| walletBlack: this.state.walletBlack, | |
| message: `${agent === "white" ? "White" : "Black"} → Claude claude-opus-4-5 [${label}] fee: -${fee}`, | |
| }); | |
| this.emitState(); | |
| // Claude responds after a delay | |
| this.schedule(() => { | |
| this.emit({ | |
| id: nextId(), | |
| timestamp: Date.now(), | |
| type: "coaching_response", | |
| agent, | |
| move: effectiveMove, | |
| san, | |
| message: `Claude → ${agent === "white" ? "White" : "Black"}: best move ${san}`, | |
| }); | |
| this.emitMoveEvent(agent, effectiveMove, san, complexity, label); | |
| this.schedule(() => this.playMove(script), 900); | |
| }, 700); | |
| } else { | |
| this.emitMoveEvent(agent, effectiveMove, san, complexity, label); | |
| this.schedule(() => this.playMove(script), 500 + Math.random() * 400); | |
| } | |
| } | |
| private emitMoveEvent(agent: AgentColor, move: string, san: string, complexity: number, label: string) { | |
| this.emit({ | |
| id: nextId(), | |
| timestamp: Date.now(), | |
| type: "move", | |
| agent, | |
| move, | |
| san, | |
| fen: this.state.fen, | |
| complexity, | |
| complexityLabel: label as "SIMPLE" | "MODERATE" | "COMPLEX" | "CRITICAL", | |
| walletWhite: this.state.walletWhite, | |
| walletBlack: this.state.walletBlack, | |
| message: `${agent === "white" ? "White" : "Black"} plays ${san}`, | |
| }); | |
| this.emitState(); | |
| } | |
| /** | |
| * Generate a plausible continuation move once the script is exhausted. | |
| * Scans the board for pieces that can make a simple forward/lateral move. | |
| */ | |
| private generateContinuationMove(): string | null { | |
| const board = this.state.board; | |
| const isWhite = this.state.turn === "white"; | |
| const files = "abcdefgh"; | |
| const candidates: string[] = []; | |
| for (let r = 0; r < 8; r++) { | |
| for (let f = 0; f < 8; f++) { | |
| const piece = board[r]?.[f]; | |
| if (!piece) continue; | |
| const ownPiece = isWhite ? piece === piece.toUpperCase() : piece === piece.toLowerCase(); | |
| if (!ownPiece) continue; | |
| // Try simple one-step moves | |
| const deltas = [ | |
| [-1, 0], [1, 0], [0, -1], [0, 1], | |
| [-1, -1], [-1, 1], [1, -1], [1, 1], | |
| ]; | |
| for (const [dr, df] of deltas) { | |
| const nr = r + dr; | |
| const nf = f + df; | |
| if (nr < 0 || nr > 7 || nf < 0 || nf > 7) continue; | |
| const target = board[nr]?.[nf]; | |
| // Can't capture own piece | |
| if (target) { | |
| const targetOwn = isWhite ? target === target.toUpperCase() : target === target.toLowerCase(); | |
| if (targetOwn) continue; | |
| } | |
| const from = `${files[f]}${8 - r}`; | |
| const to = `${files[nf]}${8 - nr}`; | |
| candidates.push(`${from}${to}`); | |
| } | |
| } | |
| } | |
| if (candidates.length === 0) return null; | |
| return candidates[Math.floor(Math.random() * candidates.length)]; | |
| } | |
| private endGame() { | |
| const outcomes: ("1-0" | "0-1" | "1/2-1/2")[] = ["1-0", "0-1", "1/2-1/2"]; | |
| const weights = [0.50, 0.35, 0.15]; | |
| const r = Math.random(); | |
| let result: "1-0" | "0-1" | "1/2-1/2" = "1-0"; | |
| let cum = 0; | |
| for (let i = 0; i < outcomes.length; i++) { | |
| cum += weights[i]; | |
| if (r < cum) { result = outcomes[i]; break; } | |
| } | |
| const prize = 18; | |
| const drawRefund = 5; | |
| if (result === "1-0") this.state.walletWhite += prize; | |
| else if (result === "0-1") this.state.walletBlack += prize; | |
| else { this.state.walletWhite += drawRefund; this.state.walletBlack += drawRefund; } | |
| this.state.isOver = true; | |
| this.state.result = result; | |
| const gameReward = result === "1-0" ? 1 : result === "0-1" ? -1 : 0; | |
| const economicReward = Math.random() * 0.6 - 0.2; | |
| const combinedReward = 0.4 * gameReward + 0.6 * economicReward; | |
| this.emit({ | |
| id: nextId(), | |
| timestamp: Date.now(), | |
| type: "game_end", | |
| result, | |
| walletWhite: this.state.walletWhite, | |
| walletBlack: this.state.walletBlack, | |
| reward: gameReward, | |
| economicReward, | |
| combinedReward, | |
| message: `Game #${this.gameCount} ended — ${result} | Combined reward: ${combinedReward.toFixed(3)}`, | |
| }); | |
| this.emitState(); | |
| // Training step every 5 games | |
| if (this.gameCount % 5 === 0) { | |
| this.schedule(() => this.doTrainingStep(), 800); | |
| } else { | |
| this.schedule(() => this.startGame(), 1200); | |
| } | |
| } | |
| private doTrainingStep() { | |
| this.metrics.step++; | |
| const s = this.metrics.step; | |
| const loss = 2.5 * Math.exp(-s / 60) + 0.3 + (Math.random() - 0.5) * 0.16; | |
| const reward = -0.4 + 0.9 / (1 + Math.exp(-0.04 * (s - 80))) + (Math.random() - 0.5) * 0.12; | |
| const winRate = 0.35 + 0.22 / (1 + Math.exp(-0.1 * (s - 40))) + (Math.random() - 0.5) * 0.04; | |
| const avgProfit = -8 + 15 / (1 + Math.exp(-0.08 * (s - 45))) + (Math.random() - 0.5) * 2; | |
| const coachingRate = Math.max(0.05, 0.35 - 0.28 * (s / 100) + (Math.random() - 0.5) * 0.04); | |
| const kl = 0.05 + 0.03 * Math.exp(-s / 40) + Math.abs((Math.random() - 0.5) * 0.02); | |
| this.metrics.loss = [...this.metrics.loss, loss].slice(-80); | |
| this.metrics.reward = [...this.metrics.reward, reward].slice(-80); | |
| this.metrics.winRate = [...this.metrics.winRate, winRate].slice(-80); | |
| this.metrics.avgProfit = [...this.metrics.avgProfit, avgProfit].slice(-80); | |
| this.metrics.coachingRate = [...this.metrics.coachingRate, coachingRate].slice(-80); | |
| this.metrics.kl = [...this.metrics.kl, kl].slice(-80); | |
| this.metrics.steps = [...this.metrics.steps, s].slice(-80); | |
| this.emit({ | |
| id: nextId(), | |
| timestamp: Date.now(), | |
| type: "training_step", | |
| trainingStep: s, | |
| trainingLoss: loss, | |
| reward, | |
| combinedReward: reward, | |
| message: `GRPO step #${s} — loss: ${loss.toFixed(4)} | reward: ${reward.toFixed(4)}`, | |
| }); | |
| this.emitMetrics(); | |
| this.schedule(() => this.startGame(), 1000); | |
| } | |
| } | |
| export const sim = new ChessEconSimulation(); | |
| // ── Economic performance data builder ──────────────────────────────────── | |
| export interface EconomicDataPoint { | |
| game: number; | |
| prizeIncome: number; | |
| coachingSpend: number; | |
| entryFee: number; | |
| netPnl: number; | |
| cumulativePnl: number; | |
| whiteWallet: number; | |
| blackWallet: number; | |
| } | |