Place2Play / server /src /engines /MafiaEngine.ts
3v324v23's picture
feat: comprehensive game logic fixes, UX upgrades, and mobile optimization
2498190
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;
}
}