realtime-state / index.js
nirkyy's picture
Update index.js
1f7dedd verified
const express = require('express');
const { spawn } = require('child_process');
const { Chess } = require('chess.js');
const app = express();
const PORT = process.env.PORT || 7860;
const STOCKFISH_PATH = '/usr/games/stockfish';
let mesin;
let isMesinSibuk = false;
const antrianRequest = [];
let resolverRequestSekarang = null;
let analisisSekarang = { score: null, bestMove: null };
app.use(express.json());
function klasifikasiLangkah(rugiCentipawn) {
if (rugiCentipawn <= 15) return 'Terbaik';
if (rugiCentipawn <= 50) return 'Luar Biasa';
if (rugiCentipawn <= 100) return 'Bagus';
if (rugiCentipawn <= 200) return 'Kurang Akurat';
if (rugiCentipawn <= 350) return 'Kesalahan';
return 'Blunder';
}
function siapkanMesin() {
return new Promise((resolve, reject) => {
try {
mesin = spawn(STOCKFISH_PATH);
} catch (error) {
return reject(new Error(`Gagal menjalankan Stockfish. Pastikan file ada di '${STOCKFISH_PATH}' dan bisa dieksekusi. Detail: ${error.message}`));
}
const waktuTungguStartup = setTimeout(() => {
reject(new Error('Waktu startup Stockfish habis. Mesin gagal mengirim "readyok".'));
}, 10000);
mesin.on('error', (err) => {
clearTimeout(waktuTungguStartup);
reject(new Error(`Gagal memulai proses Stockfish: ${err.message}`));
});
mesin.on('close', (code) => {
console.error(`Proses Stockfish berhenti dengan kode ${code}. Aplikasi akan ditutup.`);
process.exit(1);
});
mesin.stderr.on('data', (data) => {
console.error(`Pesan error dari Stockfish: ${data.toString()}`);
});
mesin.stdout.on('data', (data) => {
const output = data.toString();
if (output.includes('readyok')) {
console.log('Mesin Stockfish sudah siap.');
clearTimeout(waktuTungguStartup);
mesin.stdout.removeAllListeners('data');
mesin.stdout.on('data', (data) => {
const baris = data.toString().split('\n');
for (const teks of baris) {
if (teks.startsWith('info') && teks.includes('score')) {
const bagian = teks.split(' ');
const indeksSkor = bagian.indexOf('score');
if (indeksSkor !== -1) {
const tipeSkor = bagian[indeksSkor + 1];
const nilaiSkor = parseInt(bagian[indeksSkor + 2], 10);
if (tipeSkor === 'cp') {
analisisSekarang.score = nilaiSkor;
} else if (tipeSkor === 'mate') {
analisisSekarang.score = (nilaiSkor > 0 ? 20000 - nilaiSkor : -20000 - nilaiSkor);
}
}
}
if (teks.startsWith('bestmove')) {
const bagian = teks.split(' ');
const langkahTerbaik = bagian[1];
analisisSekarang.bestMove = langkahTerbaik;
if (resolverRequestSekarang) {
if (!langkahTerbaik || langkahTerbaik === '(none)') {
resolverRequestSekarang.reject(new Error('Tidak ditemukan langkah yang valid'));
} else {
const dari = langkahTerbaik.slice(0, 2);
const ke = langkahTerbaik.slice(2, 4);
const promosi = langkahTerbaik.length === 5 ? langkahTerbaik[4] : null;
resolverRequestSekarang.resolve({
bestMove: langkahTerbaik, dari, ke, promosi, score: analisisSekarang.score
});
}
}
isMesinSibuk = false;
resolverRequestSekarang = null;
prosesAntrian();
return;
}
}
});
resolve();
}
});
mesin.stdin.write('uci\n');
mesin.stdin.write('isready\n');
});
}
function prosesAntrian() {
if (isMesinSibuk || antrianRequest.length === 0) {
return;
}
isMesinSibuk = true;
const request = antrianRequest.shift();
resolverRequestSekarang = request;
analisisSekarang = { score: null, bestMove: null };
const batasWaktuLangkah = setTimeout(() => {
if (resolverRequestSekarang === request) {
request.reject(new Error('Waktu kalkulasi langkah oleh mesin habis'));
isMesinSibuk = false;
resolverRequestSekarang = null;
prosesAntrian();
}
}, 15000);
request.resolve = (value) => { clearTimeout(batasWaktuLangkah); request.originalResolve(value); };
request.reject = (reason) => { clearTimeout(batasWaktuLangkah); request.originalReject(reason); };
mesin.stdin.write(`position fen ${request.fen}\n`);
// Waktu mikir mesin per langkah (dalam milidetik)
mesin.stdin.write('go movetime 1000\n');
}
function dapatkanAnalisisDariMesin(fen) {
return new Promise((resolve, reject) => {
antrianRequest.push({ fen, originalResolve: resolve, originalReject: reject });
prosesAntrian();
});
}
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { return res.sendStatus(204); }
next();
});
app.get('/', (req, res) => {
const baseUrl = `${req.protocol}://${req.get('host')}`;
const doc = `
DOKUMENTASI API CATUR STOCKFISH
===============================
API ini menyediakan akses ke mesin catur Stockfish untuk mendapatkan langkah terbaik dan menganalisis permainan lengkap dengan klasifikasi langkah.
---------------------------------
ENDPOINT: GET /chessbot
---------------------------------
Memberikan analisis lengkap untuk satu posisi FEN, termasuk langkah terbaik dan evaluasi skor dari mesin.
METODE: GET
URL: ${baseUrl}/chessbot
PARAMETER QUERY:
- fen (string, wajib): String FEN dari posisi catur.
CONTOH REQUEST:
GET ${baseUrl}/chessbot?fen=rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2
CONTOH RESPON (SUKSES):
{
"bestMove": "g1f3",
"from": "g1",
"to": "f3",
"promotion": null,
"score": 35
}
// Catatan: Skor dalam satuan centipawn dari sudut pandang Putih.
// Nilai positif menguntungkan Putih, nilai negatif menguntungkan Hitam.
CONTOH RESPON (ERROR):
{
"error": "FEN tidak ditemukan"
}
---------------------------------
ENDPOINT: POST /analisis
---------------------------------
Menganalisis permainan lengkap dari string PGN. Endpoint ini memberikan **respons streaming** (langsung dikirim per bagian) menggunakan Server-Sent Events (SSE). Analisis setiap langkah dikirim sebagai potongan data terpisah segera setelah siap.
METODE: POST
URL: ${baseUrl}/analisis
BODY REQUEST:
- Content-Type: application/json
- Body: { "pgn": "<String-PGN-Anda>" }
CONTOH PAYLOAD:
{
"pgn": "[Event \\"Live Chess\\"]\\n1. e4 Nc6 2. Nf3 f6 3. e5"
}
--- FORMAT RESPON STREAMING ---
Server akan mengirim serangkaian event. Klien Anda harus mendengarkan event 'data'.
CONTOH RESPON STREAMING (SUKSES):
// Setiap baris 'data' adalah objek JSON terpisah yang dikirim server satu per satu.
data: {"moveNumber":"1.","playedMove":"e4","bestMove":"e4","fenBeforeMove":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1","classification":"Terbaik","centipawnLoss":0}
data: {"moveNumber":"1...","playedMove":"Nc6","bestMove":"e5","fenBeforeMove":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1","classification":"Kurang Akurat","centipawnLoss":78}
data: {"moveNumber":"2.","playedMove":"Nf3","bestMove":"Nf3","fenBeforeMove":"r1bqkbnr/pppppppp/2n5/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 1 2","classification":"Luar Biasa","centipawnLoss":25}
data: {"moveNumber":"2...","playedMove":"f6","bestMove":"d5","fenBeforeMove":"r1bqkbnr/pppppppp/2n5/8/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 0 2","classification":"Blunder","centipawnLoss":412}
data: {"moveNumber":"3.","playedMove":"e5","bestMove":"d4","fenBeforeMove":"r1bqkbnr/pppp2pp/2n2p2/4P3/8/5N2/PPPP1PPP/RNBQKB1R w KQkq - 0 3","classification":"Kesalahan","centipawnLoss":280}
// Setelah langkah terakhir dianalisis, server akan mengirim event 'end'.
event: end
data: {"message":"Analisis selesai"}
// Catatan: Jika terjadi error di tengah jalan, event 'error' akan dikirim beserta detailnya.
CONTOH RESPON (ERROR VALIDASI AWAL):
{
"error": "Format PGN tidak valid",
"details": "Invalid PGN"
}
`;
res.setHeader('Content-Type', 'text/plain');
res.send(doc.trim());
});
app.get('/chessbot', async (req, res) => {
const fen = req.query.fen;
if (!fen) { return res.status(400).json({ error: 'FEN tidak ditemukan' }); }
try {
const analisis = await dapatkanAnalisisDariMesin(fen);
res.json(analisis);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/analisis', async (req, res) => {
const { pgn } = req.body;
if (!pgn) { return res.status(400).json({ error: 'PGN tidak ditemukan di body request' }); }
const catur = new Chess();
try {
catur.loadPgn(pgn.replace(/\\n/g, '\n'));
} catch (e) {
return res.status(400).json({ error: 'Format PGN tidak valid', details: e.message });
}
const riwayat = catur.history({ verbose: true });
if (riwayat.length === 0) { return res.status(400).json({ error: 'PGN tidak berisi langkah yang valid' }); }
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const game = new Chess();
try {
for (const langkah of riwayat) {
const fenSebelum = game.fen();
const giliran = game.turn();
const analisisSebelum = await dapatkanAnalisisDariMesin(fenSebelum);
let skorSebelum = analisisSebelum.score;
if (giliran === 'b') { skorSebelum = -skorSebelum; }
game.move(langkah.san);
const fenSesudah = game.fen();
const analisisSesudah = await dapatkanAnalisisDariMesin(fenSesudah);
let skorSesudah = analisisSesudah.score;
if (giliran === 'b') { skorSesudah = -skorSesudah; }
const rugiCentipawn = Math.round(skorSebelum - skorSesudah);
const klasifikasi = klasifikasiLangkah(rugiCentipawn);
let sanLangkahTerbaik = analisisSebelum.bestMove;
try {
const gameSementara = new Chess(fenSebelum);
const hasilLangkah = gameSementara.move({ from: analisisSebelum.from, to: analisisSebelum.to, promotion: analisisSebelum.promotion });
if (hasilLangkah) { sanLangkahTerbaik = hasilLangkah.san; }
} catch (e) { /* Abaikan error konversi notasi */ }
const hasil = {
moveNumber: `${game.moveNumber() - (giliran === 'b' ? 0 : 1)}${giliran === 'w' ? '.' : '...'}`,
playedMove: langkah.san,
bestMove: sanLangkahTerbaik,
fenBeforeMove: fenSebelum,
classification: klasifikasi,
centipawnLoss: rugiCentipawn < 0 ? 0 : rugiCentipawn,
};
res.write(`data: ${JSON.stringify(hasil)}\n\n`);
}
res.write('event: end\ndata: {"message":"Analisis selesai"}\n\n');
res.end();
} catch (e) {
const payloadError = { error: 'Analisis gagal', details: e.message };
res.write(`event: error\ndata: ${JSON.stringify(payloadError)}\n\n`);
res.end();
}
});
async function jalankanServer() {
try {
console.log('Lagi nyiapin mesin Stockfish...');
await siapkanMesin();
app.listen(PORT, () => {
console.log(`Server jalan di port ${PORT}`);
console.log(`Dokumentasi API bisa dibuka di http://localhost:${PORT}/`);
});
} catch (error) {
console.error('Gagal memulai aplikasi:', error.message);
process.exit(1);
}
}
jalankanServer();