Skip to content
Merged
Binary file added public/images/mira/abilities/BlackmailLetter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/mira/abilities/InJail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
107 changes: 107 additions & 0 deletions src/app/dramaafera/_services/games/_meetingDeaths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { getDeathsDuringMeetings } from './_meetingDeaths';
import { getMeetingParticipants } from '@/app/dramaafera/_utils/meetingParticipants';
import type { TimelineAction } from './_buildGameTimeline';
import type { UIMeetingData } from './types';

const meetings = [
{ meetingNumber: 1, deathsSinceLastMeeting: [] },
{ meetingNumber: 2, deathsSinceLastMeeting: ['A (zastrzelony)'] },
];

function action(overrides: Partial<TimelineAction> = {}): TimelineAction {
return {
type: 'kill', target: { name: 'A' }, performer: { name: 'B' },
performerRole: 'Assassin', targetRole: null, timestampMs: 1000,
isCorrect: true, detail: '{"isGuess":true}', ...overrides,
};
}

test('places a guess victim in the meeting preceding the death summary', () => {
expect(getDeathsDuringMeetings(meetings, [action()]).get(1)).toEqual(['A']);
});

test('includes final meeting executions even without a following summary', () => {
expect(getDeathsDuringMeetings(meetings, [action({ target: { name: 'B' }, detail: '{"causeOfDeath":"Jailor"}' })]).get(2)).toEqual(['B']);
});

test.each(['guessedRole', 'guessedModifier'])('a misguess with %s kills the guesser and leaves the target alive', (field) => {
const deaths = getDeathsDuringMeetings(meetings, [action({
performer: { name: 'A' }, target: { name: 'B' }, isCorrect: false,
detail: JSON.stringify({ isGuess: true, [field]: 'Some identity' }),
})]);
expect(Array.from(deaths)).toEqual([[1, ['A']]]);
const uiMeetings: UIMeetingData[] = meetings.map(meeting => ({
...meeting, votes: {}, skipVotes: [], noVotes: [], wasTie: false, wasBlessed: false,
deathsDuringMeeting: deaths.get(meeting.meetingNumber),
}));
expect(getMeetingParticipants(uiMeetings, ['A', 'B']).get(2)).toEqual(['B']);
});

test('legacy misguesses without guessed identity retain the recorded target victim', () => {
expect(getDeathsDuringMeetings(meetings, [action({
performer: { name: 'A' }, target: { name: 'A' }, isCorrect: false,
})]).get(1)).toEqual(['A']);
});

test('successful guesses with guessed identity still kill the target', () => {
expect(getDeathsDuringMeetings(meetings, [action({
detail: '{"isGuess":true,"guessedRole":"Sheriff"}',
})]).get(1)).toEqual(['A']);
});

test('uses a recorded misguess death when the guess verdict is unknown, without duplicates', () => {
expect(Array.from(getDeathsDuringMeetings(meetings, [
action({ performer: { name: 'A' }, target: { name: 'B' }, isCorrect: null }),
action({ type: 'death', performer: { name: 'A' }, target: null, detail: '{"causeOfDeath":"Misguess"}' }),
]))).toEqual([[1, ['A']]]);
});

test('does not match a death from another timestamp to a successful guess', () => {
expect(getDeathsDuringMeetings(meetings, [
action({ performer: { name: 'B' }, target: { name: 'A' } }),
action({ type: 'death', performer: { name: 'B' }, target: null, timestampMs: 2000, detail: '{"causeOfDeath":"Misguess"}' }),
]).get(1)).toEqual(['A']);
});

test.each(['Deputy', 'Jailor', 'Prosecutor'])('recognizes %s deaths by cause', (causeOfDeath) => {
expect(getDeathsDuringMeetings(meetings, [action({ detail: JSON.stringify({ causeOfDeath }) })]).get(1)).toEqual(['A']);
});

test.each(['Deputy', 'Jailor', 'Prosecutor'])('recognizes %s kills even without detail', (performerRole) => {
expect(getDeathsDuringMeetings(meetings, [action({ performerRole, detail: null })]).get(1)).toEqual(['A']);
});

test('death events identify the victim by performer, not target', () => {
expect(getDeathsDuringMeetings(meetings, [action({
type: 'death', performer: { name: 'A' }, target: { name: 'B' }, detail: '{"causeOfDeath":"Deputy"}',
})]).get(1)).toEqual(['A']);
});

test('ignores ordinary kills, ordinary deaths of meeting roles and malformed details', () => {
expect(getDeathsDuringMeetings(meetings, [
action({ detail: null }), action({ detail: 'invalid' }), action({ detail: '[]' }),
action({ type: 'death', performerRole: 'Prosecutor', detail: '{"causeOfDeath":"Murdered"}' }),
]).size).toBe(0);
});

test('handles missing targets, empty meetings and deaths before the first meeting', () => {
expect(getDeathsDuringMeetings([], [action()]).size).toBe(0);
expect(getDeathsDuringMeetings(meetings, [action({ target: null })]).size).toBe(0);
expect(getDeathsDuringMeetings([{ meetingNumber: 1, deathsSinceLastMeeting: ['A (Guess)'] }], [action()]).size).toBe(0);
});


test('uses the first death summary when a revived victim appears again', () => {
const repeated = [
{ meetingNumber: 1, deathsSinceLastMeeting: [] },
{ meetingNumber: 2, deathsSinceLastMeeting: [' A (Guess)'] },
{ meetingNumber: 3, deathsSinceLastMeeting: ['A (Guess)'] },
];
expect(Array.from(getDeathsDuringMeetings(repeated, [action()]))).toEqual([[1, ['A']]]);
});

test('ignores unrelated actions without reading their details', () => {
const unrelated = action({ type: 'task_completed' });
Object.defineProperty(unrelated, 'detail', { get() { throw new Error('Unrelated detail read'); } });
expect(Array.from(getDeathsDuringMeetings(meetings, [unrelated, action()]))).toEqual([[1, ['A']]]);
});
77 changes: 77 additions & 0 deletions src/app/dramaafera/_services/games/_meetingDeaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { TimelineAction } from './_buildGameTimeline';

type Meeting = { meetingNumber: number; deathsSinceLastMeeting: string[] };
type Action = Pick<TimelineAction, 'type' | 'detail' | 'target' | 'performer' | 'performerRole' | 'isCorrect' | 'timestampMs'>;

const MEETING_CAUSES = new Set(['Jailor', 'Prosecutor', 'Deputy', 'Guess', 'Misguess']);
const MEETING_KILLERS = new Set(['Jailor', 'Prosecutor', 'Deputy']);

function parseDetail(detail: string | null): Record<string, unknown> {
if (!detail) return {};
try {
const parsed: unknown = JSON.parse(detail);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown> : {};
} catch {
return {};
}
}

export function getDeathsDuringMeetings(meetings: Meeting[], actions: Action[]): Map<number, string[]> {
const result = new Map<number, string[]>();
if (meetings.length === 0) return result;
// Task/vote/etc. details are irrelevant here. Parse only death-related records.
const records = actions
.filter(action => action.type === 'kill' || action.type === 'death')
.map(action => ({ action, detail: parseDetail(action.detail) }));
// Preserve findIndex semantics: the first summary mentioning a victim wins,
// even if the player was revived and appears in a later summary as well.
const firstSummaryByVictim = new Map<string, number>();
meetings.forEach((meeting, index) => {
meeting.deathsSinceLastMeeting.forEach(death => {
const name = death.split(' (')[0].trim();
if (!firstSummaryByVictim.has(name)) firstSummaryByVictim.set(name, index);
});
});
// A death action's performer is the victim, whereas a kill action's performer
// is the killer. Use a matching recorded death when a guess verdict is unknown.
const recordedDeaths = new Map<number, Set<string>>();
for (const { action, detail } of records) {
if (action.type !== 'death' || !MEETING_CAUSES.has(String(detail.causeOfDeath))) continue;
const names = recordedDeaths.get(action.timestampMs) ?? new Set<string>();
names.add(action.performer.name);
recordedDeaths.set(action.timestampMs, names);
}

for (const { action, detail } of records) {
const isGuess = detail.isGuess === true;
const isMeetingDeath = isGuess || MEETING_CAUSES.has(String(detail.causeOfDeath)) ||
Comment thread
quarties marked this conversation as resolved.
Resolved
(action.type === 'kill' && MEETING_KILLERS.has(action.performerRole));
if (!isMeetingDeath) continue;

let name = action.type === 'death' ? action.performer.name : action.target?.name;
if (action.type === 'kill') {
const hasGuessedIdentity = typeof detail.guessedRole === 'string' || typeof detail.guessedModifier === 'string';
// Since mod 1.1.0 a misguess with guessedRole/guessedModifier targets
// the living guessed player. Older payloads already target the victim.
if (detail.causeOfDeath === 'Misguess' || (isGuess && hasGuessedIdentity && action.isCorrect === false)) {
name = action.performer.name;
} else if (isGuess) {
const deaths = recordedDeaths.get(action.timestampMs);
if (deaths?.has(action.performer.name) && (!name || !deaths.has(name))) {
name = action.performer.name;
}
}
}
if (!name) continue;
// Meeting deaths appear in the following meeting's death summary. The final
// meeting has no following summary, so its guess/execution victims need actions.
const nextIndex = firstSummaryByVictim.get(name) ?? -1;
const meeting = nextIndex > 0 ? meetings[nextIndex - 1] : nextIndex === -1 ? meetings[meetings.length - 1] : undefined;
if (!meeting) continue;
const deaths = result.get(meeting.meetingNumber) ?? [];
if (!deaths.includes(name)) deaths.push(name);
result.set(meeting.meetingNumber, deaths);
}
return result;
}
9 changes: 7 additions & 2 deletions src/app/dramaafera/_services/games/getGameData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { calculateWinnerFromStats } from './winCalculator';
import { buildPlayerStats } from './_buildPlayerStats';
import { countVotes } from './_countVotes';
import { buildGameTimeline } from './_buildGameTimeline';
import { getDeathsDuringMeetings } from './_meetingDeaths';

// Fetch detailed game data
// Player relations are selected down to `name` on purpose. Identity columns
Expand Down Expand Up @@ -46,8 +47,8 @@ export async function getGameData(gameId: string): Promise<UIGameData | null> {
meetingVotes: {
where: { target: withoutDeleted, voter: withoutDeleted },
include: {
target: true,
voter: true
target: { select: { name: true } },
voter: { select: { name: true } }
}
},
skipVotes: {
Expand Down Expand Up @@ -140,6 +141,7 @@ export async function getGameData(gameId: string): Promise<UIGameData | null> {
const deathsSinceLastMeeting = JSON.parse(meeting.deathsSinceLastMeeting || '[]');

return {
exiledPlayer: game.gameActions.length > 0 ? meeting.exiledPlayer : undefined,
meetingNumber: meeting.meetingNumber,
deathsSinceLastMeeting,
votes,
Expand All @@ -152,6 +154,9 @@ export async function getGameData(gameId: string): Promise<UIGameData | null> {
};
});

const meetingDeaths = getDeathsDuringMeetings(meetings, game.gameActions);
meetings.forEach(meeting => { meeting.deathsDuringMeeting = meetingDeaths.get(meeting.meetingNumber) ?? []; });

const timeline = buildGameTimeline(game.gameActions, game.season);

const winnerInfo = calculateWinnerFromStats(game.gamePlayerStatistics, game.season);
Expand Down
3 changes: 3 additions & 0 deletions src/app/dramaafera/_services/games/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ export interface UIGameEvent {
}

export interface UIMeetingData {
deathsDuringMeeting?: string[];
/** Undefined for legacy games where the outcome was not recorded explicitly. */
exiledPlayer?: string | null;
meetingNumber: number;
deathsSinceLastMeeting: string[];
votes: { [playerName: string]: string[] };
Expand Down
20 changes: 20 additions & 0 deletions src/app/dramaafera/_utils/meetingParticipants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { getMeetingParticipants } from './meetingParticipants';
import type { UIMeetingData } from '../_services/games/types';

const meeting = (overrides: Partial<UIMeetingData>): UIMeetingData => ({
meetingNumber: 1, deathsSinceLastMeeting: [], votes: {}, skipVotes: [], noVotes: [], wasTie: false, wasBlessed: false, ...overrides,
});

test('includes living players when the final meeting ends before voting', () => {
const meetings = [meeting({ votes: { B: ['A'] }, exiledPlayer: 'B' }), meeting({ meetingNumber: 2, deathsSinceLastMeeting: ['C (zabity)'] })];
expect(getMeetingParticipants(meetings, ['A', 'B', 'C', 'D']).get(2)).toEqual(['A', 'D']);
});

test('does not add the full roster to a completed voting meeting', () => {
expect(getMeetingParticipants([meeting({ votes: { B: ['A'] } })], ['A', 'B', 'C']).get(1)).toEqual(['B', 'A']);
});

test('restores players recorded as participants after an earlier death', () => {
const meetings = [meeting({ deathsSinceLastMeeting: ['B'] }), meeting({ meetingNumber: 2, noVotes: ['B'] })];
expect(getMeetingParticipants(meetings, ['A', 'B']).get(2)).toEqual(['A', 'B']);
});
24 changes: 24 additions & 0 deletions src/app/dramaafera/_utils/meetingParticipants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { UIMeetingData } from '../_services/games/types';

/** Recover the final unvoted meeting from the roster and recorded deaths/exiles. */
export function getMeetingParticipants(meetings: UIMeetingData[], roster: string[]): Map<number, string[]> {
const alive = new Set(roster);
const result = new Map<number, string[]>();
meetings.forEach((meeting, index) => {
meeting.deathsSinceLastMeeting.forEach(death => alive.delete(death.split(' (')[0].trim()));
const recorded = new Set([
...Object.keys(meeting.votes),
...Object.values(meeting.votes).flat(),
...meeting.skipVotes,
...meeting.noVotes,
...(meeting.deathsDuringMeeting ?? []),
]);
// A recorded participant may have been revived since an earlier meeting.
recorded.forEach(name => alive.add(name));
const hasBallots = Object.values(meeting.votes).some(voters => voters.length > 0) || meeting.skipVotes.length > 0;
result.set(meeting.meetingNumber, index === meetings.length - 1 && !hasBallots ? Array.from(alive) : Array.from(recorded));
if (meeting.exiledPlayer) alive.delete(meeting.exiledPlayer);
meeting.deathsDuringMeeting?.forEach(name => alive.delete(name));
});
return result;
}
Loading
Loading