From eda4d3c461bf121dbf8cf6fa99b9fea3fc73ef26 Mon Sep 17 00:00:00 2001 From: Pavlov Alexandr Date: Sun, 14 Jun 2026 21:50:13 +0700 Subject: [PATCH 1/3] fix(statistics): cap deaths at one per game per player (one-life model) The aggregation summed per-event death evidence with no per-game cap, so counter artifacts (ACE down-and-revive emits multiple "killed" events) and duplicate kill rows inflated a player's death count. Solid Games are one-life: a player dies at most once per game, and legacy semantics are "games-died-in". Tally each replay's death evidence into per-replay scratch maps, then fold a capped contribution into the cross-replay aggregate after the replay's events are processed: total += (perReplayTotal > 0 ? 1 : 0) and by_teamkills += (perReplayByTeamkills > 0 ? 1 : 0). Both are capped together so by_teamkills can never exceed total; summed across replays, total equals games-died-in. Server-2 aggregation only - the parser and its raw d/td artifact are untouched. No schema or API-contract change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../260614-f13a-PLAN.md | 77 ++++++++ .../260614-f13a-SUMMARY.md | 82 ++++++++ src/modules/statistics/service/service.ts | 76 ++++++-- .../service/tests/aggregates.test.ts | 181 +++++++++++++++++- 4 files changed, 400 insertions(+), 16 deletions(-) create mode 100644 .planning/quick/260614-f13a-deaths-per-game-cap/260614-f13a-PLAN.md create mode 100644 .planning/quick/260614-f13a-deaths-per-game-cap/260614-f13a-SUMMARY.md diff --git a/.planning/quick/260614-f13a-deaths-per-game-cap/260614-f13a-PLAN.md b/.planning/quick/260614-f13a-deaths-per-game-cap/260614-f13a-PLAN.md new file mode 100644 index 0000000..d2df993 --- /dev/null +++ b/.planning/quick/260614-f13a-deaths-per-game-cap/260614-f13a-PLAN.md @@ -0,0 +1,77 @@ +--- +quick_id: 260614-f13a +slug: deaths-per-game-cap +status: complete +--- + +# Quick Task 260614-f13a: Cap deaths at one per game per player (one-life model) + +## Problem (root cause) + +The statistics aggregation (`calculatePlayerAndSquadAggregates`, +`src/modules/statistics/service/service.ts`) summed per-event death evidence with +no per-game cap. Two sources inflated a player's death count beyond reality: + +- **Counter events** (`player_counter` → `deaths_total` / `deaths_by_teamkills`) + add their full magnitude via `incrementDeathsByCounter`. ACE down-and-revive + produces multiple "killed" artifacts, so `deaths_total` for a single game can be + 3+. +- **Kill-row fallback** (`applyVictimDeath`) adds 1 per `kill`/`teamkill`/ + `unknown_kill` event whose victim is a known player, so duplicate kill rows count + as extra deaths. + +Both fed straight into the cross-replay aggregate, so a player who died once in a +game could be credited with several deaths. + +DECISION (product owner): all Solid Games are **one-life** — a player dies at most +once per game. Legacy semantics are "games-died-in" (a boolean per game). Target = +match legacy. + +DATA (verified): capping moves `sg` total deaths 295000 → 293744 vs legacy 297878 +(−1.4%, within coverage tolerance); among games-exact players, capped deaths match +legacy 99.3%. + +## Approach + +Keep the raw parser `d`/`td` evidence untouched (no parser changes) and cap the +**per-replay contribution** in the aggregation: + +- Tally each replay's death evidence into per-replay scratch maps + (`replayPlayerDeaths`, `replaySquadDeaths`) instead of writing straight into the + cross-replay aggregate. +- After a replay's events are processed, fold a **capped** contribution into the + aggregate via `foldCappedDeaths`: + * `deaths.total += (perReplayDeaths.total > 0 ? 1 : 0)` + * `deaths.by_teamkills += (perReplayDeaths.by_teamkills > 0 ? 1 : 0)` +- Cap **both** consistently so `by_teamkills` can never exceed `total`. + +Summed across the replays an entity appears in, `total` equals the count of games +it died in — exactly the legacy "games-died-in" semantics. The existing +`counterDeathReferences` suppression of the kill-row fallback (so a player with a +counter event is not double-counted by victim kill rows) is preserved; the cap +applies to whichever path contributed. + +This stays entirely in the **service** layer (the aggregation owner) — no parser, +schema, repository SQL, or API contract changes +(`solidstats-server-ts-conventions` §A: keep each layer doing its own job). + +## Tasks + +1. **Service** (`service/service.ts`): add per-replay death scratch maps to the + event context; redirect death writes (`applyVictimDeath`, `applyCounterEvent`, + `applySquadEvent` victim branch, `applySquadCounterEvent`) into them; fold capped + contributions into the player/squad aggregates after each replay. +2. **Unit tests** (`service/tests/aggregates.test.ts`): update the two existing + expectations that change under the cap (counter `deaths_total:3` → 1; counter + `deaths_by_teamkills:2` → `{total:1, by_teamkills:1}`); add cap-specific cases — + 3 death events in one replay → `total 1`; a teamkill death → `by_teamkills 1`; + died in 2 replays → `total 2`; never died → `total 0`; a normal + teamkill death + in one replay → `{total:1, by_teamkills:1}`. + +## Acceptance + +- `pnpm lint`, `pnpm typecheck`, `pnpm test` (unit) green. +- Statistics repository + public-stats integration suites green (run isolated; + the combined `test:integration` run hits a shared-DB truncate deadlock unrelated + to this change). +- No OpenAPI / schema / parser-contract change. diff --git a/.planning/quick/260614-f13a-deaths-per-game-cap/260614-f13a-SUMMARY.md b/.planning/quick/260614-f13a-deaths-per-game-cap/260614-f13a-SUMMARY.md new file mode 100644 index 0000000..489542a --- /dev/null +++ b/.planning/quick/260614-f13a-deaths-per-game-cap/260614-f13a-SUMMARY.md @@ -0,0 +1,82 @@ +--- +quick_id: 260614-f13a +slug: deaths-per-game-cap +status: complete +date: 2026-06-14 +--- + +# Quick Task 260614-f13a — Summary + +## What changed + +Capped deaths at one per game per player in the statistics aggregation, to match +legacy "games-died-in" semantics (Solid Games are one-life). Server-2 aggregation +only — the Rust parser and its `d`/`td` artifact are untouched. + +- `src/modules/statistics/service/service.ts` — + `calculatePlayerAndSquadAggregates` now tallies each replay's death evidence into + per-replay scratch maps (`replayPlayerDeaths`, `replaySquadDeaths`) carried on the + event context, instead of writing per-event death counts straight into the + cross-replay aggregate. After a replay's events are processed, the new + `foldCappedDeaths` helper folds a capped contribution into the player/squad + aggregates: `deaths.total += 1` iff the replay tally's `total > 0`, and + `deaths.by_teamkills += 1` iff its `by_teamkills > 0`. Both are capped together so + `by_teamkills` can never exceed `total`. The death-writing helpers + (`applyVictimDeath`, `applyCounterEvent`, `applySquadEvent` victim branch, + `applySquadCounterEvent`) now write into the scratch maps via a new `replayDeaths` + accessor; the offensive kill/teamkill accumulation and the + `counterDeathReferences` fallback suppression are unchanged. +- `src/modules/statistics/service/tests/aggregates.test.ts` — updated the two + pre-existing expectations that change under the cap (compact-counter + `deaths_total:3` now `total:1`; `deaths_by_teamkills:2` now `{total:1, + by_teamkills:1}`), renamed that case to reflect the cap, and added five + cap-specific tests: 3 kill events in one replay → `total:1`; 2 teamkill deaths → + `{total:1, by_teamkills:1}`; died in 2 replays → `total:2`; a player who never + died → `total:0`; a normal + teamkill death in one replay → `{total:1, + by_teamkills:1}` (byTeamkills capped below total only because both are 1). + +## Why this is correct (key insight) + +`total` summed across the replays an entity appears in is now the count of replays +it died in — exactly legacy "games-died-in". Capping at the per-replay fold (not +per-event) is what suppresses ACE down-and-revive artifacts and duplicate kill rows +without touching the raw parser evidence. Verified against the parity corpus: +capping moves `sg` total deaths 295000 → 293744 vs legacy 297878 (−1.4%, within +coverage tolerance), and among games-exact players capped deaths match legacy 99.3%. + +## Conventions applied + +- **`solidstats-server-ts-conventions` §A** (layer responsibilities): the cap is + business logic in the **service** layer (the aggregation owner) — no leak into the + parser, repository SQL, schema, or API contract. +- **`solidstats-server-ts-tests`** — service logic is **unit** (per-layer map); + reused the file's existing in-line `AggregateReplayInput` fixtures, hoisting one + shared fixture builder (`twoDeathEventsReplay`) to module scope to satisfy + `unicorn/consistent-function-scoping`. +- **`solidstats-shared-testing-standards`** — strong oracles (exact `toEqual` on the + `deaths` object) and one behavior per `it`; both the inflating paths (counter + magnitude and duplicate kill rows) and the cross-replay sum are covered. + +## Validation + +- `pnpm lint` — green (full repo). +- `pnpm typecheck` — green. +- `pnpm test` (unit) — 627 passed (77 files); `aggregates.test.ts` 9/9. +- Integration: `statistics/repository/tests/postgres.test.ts` 31/31 and + `public-stats/tests/postgres.test.ts` 96/96 green when run isolated against the + testcontainers Postgres. The combined `pnpm test:integration` run reported a + `deadlock detected` on the shared truncate when both files run together — an + env/harness concurrency artifact against a single shared DB, not caused by this + change (each file passes alone). +- No OpenAPI / schema / parser-contract change. + +## Acceptance + +- [x] Per-replay death contribution capped at <=1 for both `total` and + `by_teamkills`, consistently (byTeamkills never exceeds total). +- [x] Fallback path (`applyVictimDeath`) routed through the same cap; counter + suppression via `counterDeathReferences` preserved. +- [x] Unit tests: 3 events in one replay → +1; teamkill death → byTeamkills +1; + 2 replays died-in → total 2; no death → 0. +- [x] `pnpm lint`, `pnpm typecheck`, `pnpm test` green. +- [x] No parser, schema, or API-contract change. diff --git a/src/modules/statistics/service/service.ts b/src/modules/statistics/service/service.ts index 55e5faf..0f3ef74 100644 --- a/src/modules/statistics/service/service.ts +++ b/src/modules/statistics/service/service.ts @@ -93,7 +93,19 @@ export function calculatePlayerAndSquadAggregates( : [], ), ), - eventContext = { counterDeathReferences, playersByEntity }; + // Per-replay death tallies. Solid Games are one-life: a player can die at + // most once per game, so death evidence is summed within the replay here + // and then folded into the cross-replay aggregate as a capped (<=1) + // contribution. This keeps incap/revive artifacts and duplicate kill rows + // from inflating a player's death count beyond games-died-in semantics. + replayPlayerDeaths = new Map(), + replaySquadDeaths = new Map(), + eventContext = { + counterDeathReferences, + playersByEntity, + replayPlayerDeaths, + replaySquadDeaths, + }; for (const player of replay.players) { const aggregate = playerAggregate(playerAggregates, player.playerId); @@ -111,14 +123,17 @@ export function calculatePlayerAndSquadAggregates( continue; } if (event.eventType === "player_counter") { - applyCounterEvent(event, eventContext, playerAggregates); - applySquadCounterEvent(event, eventContext, squadAggregates); + applyCounterEvent(event, eventContext); + applySquadCounterEvent(event, eventContext); continue; } applyAttackerEvent(event, playersByEntity, playerAggregates); - applyVictimDeath(event, eventContext, playerAggregates); + applyVictimDeath(event, eventContext); applySquadEvent(event, eventContext, squadAggregates); } + + foldCappedDeaths(replayPlayerDeaths, playerAggregates, playerAggregate); + foldCappedDeaths(replaySquadDeaths, squadAggregates, squadAggregate); } return { @@ -164,6 +179,11 @@ interface MutableSquadAggregate extends MutablePlayerAggregate { interface ReplayEventContext { counterDeathReferences: Set; playersByEntity: Map; + // Per-replay, uncapped death tallies keyed by playerId / squadId. Folded into + // the cross-replay aggregate as a capped (<=1) contribution after the replay's + // events are processed (one-life model — see calculatePlayerAndSquadAggregates). + replayPlayerDeaths: Map; + replaySquadDeaths: Map; } function playerAggregate( @@ -231,7 +251,6 @@ function applyAttackerEvent( function applyVictimDeath( event: NormalizedParserEvent, context: ReplayEventContext, - aggregates: Map, ): void { if ( event.eventType !== "kill" && @@ -252,7 +271,7 @@ function applyVictimDeath( return; } incrementDeaths( - playerAggregate(aggregates, victim.playerId).deaths, + replayDeaths(context.replayPlayerDeaths, victim.playerId), event.eventType, ); } @@ -291,7 +310,7 @@ function applySquadEvent( const victim = context.playersByEntity.get(String(victimEntityId)); if (victim?.squadId !== undefined) { incrementDeaths( - squadAggregate(aggregates, victim.squadId).deaths, + replayDeaths(context.replaySquadDeaths, victim.squadId), event.eventType, ); } @@ -300,7 +319,6 @@ function applySquadEvent( function applyCounterEvent( event: PlayerCounterEvent, context: ReplayEventContext, - aggregates: Map, ): void { const player = context.playersByEntity.get(event.observedPlayerRef), deaths = counterDeaths(event.payload); @@ -308,7 +326,7 @@ function applyCounterEvent( return; } incrementDeathsByCounter( - playerAggregate(aggregates, player.playerId).deaths, + replayDeaths(context.replayPlayerDeaths, player.playerId), deaths, ); } @@ -316,7 +334,6 @@ function applyCounterEvent( function applySquadCounterEvent( event: PlayerCounterEvent, context: ReplayEventContext, - aggregates: Map, ): void { const player = context.playersByEntity.get(event.observedPlayerRef), deaths = counterDeaths(event.payload); @@ -324,7 +341,7 @@ function applySquadCounterEvent( return; } incrementDeathsByCounter( - squadAggregate(aggregates, player.squadId).deaths, + replayDeaths(context.replaySquadDeaths, player.squadId), deaths, ); } @@ -333,6 +350,43 @@ function emptyDeaths(): DeathStats { return { by_teamkills: 0, total: 0 }; } +function replayDeaths( + deaths: Map, + key: string, +): DeathStats { + const existing = deaths.get(key); + if (existing !== undefined) { + return existing; + } + const created = emptyDeaths(); + deaths.set(key, created); + return created; +} + +/** + * Folds a replay's per-player (or per-squad) uncapped death tally into the + * cross-replay aggregate as a capped contribution. Solid Games are one-life, so + * each replay adds at most one death and at most one teamkill death; capping both + * consistently keeps `by_teamkills` from ever exceeding `total`. Summed across + * the replays an entity appears in, `total` equals the count of games it died in + * (legacy "games-died-in" semantics). + */ +function foldCappedDeaths( + replayDeathsByKey: Map, + aggregates: Map, + resolve: (aggregates: Map, key: string) => T, +): void { + for (const [key, deaths] of replayDeathsByKey) { + const aggregate = resolve(aggregates, key); + if (deaths.total > 0) { + aggregate.deaths.total += 1; + } + if (deaths.by_teamkills > 0) { + aggregate.deaths.by_teamkills += 1; + } + } +} + function incrementDeaths( deaths: DeathStats, eventType: "kill" | "teamkill" | "unknown_kill", diff --git a/src/modules/statistics/service/tests/aggregates.test.ts b/src/modules/statistics/service/tests/aggregates.test.ts index f1b0d71..c25c7b6 100644 --- a/src/modules/statistics/service/tests/aggregates.test.ts +++ b/src/modules/statistics/service/tests/aggregates.test.ts @@ -1,7 +1,34 @@ /* eslint-disable camelcase, max-lines, max-lines-per-function, unicorn/no-null */ import { describe, expect, it } from "vitest"; -import { calculatePlayerAndSquadAggregates } from "../service.js"; +import { + type AggregateReplayInput, + calculatePlayerAndSquadAggregates, +} from "../service.js"; + +function twoDeathEventsReplay(replayId: string): AggregateReplayInput { + return { + events: [ + { + eventType: "kill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + { + eventType: "kill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + ], + players: [ + { entityRef: "101", playerId: "player-a" }, + { entityRef: "202", playerId: "player-b" }, + ], + replayId, + }; +} describe("calculatePlayerAndSquadAggregates", () => { it("calculates player and squad kills, teamkills, deaths, and replay counts", () => { @@ -145,7 +172,7 @@ describe("calculatePlayerAndSquadAggregates", () => { }); }); - it("uses compact counter deaths when present and kill-row deaths as fallback", () => { + it("caps compact counter deaths at one per game and uses kill rows as fallback", () => { const result = calculatePlayerAndSquadAggregates([ { events: [ @@ -203,7 +230,7 @@ describe("calculatePlayerAndSquadAggregates", () => { { playerId: "player-b", stats: { - deaths: { by_teamkills: 1, total: 3 }, + deaths: { by_teamkills: 1, total: 1 }, kills: 0, replay_count: 1, teamkills: 0, @@ -236,7 +263,7 @@ describe("calculatePlayerAndSquadAggregates", () => { { squadId: "squad-b", stats: { - deaths: { by_teamkills: 1, total: 3 }, + deaths: { by_teamkills: 1, total: 1 }, kills: 0, player_count: 1, replay_count: 1, @@ -320,7 +347,7 @@ describe("calculatePlayerAndSquadAggregates", () => { { playerId: "player-c", stats: { - deaths: { by_teamkills: 2, total: 2 }, + deaths: { by_teamkills: 1, total: 1 }, kills: 0, replay_count: 1, teamkills: 0, @@ -353,4 +380,148 @@ describe("calculatePlayerAndSquadAggregates", () => { }, ]); }); + + it("caps three death events in one replay at a single death (one-life model)", () => { + const result = calculatePlayerAndSquadAggregates([ + { + events: [ + { + eventType: "kill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + { + eventType: "kill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + { + eventType: "kill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + ], + players: [ + { entityRef: "101", playerId: "player-a", squadId: "squad-a" }, + { 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("caps a player's teamkill death at one byTeamkills per replay", () => { + const result = calculatePlayerAndSquadAggregates([ + { + events: [ + { + eventType: "teamkill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + { + eventType: "teamkill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + ], + players: [ + { entityRef: "101", playerId: "player-a", squadId: "squad-a" }, + { 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: 1, total: 1 }); + expect( + result.squadStats.find((squad) => squad.squadId === "squad-b")?.stats + .deaths, + ).toEqual({ by_teamkills: 1, total: 1 }); + }); + + it("counts deaths once per replay across multiple replays a player died in", () => { + const result = calculatePlayerAndSquadAggregates([ + twoDeathEventsReplay("replay-1"), + twoDeathEventsReplay("replay-2"), + ]); + + expect( + result.playerStats.find((player) => player.playerId === "player-b")?.stats + .deaths, + ).toEqual({ by_teamkills: 0, total: 2 }); + }); + + it("contributes zero deaths for a player who did not die", () => { + const result = calculatePlayerAndSquadAggregates([ + { + events: [ + { + eventType: "kill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + ], + players: [ + { entityRef: "101", playerId: "player-a" }, + { entityRef: "202", playerId: "player-b" }, + ], + replayId: "replay-1", + }, + ]); + + expect( + result.playerStats.find((player) => player.playerId === "player-a")?.stats + .deaths, + ).toEqual({ by_teamkills: 0, total: 0 }); + }); + + it("caps byTeamkills below total when a player has both a normal and a teamkill death in one replay", () => { + const result = calculatePlayerAndSquadAggregates([ + { + events: [ + { + eventType: "kill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + { + eventType: "teamkill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + ], + players: [ + { entityRef: "101", playerId: "player-a" }, + { entityRef: "202", playerId: "player-b" }, + ], + replayId: "replay-1", + }, + ]); + + expect( + result.playerStats.find((player) => player.playerId === "player-b")?.stats + .deaths, + ).toEqual({ by_teamkills: 1, total: 1 }); + }); }); From f91e8bd340935c5376398a955719f212d5526d5a Mon Sep 17 00:00:00 2001 From: Pavlov Alexandr Date: Sun, 14 Jun 2026 21:58:17 +0700 Subject: [PATCH 2/3] test(statistics): pin lone-counter deaths_total magnitude cap Add a focused isolated test proving a single player_counter event with deaths_total:3 and no competing kill row folds to deaths.total:1. This pins the counter-magnitude cap independently of the applyVictimDeath fallback path: with no kill/teamkill row present, the per-replay scratch tally is driven solely by counterDeaths, so the one-life fold (total > 0 => +1) is the only thing under test. Strong toEqual oracle on the full deaths object. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/tests/aggregates.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/modules/statistics/service/tests/aggregates.test.ts b/src/modules/statistics/service/tests/aggregates.test.ts index c25c7b6..2f047c5 100644 --- a/src/modules/statistics/service/tests/aggregates.test.ts +++ b/src/modules/statistics/service/tests/aggregates.test.ts @@ -285,6 +285,28 @@ describe("calculatePlayerAndSquadAggregates", () => { ]); }); + it("caps a lone counter's deaths_total magnitude at one death with no competing kill row", () => { + const result = calculatePlayerAndSquadAggregates([ + { + events: [ + { + eventType: "player_counter", + observedPlayerRef: "202", + payload: { deaths_total: 3 }, + sourceRef: {}, + }, + ], + players: [{ 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("Ignores unusable counter death evidence without blocking fallback deaths", () => { const result = calculatePlayerAndSquadAggregates([ { From bb751e8e85331401d7a68b1db2b971d101d67773 Mon Sep 17 00:00:00 2001 From: Pavlov Alexandr Date: Sun, 14 Jun 2026 22:17:38 +0700 Subject: [PATCH 3/3] test(statistics): cover zero-counter death-fold branch `foldCappedDeaths` guards `aggregate.deaths.total += 1` behind `if (deaths.total > 0)`. The false branch was uncovered, failing CI's 100% branch-coverage gate (99.93%). The guard is reachable, not dead: a `player_counter` event whose payload carries explicit zero counters (`deaths_total: 0` / `deaths_by_teamkills: 0`) makes `counterDeaths` return a defined `{ total: 0, by_teamkills: 0 }`, so a scratch death entry is created with `total === 0`. Removing the guard would wrongly credit a death to a player whose counter says zero deaths, so the guard stays and a focused unit test exercises its false branch instead. service.ts is back to 100% branches (84/84), functions, lines, and statements. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/tests/aggregates.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/modules/statistics/service/tests/aggregates.test.ts b/src/modules/statistics/service/tests/aggregates.test.ts index 2f047c5..51b82ec 100644 --- a/src/modules/statistics/service/tests/aggregates.test.ts +++ b/src/modules/statistics/service/tests/aggregates.test.ts @@ -516,6 +516,34 @@ describe("calculatePlayerAndSquadAggregates", () => { ).toEqual({ by_teamkills: 0, total: 0 }); }); + it("counts no death for a counter that explicitly reports zero deaths", () => { + const result = calculatePlayerAndSquadAggregates([ + { + events: [ + { + eventType: "player_counter", + observedPlayerRef: "202", + payload: { deaths_by_teamkills: 0, deaths_total: 0 }, + sourceRef: {}, + }, + ], + players: [ + { 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: 0 }); + expect( + result.squadStats.find((squad) => squad.squadId === "squad-b")?.stats + .deaths, + ).toEqual({ by_teamkills: 0, total: 0 }); + }); + it("caps byTeamkills below total when a player has both a normal and a teamkill death in one replay", () => { const result = calculatePlayerAndSquadAggregates([ {