239 lines
9.5 KiB
JavaScript
239 lines
9.5 KiB
JavaScript
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 }; |