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