Spaces:
Sleeping
Sleeping
| 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(); |