Spaces:
Running
Running
| const express = require('express'); | |
| const http = require('http'); | |
| const { Server } = require('socket.io'); | |
| const path = require('path'); | |
| const crypto = require('crypto'); | |
| const fs = require('fs'); | |
| const { fork } = require('child_process'); | |
| const { AIEngine } = require('./ai/inference'); | |
| const { loadGames } = require('./ai/train'); | |
| const { pullGames, pushGames, pushWeights, pullWeights } = require('./ai/persist'); | |
| let DATA_DIR; | |
| if (process.env.DATA_DIR) { | |
| DATA_DIR = process.env.DATA_DIR; | |
| fs.mkdirSync(DATA_DIR, { recursive: true }); | |
| console.log('Using configured storage ' + DATA_DIR); | |
| } else try { | |
| fs.accessSync('/data', fs.constants.W_OK); | |
| DATA_DIR = '/data'; | |
| console.log('Using persistent storage bucket /data'); | |
| } catch { | |
| DATA_DIR = path.join(__dirname, 'ai'); | |
| console.log('Using local storage ' + DATA_DIR); | |
| } | |
| const MODEL_PATH = path.join(DATA_DIR, 'model_weights.json'); | |
| const GIT_MODEL_PATH = path.join(__dirname, 'ai', 'model_weights.json'); | |
| let HUMAN_GAMES_PATH = path.join(DATA_DIR, 'human_games.jsonl'); | |
| let aiEngine = null; | |
| async function loadAIEngine() { | |
| if (process.env.HF_TOKEN) await pullWeights(MODEL_PATH); | |
| const wp = fs.existsSync(MODEL_PATH) ? MODEL_PATH : GIT_MODEL_PATH; | |
| if (fs.existsSync(wp)) { | |
| aiEngine = new AIEngine(wp); | |
| const size = fs.statSync(wp).size; | |
| console.log('AI engine loaded from ' + path.basename(wp) + ' (' + size + ' bytes)'); | |
| } else { | |
| console.log('No model weights found โ AI mode unavailable'); | |
| } | |
| } | |
| const app = express(); | |
| const server = http.createServer(app); | |
| const io = new Server(server, { cors: { origin: '*' } }); | |
| app.use(express.json()); | |
| app.use(express.static(path.join(__dirname, 'public'))); | |
| // โโโ Train API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| let trainingStatus = { running: false, lastRun: null, error: null, gamesUsed: 0, loss: null }; | |
| let gamesSinceLastTrain = 0; | |
| const AUTO_TRAIN_THRESHOLD = 5; | |
| const IDLE_SELF_PLAY_ENABLED = process.env.IDLE_SELF_PLAY_ENABLED !== '0'; | |
| const IDLE_SELF_PLAY_DELAY_MS = parseInt(process.env.IDLE_SELF_PLAY_DELAY_MS || '60000', 10); | |
| const IDLE_SELF_PLAY_INTERVAL_MS = parseInt(process.env.IDLE_SELF_PLAY_INTERVAL_MS || '15000', 10); | |
| const IDLE_SELF_PLAY_BATCH_SIZE = parseInt(process.env.IDLE_SELF_PLAY_BATCH_SIZE || '2', 10); | |
| const SELF_PLAY_RANDOM_RATE = parseFloat(process.env.SELF_PLAY_RANDOM_RATE || '0.15'); | |
| let connectedSockets = 0; | |
| let lastUserActivityAt = Date.now(); | |
| let selfPlayRunning = false; | |
| let selfPlayGamesGenerated = 0; | |
| function markUserActivity() { | |
| lastUserActivityAt = Date.now(); | |
| } | |
| function runTraining() { | |
| if (trainingStatus.running) return; | |
| if (!fs.existsSync(HUMAN_GAMES_PATH)) return; | |
| // Ensure weights file exists in DATA_DIR (seed from git if needed) | |
| if (!fs.existsSync(MODEL_PATH) && fs.existsSync(GIT_MODEL_PATH)) { | |
| fs.copyFileSync(GIT_MODEL_PATH, MODEL_PATH); | |
| console.log('Seeded model weights from ' + GIT_MODEL_PATH + ' to ' + MODEL_PATH); | |
| } | |
| trainingStatus.running = true; | |
| trainingStatus.error = null; | |
| const child = fork(path.join(__dirname, 'ai', 'train.js'), [ | |
| HUMAN_GAMES_PATH, MODEL_PATH, '10' | |
| ], { stdio: 'pipe' }); | |
| let stdout = ''; | |
| child.stdout.on('data', d => { stdout += d; process.stdout.write(`[train] ${d}`); }); | |
| child.stderr.on('data', d => process.stderr.write(`[train] ${d}`)); | |
| child.on('exit', async (code) => { | |
| trainingStatus.running = false; | |
| if (code === 0) { | |
| try { | |
| const match = stdout.match(/TRAIN_RESULT games=(\d+) loss=([\d.]+)/); | |
| if (match) { | |
| trainingStatus.gamesUsed = parseInt(match[1]); | |
| trainingStatus.loss = parseFloat(match[2]); | |
| } | |
| trainingStatus.lastRun = Date.now(); | |
| gamesSinceLastTrain = 0; | |
| if (fs.existsSync(MODEL_PATH) && aiEngine) aiEngine = new AIEngine(MODEL_PATH); | |
| // Push to Hub for persistence | |
| await Promise.all([ | |
| pushGames(HUMAN_GAMES_PATH), | |
| pushWeights(MODEL_PATH), | |
| ]); | |
| console.log('Training complete, AI engine reloaded'); | |
| } catch (e) { | |
| trainingStatus.error = 'Reload failed: ' + e.message; | |
| } | |
| } else { | |
| trainingStatus.error = `Process exited with code ${code}`; | |
| } | |
| }); | |
| } | |
| function saveTrainingGame(game, source = 'human') { | |
| if (!game || !Array.isArray(game.moves) || game.moves.length === 0) return false; | |
| const entry = JSON.stringify({ ...game, source, ts: game.ts || Date.now() }) + '\n'; | |
| const tryWrite = (filePath) => { fs.appendFileSync(filePath, entry, 'utf-8'); return filePath; }; | |
| let savedPath; | |
| try { | |
| savedPath = tryWrite(HUMAN_GAMES_PATH); | |
| } catch (e) { | |
| console.error('Failed to write to ' + HUMAN_GAMES_PATH + ':', e.message); | |
| if (DATA_DIR === '/data') { | |
| DATA_DIR = path.join(__dirname, 'ai'); | |
| HUMAN_GAMES_PATH = path.join(DATA_DIR, 'human_games.jsonl'); | |
| try { | |
| savedPath = tryWrite(HUMAN_GAMES_PATH); | |
| console.log('Fell back to ' + savedPath); | |
| } catch (e2) { | |
| console.error('Failed to save AI game to fallback:', e2.message); | |
| return false; | |
| } | |
| } else { | |
| return false; | |
| } | |
| } | |
| console.log(`${source} game saved to ${savedPath} (${game.moves.length} moves)`); | |
| gamesSinceLastTrain++; | |
| pushGames(HUMAN_GAMES_PATH).catch(() => {}); | |
| if (gamesSinceLastTrain >= AUTO_TRAIN_THRESHOLD && !trainingStatus.running) { | |
| setImmediate(runTraining); | |
| } | |
| return true; | |
| } | |
| app.post('/api/train', (req, res) => { | |
| if (trainingStatus.running) { | |
| return res.json({ success: false, error: 'Training already in progress' }); | |
| } | |
| if (!fs.existsSync(HUMAN_GAMES_PATH)) { | |
| return res.json({ success: false, error: 'No game data found' }); | |
| } | |
| const games = loadGames(HUMAN_GAMES_PATH); | |
| if (games.length === 0) { | |
| return res.json({ success: false, error: 'No games recorded yet' }); | |
| } | |
| res.json({ success: true, message: `Training started on ${games.length} games` }); | |
| setImmediate(runTraining); | |
| }); | |
| app.get('/api/train-status', (req, res) => { | |
| res.json({ ...trainingStatus, gamesSinceLastTrain, autoTrainThreshold: AUTO_TRAIN_THRESHOLD }); | |
| }); | |
| app.get('/api/game-count', (req, res) => { | |
| try { | |
| if (!fs.existsSync(HUMAN_GAMES_PATH)) return res.json({ count: 0 }); | |
| const data = fs.readFileSync(HUMAN_GAMES_PATH, 'utf-8').trim(); | |
| const count = data ? data.split('\n').filter(Boolean).length : 0; | |
| res.json({ count }); | |
| } catch { res.json({ count: 0 }); } | |
| }); | |
| app.get('/api/self-play-status', (req, res) => { | |
| res.json({ | |
| enabled: IDLE_SELF_PLAY_ENABLED, | |
| running: selfPlayRunning, | |
| gamesGenerated: selfPlayGamesGenerated, | |
| connectedSockets, | |
| idleForMs: Date.now() - lastUserActivityAt, | |
| idleDelayMs: IDLE_SELF_PLAY_DELAY_MS, | |
| batchSize: IDLE_SELF_PLAY_BATCH_SIZE, | |
| randomRate: SELF_PLAY_RANDOM_RATE, | |
| }); | |
| }); | |
| // โโโ Game State Factory โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| const PLAYERS = [ | |
| { symbol: 'X', color: '#FF4757', label: 'X' }, | |
| { symbol: 'Circle', color: '#2ED7F8', label: 'Circle' }, | |
| { symbol: 'Triangle', color: '#2ECC71', label: 'Triangle' }, | |
| { symbol: 'Square', color: '#FFD32A', label: 'Square' }, | |
| ]; | |
| function makeBoard() { | |
| // macroBoard[r][c] = null | player symbol (winner of that micro board) | |
| // microBoard[mr][mc][r][c] = null | player symbol | |
| const macroBoard = Array.from({ length: 3 }, () => Array(3).fill(null)); | |
| const microBoard = Array.from({ length: 3 }, () => | |
| Array.from({ length: 3 }, () => | |
| Array.from({ length: 3 }, () => Array(3).fill(null)) | |
| ) | |
| ); | |
| return { macroBoard, microBoard }; | |
| } | |
| function checkLine(a, b, c) { | |
| return a && a === b && b === c ? a : null; | |
| } | |
| function checkWinner3x3(grid) { | |
| for (let i = 0; i < 3; i++) { | |
| const rw = checkLine(grid[i][0], grid[i][1], grid[i][2]); | |
| if (rw) return rw; | |
| const cw = checkLine(grid[0][i], grid[1][i], grid[2][i]); | |
| if (cw) return cw; | |
| } | |
| const d1 = checkLine(grid[0][0], grid[1][1], grid[2][2]); | |
| if (d1) return d1; | |
| const d2 = checkLine(grid[0][2], grid[1][1], grid[2][0]); | |
| if (d2) return d2; | |
| return null; | |
| } | |
| function isFull3x3(grid) { | |
| return grid.every(row => row.every(cell => cell !== null)); | |
| } | |
| // Check for global line wins across the full 9x9 grid. | |
| // Any 3 consecutive same-symbol cells in a straight line (horizontal, vertical, | |
| // or diagonal) across ANY board boundary counts. Each matching trio awards the | |
| // macro square that each of the three cells belongs to (if not already won). | |
| function checkGlobalLines(microBoard, macroBoard) { | |
| // Build flat 9x9 โ null out cells in owned boards so they don't contribute to new lines | |
| const flat = Array.from({ length: 9 }, () => Array(9).fill(null)); | |
| for (let mr = 0; mr < 3; mr++) | |
| for (let mc = 0; mc < 3; mc++) | |
| for (let r = 0; r < 3; r++) | |
| for (let c = 0; c < 3; c++) | |
| flat[mr * 3 + r][mc * 3 + c] = microBoard[mr][mc][r][c]; | |
| const winners = new Set(); | |
| // Helper: test a trio of (row,col) positions; award their macro squares if matched | |
| function testTrio(r0, c0, r1, c1, r2, c2) { | |
| const w = checkLine(flat[r0][c0], flat[r1][c1], flat[r2][c2]); | |
| if (!w) return; | |
| const cells = [[r0,c0],[r1,c1],[r2,c2]]; | |
| // Award unowned macro squares (lines through owned boards still count) | |
| cells.forEach(([r,c]) => { | |
| const mr = Math.floor(r/3), mc = Math.floor(c/3); | |
| if (!macroBoard[mr][mc]) winners.add(`${mr},${mc},${w}`); | |
| }); | |
| } | |
| // All 9 full rows โ every consecutive triple along each row | |
| for (let gr = 0; gr < 9; gr++) | |
| for (let sc = 0; sc <= 6; sc++) | |
| testTrio(gr, sc, gr, sc+1, gr, sc+2); | |
| // All 9 full columns โ every consecutive triple along each column | |
| for (let gc = 0; gc < 9; gc++) | |
| for (let sr = 0; sr <= 6; sr++) | |
| testTrio(sr, gc, sr+1, gc, sr+2, gc); | |
| // All diagonals (top-left โ bottom-right), consecutive triples | |
| for (let sr = 0; sr <= 6; sr++) | |
| for (let sc = 0; sc <= 6; sc++) | |
| testTrio(sr, sc, sr+1, sc+1, sr+2, sc+2); | |
| // All anti-diagonals (top-right โ bottom-left), consecutive triples | |
| for (let sr = 0; sr <= 6; sr++) | |
| for (let sc = 2; sc < 9; sc++) | |
| testTrio(sr, sc, sr+1, sc-1, sr+2, sc-2); | |
| return winners; // set of "mr,mc,symbol" | |
| } | |
| function evaluateBoard(state) { | |
| const { microBoard, macroBoard } = state; | |
| // First, check standard micro-board wins | |
| for (let mr = 0; mr < 3; mr++) { | |
| for (let mc = 0; mc < 3; mc++) { | |
| if (!macroBoard[mr][mc]) { | |
| const w = checkWinner3x3(microBoard[mr][mc]); | |
| if (w) macroBoard[mr][mc] = w; | |
| } | |
| } | |
| } | |
| // Then apply global cross-board lines | |
| const globalWins = checkGlobalLines(microBoard, macroBoard); | |
| for (const entry of globalWins) { | |
| const [mr, mc, sym] = entry.split(','); | |
| if (!macroBoard[+mr][+mc]) { | |
| macroBoard[+mr][+mc] = sym; | |
| } | |
| } | |
| // Check macro winner | |
| const macroWinner = checkWinner3x3(macroBoard); | |
| if (macroWinner) return { winner: macroWinner, type: 'macro' }; | |
| // All macro squares owned (each micro board won by someone) โ majority | |
| const allOwned = macroBoard.flat().every(cell => cell !== null); | |
| if (allOwned || (() => { | |
| for (let mr = 0; mr < 3; mr++) | |
| for (let mc = 0; mc < 3; mc++) | |
| if (!isFull3x3(microBoard[mr][mc])) return false; | |
| return true; | |
| })()) { | |
| const counts = {}; | |
| for (let mr = 0; mr < 3; mr++) | |
| for (let mc = 0; mc < 3; mc++) { | |
| const owner = macroBoard[mr][mc]; | |
| if (owner) counts[owner] = (counts[owner] || 0) + 1; | |
| } | |
| if (Object.keys(counts).length === 0) return { winner: null, type: 'draw' }; | |
| const topScore = Math.max(...Object.values(counts)); | |
| const topPlayers = Object.keys(counts).filter(k => counts[k] === topScore); | |
| if (topPlayers.length === 1) return { winner: topPlayers[0], type: 'majority' }; | |
| return { winner: null, type: 'draw' }; | |
| } | |
| return null; | |
| } | |
| // โโโ Room Management โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| const rooms = {}; | |
| function generateCode() { | |
| return crypto.randomBytes(3).toString('hex').toUpperCase(); | |
| } | |
| function getRoomState(room) { | |
| return { | |
| players: room.players.map(p => ({ id: p.id, name: p.name, symbol: p.symbol, color: p.color })), | |
| board: room.board, | |
| currentTurn: room.currentTurn, | |
| started: room.started, | |
| gameOver: room.gameOver, | |
| winner: room.winner, | |
| winType: room.winType, | |
| hostId: room.hostId, | |
| }; | |
| } | |
| function hasActiveRooms() { | |
| return Object.values(rooms).some(room => room.players.length > 0); | |
| } | |
| function isIdleForSelfPlay() { | |
| return IDLE_SELF_PLAY_ENABLED && | |
| aiEngine && | |
| !trainingStatus.running && | |
| connectedSockets === 0 && | |
| !hasActiveRooms() && | |
| Date.now() - lastUserActivityAt >= IDLE_SELF_PLAY_DELAY_MS; | |
| } | |
| function chooseSelfPlayMove(board, symbol) { | |
| const valid = []; | |
| for (let mr = 0; mr < 3; mr++) | |
| for (let mc = 0; mc < 3; mc++) | |
| for (let r = 0; r < 3; r++) | |
| for (let c = 0; c < 3; c++) | |
| if (board.microBoard[mr][mc][r][c] === null) | |
| valid.push({ mr, mc, r, c }); | |
| if (valid.length === 0) return null; | |
| if (Math.random() < SELF_PLAY_RANDOM_RATE) { | |
| return valid[Math.floor(Math.random() * valid.length)]; | |
| } | |
| return aiEngine.getMove(board.macroBoard, board.microBoard, symbol); | |
| } | |
| function generateSelfPlayGame() { | |
| const board = makeBoard(); | |
| const symbols = ['X', 'Circle']; | |
| const moves = []; | |
| let currentTurn = 0; | |
| let result = null; | |
| for (let ply = 0; ply < 81 && !result; ply++) { | |
| const symbol = symbols[currentTurn]; | |
| const move = chooseSelfPlayMove(board, symbol); | |
| if (!move) break; | |
| if (board.microBoard[move.mr][move.mc][move.r][move.c] !== null) break; | |
| board.microBoard[move.mr][move.mc][move.r][move.c] = symbol; | |
| moves.push({ ...move, symbol }); | |
| result = evaluateBoard(board); | |
| currentTurn = (currentTurn + 1) % symbols.length; | |
| } | |
| if (moves.length < 2) return null; | |
| return { | |
| moves, | |
| humanSymbol: null, | |
| policySymbols: symbols, | |
| winner: result ? result.winner : null, | |
| }; | |
| } | |
| async function runIdleSelfPlayBatch() { | |
| if (selfPlayRunning || !isIdleForSelfPlay()) return; | |
| selfPlayRunning = true; | |
| let generated = 0; | |
| try { | |
| for (let i = 0; i < IDLE_SELF_PLAY_BATCH_SIZE; i++) { | |
| if (!isIdleForSelfPlay()) break; | |
| const game = generateSelfPlayGame(); | |
| if (game && saveTrainingGame(game, 'self-play')) { | |
| generated++; | |
| selfPlayGamesGenerated++; | |
| } | |
| await new Promise(resolve => setImmediate(resolve)); | |
| } | |
| if (generated > 0 && !trainingStatus.running) setImmediate(runTraining); | |
| } catch (e) { | |
| console.error('Idle self-play failed:', e.message); | |
| } finally { | |
| selfPlayRunning = false; | |
| } | |
| } | |
| // โโโ Socket.io โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| io.on('connection', (socket) => { | |
| connectedSockets++; | |
| markUserActivity(); | |
| socket.use((packet, next) => { | |
| markUserActivity(); | |
| next(); | |
| }); | |
| // โโ Create Room โโ | |
| socket.on('createRoom', ({ playerName }) => { | |
| const code = generateCode(); | |
| const player = { | |
| id: socket.id, | |
| name: playerName || 'Host', | |
| ...PLAYERS[0], | |
| }; | |
| rooms[code] = { | |
| code, | |
| hostId: socket.id, | |
| players: [player], | |
| board: makeBoard(), | |
| currentTurn: 0, | |
| started: false, | |
| gameOver: false, | |
| winner: null, | |
| winType: null, | |
| }; | |
| socket.join(code); | |
| socket.roomCode = code; | |
| socket.emit('roomCreated', { code, state: getRoomState(rooms[code]) }); | |
| systemMsg(code, `${player.name} created the room.`); | |
| }); | |
| // โโ Join Room โโ | |
| socket.on('joinRoom', ({ code, playerName }) => { | |
| const room = rooms[code.toUpperCase()]; | |
| if (!room) return socket.emit('error', 'Room not found.'); | |
| if (room.started) return socket.emit('error', 'Game already started.'); | |
| if (room.players.length >= 4) return socket.emit('error', 'Room is full.'); | |
| const idx = room.players.length; | |
| const player = { | |
| id: socket.id, | |
| name: playerName || `Player ${idx + 1}`, | |
| ...PLAYERS[idx], | |
| }; | |
| room.players.push(player); | |
| socket.join(code.toUpperCase()); | |
| socket.roomCode = code.toUpperCase(); | |
| socket.emit('joinedRoom', { code: code.toUpperCase(), state: getRoomState(room) }); | |
| io.to(code.toUpperCase()).emit('roomUpdate', getRoomState(room)); | |
| systemMsg(code.toUpperCase(), `${player.name} joined as ${player.symbol}.`); | |
| }); | |
| // โโ Start Game โโ | |
| socket.on('startGame', () => { | |
| const room = rooms[socket.roomCode]; | |
| if (!room || socket.id !== room.hostId) return; | |
| if (room.players.length < 2) return socket.emit('error', 'Need at least 2 players.'); | |
| room.started = true; | |
| room.board = makeBoard(); | |
| room.currentTurn = 0; | |
| io.to(room.code).emit('gameStarted', getRoomState(room)); | |
| systemMsg(room.code, `Game started! ${room.players[0].name}'s turn.`); | |
| }); | |
| // โโ Make Move โโ | |
| socket.on('makeMove', ({ mr, mc, r, c }) => { | |
| const room = rooms[socket.roomCode]; | |
| if (!room || !room.started || room.gameOver) return; | |
| const playerIdx = room.players.findIndex(p => p.id === socket.id); | |
| if (playerIdx !== room.currentTurn) return socket.emit('error', 'Not your turn.'); | |
| const cell = room.board.microBoard[mr][mc][r][c]; | |
| if (cell !== null) return socket.emit('error', 'Cell occupied.'); | |
| const player = room.players[playerIdx]; | |
| room.board.microBoard[mr][mc][r][c] = player.symbol; | |
| // Track micro-board state before evaluation | |
| const prevMacro = room.board.macroBoard.map(row => [...row]); | |
| const result = evaluateBoard(room.board); | |
| // Notify micro-board wins | |
| for (let imr = 0; imr < 3; imr++) { | |
| for (let imc = 0; imc < 3; imc++) { | |
| if (!prevMacro[imr][imc] && room.board.macroBoard[imr][imc]) { | |
| const winner = room.board.macroBoard[imr][imc]; | |
| const wp = room.players.find(p => p.symbol === winner); | |
| systemMsg(room.code, `${wp?.name || winner} won a micro-board!`); | |
| } | |
| } | |
| } | |
| if (result) { | |
| room.gameOver = true; | |
| room.winner = result.winner; | |
| room.winType = result.type; | |
| const wp = room.players.find(p => p.symbol === result.winner); | |
| if (result.type === 'draw') { | |
| systemMsg(room.code, "It's a draw!"); | |
| } else if (result.type === 'majority') { | |
| systemMsg(room.code, `${wp?.name || result.winner} wins by majority!`); | |
| } else { | |
| systemMsg(room.code, `${wp?.name || result.winner} wins!`); | |
| } | |
| io.to(room.code).emit('boardUpdate', getRoomState(room)); | |
| io.to(room.code).emit('gameOver', { winner: result.winner, winType: result.type, state: getRoomState(room) }); | |
| } else { | |
| // Advance turn (skip players who have disconnected... simplified: cycle) | |
| room.currentTurn = (room.currentTurn + 1) % room.players.length; | |
| const nextPlayer = room.players[room.currentTurn]; | |
| systemMsg(room.code, `${nextPlayer.name}'s turn.`); | |
| io.to(room.code).emit('boardUpdate', getRoomState(room)); | |
| } | |
| }); | |
| // โโ Local Move (single device) โโ | |
| socket.on('localMove', ({ mr, mc, r, c, players, board, currentTurn }) => { | |
| // Reconstruct and validate | |
| if (board.microBoard[mr][mc][r][c] !== null) { | |
| return socket.emit('localError', 'Cell occupied.'); | |
| } | |
| const player = players[currentTurn]; | |
| board.microBoard[mr][mc][r][c] = player.symbol; | |
| const prevMacro = board.macroBoard.map(row => [...row]); | |
| const result = evaluateBoard(board); | |
| const microWins = []; | |
| for (let imr = 0; imr < 3; imr++) { | |
| for (let imc = 0; imc < 3; imc++) { | |
| if (!prevMacro[imr][imc] && board.macroBoard[imr][imc]) { | |
| microWins.push({ mr: imr, mc: imc, winner: board.macroBoard[imr][imc] }); | |
| } | |
| } | |
| } | |
| const nextTurn = result ? currentTurn : (currentTurn + 1) % players.length; | |
| socket.emit('localUpdate', { board, currentTurn: nextTurn, result, microWins }); | |
| }); | |
| // โโ AI Move โโ | |
| socket.on('requestAIMove', ({ board, aiSymbol }) => { | |
| if (!aiEngine) return socket.emit('error', 'AI engine not available'); | |
| const move = aiEngine.getMove(board.macroBoard, board.microBoard, aiSymbol); | |
| socket.emit('aiMove', move); | |
| }); | |
| // โโ Save AI Game (for training) โโ | |
| socket.on('saveAIGame', ({ moves, humanSymbol, winner }) => { | |
| if (!Array.isArray(moves) || moves.length === 0) return; | |
| saveTrainingGame({ moves, humanSymbol, winner }, 'human'); | |
| }); | |
| // โโ Chat โโ | |
| socket.on('chatMsg', ({ text }) => { | |
| const room = rooms[socket.roomCode]; | |
| if (!room) return; | |
| const player = room.players.find(p => p.id === socket.id); | |
| if (!player) return; | |
| if (!text || text.trim().length === 0) return; | |
| io.to(room.code).emit('chatMsg', { | |
| type: 'player', | |
| name: player.name, | |
| symbol: player.symbol, | |
| color: player.color, | |
| text: text.trim().substring(0, 200), | |
| ts: Date.now(), | |
| }); | |
| }); | |
| // โโ Restart โโ | |
| socket.on('restartGame', () => { | |
| const room = rooms[socket.roomCode]; | |
| if (!room || socket.id !== room.hostId) return; | |
| room.board = makeBoard(); | |
| room.currentTurn = 0; | |
| room.gameOver = false; | |
| room.winner = null; | |
| room.winType = null; | |
| io.to(room.code).emit('gameStarted', getRoomState(room)); | |
| systemMsg(room.code, 'Game restarted!'); | |
| }); | |
| // โโ Disconnect โโ | |
| socket.on('disconnect', () => { | |
| connectedSockets = Math.max(0, connectedSockets - 1); | |
| markUserActivity(); | |
| const room = rooms[socket.roomCode]; | |
| if (!room) return; | |
| const idx = room.players.findIndex(p => p.id === socket.id); | |
| if (idx === -1) return; | |
| const player = room.players[idx]; | |
| systemMsg(room.code, `${player.name} disconnected.`); | |
| room.players.splice(idx, 1); | |
| if (room.players.length === 0) { | |
| delete rooms[socket.roomCode]; | |
| return; | |
| } | |
| // Transfer host if needed | |
| if (socket.id === room.hostId) { | |
| room.hostId = room.players[0].id; | |
| systemMsg(room.code, `${room.players[0].name} is now the host.`); | |
| } | |
| // Adjust currentTurn | |
| if (room.currentTurn >= room.players.length) { | |
| room.currentTurn = 0; | |
| } | |
| io.to(room.code).emit('roomUpdate', getRoomState(room)); | |
| }); | |
| function systemMsg(code, text) { | |
| io.to(code).emit('chatMsg', { | |
| type: 'system', | |
| text, | |
| ts: Date.now(), | |
| }); | |
| } | |
| }); | |
| const PORT = process.env.PORT || 7860; | |
| (async () => { | |
| try { | |
| if (process.env.HF_TOKEN) await pullGames(HUMAN_GAMES_PATH); | |
| await loadAIEngine(); | |
| } catch (e) { console.error('Startup init error:', e.message); } | |
| server.listen(PORT, '0.0.0.0', () => { | |
| console.log(`Super TTT running on port ${PORT}`); | |
| if (IDLE_SELF_PLAY_ENABLED) { | |
| setInterval(runIdleSelfPlayBatch, IDLE_SELF_PLAY_INTERVAL_MS); | |
| console.log(`Idle self-play enabled after ${IDLE_SELF_PLAY_DELAY_MS}ms of inactivity`); | |
| } | |
| }); | |
| })(); | |