516 lines
No EOL
15 KiB
JavaScript
516 lines
No EOL
15 KiB
JavaScript
import express from 'express';
|
|
import ffish from 'ffish';
|
|
import ChessEngine from './engine.js';
|
|
import { getEnginePath, verifyEnginePath } from './engine-path.js';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import os from 'os';
|
|
|
|
function getLogPath() {
|
|
if (process.platform === 'linux') {
|
|
// Use ~/.local/share/ChessBuilder/logs
|
|
return path.join(os.homedir(), '.local', 'share', 'ChessBuilder', 'logs');
|
|
} else if (process.platform === 'win32') {
|
|
// Use %APPDATA%\ChessBuilder\logs
|
|
return path.join(os.homedir(), 'AppData', 'Roaming', 'ChessBuilder', 'logs');
|
|
} else {
|
|
// macOS: ~/Library/Logs/ChessBuilder
|
|
return path.join(os.homedir(), 'Library', 'ChessBuilder', 'logs');
|
|
}
|
|
}
|
|
|
|
// Ensure log directory exists
|
|
const logDir = getLogPath();
|
|
fs.mkdirSync(logDir, { recursive: true });
|
|
|
|
const logFile = path.join(logDir, 'chess-server.log');
|
|
console.log(`Logging to: ${logFile}`);
|
|
|
|
// Create a write stream for logging
|
|
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
|
|
// Redirect console.log and console.error to both console and file
|
|
const originalConsoleLog = console.log;
|
|
const originalConsoleError = console.error;
|
|
|
|
console.log = (...args) => {
|
|
const message = args.map(arg =>
|
|
typeof arg === 'object' ? JSON.stringify(arg) : arg
|
|
).join(' ') + '\n';
|
|
|
|
logStream.write(`[${new Date().toISOString()}] ${message}`);
|
|
originalConsoleLog.apply(console, args);
|
|
};
|
|
|
|
console.error = (...args) => {
|
|
const message = args.map(arg =>
|
|
typeof arg === 'object' ? JSON.stringify(arg) : arg
|
|
).join(' ') + '\n';
|
|
|
|
logStream.write(`[${new Date().toISOString()}] ERROR: ${message}`);
|
|
originalConsoleError.apply(console, args);
|
|
};
|
|
|
|
|
|
try {
|
|
fs.writeFileSync(logFile, `Server started at ${new Date().toISOString()}\n`, { flag: 'a' });
|
|
fs.writeFileSync(logFile, `Process ID: ${process.pid}\n`, { flag: 'a' });
|
|
fs.writeFileSync(logFile, `Working directory: ${process.cwd()}\n`, { flag: 'a' });
|
|
fs.writeFileSync(logFile, `Node version: ${process.version}\n`, { flag: 'a' });
|
|
} catch (error) {
|
|
console.error('Failed to write startup log:', error);
|
|
}
|
|
|
|
const app = express();
|
|
const port = 27531;
|
|
|
|
let board = null;
|
|
let engine = null;
|
|
let isReady = false;
|
|
let lastResponse = null
|
|
const SERVER_WAIT_THRESHOLD = 2 * 60 * 1000;
|
|
const CHECK_INTERVAL = 5000;
|
|
|
|
// Initialize ffish and engine
|
|
ffish.onRuntimeInitialized = async () => {
|
|
try {
|
|
isReady = true;
|
|
console.log('Fairy-Chess library ready asd');
|
|
|
|
// Get and verify engine path
|
|
const enginePath = getEnginePath();
|
|
console.log('ENGINE PATH', enginePath);
|
|
|
|
// Initialize Stockfish
|
|
fs.chmodSync(enginePath, '755');
|
|
engine = new ChessEngine(enginePath);
|
|
await engine.start();
|
|
console.log('Chess engine initialized at:', enginePath, engine.isReady);
|
|
|
|
// Set initial engine options
|
|
if (engine.isReady) {
|
|
engine.sendCommand('setoption name Threads value 4');
|
|
engine.sendCommand('setoption name Hash value 128');
|
|
engine.sendCommand('setoption name MultiPV value 1');
|
|
engine.sendCommand('setoption name UCI_LimitStrength value true');
|
|
engine.sendCommand('uci');
|
|
}
|
|
} catch (error) {
|
|
console.log('Initialization error:', error);
|
|
}
|
|
};
|
|
|
|
app.use(express.json());
|
|
|
|
// Health check endpoint
|
|
app.get('/health', (req, res) => {
|
|
console.log("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
|
|
console.log("@@@@@@@@@@@@@@@@@HEALTH CHECK@@@@@@@@@@@@@@@@@@@@@@")
|
|
console.log("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
|
|
console.log(JSON.stringify(ffish.variants()))
|
|
lastResponse = new Date().getTime()
|
|
res.json({
|
|
status: 'ok',
|
|
engineReady: isReady && engine && engine.isReady,
|
|
enginePath: getEnginePath(),
|
|
platform: process.platform,
|
|
variants: ffish.variants()
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// Set Options
|
|
app.post('/setoptions', (req, res) => {
|
|
lastResponse = new Date().getTime()
|
|
const { name, value, options } = req.body;
|
|
if(name && value !== null){
|
|
engine.sendCommand(`setoption name ${name} value ${value}`);
|
|
res.json({
|
|
status: 'ok',
|
|
success: name,
|
|
errs: null,
|
|
});
|
|
}else if(options && Array.isArray(options)){
|
|
let success = [];
|
|
let errs = [];
|
|
for(let x = 0; x < options.length; x++){
|
|
let option = options[x];
|
|
const {name, value} = option;
|
|
if(name && value !== null ){
|
|
engine.sendCommand(`setoption name ${name} value ${value}`);
|
|
success.push(name)
|
|
}else {
|
|
errs.push(name)
|
|
}
|
|
}
|
|
res.json({
|
|
status: 'ok',
|
|
success: success,
|
|
errs: errs,
|
|
});
|
|
}else {
|
|
return res.status(400).json({ error: 'No Params Provided' });
|
|
}
|
|
|
|
|
|
});
|
|
|
|
// Validate FEN endpoint
|
|
app.post('/validate', (req, res) => {
|
|
lastResponse = new Date().getTime()
|
|
const { fen, variant = 'chess' } = req.body;
|
|
|
|
if (!fen) {
|
|
return res.status(400).json({ error: 'FEN string required' });
|
|
}
|
|
|
|
try {
|
|
// Create temporary board to validate FEN
|
|
const tempBoard = new ffish.Board(variant);
|
|
const isValid = tempBoard.setFen(fen);
|
|
tempBoard.delete();
|
|
|
|
res.json({
|
|
status: 'ok',
|
|
isValid,
|
|
startingFen: ffish.startingFen(variant)
|
|
});
|
|
} catch (error) {
|
|
res.status(400).json({ error: 'Invalid FEN or variant' });
|
|
}
|
|
});
|
|
|
|
// New game endpoint
|
|
app.post('/new', async (req, res) => {
|
|
lastResponse = new Date().getTime()
|
|
const { variant = 'chess' } = req.body;
|
|
|
|
try {
|
|
if (board) {
|
|
board.delete();
|
|
}
|
|
|
|
board = new ffish.Board(variant);
|
|
|
|
const engineAnalysis = await engine.createNewGame();
|
|
res.json({
|
|
status: 'ok',
|
|
fen: board.fen(),
|
|
legalMoves: board.legalMoves().split(' '),
|
|
legalMovesSan: board.legalMovesSan().split(' '),
|
|
isCheck: board.isCheck(),
|
|
turn: board.turn(),
|
|
moveStack: board.moveStack()
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Set position endpoint
|
|
app.post('/position', async(req, res) => {
|
|
lastResponse = new Date().getTime()
|
|
const { fen, variant = 'chess', start } = req.body;
|
|
|
|
|
|
try {
|
|
//we have a lot of funky rules lets not validate
|
|
// const isValid = ffish.validateFen(fen) == 1
|
|
|
|
// if (!isValid) {
|
|
// return res.status(400).json({ error: 'Invalid FEN string',
|
|
// fen,
|
|
// variant,
|
|
// boardExists: !!board, reqBody: JSON.stringify(req.body) });
|
|
// }else {
|
|
// // if (board) {
|
|
// // board.delete();
|
|
// // }
|
|
// board.setFen(fen);
|
|
// }
|
|
if(start){
|
|
await engine.startPos()
|
|
engine.sendCommand('d');
|
|
}else if(fen){
|
|
board.setFen(fen);
|
|
await engine.setBoardPosition(fen)
|
|
}
|
|
|
|
res.json({
|
|
status: 'ok',
|
|
msg: "Set Position",
|
|
legalMoves: board.legalMoves().split(' '),
|
|
legalMovesSan: board.legalMovesSan().split(' '),
|
|
isCheck: board.isCheck(),
|
|
isGameOver: board.isGameOver(),
|
|
result: board.result(),
|
|
turn: board.turn(),
|
|
moveStack: board.moveStack()
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message, poserr: true });
|
|
}
|
|
});
|
|
|
|
// Make move endpoint
|
|
app.post('/move', (req, res) => {
|
|
lastResponse = new Date().getTime()
|
|
const { move, notation = 'uci', variant = 'chess' } = req.body;
|
|
|
|
if (!board) {
|
|
return res.status(400).json({ error: 'No active board' });
|
|
}
|
|
|
|
if (!move) {
|
|
return res.status(400).json({ error: 'Move required' });
|
|
}
|
|
|
|
if (board) {
|
|
board.delete();
|
|
}
|
|
|
|
board = new ffish.Board(variant);
|
|
try {
|
|
if (notation === 'san') {
|
|
board.pushSan(move);
|
|
} else {
|
|
board.push(move);
|
|
}
|
|
|
|
|
|
const response = {
|
|
status: 'ok',
|
|
fen: board.fen(),
|
|
legalMoves: board.legalMoves().split(' '),
|
|
legalMovesSan: board.legalMovesSan().split(' '),
|
|
isCheck: board.isCheck(),
|
|
isGameOver: board.isGameOver(),
|
|
result: board.result(),
|
|
turn: board.turn(),
|
|
moveStack: board.moveStack(),
|
|
fullmoveNumber: board.fullmoveNumber(),
|
|
halfmoveClock: board.halfmoveClock()
|
|
};
|
|
|
|
if (board.isGameOver()) {
|
|
response.hasInsufficientMaterial = {
|
|
white: board.hasInsufficientMaterial(true), // true for white
|
|
black: board.hasInsufficientMaterial(false) // false for black
|
|
};
|
|
response.gameResult = board.result();
|
|
}
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// State endpoint
|
|
app.get('/state', (req, res) => {
|
|
lastResponse = new Date().getTime()
|
|
if (!board) {
|
|
return res.status(400).json({ error: 'No active board' });
|
|
}
|
|
|
|
try {
|
|
res.json({
|
|
status: 'ok',
|
|
fen: board.fen(),
|
|
legalMoves: board.legalMoves().split(' '),
|
|
legalMovesSan: board.legalMovesSan().split(' '),
|
|
isCheck: board.isCheck(),
|
|
isGameOver: board.isGameOver(),
|
|
result: board.result(),
|
|
turn: board.turn(),
|
|
moveStack: board.moveStack(),
|
|
fullmoveNumber: board.fullmoveNumber(),
|
|
halfmoveClock: board.halfmoveClock(),
|
|
variant: board.variant(),
|
|
is960: board.is960()
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/*
|
|
createNewGame
|
|
*/
|
|
app.post('/newgame', async (req, res) => {
|
|
lastResponse = new Date().getTime()
|
|
|
|
|
|
try {
|
|
|
|
const response = {
|
|
status: 'ok',
|
|
};
|
|
|
|
// If engine is available, get engine analysis
|
|
if (engine && engine.isReady) {
|
|
const engineAnalysis = await engine.createNewGame();
|
|
}
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Analysis endpoint
|
|
app.post('/analyze', async (req, res) => {
|
|
lastResponse = new Date().getTime()
|
|
if (!board) {
|
|
return res.status(400).json({ error: 'No active board' });
|
|
}
|
|
|
|
try {
|
|
const { depth = 15, movetime = 1000 } = req.body;
|
|
|
|
// Get basic position analysis
|
|
const positionAnalysis = {
|
|
status: 'ok',
|
|
fen: board.fen(),
|
|
isCheck: board.isCheck(),
|
|
isGameOver: board.isGameOver(),
|
|
result: board.result(),
|
|
hasInsufficientMaterial: {
|
|
white: board.hasInsufficientMaterial(true), // true for white
|
|
black: board.hasInsufficientMaterial(false) // false for black
|
|
},
|
|
legalMoves: board.legalMoves().split(' '),
|
|
legalMovesSan: board.legalMovesSan().split(' '),
|
|
moveStack: board.moveStack()
|
|
};
|
|
|
|
// If engine is available, get engine analysis
|
|
if (engine && engine.isReady) {
|
|
const engineAnalysis = await engine.getAnalysis(board.fen(), {
|
|
depth,
|
|
movetime
|
|
});
|
|
positionAnalysis.engineAnalysis = engineAnalysis;
|
|
}
|
|
|
|
res.json(positionAnalysis);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Engine move endpoint
|
|
app.post('/enginemove', async (req, res) => {
|
|
lastResponse = new Date().getTime()
|
|
if (!board) {
|
|
return res.status(400).json({ error: 'No active board' });
|
|
}
|
|
|
|
if (!engine || !engine.isReady) {
|
|
return res.status(503).json({
|
|
error: 'Engine not available',
|
|
details: 'Chess engine is not initialized or not ready'
|
|
});
|
|
}
|
|
|
|
const {
|
|
depth = 15,
|
|
movetime = 1000,
|
|
nodes = null,
|
|
fen
|
|
} = req.body;
|
|
|
|
try {
|
|
// const fen = board.fen();
|
|
const analysis = await engine.getAnalysis(fen, {
|
|
depth,
|
|
movetime,
|
|
nodes
|
|
});
|
|
|
|
if (analysis.bestMove) {
|
|
board.push(analysis.bestMove);
|
|
}
|
|
|
|
res.json({
|
|
status: 'ok',
|
|
move: analysis.bestMove,
|
|
analysis: analysis,
|
|
fen: board.fen(),
|
|
legalMoves: board.legalMoves().split(' '),
|
|
isCheck: board.isCheck(),
|
|
isGameOver: board.isGameOver(),
|
|
turn: board.turn()
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
error: 'Engine analysis failed',
|
|
details: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
app.post('/shutdown', (req, res) => {
|
|
lastResponse = new Date().getTime()
|
|
res.json({ status: 'shutting_down' });
|
|
|
|
// Give time for response to be sent
|
|
setTimeout(() => {
|
|
closeServer()
|
|
}, 100);
|
|
});
|
|
|
|
// Cleanup handling
|
|
process.on('SIGTERM', () => {
|
|
console.log('Shutting down...');
|
|
closeServer()
|
|
logStream.end();
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
console.log('Received SIGINT, shutting down...');
|
|
closeServer()
|
|
logStream.end();
|
|
});
|
|
|
|
app.listen(port, () => {
|
|
lastResponse = new Date().getTime()
|
|
startIdleMonitor();
|
|
console.log(`Fairy-Chess server running on port ${port}`);
|
|
});
|
|
|
|
function startIdleMonitor() {
|
|
const checkIdle = () => {
|
|
const currentTime = new Date().getTime();
|
|
const timeSinceLastResponse = currentTime - lastResponse;
|
|
|
|
if (timeSinceLastResponse > SERVER_WAIT_THRESHOLD) {
|
|
console.log(`Server idle for ${timeSinceLastResponse/1000} seconds. Shutting down...`);
|
|
console.log(currentTime, lastResponse, timeSinceLastResponse, SERVER_WAIT_THRESHOLD)
|
|
closeServer();
|
|
}
|
|
};
|
|
|
|
// Start the monitoring interval
|
|
const monitorInterval = setInterval(checkIdle, CHECK_INTERVAL);
|
|
|
|
// Clean up interval on server close
|
|
process.on('SIGTERM', () => {
|
|
clearInterval(monitorInterval);
|
|
closeServer();
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
clearInterval(monitorInterval);
|
|
closeServer();
|
|
});
|
|
}
|
|
|
|
function closeServer(){
|
|
if (board) board.delete();
|
|
if (engine) engine.quit();
|
|
process.exit(0);
|
|
} |