ChessNut/server.js
2025-11-09 17:48:59 +01:00

220 lines
8.4 KiB
JavaScript

const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const { Chess } = require('chess.js');
const { v4: uuidv4 } = require('uuid');
const app = express();
const path = require('path');
const server = http.createServer(app);
const io = new Server(server, { cors: { origin: '*' } });
app.use(express.json());
app.use(express.static('public'));
// Serve provided assets (piece sets, boards, etc.) under /assets
app.use('/assets', express.static(path.join(__dirname, 'assets')));
// In-memory rooms store. For production replace with persistent store.
// room = { id, chess: Chess, players: [{id, socketId, color}], status }
const rooms = new Map();
app.post('/rooms', (req, res) => {
const id = uuidv4().slice(0, 8);
// allow optional board size in request body (default 8)
const size = (req.body && parseInt(req.body.size, 10)) || 8;
// For standard 8x8 chess we use chess.js. For other sizes we keep a placeholder state
const chess = size === 8 ? new Chess() : null;
// hostId will be set when the first player joins
rooms.set(id, { id, chess, players: [], status: 'waiting', hostId: null, size, cards: {}, playedCards: [], removalTimers: new Map() });
res.json({ roomId: id, size });
});
app.get('/rooms/:id', (req, res) => {
const room = rooms.get(req.params.id);
if (!room) return res.status(404).json({ error: 'room not found' });
const fen = room.chess ? room.chess.fen() : null;
res.json({ id: room.id, fen, size: room.size, players: room.players.map(p => ({ id: p.id, color: p.color })), status: room.status, hostId: room.hostId, cards: Object.keys(room.cards || {}) });
});
io.on('connection', (socket) => {
console.log('socket connected', socket.id);
socket.on('room:join', ({ roomId, playerId }, cb) => {
const room = rooms.get(roomId);
if (!room) return cb && cb({ error: 'room not found' });
const assignedId = playerId || uuidv4();
// If this player was pending removal (disconnect during navigation), cancel removal
if(room.removalTimers && room.removalTimers.has(assignedId)){
clearTimeout(room.removalTimers.get(assignedId));
room.removalTimers.delete(assignedId);
}
// If playerId corresponds to an existing player, treat this as a reconnection
let existing = room.players.find(p => p.id === assignedId);
let color;
if(existing){
existing.socketId = socket.id;
color = existing.color;
} else {
color = room.players.length === 0 ? 'white' : 'black';
room.players.push({ id: assignedId, socketId: socket.id, color });
}
socket.join(roomId);
socket.data.roomId = roomId;
socket.data.playerId = assignedId;
// set hostId when first player joins
if (!room.hostId) room.hostId = assignedId;
io.to(roomId).emit('room:update', {
fen: room.chess ? room.chess.fen() : null,
players: room.players.map(p => ({ id: p.id, color: p.color })),
status: room.status,
hostId: room.hostId,
size: room.size,
cards: Object.keys(room.cards || {})
});
cb && cb({ ok: true, color, roomId, playerId: assignedId, hostId: room.hostId });
});
socket.on('game:move', ({ roomId, from, to, promotion }, cb) => {
const room = rooms.get(roomId);
if (!room) return cb && cb({ error: 'room not found' });
const chess = room.chess;
// If we have a chess.js instance (standard 8x8) use it for validation
if (!chess) return cb && cb({ error: 'custom board sizes not yet supported for moves' });
// Validate move
const move = chess.move({ from, to, promotion });
if (!move) return cb && cb({ error: 'invalid move' });
io.to(roomId).emit('move:moved', { move, fen: chess.fen() });
if (chess.isGameOver()) {
room.status = 'finished';
let result = 'draw';
if (chess.isCheckmate()) result = 'checkmate';
else if (chess.isStalemate()) result = 'stalemate';
io.to(roomId).emit('game:over', { result, fen: chess.fen() });
}
cb && cb({ ok: true, move, fen: chess.fen() });
});
// Host can start the game explicitly
socket.on('game:start', ({ roomId }, cb) => {
const room = rooms.get(roomId);
if (!room) return cb && cb({ error: 'room not found' });
// verify sender is the host (first player)
const senderId = socket.data.playerId;
if (!senderId) return cb && cb({ error: 'not joined' });
if (room.players.length < 2) return cb && cb({ error: 'need 2 players to start' });
// verify sender is the host (explicit hostId)
if (!room.hostId || room.hostId !== senderId) return cb && cb({ error: 'only host can start' });
room.status = 'playing';
io.to(roomId).emit('game:started', { roomId, fen: room.chess.fen() });
io.to(roomId).emit('room:update', {
fen: room.chess ? room.chess.fen() : null,
players: room.players.map(p => ({ id: p.id, color: p.color })),
status: room.status,
hostId: room.hostId,
size: room.size,
cards: Object.keys(room.cards || {})
});
cb && cb({ ok: true });
});
socket.on('disconnect', () => {
const roomId = socket.data.roomId;
if (!roomId) return;
const room = rooms.get(roomId);
if (!room) return;
// delay removal to allow fast reconnects (e.g. navigation between pages)
const playerId = socket.data.playerId;
if(playerId && room.removalTimers){
const t = setTimeout(()=>{
room.players = room.players.filter(p => p.id !== playerId);
// if the host left, promote the next player to host (or null)
if (room.hostId && !room.players.find(p => p.id === room.hostId)) {
room.hostId = room.players[0] ? room.players[0].id : null;
}
if (room.players.length < 2 && room.status === 'playing') room.status = 'waiting';
io.to(roomId).emit('room:update', {
fen: room.chess ? room.chess.fen() : null,
players: room.players.map(p => ({ id: p.id, color: p.color })),
status: room.status,
hostId: room.hostId,
size: room.size,
cards: Object.keys(room.cards || {})
});
room.removalTimers.delete(playerId);
}, 5000);
room.removalTimers.set(playerId, t);
} else {
// fallback: immediate removal
room.players = room.players.filter(p => p.socketId !== socket.id);
if (room.players.length < 2 && room.status === 'playing') room.status = 'waiting';
if (room.hostId && !room.players.find(p => p.id === room.hostId)) {
room.hostId = room.players[0] ? room.players[0].id : null;
}
io.to(roomId).emit('room:update', {
fen: room.chess ? room.chess.fen() : null,
players: room.players.map(p => ({ id: p.id, color: p.color })),
status: room.status,
hostId: room.hostId,
size: room.size,
cards: Object.keys(room.cards || {})
});
}
});
// Provide legal moves for a given square (for standard 8x8 via chess.js)
socket.on('game:legalMoves', ({ roomId, square }, cb) => {
const room = rooms.get(roomId);
if (!room) return cb && cb({ error: 'room not found' });
if (!room.chess) return cb && cb({ error: 'legal moves not supported for custom board sizes yet', moves: [] });
try{
const moves = room.chess.moves({ square, verbose: true }) || [];
return cb && cb({ ok: true, moves });
}catch(e){
return cb && cb({ error: 'invalid square', moves: [] });
}
});
// Simple cards API via sockets: list/play
socket.on('card:list', ({ roomId }, cb) => {
const room = rooms.get(roomId);
if(!room) return cb && cb({ error: 'room not found' });
// return available card types (placeholder)
const available = [
{ id: 'invert', name: 'Invert Turn', description: 'Swap movement directions for one move' },
{ id: 'teleport', name: 'Teleport', description: 'Move one piece to any empty square' }
];
cb && cb({ ok: true, cards: available });
});
socket.on('card:play', ({ roomId, playerId, cardId, payload }, cb) => {
const room = rooms.get(roomId);
if(!room) return cb && cb({ error: 'room not found' });
// store played card (placeholder behaviour) and broadcast
const played = { id: uuidv4().slice(0,8), playerId, cardId, payload, ts: Date.now() };
room.playedCards = room.playedCards || [];
room.playedCards.push(played);
io.to(roomId).emit('card:played', played);
cb && cb({ ok: true, played });
});
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`ChessNut server listening on port ${PORT}`);
});