Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 65 additions & 11 deletions src/modules/statistics/service/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, DeathStats>(),
replaySquadDeaths = new Map<string, DeathStats>(),
eventContext = {
counterDeathReferences,
playersByEntity,
replayPlayerDeaths,
replaySquadDeaths,
};

for (const player of replay.players) {
const aggregate = playerAggregate(playerAggregates, player.playerId);
Expand All @@ -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 {
Expand Down Expand Up @@ -164,6 +179,11 @@ interface MutableSquadAggregate extends MutablePlayerAggregate {
interface ReplayEventContext {
counterDeathReferences: Set<string>;
playersByEntity: Map<string, AggregatePlayerEvidence>;
// 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<string, DeathStats>;
replaySquadDeaths: Map<string, DeathStats>;
}

function playerAggregate(
Expand Down Expand Up @@ -231,7 +251,6 @@ function applyAttackerEvent(
function applyVictimDeath(
event: NormalizedParserEvent,
context: ReplayEventContext,
aggregates: Map<string, MutablePlayerAggregate>,
): void {
if (
event.eventType !== "kill" &&
Expand All @@ -252,7 +271,7 @@ function applyVictimDeath(
return;
}
incrementDeaths(
playerAggregate(aggregates, victim.playerId).deaths,
replayDeaths(context.replayPlayerDeaths, victim.playerId),
event.eventType,
);
}
Expand Down Expand Up @@ -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,
);
}
Expand All @@ -300,31 +319,29 @@ function applySquadEvent(
function applyCounterEvent(
event: PlayerCounterEvent,
context: ReplayEventContext,
aggregates: Map<string, MutablePlayerAggregate>,
): void {
const player = context.playersByEntity.get(event.observedPlayerRef),
deaths = counterDeaths(event.payload);
if (player === undefined || deaths === undefined) {
return;
}
incrementDeathsByCounter(
playerAggregate(aggregates, player.playerId).deaths,
replayDeaths(context.replayPlayerDeaths, player.playerId),
deaths,
);
}

function applySquadCounterEvent(
event: PlayerCounterEvent,
context: ReplayEventContext,
aggregates: Map<string, MutableSquadAggregate>,
): void {
const player = context.playersByEntity.get(event.observedPlayerRef),
deaths = counterDeaths(event.payload);
if (player?.squadId === undefined || deaths === undefined) {
return;
}
incrementDeathsByCounter(
squadAggregate(aggregates, player.squadId).deaths,
replayDeaths(context.replaySquadDeaths, player.squadId),
deaths,
);
}
Expand All @@ -333,6 +350,43 @@ function emptyDeaths(): DeathStats {
return { by_teamkills: 0, total: 0 };
}

function replayDeaths(
deaths: Map<string, DeathStats>,
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<T extends MutablePlayerAggregate>(
replayDeathsByKey: Map<string, DeathStats>,
aggregates: Map<string, T>,
resolve: (aggregates: Map<string, T>, 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",
Expand Down
Loading