ChessNut/server.js
2025-11-22 15:38:07 +01:00

430 lines
16 KiB
JavaScript

const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
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, boardState: object|null, players: [{id, socketId, color}], status, size }
const rooms = new Map();
// Placeholder: compute legal moves for a square in the given room.
// `boardState` is the canonical JSON representation of the board for this server.
// Example boardState: { width:8, height:8, turn:'w', version:1, pieces: [{ square:'e2', type:'P', color:'w', id:'w_p1' }, ... ] }
function computeLegalMoves(room, square){
// Basic pseudo-legal move generator for standard chess-like pieces.
// - Does not perform check detection (moves that leave king in check are allowed).
// - Does not implement castling or en-passant.
// - Returns moves as array of { from, to }.
if(!room || !room.boardState || !square) return [];
const state = room.boardState;
const width = state.width || 8;
const height = state.height || 8;
// helpers
function squareToCoord(sq){
if(!/^[a-z][1-9][0-9]*$/i.test(sq)) return null;
const file = sq.charCodeAt(0) - 'a'.charCodeAt(0);
const rank = parseInt(sq.slice(1),10) - 1; // 0-indexed
return { x: file, y: rank };
}
function coordToSquare(x,y){
if(x < 0 || y < 0 || x >= width || y >= height) return null;
return String.fromCharCode('a'.charCodeAt(0) + x) + (y+1);
}
function getPieceAt(sq){
if(!sq) return null;
return (state.pieces || []).find(p => p.square === sq) || null;
}
function isInside(x,y){ return x >= 0 && y >= 0 && x < width && y < height; }
const piece = getPieceAt(square);
if(!piece) return [];
const color = piece.color; // 'w' or 'b'
const fromCoord = squareToCoord(square);
if(!fromCoord) return [];
const moves = [];
// add move helper
function pushIfEmptyOrCapture(tx,ty){
if(!isInside(tx,ty)) return false;
const toSq = coordToSquare(tx,ty);
const occupant = getPieceAt(toSq);
if(!occupant){
moves.push({ from: square, to: toSq });
return true; // can continue sliding
}
// capture allowed if different color
if(occupant.color !== color){
moves.push({ from: square, to: toSq });
}
return false; // blocked
}
const x = fromCoord.x, y = fromCoord.y;
const type = (piece.type || '').toUpperCase();
if(type === 'P'){
// pawn
const forward = (color === 'w') ? 1 : -1;
const startRank = (color === 'w') ? 1 : (height - 2);
const oneY = y + forward;
const oneSq = coordToSquare(x, oneY);
if(isInside(x, oneY) && !getPieceAt(oneSq)){
moves.push({ from: square, to: oneSq });
// double push from starting rank
const twoY = y + forward*2;
const twoSq = coordToSquare(x, twoY);
if(y === startRank && isInside(x, twoY) && !getPieceAt(twoSq)){
moves.push({ from: square, to: twoSq });
}
}
// captures
[[x-1, y+forward],[x+1, y+forward]].forEach(([tx,ty])=>{
if(!isInside(tx,ty)) return;
const tsq = coordToSquare(tx,ty);
const occ = getPieceAt(tsq);
if(occ && occ.color !== color) moves.push({ from: square, to: tsq });
});
return moves;
}
if(type === 'N'){
const deltas = [[1,2],[2,1],[-1,2],[-2,1],[1,-2],[2,-1],[-1,-2],[-2,-1]];
deltas.forEach(([dx,dy])=>{
const tx = x + dx, ty = y + dy;
if(!isInside(tx,ty)) return;
const tsq = coordToSquare(tx,ty);
const occ = getPieceAt(tsq);
if(!occ || occ.color !== color) moves.push({ from: square, to: tsq });
});
return moves;
}
if(type === 'B' || type === 'Q' || type === 'R'){
const directions = [];
if(type === 'B' || type === 'Q') directions.push([1,1],[1,-1],[-1,1],[-1,-1]);
if(type === 'R' || type === 'Q') directions.push([1,0],[-1,0],[0,1],[0,-1]);
directions.forEach(([dx,dy])=>{
let tx = x + dx, ty = y + dy;
while(isInside(tx,ty)){
const cont = pushIfEmptyOrCapture(tx,ty);
if(!cont) break;
tx += dx; ty += dy;
}
});
return moves;
}
if(type === 'K'){
for(let dx=-1; dx<=1; dx++) for(let dy=-1; dy<=1; dy++){
if(dx === 0 && dy === 0) continue;
const tx = x + dx, ty = y + dy;
if(!isInside(tx,ty)) continue;
const tsq = coordToSquare(tx,ty);
const occ = getPieceAt(tsq);
if(!occ || occ.color !== color) moves.push({ from: square, to: tsq });
}
return moves;
}
return moves;
}
function startingBoardState8(){
// returns a simple pieces array with square names for standard chess starting position
const rows = [
'rnbqkbnr',
'pppppppp',
'........',
'........',
'........',
'........',
'PPPPPPPP',
'RNBQKBNR'
];
const pieces = [];
for(let r=0;r<8;r++){
const row = rows[r];
for(let f=0;f<8;f++){
const ch = row[f];
if(ch === '.') continue;
const fileLetter = String.fromCharCode('a'.charCodeAt(0) + f);
const rank = 8 - r;
const square = `${fileLetter}${rank}`;
const isWhite = (ch === ch.toUpperCase());
const type = ch.toUpperCase();
const color = isWhite ? 'w' : 'b';
const id = (color === 'w' ? 'w_' : 'b_') + type + '_' + pieces.length;
pieces.push({ square, type, color, id });
}
}
return { width: 8, height: 8, turn: 'w', version: 1, pieces };
}
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 we initialize a boardState JSON as the canonical board state.
const boardState = size === 8 ? startingBoardState8() : null;
// hostId will be set when the first player joins
rooms.set(id, { id, boardState, 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 boardState = room.boardState || null;
res.json({ id: room.id, boardState, 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', {
boardState: room.boardState || 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' });
// Basic move handling: validate turn, validate piece ownership, validate target is one of computeLegalMoves,
// apply the move to room.boardState, handle captures, flip turn and broadcast the updated board.
try{
const senderId = socket.data.playerId;
if(!senderId) return cb && cb({ error: 'not joined' });
const roomPlayer = room.players.find(p => p.id === senderId);
if(!roomPlayer) return cb && cb({ error: 'player not in room' });
if(!room.boardState) return cb && cb({ error: 'no board state' });
const board = room.boardState;
// determine player's color short form ('w'|'b')
const playerColorShort = (roomPlayer.color && roomPlayer.color[0]) || null;
if(!playerColorShort) return cb && cb({ error: 'invalid player color' });
// must be player's turn
if(board.turn !== playerColorShort) return cb && cb({ error: 'not your turn' });
// find piece at 'from'
const pieces = board.pieces || [];
const moving = pieces.find(p => p.square === from);
if(!moving) return cb && cb({ error: 'no piece at source' });
if(moving.color !== playerColorShort) return cb && cb({ error: 'not your piece' });
// validate that 'to' is among legal moves
const legal = computeLegalMoves(room, from) || [];
const ok = legal.some(m => m.to === to);
if(!ok) return cb && cb({ error: 'illegal move' });
// apply move: remove any piece on target (capture)
const targetIndex = pieces.findIndex(p => p.square === to);
if(targetIndex >= 0){
// remove captured piece
pieces.splice(targetIndex, 1);
}
// move the piece
moving.square = to;
// advance version and flip turn
board.version = (board.version || 0) + 1;
board.turn = (board.turn === 'w') ? 'b' : 'w';
// broadcast move and new board state
const moved = { playerId: senderId, from, to, boardState: board };
io.to(roomId).emit('move:moved', moved);
io.to(roomId).emit('room:update', {
boardState: board,
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 || {})
});
return cb && cb({ ok: true, moved });
}catch(err){
console.error('game:move error', err);
return cb && cb({ error: 'server error' });
}
});
// 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, boardState: room.boardState });
io.to(roomId).emit('room:update', {
boardState: room.boardState || 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', {
boardState: room.boardState || 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', {
boardState: room.boardState || 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 || {})
});
}
});
// delegates to `computeLegalMoves` which is a stub you should implement.
// legalMoves API removed (movement UI disabled)
// Re-add legalMoves API to compute and return pseudo-legal moves for a square.
socket.on('game:legalMoves', ({ roomId, square }, cb) => {
const room = rooms.get(roomId);
if (!room) return cb && cb({ error: 'room not found' });
try{
const moves = computeLegalMoves(room, square) || [];
return cb && cb({ ok: true, moves });
}catch(e){
console.error('game:legalMoves error', e);
return cb && cb({ error: 'invalid square', moves: [] });
}
});
// propagate selection made by one client to the other clients in the same room
socket.on('game:select', ({ roomId, square }, cb) => {
const room = rooms.get(roomId);
if(!room) return cb && cb({ error: 'room not found' });
const playerId = socket.data.playerId || null;
// compute legal moves for the selected square (allow capturing king)
let moves = [];
try{
if(square) moves = computeLegalMoves(room, square) || [];
}catch(e){
console.error('computeLegalMoves error', e);
moves = [];
}
// broadcast selection + moves to ALL clients in the room (including sender)
io.to(roomId).emit('game:select', { playerId, square, moves });
cb && cb({ ok: true });
});
// 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}`);
});