serverplace / src /managers /GameManager.ts
3v324v23's picture
Update Server: Fix Vote Bug, Memory Leak, and Role Reveal
05d8628
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();