From f1390e2039ab2fc456e1b0b3b2ace9d0ad4666dc Mon Sep 17 00:00:00 2001 From: Pavlov Alexandr Date: Mon, 15 Jun 2026 00:37:30 +0700 Subject: [PATCH] fix(statistics): credit deaths from the authoritative raw_snapshot counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk full-run recalc reads the per-player death counter from the parser_events table, but full-run never re-persists parser_events from raw_snapshot (only the single-replay audit path does). ~90% of sg replays have 0 player_counter rows in parser_events while raw_snapshot carries the compact counters, so a death with no independent victim kill-row (null-killer / suicide / environmental, d>0) is dropped from both death paths — games are counted (built from raw_snapshot) but the death is lost. Verified on staging: sg deaths 282720 vs parser's own d>0 signal 293744 (legacy 297878); the gap ≈ sg null-killer+suicide death-events (nkd 6577 + su 6143). Fix: resolvedPlayers attaches counterDeaths from the authoritative raw_snapshot (artifact player.d/td); the service folds it into the per-replay scratch alongside the event paths. The one-life cap (foldCappedDeaths, <=1 death & <=1 by_teamkills per replay) is unchanged, so it is collapse-safe (no double-count) and by_teamkills<=total holds. Games, parser/OCAP contract, schema and API untouched. Clean extraction of the deaths fix only — the debug branch's unrelated Sentry revert is excluded. Needs ops:stats:recalculate post-deploy to materialize the numbers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../260615-f13b-PLAN.md | 91 +++++++++++++ .../260615-f13b-SUMMARY.md | 107 +++++++++++++++ .../statistics/repository/repository.ts | 28 ++-- .../repository/tests/postgres.test.ts | 54 ++++++++ src/modules/statistics/service/service.ts | 64 ++++++++- .../service/tests/aggregates.test.ts | 125 ++++++++++++++++++ 6 files changed, 453 insertions(+), 16 deletions(-) create mode 100644 .planning/quick/260615-f13b-deaths-undercount/260615-f13b-PLAN.md create mode 100644 .planning/quick/260615-f13b-deaths-undercount/260615-f13b-SUMMARY.md diff --git a/.planning/quick/260615-f13b-deaths-undercount/260615-f13b-PLAN.md b/.planning/quick/260615-f13b-deaths-undercount/260615-f13b-PLAN.md new file mode 100644 index 0000000..daf9594 --- /dev/null +++ b/.planning/quick/260615-f13b-deaths-undercount/260615-f13b-PLAN.md @@ -0,0 +1,91 @@ +--- +quick_id: 260615-f13b +slug: deaths-undercount +status: in-progress +date: 2026-06-15 +branch: fix/f13a-deaths-debug +--- + +# Quick Task 260615-f13b — Deaths under-count: counter death signal read from stale `parser_events` + +## Symptom (proven, staging post-fix #18) + +- sg all-time `deaths.total` = **282720**; parser's own d>0 signal = **293744** + (distinct (lower-name, replay) with artifact `d>0`). Recalc loses ~**11024** + death-games vs the parser's own signal — a server-2 aggregation bug. +- Exemplar player **Zero** (`canonical_players.id = + 000a2ac8-443d-434c-9d30-ea8613c4c0ed`): recalc sg all-time games=894, + deaths=621; parser has Zero with d>0 in **653** distinct sg replays ⇒ **32** + death-games dropped while their GAME is counted. + +## Root cause — CONFIRMED by instrumentation on real data (not code reading) + +The recalc has two death sources in `service.ts`: +1. **counter path** — `player_counter` event with `deaths_total`/`deaths_by_teamkills` + (`applyCounterEvent`), and +2. **victim kill-row path** — another player's kill-row names the victim's eid + (`applyVictimDeath`). + +Both read from the `events` array, which the repository builds from the +**`parser_events` table** (`loadParserEvents`). But: + +- `parser_events` is re-persisted ONLY by the single-replay audit path + (`recalculateParserResult` → `persistParserArtifact` → `replaceParserEvents`). + The **bulk full-run** (`full-run-recalculation.ts`) NEVER re-persists events — + it reads whatever `parser_events` already exist. +- For ~90% of current sg replays (1850 / 2059 with artifact `d` players), the + persisted `parser_events` contain **0 `player_counter` rows**, while + `raw_snapshot` carries the compact counters. (Staging count, query in + `findings.sql`.) The counter death path therefore finds nothing. +- The player evidence (`resolvedPlayers`) AND games are built from + `raw_snapshot`, so the game is counted; only the death — when it has no + independent victim kill-row (null-killer / suicide / environmental / bleed-out + `d>0` with no attacker) — is lost. + +**Instrumentation evidence** (`instrument.ts`, real exported rows, real +`buildPlayerIdentityIndex` / `bestPlayerIdentityIndexed` / `playerIdentityMatchPriority` +/ `calculatePlayerAndSquadAggregates`): across 16 of Zero's d>0 replays, Zero +**resolves in all 16** (so the F12-timestamp/nickname-active hypothesis is +REFUTED — he matches via the time-ungated `display_name` priority-1 branch even +for pre-2026-05-10 replays), yet only 6 deaths are credited (3 via counter, 3 via +victim) and **10 are DROPPED** because for those replays `parser_events` has no +counter event for Zero's eid AND no kill-row names his eid as victim. + +Concrete dropped replay: **9eb1a2ad-60b8-4692-8520-47d8362c207c (2021-05-14)**, +current prid `3662f461-e6e1-460f-a985-9902e17c6f9e`. raw_snapshot Zero = +`{d:1, nkd:1, eid:203}`; `parser_events` for this prid = 140 kill / 18 +destroyed_vehicle / 6 teamkill / 1 diagnostic, **0 player_counter**; no kill-row +with `victim_entity_id=203` ⇒ death dropped, game kept. + +So the bug is: **the death counter signal is read from the stale `parser_events` +table instead of the authoritative `raw_snapshot`.** + +## Fix (minimal, service-layer + repository wiring) + +Read the per-player counter death signal from `raw_snapshot` (the same source +already used for player resolution and games) and fold it through the existing +capped fold, instead of depending on `parser_events.player_counter`. + +- Carry the artifact's compact counters (`d` → total, `td` → by_teamkills) onto + the resolved player evidence in `resolvedPlayers` (repository), keyed by the + resolved `playerId`/`squadId`. +- In `calculatePlayerAndSquadAggregates` (service), tally those counter deaths + into the per-replay scratch maps alongside the existing victim path, then + `foldCappedDeaths` as today. The `player_counter` EVENT path stays as a + no-op-when-absent fallback (kept for the audit path / OCAP events), but the + authoritative signal now comes from the artifact. + +### Invariants preserved +- One-life cap: ≤1 `total` and ≤1 `by_teamkills` per replay (`foldCappedDeaths`). +- `by_teamkills ≤ total` (counter total = `max(d, td)`, same as `counterDeaths`). +- Games unaffected (player/replay set already from `raw_snapshot`). +- Parser / OCAP contract untouched; no schema or API change. + +## Tests +- Regression unit test in `aggregates.test.ts`: a replay whose player carries an + artifact counter death but NO `player_counter` event and NO victim kill-row + must yield `deaths.total = 1` (red before fix, green after). + +## Gates +- `pnpm lint`, `pnpm typecheck`, `pnpm test`; `service.ts` 100% unit coverage. + Integration coverage gate needs Docker — note if unavailable. diff --git a/.planning/quick/260615-f13b-deaths-undercount/260615-f13b-SUMMARY.md b/.planning/quick/260615-f13b-deaths-undercount/260615-f13b-SUMMARY.md new file mode 100644 index 0000000..340d914 --- /dev/null +++ b/.planning/quick/260615-f13b-deaths-undercount/260615-f13b-SUMMARY.md @@ -0,0 +1,107 @@ +--- +quick_id: 260615-f13b +slug: deaths-undercount +status: complete +date: 2026-06-15 +branch: fix/f13a-deaths-debug +--- + +# Quick Task 260615-f13b — Summary + +## Confirmed runtime root cause (instrumented on real staging data) + +The recalc reads the death **counter** signal from the `parser_events` table, +which is **stale relative to `raw_snapshot`**. The bulk full-run +(`full-run-recalculation.ts`) never re-persists `parser_events` — only the +single-replay audit path (`recalculateParserResult` → `persistParserArtifact` → +`replaceParserEvents`) does. So for ~90% of current sg replays (1850 / 2059 with +artifact `d` players) `parser_events` has **0 `player_counter` rows**, while +`raw_snapshot` carries the compact counters. The death counter path +(`applyCounterEvent`) therefore finds nothing. + +Player resolution AND games are built from `raw_snapshot`, so the game is +counted; only the death — when it has no independent victim kill-row (null-killer +/ suicide / environmental / bleed-out `d>0` with no attacker) — is lost. This is +the platform-wide ~11024 death-game undercount. + +**Instrumentation** (`instrument.ts`, real exported rows, real +`buildPlayerIdentityIndex` / `bestPlayerIdentityIndexed` / `playerIdentityMatchPriority` +/ `calculatePlayerAndSquadAggregates`): across 16 of Zero's d>0 replays, Zero +**resolved in all 16** — so the F12-timestamp / `isNicknameActive` hypothesis is +**REFUTED**; he matches via the time-ungated `display_name` priority-1 branch even +for pre-2026-05-10 replays. Pre-fix only 6/16 deaths credited (3 counter, 3 +victim), **10 DROPPED**; post-fix **16/16, 0 dropped**. + +Concrete dropped replay: **9eb1a2ad-60b8-4692-8520-47d8362c207c (2021-05-14)**, +current prid `3662f461-e6e1-460f-a985-9902e17c6f9e`. raw_snapshot Zero = +`{d:1, nkd:1, eid:203}`; `parser_events` = 140 kill / 18 destroyed_vehicle / +6 teamkill / 1 diagnostic, **0 player_counter**; no kill row with +`victim_entity_id=203`. Logged decision: `deathCredited=0, branch=DROPPED`. + +The 4 prior code-reading hypotheses failed because the divergence is +runtime-only: the event-table state cannot be inferred from `raw_snapshot` by +reading code — it required querying both and comparing. + +## The fix (minimal, service + repository) + +Read the per-player death counter from the authoritative `raw_snapshot` (same +source already used for resolution and games), not the stale events table. + +- `service.ts` — `AggregatePlayerEvidence` gains optional `counterDeaths?: + DeathStats`. New `tallyArtifactCounterDeaths` folds it into the per-replay + scratch maps (player + squad) alongside the existing event paths. Exported + `artifactCounterDeaths(d, td)` builds the `DeathStats` with the SAME semantics + as the event-based `counterDeaths` (`total = max(d, td)`, `by_teamkills = td`); + `counterDeaths` now delegates to it (DRY). +- `repository.ts` — `resolvedPlayers` attaches `counterDeaths = + artifactCounterDeaths(player.d, player.td)` onto each resolved evidence row. + +### Invariants preserved +- One-life cap: ≤1 `total` and ≤1 `by_teamkills` per replay (`foldCappedDeaths`, + unchanged). Additive with the event paths — any overlap collapses to 1. +- `by_teamkills ≤ total` (the `max` cap). +- Games unaffected (player/replay set already from `raw_snapshot`). +- Parser / OCAP contract, schema, and API untouched. + +## Tests (red → green) +- `service/tests/aggregates.test.ts` — 4 new unit tests: artifact counter credits + a death with no event and no victim row; teamkill variant (`by_teamkills ≤ + total`); no double-count when artifact counter + victim row both present; + N-distinct-replays via artifact counter alone. 3 fail without the fix + (verified), all pass with it. +- `repository/tests/postgres.test.ts` — 1 new integration test: raw_snapshot + `d:1, nkd:1`, parser_events with NO counter and NO victim row → death credited + (`total:1`) through the real DB recalc. + +## Gates +- `pnpm lint` — green. `pnpm typecheck` — green. `pnpm format` — green. +- `pnpm test` (unit) — 661 passed (was 657 + 4 new); `aggregates.test.ts` 18/18. +- Coverage: `service.ts` is **100%** (stmts/branches/functions/lines) from the + service-module unit suite. The full `pnpm test:coverage` gate runs the + integration files (testcontainers PostgreSQL/RabbitMQ/S3); **Docker is + unavailable in this environment**, so the repo-wide 100% gate could not be + exercised. The `repository.ts` change (`resolvedPlayers` → `counterDeaths`) is + covered by the new `postgres.test.ts` integration test (runs under Docker in + CI). Per `solidstats-server-ts-tests` coverage-suppression guidance for the + Docker-unavailable case, the unit-coverable changed file is confirmed 100%. +- No OpenAPI / schema / parser-contract change (Phase-1 API gate N/A). + +## Config +- `eslint.config.js` — added `.planning/**` to ignores (the instrumentation + script and fixtures are GSD artifacts, not application source / outside the + tsconfig project), consistent with the existing `.agents/**` / `.claude/**` + ignores. + +## Artifacts +- `instrument.ts` + `replays.json` / `identities-zero.json` / `fixture-replays.csv` + — the reproduction and real exported fixtures. +- `findings.sql` — the read-only staging diagnostics. + +## Acceptance +- [x] Root cause confirmed by RUNNING (instrument logs the dropped replay's exact decision). +- [x] F12-timestamp / nickname-active hypothesis explicitly refuted with logged evidence. +- [x] Fix reads death from authoritative raw_snapshot; games unaffected. +- [x] One-life cap + `by_teamkills ≤ total` preserved. +- [x] Regression tests (unit red→green + integration) added. +- [x] lint / typecheck / format / unit tests green; `service.ts` 100%. +- [x] No parser, schema, or API-contract change. diff --git a/src/modules/statistics/repository/repository.ts b/src/modules/statistics/repository/repository.ts index d5a3cc8..c1187b0 100644 --- a/src/modules/statistics/repository/repository.ts +++ b/src/modules/statistics/repository/repository.ts @@ -12,6 +12,7 @@ import { type CommanderReplayInput, } from "../service/commander.js"; import { + artifactCounterDeaths, calculatePlayerAndSquadAggregates, type AggregatePlayerEvidence, type AggregateReplayInput, @@ -993,19 +994,22 @@ function resolvedPlayers(input: { return []; } const squadId = input.memberships.find( - (membership) => membership.player_id === identity.player_id, - )?.squad_id; + (membership) => membership.player_id === identity.player_id, + )?.squad_id, + // Authoritative per-player death signal from the artifact (`d`/`td`). The + // bulk full-run never re-persists `player_counter` events, so the + // event-based counter path is empty for most replays; carrying the + // artifact counter onto the evidence lets the aggregation credit deaths + // (incl. null-killer/suicide ones with no victim kill-row) from the same + // source it uses for player resolution and games (260615-f13b). + counterDeaths = artifactCounterDeaths(player.d, player.td); return [ - squadId === undefined - ? { - entityRef: String(player.eid), - playerId: identity.player_id, - } - : { - entityRef: String(player.eid), - playerId: identity.player_id, - squadId, - }, + { + entityRef: String(player.eid), + playerId: identity.player_id, + ...(counterDeaths === undefined ? {} : { counterDeaths }), + ...(squadId === undefined ? {} : { squadId }), + }, ]; }); } diff --git a/src/modules/statistics/repository/tests/postgres.test.ts b/src/modules/statistics/repository/tests/postgres.test.ts index 8168473..1e28a84 100644 --- a/src/modules/statistics/repository/tests/postgres.test.ts +++ b/src/modules/statistics/repository/tests/postgres.test.ts @@ -262,6 +262,60 @@ describe("PgStatisticsRepository", () => { }); }); + it("credits a death from the artifact counter when parser_events has no counter event and no victim kill row (260615-f13b)", async () => { + // Real staging repro: the bulk full-run never re-persists `player_counter` + // parser_events, so for ~90% of replays the events table has NO counter row, + // while raw_snapshot carries the player's d/td. A null-killer/suicide death + // (d>0, no attacker kill row) was dropped while the game stayed counted. The + // fix reads the death from the authoritative raw_snapshot via resolvedPlayers + // -> counterDeaths, so the death is credited end-to-end through the real DB. + const rotationId = await seedRotation(), + playerA = await seedPlayer("Alpha", "steam-a"), + parserResultId = await seedParserResult({ + rawSnapshot: { + contract_version: "3.0.0", + parser: {}, + // Alpha died once (d:1) with no killer (nkd:1) — the parser's own death + // signal, present only in raw_snapshot. + players: [{ d: 1, eid: 101, n: "Alpha", nkd: 1, sid: "steam-a" }], + replay: missionEnvelope("sg_assault"), + source: {}, + status: "success", + }, + replayTimestamp: "2026-02-01T12:00:00.000Z", + }); + + await fullRunRepository.classifyGameTypesForCurrentReplays(); + // Seed parser_events that DELIBERATELY lack any player_counter for eid 101 + // and any kill row naming 101 as victim — exactly the stale-events state in + // staging. A lone diagnostic keeps the event set non-empty. + await repository.replaceParserEvents(parserResultId, [ + { + eventType: "diagnostic", + observedPlayerRef: null, + payload: { code: "schema.note" }, + sourceRef: { index: 0 }, + }, + ]); + + await repository.recalculatePlayerAndSquadStatsForParserResult( + parserResultId, + ); + + const playerStats = await pool.query<{ + player_id: string; + stats: StatsRow; + }>( + "select player_id, stats from player_stats where rotation_id = $1 order by player_id", + [rotationId], + ); + + expect(statsById(playerStats.rows)[playerA]?.deaths).toEqual({ + by_teamkills: 0, + total: 1, + }); + }); + it("creates fallback player identities from no-SteamID parser names", async () => { const rotationId = await seedRotation(), parserResultId = await seedParserResult({ diff --git a/src/modules/statistics/service/service.ts b/src/modules/statistics/service/service.ts index 0ead0db..4f9d959 100644 --- a/src/modules/statistics/service/service.ts +++ b/src/modules/statistics/service/service.ts @@ -13,6 +13,14 @@ export interface StatisticsRepository { } export interface AggregatePlayerEvidence { + // The player's own compact death counters from the parser artifact + // (`raw_snapshot`): `total` = `d`, `by_teamkills` = `td`. This is the parser's + // authoritative per-player death signal and is always present on the artifact, + // unlike the `player_counter` parser_events row which the bulk full-run never + // re-persists (260615-f13b). Carried here so the aggregation reads deaths from + // the same authoritative source it already uses for player resolution and game + // counts, instead of depending on a possibly-stale events table. + counterDeaths?: DeathStats; entityRef: string; playerId: string; squadId?: string; @@ -115,6 +123,8 @@ export function calculatePlayerAndSquadAggregates( squad.playerIds.add(player.playerId); squad.replayIds.add(replay.replayId); } + + tallyArtifactCounterDeaths(player, eventContext); } for (const event of replay.events) { @@ -338,6 +348,34 @@ function applySquadCounterEvent( ); } +/** + * Tallies a player's authoritative artifact death counter (`raw_snapshot` `d`/`td`, + * carried as `counterDeaths`) into the per-replay scratch maps. The bulk full-run + * never re-persists `player_counter` parser_events, so for most replays the + * event-based counter path finds nothing; this recovers deaths with no independent + * victim kill-row (null-killer, suicide, environmental, bleed-out) that were + * otherwise dropped while the game was still counted (260615-f13b). Additive with + * the event paths — `foldCappedDeaths` collapses any overlap to <=1 death per replay. + */ +function tallyArtifactCounterDeaths( + player: AggregatePlayerEvidence, + context: ReplayEventContext, +): void { + if (player.counterDeaths === undefined) { + return; + } + incrementDeathsByCounter( + replayDeaths(context.replayPlayerDeaths, player.playerId), + player.counterDeaths, + ); + if (player.squadId !== undefined) { + incrementDeathsByCounter( + replayDeaths(context.replaySquadDeaths, player.squadId), + player.counterDeaths, + ); + } +} + function emptyDeaths(): DeathStats { return { by_teamkills: 0, total: 0 }; } @@ -397,11 +435,20 @@ function incrementDeathsByCounter( deaths.by_teamkills += counter.by_teamkills; } -function counterDeaths( - payload: Record, +/** + * Builds a player's death contribution from the parser artifact's compact + * counters (`d` total deaths, `td` teamkill deaths) using the SAME semantics as + * the event-based `counterDeaths`: `total = max(d, td)` (so `by_teamkills` can + * never exceed `total`), `by_teamkills = td`. Returns `undefined` when neither + * counter is a usable non-negative number, so a player with no death signal + * carries none (260615-f13b). + */ +export function artifactCounterDeaths( + deathsTotal: unknown, + deathsByTeamkills: unknown, ): DeathStats | undefined { - const total = nonNegativeCounter(payload["deaths_total"]), - byTeamkills = nonNegativeCounter(payload["deaths_by_teamkills"]); + const total = nonNegativeCounter(deathsTotal), + byTeamkills = nonNegativeCounter(deathsByTeamkills); if (total === undefined && byTeamkills === undefined) { return undefined; } @@ -411,6 +458,15 @@ function counterDeaths( }; } +function counterDeaths( + payload: Record, +): DeathStats | undefined { + return artifactCounterDeaths( + payload["deaths_total"], + payload["deaths_by_teamkills"], + ); +} + function nonNegativeCounter(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value diff --git a/src/modules/statistics/service/tests/aggregates.test.ts b/src/modules/statistics/service/tests/aggregates.test.ts index 84c9ff6..8dfec43 100644 --- a/src/modules/statistics/service/tests/aggregates.test.ts +++ b/src/modules/statistics/service/tests/aggregates.test.ts @@ -30,6 +30,20 @@ function twoDeathEventsReplay(replayId: string): AggregateReplayInput { }; } +function artifactCounterDeathReplay(replayId: string): AggregateReplayInput { + return { + events: [], + players: [ + { + counterDeaths: { by_teamkills: 0, total: 1 }, + entityRef: "202", + playerId: "player-b", + }, + ], + replayId, + }; +} + function counterAndKillReplay(replayId: string): AggregateReplayInput { return { events: [ @@ -695,4 +709,115 @@ describe("calculatePlayerAndSquadAggregates", () => { .deaths, ).toEqual({ by_teamkills: 0, total: 3 }); }); + + it("credits a death from the artifact counter (counterDeaths) when there is no player_counter event and no victim kill row", () => { + // 260615-f13b repro. The bulk full-run never re-persists `player_counter` + // parser_events, so for most replays the only death signal is the artifact's + // own d/td carried on the resolved player evidence as `counterDeaths`. A + // null-killer/suicide death has NO victim kill row, so before the fix the + // death was dropped while the game stayed counted. With the artifact counter + // tallied, the player is credited exactly one death. + const result = calculatePlayerAndSquadAggregates([ + { + events: [], + players: [ + { + counterDeaths: { by_teamkills: 0, total: 1 }, + entityRef: "202", + playerId: "player-b", + squadId: "squad-b", + }, + ], + replayId: "replay-1", + }, + ]); + + expect( + result.playerStats.find((player) => player.playerId === "player-b")?.stats + .deaths, + ).toEqual({ by_teamkills: 0, total: 1 }); + expect( + result.squadStats.find((squad) => squad.squadId === "squad-b")?.stats + .deaths, + ).toEqual({ by_teamkills: 0, total: 1 }); + }); + + it("credits a teamkill death from the artifact counter and keeps by_teamkills <= total", () => { + const result = calculatePlayerAndSquadAggregates([ + { + events: [], + players: [ + { + counterDeaths: { by_teamkills: 1, total: 1 }, + entityRef: "202", + playerId: "player-b", + squadId: "squad-b", + }, + ], + replayId: "replay-1", + }, + ]); + + const playerDeaths = result.playerStats.find( + (player) => player.playerId === "player-b", + )?.stats.deaths; + expect(playerDeaths).toEqual({ by_teamkills: 1, total: 1 }); + expect(playerDeaths?.by_teamkills ?? 0).toBeLessThanOrEqual( + playerDeaths?.total ?? 0, + ); + expect( + result.squadStats.find((squad) => squad.squadId === "squad-b")?.stats + .deaths, + ).toEqual({ by_teamkills: 1, total: 1 }); + }); + + it("does not double-count when the artifact counter and a victim kill row both report the death", () => { + // The artifact counter path and the event paths both tally into the per- + // replay scratch; `foldCappedDeaths` collapses the overlap to a single death. + const result = calculatePlayerAndSquadAggregates([ + { + events: [ + { + eventType: "kill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + { + eventType: "player_counter", + observedPlayerRef: "202", + payload: { deaths_total: 1 }, + sourceRef: {}, + }, + ], + players: [ + { entityRef: "101", playerId: "player-a" }, + { + counterDeaths: { by_teamkills: 0, total: 1 }, + entityRef: "202", + playerId: "player-b", + }, + ], + replayId: "replay-1", + }, + ]); + + expect( + result.playerStats.find((player) => player.playerId === "player-b")?.stats + .deaths, + ).toEqual({ by_teamkills: 0, total: 1 }); + }); + + it("counts one death per distinct replay a player died in via the artifact counter alone", () => { + const result = calculatePlayerAndSquadAggregates([ + artifactCounterDeathReplay("replay-1"), + artifactCounterDeathReplay("replay-2"), + artifactCounterDeathReplay("replay-3"), + ]); + + expect( + result.playerStats.find((player) => player.playerId === "player-b")?.stats + .deaths, + ).toEqual({ by_teamkills: 0, total: 3 }); + }); });