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 = new Map(); private io: Server | null = null; private globalTicker: NodeJS.Timeout; // Registry of Game Engines private engines: Record = { '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();