Skip to content

fix(statistics): cap deaths at one per game per player (one-life model) - #17

Merged
Afgan0r merged 3 commits into
masterfrom
fix/f13a-deaths-per-game-cap
Jun 14, 2026
Merged

Afgan0r merged 3 commits into
masterfrom
fix/f13a-deaths-per-game-cap

Conversation

@Afgan0r

@Afgan0r Afgan0r commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Task F13a — cap deaths at one per game per player (one-life model)

Problem

The statistics aggregation (calculatePlayerAndSquadAggregates) summed per-event death evidence with no per-game cap. Counter artifacts (ACE down-and-revive emits multiple "killed" events → deaths_total of 3+) and duplicate kill rows inflated a player's death count. Per the product-owner decision, all Solid Games are one-life: a player dies at most once per game, and legacy semantics are "games-died-in" (a boolean per game).

Fix (server-2 aggregation only)

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:

  • deaths.total += (perReplayTotal > 0 ? 1 : 0)
  • deaths.by_teamkills += (perReplayByTeamkills > 0 ? 1 : 0)

Both are capped together 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 legacy "games-died-in". The counterDeathReferences fallback suppression is preserved.

The Rust parser and its raw d/td artifact are untouched. No schema or API-contract change.

Validation data (parity corpus)

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%.

Files

  • src/modules/statistics/service/service.ts — per-replay scratch maps + foldCappedDeaths.
  • src/modules/statistics/service/tests/aggregates.test.ts — updated 2 cap-affected expectations + 5 new cap-specific cases.
  • .planning/quick/260614-f13a-deaths-per-game-cap/ — GSD-quick PLAN + SUMMARY.

Checks

  • pnpm lint ✅ · pnpm typecheck ✅ · pnpm test (unit) ✅ 627 passed.
  • Integration: statistics/repository 31/31 and public-stats 96/96 green run isolated; the combined test:integration run hits a shared-DB truncate deadlock when both files run together — an env/harness concurrency artifact, not caused by this change (each file passes alone).

🤖 Generated with Claude Code

Afgan0r and others added 3 commits June 14, 2026 21:50
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
@Afgan0r
Afgan0r marked this pull request as ready for review June 14, 2026 15:27
@Afgan0r
Afgan0r merged commit 581ae34 into master Jun 14, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant