Repository navigation
Rozbudowa spotkań i głosowań w podsumowaniu gry #371
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from 6 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
903298f
feat(dramaafera): rozbuduj podsumowanie spotkan i glosowan
MalkizH3 0073d59
Merge branch 'main' into meetingi
quarties df8bbb9
Merge branch 'main' into meetingi
quarties 0d79c2a
Popraw zgony na spotkaniach i uwagi do widoku głosowania
MalkizH3 5164b0e
Merge branch 'main' into meetingi
MalkizH3 dc5d4f8
perf(meetings): index death summaries and skip unrelated action details
MalkizH3 b49f245
fix(meetings): require death evidence for guess attempts
MalkizH3 cc97a8b
Merge branch 'main' into meetingi
MalkizH3 b632208
Merge branch 'main' into meetingi
MalkizH3 9b4980d
fix(dramaafera): retain guess deaths in the final meeting
MalkizH3 e315d0b
Merge branch 'main' into meetingi
MalkizH3 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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
107
src/app/dramaafera/_services/games/_meetingDeaths.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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']]]); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) || | ||
| (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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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']); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.