Supertictactoe / server.js
ausername-12345
Add idle self-play training
8e4c696
Raw
History Blame Contribute Delete
23.5 kB
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`);
}
});
})();