Files

239 lines
9.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const RoomManager = require('./game/room');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: process.env.CORS_ORIGIN || '*',
methods: ['GET', 'POST']
},
maxHttpBufferSize: 1e6, // 1 MB:擋住超大封包
transports: ['websocket', 'polling'], // polling 當 fallback,避免某些 Proxy 擋 ws
pingInterval: 25000,
pingTimeout: 20000
});
const roomManager = new RoomManager();
app.use(express.json());
// 動作去重:擋掉連點 / 網路重送(依 roomId + requestId
const recentActions = new Map();
const DUP_WINDOW_MS = 3000;
const REJOIN_GRACE_MS = 60000; // 離線緩衝:60 秒內可重連回座位
function isDuplicate(roomId, requestId) {
if (!requestId) return false; // 沒有 requestId 的舊連線不擋(向後相容)
const key = (roomId || 'none') + ':' + requestId;
const now = Date.now();
if (recentActions.has(key) && now - recentActions.get(key) < DUP_WINDOW_MS) return true;
recentActions.set(key, now);
if (recentActions.size > 5000) {
for (const [k, t] of recentActions) if (now - t >= DUP_WINDOW_MS) recentActions.delete(k);
}
return false;
}
// 把每個玩家的「私密手牌」推給他自己(不公開)
function broadcastHands(room) {
if (!room || !room.engine) return;
for (const playerId of room.players) {
io.to(playerId).emit('yourHand', room.engine.getPlayerHand(playerId));
}
}
// 單一錯誤回報:ack 優先,其次 error 事件
function fail(ack, message, socket) {
if (typeof ack === 'function') ack({ ok: false, error: message });
else if (socket) socket.emit('error', { message });
}
function roomIdOf(data) {
return (data && typeof data.roomId === 'string') ? data.roomId : '';
}
// Socket.IO 事件
io.on('connection', (socket) => {
console.log(`玩家連線: ${socket.id}`);
// 建立房間
socket.on('createRoom', (data, ack) => {
const room = roomManager.createRoom(socket.id);
socket.join(room.id);
socket.data = { roomId: room.id };
io.to(room.id).emit('roomCreated', room);
if (typeof ack === 'function') ack({ ok: true, room });
console.log(`房間建立: ${room.id}`);
});
// 加入房間
socket.on('joinRoom', (data, ack) => {
const roomId = roomIdOf(data);
const result = roomManager.joinRoom(roomId, socket.id);
if (result.error) { fail(ack, result.error, socket); return; }
socket.join(roomId);
socket.data = { roomId };
roomManager.getRoom(roomId).disconnectedAt = {};
io.to(roomId).emit('playerJoined', result.room);
if (typeof ack === 'function') ack({ ok: true, room: result.room });
console.log(`玩家加入: ${roomId}`);
});
// 重連:回到離線緩衝中的座位
socket.on('rejoin', (data, ack) => {
const roomId = roomIdOf(data);
const playerId = (data && typeof data.playerId === 'string') ? data.playerId : '';
const room = roomManager.getRoom(roomId);
if (!room) { fail(ack, '房間不存在', socket); return; }
if (!room.players.includes(playerId)) { fail(ack, '找不到該座位', socket); return; }
const mark = room.disconnectedAt && room.disconnectedAt[playerId];
if (!mark || Date.now() - mark > REJOIN_GRACE_MS) { fail(ack, '座位已失效', socket); return; }
delete room.disconnectedAt[playerId];
socket.join(roomId);
socket.join(playerId); // rejoin: tie new socket to old seat so broadcastHands (io.to(playerId)) reaches it
socket.data = { roomId, seatId: playerId }; // identity separated from connection
io.to(roomId).emit('playerRejoined', { playerId, room, gameState: room.engine ? room.engine.getGameState() : null });
socket.emit('resync', { room, gameState: room.engine ? room.engine.getGameState() : null });
if (room.engine) socket.emit('yourHand', room.engine.getPlayerHand(playerId));
if (typeof ack === 'function') ack({ ok: true });
broadcastHands(room);
console.log(`玩家重連: ${playerId} -> ${roomId}`);
});
// 開始遊戲
socket.on('startGame', (data, ack) => {
const roomId = roomIdOf(data);
if (isDuplicate(roomId, data && data.requestId)) { fail(ack, '動作重複,請稍候', socket); return; }
if (!socket.rooms.has(roomId)) { fail(ack, '尚未加入該房間', socket); return; }
const result = roomManager.startGame(roomId);
if (result.error) { fail(ack, result.error, socket); return; }
io.to(roomId).emit('gameStarted', result.gameState);
broadcastHands(roomManager.getRoom(roomId));
if (typeof ack === 'function') ack({ ok: true });
console.log(`開始遊戲: ${roomId}`);
});
// 再來一局:遊戲結束後以同一批玩家重開
socket.on('restartGame', (data, ack) => {
const roomId = roomIdOf(data);
if (isDuplicate(roomId, data && data.requestId)) { fail(ack, '動作重複,請稍候', socket); return; }
if (!socket.rooms.has(roomId)) { fail(ack, '尚未加入該房間', socket); return; }
const result = roomManager.restart(roomId);
if (result.error) { fail(ack, result.error, socket); return; }
io.to(roomId).emit('gameStarted', result.gameState);
broadcastHands(roomManager.getRoom(roomId));
if (typeof ack === 'function') ack({ ok: true });
console.log(`再來一局: ${roomId}`);
});
// 出牌
socket.on('playCard', (data, ack) => {
const roomId = roomIdOf(data);
if (isDuplicate(roomId, data && data.requestId)) { fail(ack, '動作重複,請稍候', socket); return; }
if (!socket.rooms.has(roomId)) { fail(ack, '尚未加入該房間', socket); return; }
const room = roomManager.getRoom(roomId);
if (!room || !room.engine) { fail(ack, '遊戲尚未開始', socket); return; }
const actorId = (socket.data && socket.data.seatId) || socket.id;
const cardIndices = (data && Array.isArray(data.cardIndices)) ? data.cardIndices : [];
const result = room.engine.playCard(actorId, cardIndices);
if (result.error) { fail(ack, result.error, socket); return; }
// 資訊邊界:出牌時只公告「誰出了幾張」,不洩漏實際牌值(質疑時才揭露)
io.to(roomId).emit('cardPlayed', {
playerId: actorId,
playedGroup: { playerId: actorId, cardCount: result.playedGroup.cards.length },
nextPlayerId: result.nextPlayerId,
systemChallenge: result.systemChallenge || null,
gameState: room.engine.getGameState()
});
broadcastHands(room);
roomManager.syncStatus(roomId);
if (typeof ack === 'function') ack({ ok: true });
});
// 質疑上一組出的牌
socket.on('challenge', (data, ack) => {
const roomId = roomIdOf(data);
if (isDuplicate(roomId, data && data.requestId)) { fail(ack, '動作重複,請稍候', socket); return; }
if (!socket.rooms.has(roomId)) { fail(ack, '尚未加入該房間', socket); return; }
const room = roomManager.getRoom(roomId);
if (!room || !room.engine) { fail(ack, '遊戲尚未開始', socket); return; }
const actorId = (socket.data && socket.data.seatId) || socket.id;
const result = room.engine.challenge(actorId);
if (result.error) { fail(ack, result.error, socket); return; }
io.to(roomId).emit('challenged', {
challengerId: actorId,
result,
gameState: room.engine.getGameState()
});
broadcastHands(room);
roomManager.syncStatus(roomId);
if (typeof ack === 'function') ack({ ok: true });
});
// 斷線:遊戲中先進入「離線緩衝」,緩衝逾時才離場
socket.on('disconnect', (reason) => {
console.log(`玩家斷線: ${socket.id} (${reason})`);
const seatId = (socket.data && socket.data.seatId) || socket.id;
const seat = roomManager.findRoomOfPlayer(seatId);
if (!seat) return;
const { roomId, room } = seat;
if (room.engine) {
room.disconnectedAt[seatId] = Date.now();
io.to(roomId).emit('playerDisconnected', { playerId: seatId, room, reconnecting: true });
setTimeout(() => {
const cur = roomManager.getRoom(roomId);
if (cur && cur.disconnectedAt && cur.disconnectedAt[seatId] && Date.now() - cur.disconnectedAt[seatId] >= REJOIN_GRACE_MS) {
delete cur.disconnectedAt[seatId];
roomManager.removePlayer(seatId);
io.to(roomId).emit('playerDisconnected', {
playerId: seatId,
room: cur,
reconnecting: false,
gameState: cur.engine ? cur.engine.getGameState() : null
});
broadcastHands(cur);
}
}, REJOIN_GRACE_MS).unref();
} else {
roomManager.removePlayer(seatId);
io.to(roomId).emit('playerDisconnected', { playerId: seatId, room, reconnecting: false });
}
});
});
// REST API
app.get('/api/rooms', (req, res) => {
res.json(roomManager.listRooms());
});
app.post('/api/rooms', (req, res) => {
const { hostId } = req.body || {};
const room = roomManager.createRoom(hostId || 'unknown');
res.json(room);
});
// 優雅關閉:收到中斷訊號時先關閉 Socket.IO 再退出
function shutdown(signal) {
console.log(`收到 ${signal},準備關閉伺服器...`);
io.close(() => {
console.log('伺服器已關閉');
process.exit(0);
});
// 保險:2 秒內沒關乾淨就強制退出
setTimeout(() => process.exit(0), 2000).unref();
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`伺服器啟動在 http://localhost:${PORT}`);
});
module.exports = { app, io };