Spaces:
Sleeping
Sleeping
| import { IGameEngine } from './IGameEngine'; | |
| import { Room, GamePhase, Player, Role } from '../types'; | |
| import { BotManager } from '../managers/BotManager'; | |
| export class MafiaEngine implements IGameEngine { | |
| public getPhaseDuration(phase: GamePhase, room?: Room): number { | |
| if (room && room.settings) { | |
| if (phase === 'NIGHT') return room.settings.timers.NIGHT; | |
| if (phase === 'DAY') return room.settings.timers.DAY; | |
| if (phase === 'VOTING') return room.settings.timers.VOTING; | |
| if (phase === 'VOTING_RESULT') return 5; | |
| return 0; | |
| } | |
| const timings: Record<string, number> = { | |
| 'NIGHT': 15, | |
| 'DAY': 30, | |
| 'VOTING': 15, | |
| 'VOTING_RESULT': 5, | |
| 'GAME_OVER': 0 | |
| }; | |
| return timings[phase] || 0; | |
| } | |
| public onStart(room: Room): void { | |
| room.round = 1; | |
| this.assignRoles(room); | |
| this.startPhase(room, 'NIGHT'); | |
| } | |
| public onTick(room: Room): void { | |
| // Mafia specific tick logic (if any) | |
| } | |
| public onNextPhase(room: Room): void { | |
| switch (room.phase as string) { | |
| case 'NIGHT': | |
| this.resolveNight(room); | |
| if (room.status !== 'GAME_OVER') this.startPhase(room, 'DAY'); | |
| break; | |
| case 'DAY': | |
| this.startPhase(room, 'VOTING'); | |
| break; | |
| case 'VOTING': | |
| this.resolveVoting(room); | |
| break; | |
| case 'VOTING_RESULT': | |
| // Finalize elimination here (at the end of result phase) | |
| this.finalizeElimination(room); | |
| if (room.status !== 'GAME_OVER') { | |
| room.round++; | |
| this.startPhase(room, 'NIGHT'); | |
| } | |
| break; | |
| } | |
| } | |
| private getVoteResult(room: Room): { candidateId: string | null, isTie: boolean } { | |
| let maxVotes = 0; | |
| let candidateId: string | null = null; | |
| let isTie = false; | |
| for (const [targetId, count] of Object.entries(room.votes)) { | |
| if (count > maxVotes) { | |
| maxVotes = count; | |
| candidateId = targetId; | |
| isTie = false; | |
| } else if (count === maxVotes) { | |
| isTie = true; | |
| } | |
| } | |
| return { candidateId, isTie }; | |
| } | |
| private finalizeElimination(room: Room) { | |
| const { candidateId, isTie } = this.getVoteResult(room); | |
| if (candidateId && !isTie) { | |
| const victim = room.players.find(p => p.id === candidateId); | |
| if (victim) { | |
| victim.isAlive = false; | |
| room.history.push({ | |
| phase: 'VOTING_RESULT', | |
| round: room.round, | |
| actorId: 'SYSTEM', | |
| targetId: victim.id, | |
| action: 'VOTE_OUT', | |
| result: 'ELIMINATED' | |
| }); | |
| } | |
| } else { | |
| room.history.push({ | |
| phase: 'VOTING_RESULT', | |
| round: room.round, | |
| actorId: 'SYSTEM', | |
| action: 'VOTE', | |
| result: 'NO_ELIMINATION' | |
| }); | |
| } | |
| this.checkWinCondition(room); | |
| } | |
| public handleAction(room: Room, actorId: string, action: string, payload: any): void { | |
| const actor = room.players.find(p => p.id === actorId); | |
| if (!actor || !actor.isAlive) return; | |
| if (room.phase === 'NIGHT') { | |
| if (actor.actionTarget) return; // Night actions are final | |
| const { targetId, type } = payload; | |
| const target = room.players.find(p => p.id === targetId); | |
| if (type === 'KILL' && actor.role === 'MAFIA') { | |
| if (target?.role === 'MAFIA') { | |
| console.log(`[MafiaEngine] Blocked friendly fire: ${actor.username} tried to kill teammate ${target.username}`); | |
| return; | |
| } | |
| room.nightActions.mafiaVote[actor.id] = targetId; | |
| actor.actionTarget = targetId; | |
| } else if (type === 'SAVE' && actor.role === 'DOCTOR') { | |
| room.nightActions.doctorTarget = targetId; | |
| actor.actionTarget = targetId; | |
| } else if (type === 'INVESTIGATE' && actor.role === 'DETECTIVE') { | |
| room.nightActions.detectiveTarget = targetId; | |
| actor.actionTarget = targetId; | |
| } | |
| } else if (room.phase === 'DAY') { | |
| if (action === 'STEAL' && actor.role === 'THIEF') { | |
| if (actor.thiefCooldown === room.round) return; // Prevent "hot potato" effect | |
| const { targetId } = payload; | |
| const victim = room.players.find(p => p.id === targetId); | |
| if (!victim || !victim.isAlive || victim.id === actor.id) return; | |
| // SPECIAL RULE: Stealing from Mafia is FATAL | |
| if (victim.role === 'MAFIA') { | |
| actor.isAlive = false; // Thief dies | |
| victim.isRobbed = true; // Still mark him to trigger the burning card animation | |
| room.history.push({ | |
| phase: 'DAY', | |
| round: room.round, | |
| actorId: actor.id, | |
| targetId: victim.id, | |
| action: 'STEAL', | |
| result: 'THIEF_DIED_BY_MAFIA' | |
| }); | |
| return; | |
| } | |
| const victimRole = victim.role || 'VILLAGER'; | |
| // THE SWAP (Normal) | |
| actor.role = victimRole; | |
| victim.role = 'THIEF'; | |
| victim.isRobbed = true; | |
| victim.thiefCooldown = room.round; // Cooldown for the new thief until next round | |
| room.history.push({ | |
| phase: 'DAY', | |
| round: room.round, | |
| actorId: actor.id, | |
| targetId: victim.id, | |
| action: 'STEAL', | |
| result: 'ROLE_STOLEN' | |
| }); | |
| } else if (action === 'SWAP_VOTES' && actor.role === 'PLAYWRIGHT') { | |
| const { targetA, targetB } = payload; | |
| room.nightActions.playwrightSwap = { targetA, targetB }; | |
| room.history.push({ | |
| phase: 'DAY', | |
| round: room.round, | |
| actorId: actor.id, | |
| targetId: `${targetA},${targetB}`, | |
| action: 'SWAP_VOTES', | |
| result: 'SUCCESS' | |
| }); | |
| } | |
| } else if (room.phase === 'VOTING') { | |
| const { targetId } = payload; | |
| if (actor.actionTarget) return; // Already voted | |
| room.votes[targetId] = (room.votes[targetId] || 0) + 1; | |
| actor.actionTarget = targetId; | |
| const target = room.players.find(p => p.id === targetId); | |
| if (target) target.votesReceived = (target.votesReceived || 0) + 1; | |
| // AUTO-SKIP REMOVED: Game waits for full timer. | |
| } | |
| } | |
| private startPhase(room: Room, phase: GamePhase) { | |
| room.phase = phase; | |
| room.timer = this.getPhaseDuration(phase, room); | |
| if (phase === 'NIGHT' && room.round === 1) room.timer += 5; | |
| // Reset phase state | |
| if (phase === 'NIGHT') { | |
| room.nightActions = { mafiaVote: {}, doctorTarget: null, detectiveTarget: null, playwrightSwap: null }; | |
| room.players.forEach(p => p.actionTarget = null); | |
| } else if (phase === 'VOTING') { | |
| room.votes = {}; | |
| room.players.forEach(p => p.votesReceived = 0); | |
| } | |
| if (phase === 'NIGHT' || phase === 'VOTING') { | |
| this.triggerBotActions(room); | |
| } | |
| } | |
| private triggerBotActions(room: Room) { | |
| const delay = Math.floor(Math.random() * 3000) + 2000; | |
| setTimeout(() => { | |
| if (room.status !== 'PLAYING') return; | |
| const bots = room.players.filter(p => p.isBot && p.isAlive); | |
| bots.forEach(bot => { | |
| const decision = BotManager.decideAction(room, bot); | |
| if (decision.action) { | |
| this.handleAction(room, bot.id, decision.action, { | |
| targetId: decision.targetId, | |
| targetA: decision.targetA, | |
| targetB: decision.targetB, | |
| type: decision.action | |
| }); | |
| } | |
| }); | |
| }, delay); | |
| } | |
| private assignRoles(room: Room) { | |
| const playerCount = room.players.length; | |
| let roles: Role[] = []; | |
| // 1. Build the "Wishlist" from Settings | |
| const requestedMafia = room.settings?.mafiaCount || 1; | |
| const hasDoctor = room.settings?.roles.DOCTOR !== false; | |
| const hasDetective = room.settings?.roles.DETECTIVE !== false; | |
| const hasJoker = !!room.settings?.roles.JOKER; | |
| const hasThief = !!room.settings?.roles.THIEF; | |
| const hasPlaywright = !!room.settings?.roles.PLAYWRIGHT; | |
| // 2. Smart Allocation Logic | |
| // Priority: MAFIA -> DOCTOR -> DETECTIVE -> THIEF -> PLAYWRIGHT -> JOKER -> VILLAGERS | |
| // Add Mafia (but never more than playerCount / 2) | |
| const actualMafiaCount = Math.min(requestedMafia, Math.floor(playerCount / 2)); | |
| for (let i = 0; i < actualMafiaCount; i++) roles.push('MAFIA'); | |
| // Add special roles if slots remain | |
| if (roles.length < playerCount && hasDoctor) roles.push('DOCTOR'); | |
| if (roles.length < playerCount && hasDetective) roles.push('DETECTIVE'); | |
| if (roles.length < playerCount && hasThief) roles.push('THIEF'); | |
| if (roles.length < playerCount && hasPlaywright) roles.push('PLAYWRIGHT'); | |
| if (roles.length < playerCount && hasJoker) roles.push('JOKER'); | |
| // 3. Fill the rest with Villagers | |
| while (roles.length < playerCount) { | |
| roles.push('VILLAGER'); | |
| } | |
| // 4. Shuffle and Assign | |
| for (let i = roles.length - 1; i > 0; i--) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| [roles[i], roles[j]] = [roles[j], roles[i]]; | |
| } | |
| room.players.forEach((p, i) => { | |
| p.role = roles[i]; | |
| p.isAlive = true; | |
| p.votesReceived = 0; | |
| p.actionTarget = null; | |
| }); | |
| } | |
| private resolveNight(room: Room) { | |
| const { mafiaVote, doctorTarget } = room.nightActions; | |
| // 1. Tally Mafia Votes (Only from ALIVE Mafia) | |
| const aliveMafiaIds = room.players.filter(p => p.role === 'MAFIA' && p.isAlive).map(p => p.id); | |
| const tally: Record<string, number> = {}; | |
| aliveMafiaIds.forEach(mId => { | |
| const targetId = mafiaVote[mId]; | |
| if (targetId) { | |
| tally[targetId] = (tally[targetId] || 0) + 1; | |
| } | |
| }); | |
| // 2. Find target with MOST votes | |
| let maxVotes = 0; | |
| let tiedTargets: string[] = []; | |
| for (const [targetId, count] of Object.entries(tally)) { | |
| if (count > maxVotes) { | |
| maxVotes = count; | |
| tiedTargets = [targetId]; | |
| } else if (count === maxVotes) { | |
| tiedTargets.push(targetId); | |
| } | |
| } | |
| // 3. Resolve target (TIE BREAKER: Prefer Human Choice, then Random) | |
| let mafiaTargetId: string | null = null; | |
| if (tiedTargets.length > 0) { | |
| // Find if a human alive mafia voted for one of the tied targets | |
| const humanMafia = room.players.find(p => p.role === 'MAFIA' && p.isAlive && !p.isBot && p.actionTarget); | |
| if (humanMafia && tiedTargets.includes(humanMafia.actionTarget!)) { | |
| mafiaTargetId = humanMafia.actionTarget!; | |
| } else { | |
| mafiaTargetId = tiedTargets[Math.floor(Math.random() * tiedTargets.length)]; | |
| } | |
| } | |
| let victimName = 'Tidak ada'; | |
| let victimAvatar = ''; | |
| if (mafiaTargetId) { | |
| if (mafiaTargetId !== doctorTarget) { | |
| const victim = room.players.find(p => p.id === mafiaTargetId); | |
| const mafia = room.players.find(p => p.role === 'MAFIA'); | |
| if (victim && victim.isAlive) { | |
| victim.isAlive = false; | |
| victimName = victim.username; | |
| victimAvatar = victim.avatar; | |
| // Find the actual voter (killer) to notify the victim | |
| const mafiaVoter = Object.keys(room.nightActions.mafiaVote).find(id => room.nightActions.mafiaVote[id] === mafiaTargetId); | |
| room.history.push({ | |
| phase: 'NIGHT', | |
| round: room.round, | |
| actorId: mafiaVoter || mafia?.id || 'MAFIA', | |
| targetId: victim.id, | |
| action: 'KILLED_BY_MAFIA', | |
| result: 'SUCCESS' | |
| }); | |
| } | |
| } else if (doctorTarget) { | |
| room.history.push({ | |
| phase: 'NIGHT', | |
| round: room.round, | |
| actorId: 'DOCTOR', | |
| targetId: doctorTarget, | |
| action: 'SAVED_BY_DOCTOR', | |
| result: 'SUCCESS' | |
| }); | |
| } | |
| } | |
| // 3.5. Resolve Detective Investigation | |
| const { detectiveTarget } = room.nightActions; | |
| if (detectiveTarget) { | |
| const target = room.players.find(p => p.id === detectiveTarget); | |
| const detective = room.players.find(p => p.role === 'DETECTIVE' && p.isAlive); | |
| if (target && detective) { | |
| const result = target.role === 'MAFIA' ? 'MAFIA' : 'INNOCENT'; | |
| room.history.push({ | |
| phase: 'NIGHT', | |
| round: room.round, | |
| actorId: detective.id, | |
| targetId: target.id, | |
| action: 'INVESTIGATE', | |
| result: result | |
| }); | |
| } | |
| } | |
| // 4. Set Night Result Message | |
| if (!mafiaTargetId) { | |
| room.lastNightResult = 'Malam yang damai.'; | |
| } else if (victimName !== 'No one') { | |
| room.lastNightResult = `${victimName} telah terbunuh.`; | |
| } else { | |
| // Kill was attempted but failed (Doctor saved) | |
| // Set to empty string to avoid redundant "Mafia gagal" message | |
| room.lastNightResult = ''; | |
| } | |
| this.checkWinCondition(room); | |
| } | |
| private resolveVoting(room: Room) { | |
| // 1. Check for Playwright Vote Swap | |
| const hasSwap = !!room.nightActions.playwrightSwap; | |
| if (hasSwap) { | |
| const { targetA, targetB } = room.nightActions.playwrightSwap!; | |
| const votesA = room.votes[targetA] || 0; | |
| const votesB = room.votes[targetB] || 0; | |
| room.votes[targetA] = votesB; | |
| room.votes[targetB] = votesA; | |
| const pA = room.players.find(p => p.id === targetA); | |
| const pB = room.players.find(p => p.id === targetB); | |
| if (pA) pA.votesReceived = votesB; | |
| if (pB) pB.votesReceived = votesA; | |
| room.history.push({ | |
| phase: 'VOTING_RESULT', | |
| round: room.round, | |
| actorId: 'SYSTEM', | |
| targetId: `${targetA},${targetB}`, | |
| action: 'VOTES_SWAPPED', | |
| result: 'SUCCESS' | |
| }); | |
| } | |
| const { candidateId, isTie } = this.getVoteResult(room); | |
| // 2. Prepare the result but don't kill yet (will happen in startPhase of VOTING_RESULT) | |
| if (candidateId && !isTie) { | |
| room.lastNightResult = `Hasil voting: ${room.players.find(p => p.id === candidateId)?.username} telah dieliminasi.`; | |
| } else { | |
| room.lastNightResult = "Hasil voting: Tidak ada pemain yang dieliminasi."; | |
| } | |
| // 3. Move to VOTING_RESULT phase | |
| this.startPhase(room, 'VOTING_RESULT' as any); | |
| // 4. Set dynamic timer for the Result phase | |
| room.timer = hasSwap ? 15 : 10; | |
| } | |
| private checkWinCondition(room: Room) { | |
| if (room.status === 'GAME_OVER') return; | |
| // 1. HIGHEST PRIORITY: JOKER | |
| // Check if a Joker was eliminated in the current or previous round | |
| const jokerWin = room.players.find(p => p.role === 'JOKER' && !p.isAlive && room.history.some(h => h.actorId === 'SYSTEM' && h.targetId === p.id && h.action === 'VOTE_OUT')); | |
| if (jokerWin) { | |
| this.endGame(room, 'JOKER'); | |
| return; | |
| } | |
| // 2. SECOND PRIORITY: PLAYWRIGHT | |
| const playwright = room.players.find(p => p.role === 'PLAYWRIGHT'); | |
| if (playwright && playwright.elimTeamHistory) { | |
| const uniqueTeams = new Set(playwright.elimTeamHistory); | |
| if (uniqueTeams.size >= 2) { | |
| this.endGame(room, 'PLAYWRIGHT'); | |
| return; | |
| } | |
| } | |
| // 3. STANDARD TEAMS | |
| const alive = room.players.filter(p => p.isAlive); | |
| const mafia = alive.filter(p => p.role === 'MAFIA'); | |
| const villagers = alive.filter(p => p.role !== 'MAFIA'); | |
| if (mafia.length === 0) { | |
| this.endGame(room, 'VILLAGERS'); | |
| } else if (mafia.length >= villagers.length) { | |
| this.endGame(room, 'MAFIA'); | |
| } | |
| } | |
| private endGame(room: Room, winner: string) { | |
| room.status = 'GAME_OVER'; // Use consistent status | |
| room.phase = 'GAME_OVER'; | |
| room.winner = winner as any; | |
| room.timer = 0; | |
| } | |
| } |