Spaces:
Sleeping
Sleeping
| import { Server } from 'socket.io'; | |
| import { Room, Player, GameType, GamePhase, ActionLog } from '../types'; | |
| import { v4 as uuidv4 } from 'uuid'; | |
| import { sanitizeRoomState } from '../utils/sanitizer'; | |
| import { MafiaEngine } from '../engines/MafiaEngine'; | |
| import { IGameEngine } from '../engines/IGameEngine'; | |
| import { BotManager } from './BotManager'; | |
| // CONFIGURATION | |
| const MAX_TOTAL_ROOMS = 500; | |
| const ROOM_LOBBY_MAX_LIFESPAN = 60 * 60 * 1000; | |
| const GAME_MAX_LIFESPAN = 2 * 60 * 60 * 1000; | |
| class GameManager { | |
| private rooms: Map<string, Room> = new Map(); | |
| private io: Server | null = null; | |
| private globalTicker: NodeJS.Timeout; | |
| // Registry of Game Engines | |
| private engines: Record<GameType, IGameEngine> = { | |
| 'MAFIA': new MafiaEngine(), | |
| }; | |
| constructor() { | |
| this.globalTicker = setInterval(() => { | |
| this.gameLoop(); | |
| }, 1000); | |
| setInterval(() => this.cleanupRooms(), 30 * 1000); | |
| } | |
| public setIo(io: Server) { | |
| this.io = io; | |
| } | |
| private gameLoop() { | |
| if (!this.io) return; | |
| for (const room of this.rooms.values()) { | |
| // OPTIMIZATION: Skip inactive rooms immediately | |
| if (room.status !== 'PLAYING') continue; | |
| const engine = this.engines[room.gameType]; | |
| // 1. Engine Tick | |
| engine.onTick(room); | |
| // 2. Global Timer | |
| if (room.timer > 0) { | |
| room.timer--; | |
| this.io.to(room.id).emit('game_phase_update', room.phase, room.timer); | |
| } | |
| // 3. Phase Transition | |
| if (room.timer <= 0) { | |
| const oldLivingIds = new Set(room.players.filter(p => p.isAlive).map(p => p.id)); | |
| const currentPhase = room.phase; | |
| engine.onNextPhase(room); | |
| // DETECT NEW DEATHS FOR NOTIFICATIONS | |
| room.players.forEach(p => { | |
| if (!p.isAlive && oldLivingIds.has(p.id)) { | |
| if (currentPhase === 'NIGHT') { | |
| const deathLog = room.history.find(h => h.targetId === p.id && h.round === room.round && h.action === 'KILLED_BY_MAFIA'); | |
| if (deathLog) { | |
| let killerName = 'Mafia'; | |
| let killerAvatar = 'mafia.jpeg'; | |
| const actualKiller = room.players.find(k => k.id === deathLog.actorId); | |
| if (actualKiller) { | |
| killerName = actualKiller.username; | |
| killerAvatar = actualKiller.avatar; | |
| } | |
| this.sendPrivateMessage(p.id, `YOU_KILLED_BY:${killerName}`, killerAvatar); | |
| } | |
| } | |
| } | |
| }); | |
| this.broadcastRoomUpdate(room); | |
| // BROADCAST RESULTS AT START OF DAY | |
| if (room.phase === 'DAY') { | |
| let totalNotificationDelay = 0; | |
| // 1. Public Doctor Success (First Priority) | |
| const saveLog = room.history.find(h => h.round === room.round && h.action === 'SAVED_BY_DOCTOR' && h.result === 'SUCCESS'); | |
| if (saveLog) { | |
| const savedPlayer = room.players.find(s => s.id === saveLog.targetId); | |
| if (savedPlayer) { | |
| setTimeout(() => { | |
| this.broadcastMessage(room.id, 'System', `PUBLIK_DOKTER_SUKSES:${savedPlayer.username}`, 'system', 'doctor.jpeg'); | |
| }, totalNotificationDelay); | |
| totalNotificationDelay += 4000; | |
| } | |
| } | |
| // 2. Public Detective Success (Second Priority) | |
| const investLog = room.history.find(h => h.round === room.round && h.action === 'INVESTIGATE' && h.result === 'MAFIA'); | |
| if (investLog) { | |
| setTimeout(() => { | |
| this.broadcastMessage(room.id, 'System', `PUBLIK_DETEKTIF_SUKSES`, 'system', 'detective.jpeg'); | |
| }, totalNotificationDelay); | |
| totalNotificationDelay += 4000; | |
| } | |
| // 2.5 Private Detective Intel (Send to Detective only) | |
| const anyInvestLog = room.history.find(h => h.round === room.round && h.action === 'INVESTIGATE'); | |
| if (anyInvestLog && anyInvestLog.targetId) { | |
| const detective = room.players.find(p => p.role === 'DETECTIVE' && p.isAlive); | |
| const target = room.players.find(p => p.id === anyInvestLog.targetId); | |
| if (detective && target) { | |
| const resultLabel = anyInvestLog.result === 'MAFIA' ? 'MAFIA' : 'WARGA'; | |
| this.sendPrivateMessage(detective.id, `Investigasi: ${target.username} adalah ${resultLabel}`); | |
| } | |
| } | |
| // 3. Death/Peaceful Report (Last Priority) | |
| if (room.lastNightResult && room.lastNightResult.trim() !== '') { | |
| const victim = room.players.find(p => !p.isAlive && oldLivingIds.has(p.id)); | |
| setTimeout(() => { | |
| this.broadcastMessage(room.id, 'System', room.lastNightResult, 'system', victim?.avatar); | |
| }, totalNotificationDelay); | |
| totalNotificationDelay += 4000; | |
| } | |
| if (totalNotificationDelay > 0) { | |
| room.timer += Math.ceil(totalNotificationDelay / 1000); | |
| } | |
| } | |
| if (room.phase === 'GAME_OVER') { | |
| this.io.to(room.id).emit('game_over', room.winner!); | |
| } else if (room.phase !== 'DAY') { | |
| const phaseLabel = room.phase === 'NIGHT' ? 'MALAM' : room.phase === 'VOTING' ? 'VOTING' : room.phase; | |
| this.broadcastMessage(room.id, 'System', `Fase: ${phaseLabel}`, 'system'); | |
| } | |
| } | |
| // } // Removed extra brace | |
| } | |
| } | |
| // --- ROOM MANAGEMENT --- | |
| private cleanupPlayerSession(userId: string, socketId: string, exceptRoomId?: string) { | |
| // Find if this user is already in any other room | |
| for (const [rid, room] of this.rooms.entries()) { | |
| // SKIP the room we are trying to join/create | |
| if (rid === exceptRoomId) continue; | |
| const playerIdx = room.players.findIndex(p => p.userId === userId || p.id === socketId); | |
| if (playerIdx !== -1) { | |
| const player = room.players[playerIdx]; | |
| // If Host is leaving, disband the room immediately | |
| if (player.isHost) { | |
| BotManager.cleanup(rid); | |
| this.rooms.delete(rid); | |
| this.io?.in(rid).emit('error_message', 'Host telah membubarkan ruangan.'); | |
| this.io?.in(rid).emit('kicked'); // Force client redirect | |
| this.io?.emit('room_list_update', this.getPublicRooms()); | |
| } else { | |
| // Just remove the player | |
| room.players.splice(playerIdx, 1); | |
| // OPTIMIZATION: Instant cleanup if room becomes empty | |
| if (room.players.length === 0) { | |
| BotManager.cleanup(rid); | |
| this.rooms.delete(rid); | |
| } else { | |
| this.broadcastRoomUpdate(room); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| public createRoom(name: string, isPublic: boolean, gameType: GameType, hostUser: { userId: string; username: string; avatar: string }, socketId: string, initialSettings?: any): Room { | |
| this.cleanupPlayerSession(hostUser.userId, socketId); // ENFORCE SINGLE ROOM POLICY | |
| if (this.rooms.size >= MAX_TOTAL_ROOMS) throw new Error("Server Penuh"); | |
| const roomId = uuidv4().slice(0, 6).toUpperCase(); | |
| const hostPlayer: Player = { ...hostUser, id: socketId, isHost: true, isAlive: true, isOnline: true, votesReceived: 0, isBot: false, actionTarget: null }; | |
| const newRoom: Room = { | |
| id: roomId, name, isPublic, gameType, status: 'WAITING', phase: 'LOBBY', | |
| players: [hostPlayer], hostId: hostUser.userId, maxPlayers: 12, | |
| settings: initialSettings || { | |
| timers: { NIGHT: 15, DAY: 30, VOTING: 15 }, | |
| mafiaCount: 1, | |
| roles: { DOCTOR: true, DETECTIVE: true, JOKER: true, THIEF: false, PLAYWRIGHT: false } | |
| }, | |
| createdAt: Date.now(), lastActivity: Date.now(), round: 0, timer: 0, winner: null, | |
| nightActions: { mafiaVote: {}, doctorTarget: null, detectiveTarget: null, playwrightSwap: null }, | |
| votes: {}, lastNightResult: '', history: [], chatHistory: [] | |
| }; | |
| this.rooms.set(roomId, newRoom); | |
| return newRoom; | |
| } | |
| public updateSettings(roomId: string, socketId: string, settings: any) { | |
| const room = this.rooms.get(roomId); | |
| if (!room || room.status !== 'WAITING') return; | |
| const host = room.players.find(p => p.id === socketId); | |
| if (!host || !host.isHost) return; | |
| const pCount = room.players.length; | |
| let newSettings = { ...settings }; | |
| // SERVER-SIDE ENFORCEMENT OF SMART BALANCING | |
| const maxMafia = pCount >= 12 ? 3 : pCount >= 8 ? 2 : 1; | |
| newSettings.mafiaCount = Math.min(newSettings.mafiaCount, maxMafia); | |
| let neutrals = (newSettings.roles.JOKER?1:0)+(newSettings.roles.THIEF?1:0)+(newSettings.roles.PLAYWRIGHT?1:0); | |
| let townCount = pCount - newSettings.mafiaCount - neutrals; | |
| if (neutrals > townCount) { | |
| if (newSettings.roles.PLAYWRIGHT) { newSettings.roles.PLAYWRIGHT = false; neutrals--; } | |
| if (neutrals > townCount && newSettings.roles.THIEF) { newSettings.roles.THIEF = false; neutrals--; } | |
| if (neutrals > townCount && newSettings.roles.JOKER) { newSettings.roles.JOKER = false; neutrals--; } | |
| } | |
| room.settings = newSettings; | |
| this.broadcastRoomUpdate(room); | |
| } | |
| public joinRoom(roomId: string, user: { userId: string; username: string; avatar: string }, socketId: string): Room | null { | |
| this.cleanupPlayerSession(user.userId, socketId, roomId); // Enforce single room, BUT keep current room safe | |
| const room = this.rooms.get(roomId); | |
| if (!room) return null; | |
| const existing = room.players.find(p => p.userId === user.userId); | |
| if (existing) { | |
| const oldSocketId = existing.id; | |
| existing.id = socketId; | |
| existing.isOnline = true; | |
| room.lastActivity = Date.now(); | |
| // --- STATE MIGRATION (Fix for lost votes/actions on Reconnect) --- | |
| // 1. Migrate Votes Received | |
| if (room.votes[oldSocketId] !== undefined) { | |
| room.votes[socketId] = room.votes[oldSocketId]; | |
| delete room.votes[oldSocketId]; | |
| } | |
| // 2. Migrate Night Targets (If this player is being targeted) | |
| if (room.nightActions.doctorTarget === oldSocketId) room.nightActions.doctorTarget = socketId; | |
| if (room.nightActions.detectiveTarget === oldSocketId) room.nightActions.detectiveTarget = socketId; | |
| // Update Mafia Votes (Values are target IDs) | |
| for (const [voterId, targetId] of Object.entries(room.nightActions.mafiaVote)) { | |
| if (targetId === oldSocketId) { | |
| room.nightActions.mafiaVote[voterId] = socketId; | |
| } | |
| } | |
| // 3. Migrate Actor Actions (If this player is the actor) | |
| // Mafia Vote (Key is voter ID) | |
| if (room.nightActions.mafiaVote[oldSocketId]) { | |
| room.nightActions.mafiaVote[socketId] = room.nightActions.mafiaVote[oldSocketId]; | |
| delete room.nightActions.mafiaVote[oldSocketId]; | |
| } | |
| // Playwright Swap Targets | |
| if (room.nightActions.playwrightSwap) { | |
| if (room.nightActions.playwrightSwap.targetA === oldSocketId) room.nightActions.playwrightSwap.targetA = socketId; | |
| if (room.nightActions.playwrightSwap.targetB === oldSocketId) room.nightActions.playwrightSwap.targetB = socketId; | |
| } | |
| // ---------------------------------------------------------------- | |
| return room; | |
| } | |
| if (room.status !== 'WAITING' || room.players.length >= room.maxPlayers) return null; | |
| room.players.push({ ...user, id: socketId, isHost: false, isAlive: true, isOnline: true, votesReceived: 0, isBot: false, actionTarget: null }); | |
| room.lastActivity = Date.now(); | |
| return room; | |
| } | |
| public startGame(roomId: string, initiatorId: string) { | |
| const room = this.rooms.get(roomId); | |
| if (!room) return; | |
| if (room.status === 'GAME_OVER') { | |
| room.status = 'WAITING'; | |
| room.phase = 'LOBBY'; | |
| room.round = 0; | |
| room.timer = 0; | |
| room.history = []; | |
| room.chatHistory = []; | |
| room.winner = null; | |
| room.players.forEach(p => { | |
| p.isAlive = true; | |
| p.role = undefined; | |
| p.actionTarget = null; | |
| p.votesReceived = 0; | |
| }); | |
| } | |
| const initiator = room.players.find(p => p.id === initiatorId); | |
| if (!initiator || !initiator.isHost) return; | |
| if (room.players.length < 4) { | |
| this.sendError(initiatorId, "Butuh minimal 4 pemain."); | |
| return; | |
| } | |
| // AUTO-ADJUST SETTINGS BEFORE START based on current player count | |
| // This prevents stuck games when players leave and the old settings are too ambitious | |
| const pCount = room.players.length; | |
| const maxMafia = pCount >= 12 ? 3 : pCount >= 8 ? 2 : 1; | |
| if (room.settings.mafiaCount > maxMafia) room.settings.mafiaCount = maxMafia; | |
| let neutrals = (room.settings.roles.JOKER?1:0)+(room.settings.roles.THIEF?1:0)+(room.settings.roles.PLAYWRIGHT?1:0); | |
| // Ensure at least 1 villager or appropriate balance logic | |
| // Basic rule: Mafia + Neutrals must be < Town | |
| // Or strictly: ensure enough slots. | |
| // Simple sanitization: Priority drop for Neutrals if overflow | |
| // Town = Total - Mafia - Neutrals. Town must be >= 1 (or >= Mafia generally) | |
| // Let's reuse strict validation logic similar to updateSettings but auto-applying | |
| let townCount = pCount - room.settings.mafiaCount - neutrals; | |
| while (neutrals > 0 && townCount < 1) { // Ensure at least 1 town/villager slot exists ideally | |
| if (room.settings.roles.PLAYWRIGHT) { room.settings.roles.PLAYWRIGHT = false; } | |
| else if (room.settings.roles.THIEF) { room.settings.roles.THIEF = false; } | |
| else if (room.settings.roles.JOKER) { room.settings.roles.JOKER = false; } | |
| neutrals--; | |
| townCount++; | |
| } | |
| room.status = 'PLAYING'; | |
| this.engines[room.gameType].onStart(room); | |
| this.broadcastRoomUpdate(room); | |
| } | |
| public resetRoom(roomId: string, socketId: string) { | |
| const room = this.rooms.get(roomId); | |
| if (!room || room.status !== 'GAME_OVER') return; | |
| room.status = 'WAITING'; | |
| room.phase = 'LOBBY'; | |
| room.round = 0; | |
| room.timer = 0; | |
| room.winner = null; | |
| room.history = []; | |
| room.players.forEach(p => { | |
| p.isAlive = true; | |
| p.role = undefined; | |
| p.actionTarget = null; | |
| p.votesReceived = 0; | |
| }); | |
| this.broadcastRoomUpdate(room); | |
| this.broadcastMessage(room.id, 'System', 'Room telah di-reset. Menunggu Host memulai permainan baru.', 'system'); | |
| } | |
| public submitVote(roomId: string, socketId: string, targetId: string) { | |
| const room = this.rooms.get(roomId); | |
| if (!room || room.status !== 'PLAYING') return; | |
| this.engines[room.gameType].handleAction(room, socketId, 'VOTE', { targetId }); | |
| this.broadcastRoomUpdate(room); | |
| } | |
| public submitNightAction(roomId: string, socketId: string, action: string, targetId: string) { | |
| const room = this.rooms.get(roomId); | |
| if (!room || room.status !== 'PLAYING' || room.phase !== 'NIGHT') return; | |
| this.engines[room.gameType].handleAction(room, socketId, action, { targetId, type: action }); | |
| this.sendPrivateMessage(socketId, "Aksi diterima."); | |
| this.broadcastRoomUpdate(room); | |
| } | |
| public handleDayAction(roomId: string, socketId: string, action: string, targetId?: string, targetA?: string, targetB?: string) { | |
| const room = this.rooms.get(roomId); | |
| if (!room || room.status !== 'PLAYING' || room.phase !== 'DAY') return; | |
| const actor = room.players.find(p => p.id === socketId); | |
| if (!actor || !actor.isAlive) return; | |
| if (action === 'STEAL' && actor.role === 'THIEF' && targetId) { | |
| const victim = room.players.find(p => p.id === targetId); | |
| if (!victim) return; | |
| this.engines[room.gameType].handleAction(room, socketId, 'STEAL', { targetId }); | |
| const history = room.history[room.history.length - 1]; | |
| if (history.result === 'THIEF_DIED_BY_MAFIA') { | |
| this.io?.to(room.id).emit('PLAYER_ROBBED', { victimId: targetId, isMafia: true }); | |
| this.broadcastMessage(room.id, 'System', `Pencurian gagal! ${actor.username} mencoba mencuri dari Mafia dan terbunuh.`, 'system', 'thief.jpeg'); | |
| this.sendPrivateMessage(actor.id, `Kamu mencoba mencuri dari Mafia dan terbunuh di tempat.`); | |
| } else { | |
| this.io?.to(room.id).emit('PLAYER_ROBBED', { victimId: targetId, isMafia: false }); | |
| this.sendPrivateMessage(actor.id, `Pencurian berhasil. Kamu sekarang adalah ${actor.role}.`); | |
| this.sendPrivateMessage(victim.id, `IDENTITAS KAMU DICURI! Kamu sekarang adalah PENCURI.`); | |
| } | |
| this.broadcastRoomUpdate(room); | |
| } else if (action === 'SWAP_VOTES' && actor.role === 'PLAYWRIGHT' && targetA && targetB) { | |
| this.engines[room.gameType].handleAction(room, socketId, 'SWAP_VOTES', { targetA, targetB }); | |
| this.broadcastRoomUpdate(room); | |
| } | |
| } | |
| public handleChat(roomId: string, socketId: string, text: string) { | |
| const room = this.rooms.get(roomId); | |
| if (!room) return; | |
| const player = room.players.find(p => p.id === socketId); | |
| if (!player) return; | |
| // Enforcement: Dead players cannot chat during active gameplay | |
| if (room.status === 'PLAYING' && !player.isAlive) { | |
| return; | |
| } | |
| if (room.status === 'PLAYING') { | |
| room.chatHistory.push({ senderId: player.id, senderName: player.username, text, phase: room.phase, timestamp: Date.now() }); | |
| } | |
| this.broadcastMessage(room.id, player.username, text, 'chat'); | |
| } | |
| public addBot(roomId: string, hostId: string) { | |
| const room = this.rooms.get(roomId); | |
| if (!room || room.status !== 'WAITING') return; | |
| if (room.players.length >= room.maxPlayers) return; | |
| const botId = `BOT-${uuidv4().slice(0,4)}`; | |
| const botNames = ['Alpha','Beta','Gamma','Delta','Omega','Zeta','Titan','Rex','Shadow','Neo']; | |
| const botAvatars = ['detective.jpeg', 'doctor.jpeg', 'joker.jpeg', 'mafia.jpeg', 'thief.jpeg', 'playwright.jpeg', 'warga1.jpeg', 'warga2.jpeg', 'warga.jpeg']; | |
| const randomName = botNames[Math.floor(Math.random() * botNames.length)]; | |
| room.players.push({ | |
| id: botId, userId: botId, | |
| username: `${randomName} Bot`, | |
| avatar: botAvatars[Math.floor(Math.random() * botAvatars.length)], | |
| isHost: false, isAlive: true, isOnline: true, isBot: true, votesReceived: 0, | |
| actionTarget: null | |
| }); | |
| this.broadcastRoomUpdate(room); | |
| } | |
| public kickPlayer(roomId: string, hostSocketId: string, targetId: string) { | |
| const room = this.rooms.get(roomId); | |
| if (!room || room.status !== 'WAITING') return; | |
| const host = room.players.find(p => p.id === hostSocketId); | |
| if (!host || !host.isHost || host.id === targetId) return; | |
| const targetIndex = room.players.findIndex(p => p.id === targetId); | |
| if (targetIndex !== -1) { | |
| this.io?.to(targetId).emit('kicked'); | |
| room.players.splice(targetIndex, 1); | |
| this.broadcastRoomUpdate(room); | |
| } | |
| } | |
| public playerDisconnect(socketId: string) { | |
| for (const room of this.rooms.values()) { | |
| const player = room.players.find(p => p.id === socketId); | |
| if (player) { | |
| player.isOnline = false; | |
| room.lastActivity = Date.now(); | |
| // If Host disconnects in lobby, we might want to reassign host, but for now just marking offline is safer for refresh/navigation. | |
| // CleanupRooms will handle abandoned rooms after 60s. | |
| this.broadcastRoomUpdate(room); | |
| return; | |
| } | |
| } | |
| } | |
| private cleanupRooms() { | |
| const now = Date.now(); | |
| for (const [id, room] of this.rooms.entries()) { | |
| const empty = room.players.length === 0 || room.players.every(p => !p.isOnline && !p.isBot); | |
| let shouldDelete = false; | |
| if (empty && (now - room.lastActivity > 60000)) shouldDelete = true; | |
| else if (room.status === 'WAITING' && (now - room.createdAt > ROOM_LOBBY_MAX_LIFESPAN)) shouldDelete = true; | |
| else if (room.status === 'PLAYING' && (now - room.createdAt > GAME_MAX_LIFESPAN)) shouldDelete = true; | |
| if (shouldDelete) { | |
| BotManager.cleanup(id); | |
| this.rooms.delete(id); | |
| } | |
| } | |
| } | |
| private broadcastRoomUpdate(room: Room) { | |
| this.io?.sockets.adapter.rooms.get(room.id)?.forEach(sid => { | |
| this.io?.sockets.sockets.get(sid)?.emit('room_state_update', sanitizeRoomState(room, sid)); | |
| }); | |
| // Handle Private Notification based on last history entry | |
| const lastLog = room.history[room.history.length - 1]; | |
| if (lastLog && room.phase === 'DAY' && room.round > 0) { | |
| if (lastLog.action === 'KILLED_BY_MAFIA' && lastLog.targetId) { | |
| const killer = room.players.find(p => p.id === lastLog.actorId); | |
| this.io?.to(lastLog.targetId).emit('private_message', { | |
| sender: 'SYSTEM', | |
| text: `YOU_KILLED_BY:${killer?.username || 'Mafia'}` | |
| }); | |
| } else if (lastLog.action === 'INVESTIGATE' && lastLog.actorId) { | |
| this.io?.to(lastLog.actorId).emit('private_message', { | |
| sender: 'SYSTEM', | |
| text: `Investigasi: ${lastLog.targetId} adalah ${lastLog.result === 'MAFIA' ? 'MAFIA' : 'WARGA'}` | |
| }); | |
| } | |
| } | |
| } | |
| private broadcastMessage(roomId: string, sender: string, text: string, type: 'chat' | 'system', avatar?: string) { | |
| this.io?.to(roomId).emit('new_message', { sender, text, type, avatar }); | |
| } | |
| private sendPrivateMessage(socketId: string, text: string, avatar?: string) { | |
| this.io?.to(socketId).emit('private_message', { text, avatar }); | |
| } | |
| private sendError(socketId: string, msg: string) { | |
| this.io?.to(socketId).emit('error_message', msg); | |
| } | |
| public getRoom(roomId: string) { return this.rooms.get(roomId); } | |
| public getPublicRooms() { return Array.from(this.rooms.values()).filter(r => r.isPublic && r.status === 'WAITING').slice(0, 20); } | |
| } | |
| export default new GameManager(); | |