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.
21 changes: 21 additions & 0 deletions src/app/dramaafera/_services/games/_meetingDeaths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getDeathsDuringMeetings } from './_meetingDeaths';

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

test('places a guess victim in the meeting preceding the death summary', () => {
expect(getDeathsDuringMeetings(meetings, [{ type: 'kill', target: { name: 'A' }, detail: '{"isGuess":true}' }]).get(1)).toEqual(['A']);
});

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

test('ignores ordinary kills and malformed details', () => {
expect(getDeathsDuringMeetings(meetings, [
{ type: 'kill', target: { name: 'A' }, detail: null },
{ type: 'kill', target: { name: 'A' }, detail: 'invalid' },
]).size).toBe(0);
});
25 changes: 25 additions & 0 deletions src/app/dramaafera/_services/games/_meetingDeaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
type Meeting = { meetingNumber: number; deathsSinceLastMeeting: string[] };
type Action = { type: string; detail: string | null; target: { name: string } | null };

export function getDeathsDuringMeetings(meetings: Meeting[], actions: Action[]): Map<number, string[]> {
const result = new Map<number, string[]>();
for (const action of actions) {
if (action.type !== 'kill' || !action.target || !action.detail) continue;
let detail: unknown;
try { detail = JSON.parse(action.detail); } catch { continue; }
if (!detail || typeof detail !== 'object') continue;
const isMeetingDeath = ('isGuess' in detail && detail.isGuess === true) ||
('causeOfDeath' in detail && ['Jailor', 'Prosecutor', 'Guess', 'Misguess'].includes(String(detail.causeOfDeath)));
Comment thread
quarties marked this conversation as resolved.
Resolved
if (!isMeetingDeath) continue;
const name = action.target.name;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Ustal faktyczną ofiarę nietrafionego zgadywania zamiast zawsze używać action.target.

Kontrakt w src/app/api/schema/gamesV2.ts wyraźnie opisuje, że dla nietrafionego zgadywania Vigilante/Assassina z guessedRole lub guessedModifier pole target wskazuje zgadywanego gracza, który nadal żyje; ginie zgadujący. detailFor zachowuje te pola w bazie. Tutaj każde kill z isGuess: true bezwarunkowo traktuje cel jako ofiarę, a zdarzenia death są pomijane.

Przykład: na spotkaniu #1 A błędnie zgaduje rolę B, a podsumowanie spotkania #2 zawiera zgon A. Helper szuka B w podsumowaniach; jeśli B przeżyje grę, wybiera ostatnie spotkanie i zwraca #2 -> [B] zamiast #1 -> [A]. Widok błędnie oznacza B jako zabitego i usuwa go z odtwarzanego zbioru żywych. Należy rozwiązać rzeczywistą ofiarę z semantyki akcji (z uwzględnieniem autora/zdarzenia śmierci), dopiero potem przypisać spotkanie. Proszę dodać test dla nietrafionego zgadywania z żyjącym celem.

// 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 = meetings.findIndex(meeting => meeting.deathsSinceLastMeeting.some(death => death.split(' (')[0].trim() === name));
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