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`); } }); })();