Repository navigation
fix(statistics): cap deaths at one per game per player (one-life model) - #17
Merged
Merged
Conversation
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>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
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_totalof 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_teamkillscan never exceedtotal. Summed across the replays an entity appears in,totalequals the count of games it died in — exactly legacy "games-died-in". ThecounterDeathReferencesfallback suppression is preserved.The Rust parser and its raw
d/tdartifact are untouched. No schema or API-contract change.Validation data (parity corpus)
Capping moves
sgtotal 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.statistics/repository31/31 andpublic-stats96/96 green run isolated; the combinedtest:integrationrun hits a shared-DB truncatedeadlockwhen both files run together — an env/harness concurrency artifact, not caused by this change (each file passes alone).🤖 Generated with Claude Code