diff --git a/.planning/STATE.md b/.planning/STATE.md index 128be2c..b9a6875 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-05-31) Phase: Parity / Phase 1 — Game-Type-Aware Statistics — COMPLETE (5/5 plans + review fixes) Plan: 01-01..01-05 done; code-review BLOCK→fixed→APPROVE Status: Phase implemented + reviewed; landing to master. Migration 0008 (game_type dimension + nullable rotation_id + NULLS NOT DISTINCT + is_show) & 0009 (stale NULL-type cleanup); set-based classification; per-type + all-time recalc; per-type legacy-export/parity-sql; audit path made game-type-correct. pnpm verify green, 100% cov, OpenAPI diff empty. Deferred: large-bucket perf pass (review findings 3/4/5 + parity-driver flag). -Last activity: 2026-06-14 — Phase 1 (Game-Type-Aware Statistics) complete; 2 blocker fixes (audit-path per-type correctness, 0009 stale-row cleanup) + parity divergence fixes; re-review APPROVE +Last activity: 2026-06-15 — Completed quick task 260615-u06: F9 excludePlayers (player-leaderboard exclusion, squad-parity-safe) ## Performance Metrics @@ -133,6 +133,7 @@ Decisions are logged in PROJECT.md Key Decisions table. Recent decisions affecti | 260614-c0d | Fix F7 — set-based recalculation so parity check can pass | 2026-06-14 | 2930f10 | [260614-c0d-f7-set-based-recalculation-parity](./quick/260614-c0d-f7-set-based-recalculation-parity/) | | 260614-fw2 | Set-based canonical player identity resolution in per-rotation recalc (behavior-preserving) | 2026-06-14 | fa7c54b | [260614-fw2-perf-set-based-canonical-player-identity](./quick/260614-fw2-perf-set-based-canonical-player-identity/) | | 260614-r9k | Guard all-time recalc against NULL replay_timestamp (toISOString crash) | 2026-06-14 | b0275e0 | [260614-r9k-recalc-null-timestamp-guard](./quick/260614-r9k-recalc-null-timestamp-guard/) | +| 260615-u06 | F9 — apply the legacy excludePlayers exclusion to the player leaderboard | 2026-06-15 | b80a235 | [260615-u06-f9-excludeplayers-apply-the-legacy-exclu](./quick/260615-u06-f9-excludeplayers-apply-the-legacy-exclu/) | ## Deferred Items diff --git a/.planning/quick/260615-u06-f9-excludeplayers-apply-the-legacy-exclu/260615-u06-PLAN.md b/.planning/quick/260615-u06-f9-excludeplayers-apply-the-legacy-exclu/260615-u06-PLAN.md new file mode 100644 index 0000000..3562979 --- /dev/null +++ b/.planning/quick/260615-u06-f9-excludeplayers-apply-the-legacy-exclu/260615-u06-PLAN.md @@ -0,0 +1,86 @@ +--- +quick_id: 260615-u06 +slug: f9-excludeplayers-apply-the-legacy-exclu +title: "F9 — apply the legacy excludePlayers date-interval exclusion" +status: in-progress +date: 2026-06-15 +--- + +# F9 — excludePlayers + +## Context + +F9 ("apply legacy include/exclude config") is two-thirds already implemented: +`excludeReplays.json` and `includeReplays.json` live in +`src/modules/statistics/game-type/game-type-config.ts` and are applied by +`classify-game-type.ts` (with the exclude-list invariant test). The only +unimplemented piece is **`excludePlayers.json`** — drop a player's per-game +result row from the aggregate when the squad-stripped callsign matches an +excluded name (case-insensitive) and the game date falls inside the entry's +inclusive interval. + +Legacy source of truth: `sg-replay-parser/src/3 - statistics/global/add.ts` +(`readExcludePlayer` + interval check via `0 - utils/isInInterval.ts`), name +normalization via `0 - utils/getPlayerName.ts` (strips `[...]` squad prefixes). +Legacy applies the filter through `calculateGlobalStatistics`, which +`rotations/index.ts` calls per rotation — so the filter reaches **all-time and +per-rotation player stats**. + +## Design + +Mirror the `game-type-config.ts` pattern: a pure, committed spec module plus a +pure predicate, applied at the artifact→evidence seam. + +- `exclude-players/exclude-players-config.ts` — `EXCLUDE_PLAYERS` (the 5 legacy + entries, `null` bounds kept to mirror the source JSON 1:1). +- `exclude-players/exclude-players.ts` — `normalizeExcludeName` (legacy + `getPlayerName` strip), `isWithinInterval` (inclusive + `[minDate ?? -inf, maxDate ?? +inf]`), `isPlayerExcluded`. +- `repository.ts` `resolvedPlayers()` — `.filter(p => !isPlayerExcluded(p.n, replayTimestamp))` + before mapping to evidence. Filtering at this single chokepoint keeps the + decision pure/unit-testable and introduces no new uncovered branch in the + integration-only repository file. + +Semantics preserved from legacy: an excluded player contributes nothing, but a +death they inflicted on another player (recorded on the victim's own row) still +counts — `applyAttackerEvent`/`applyVictimDeath` already guard a missing +attacker, so removing the player from `playersByEntity` is safe. + +## Tasks + +1. Add the `exclude-players` spec module (config + predicate) with unit tests + at 100% branch coverage. +2. Apply the filter in `resolvedPlayers`. +3. Add an integration test proving the excluded player gets no `player_stats` + row while the victim's death survives. + +## Scope decision (resolved) + +The `f9-parity-scope-review` workflow confirmed a real divergence: **squad stats +are a compared parity surface** (`squadStatsSql` in `parity-sql.ts`, `squadExport` +in `legacy-public-export.ts`), and legacy does **not** apply excludePlayers to +squads. A filter at the shared `resolvedPlayers` seam would have dropped excluded +players from squad stats too — a squad-parity regression (before F9, server-2 +counted these players in squads, matching legacy). + +The user also clarified the intent: excludePlayers is an identity-collision +patch — one callsign can map to two real people across a nickname change, so the +ambiguous-interval games are dropped from the **player leaderboard** (attribution +surface), not from squad membership. + +Final scope (matches legacy on every compared surface): + +| Aggregate | Legacy | server-2 (F9) | +|-----------|--------|---------------| +| player_stats (all-time) | excluded | excluded ✓ | +| player_stats (per-rotation) | excluded | excluded ✓ | +| squad_stats | **kept** | **kept** ✓ | +| bounty_points | n/a (no legacy bounty) | excluded (consistent, not parity-compared) | +| commander_side_stats | n/a | unaffected | + +Implementation: the exclusion is a per-(player, replay) **flag** on +`AggregatePlayerEvidence` (set in `resolvedPlayers`). `calculatePlayerAndSquadAggregates` +keeps two entity maps — `playerEntities` (non-excluded, credits the leaderboard) +and `squadEntities` (all, credits squads) — so an excluded player vanishes from +player stats while still counting toward their squad, exactly as legacy +`getSquadInfo` does. Bounty filters excluded players from kill attribution. diff --git a/.planning/quick/260615-u06-f9-excludeplayers-apply-the-legacy-exclu/260615-u06-SUMMARY.md b/.planning/quick/260615-u06-f9-excludeplayers-apply-the-legacy-exclu/260615-u06-SUMMARY.md new file mode 100644 index 0000000..c898fd1 --- /dev/null +++ b/.planning/quick/260615-u06-f9-excludeplayers-apply-the-legacy-exclu/260615-u06-SUMMARY.md @@ -0,0 +1,70 @@ +--- +quick_id: 260615-u06 +slug: f9-excludeplayers-apply-the-legacy-exclu +title: "F9 — apply the legacy excludePlayers date-interval exclusion" +status: complete +date: 2026-06-15 +--- + +# F9 excludePlayers — Summary + +## What shipped + +Implemented the last unfilled piece of F9 — the legacy `excludePlayers.json` +date-interval player exclusion. (`excludeReplays`/`includeReplays` were already +done earlier in `game-type/game-type-config.ts`; verified, not duplicated.) + +New pure spec module `src/modules/statistics/exclude-players/`: +- `exclude-players-config.ts` — `EXCLUDE_PLAYERS` (the 5 legacy entries). +- `exclude-players.ts` — `normalizeExcludeName` (legacy `getPlayerName` strip, + incl. stray brackets), `isWithinInterval` (inclusive + `[minDate ?? -inf, maxDate ?? +inf]`), `isPlayerExcluded`. + +Applied as a per-(player, replay) `excluded` flag on `AggregatePlayerEvidence`, +set in `repository.ts` `resolvedPlayers`. `calculatePlayerAndSquadAggregates` +splits into two entity maps so the exclusion reaches the **player leaderboard +only** (all-time + per-rotation), never squad stats — matching legacy on every +compared parity surface. Bounty drops excluded players from kill attribution +(consistency choice; bounty is not parity-compared and has no legacy analog). + +## Why this scope + +A `f9-parity-scope-review` workflow (5 agents) mapped the exclusion scope in +legacy vs server-2 against what the parity harness actually compares. Finding: +squad stats ARE compared and legacy does NOT exclude players from them, so a +naive filter at the shared `resolvedPlayers` seam would have regressed squad +parity. The user confirmed intent: excludePlayers is an identity-collision patch +for the player leaderboard, not squads. Filter narrowed accordingly. The review +also produced the conventions nits folded in below. + +## Conventions nits addressed + +- Docstrings corrected: `+inf` upper sentinel (not `now()`), with the + intentional/safe divergence noted; legacy lowercases at the comparison site. +- `normalizeExcludeName` now strips a leftover lone `[`/`]` too (1:1 with legacy + `getPlayerName`). +- Added the inclusive `maxDate` boundary test (`mayson` @ `2023-01-01` → true) + and a stray-bracket normalization test. + +## Tests + +- Unit: `exclude-players` module — 100% branch; `aggregates.test.ts` — excluded + player dropped from the leaderboard but kept in their squad (kill + death + + membership), incl. the artifact-counter-death path. +- Integration (`postgres.test.ts`): excluded player gets no `player_stats` row, + the death they dealt to a victim survives, and their squad still counts them. + +## Verification + +Full `verify`-equivalent suite green against local Docker services: +`format`, `lint`, `typecheck`, **868 tests**, **100% coverage** +(stmts/branch/funcs/lines), `openapi:check`, `ops:boundary:check`, +`ops:backup:check`. + +## Not done (deliberately out of scope) + +- Staging deploy + recalc (manual ops; do when ready to re-verify parity). +- Updating `plans/product/PARITY-BASELINE-FINDINGS.md` F9 row (separate `plans` + repo). +- Remaining parity items: F5 (orphaned `published` parse_jobs reconciler), + the ~34-replay coverage gap, F14 → v4. diff --git a/src/modules/statistics/exclude-players/exclude-players-config.ts b/src/modules/statistics/exclude-players/exclude-players-config.ts new file mode 100644 index 0000000..94f51e5 --- /dev/null +++ b/src/modules/statistics/exclude-players/exclude-players-config.ts @@ -0,0 +1,39 @@ +/* eslint-disable unicorn/no-null -- null is the legacy excludePlayers.json bound sentinel, kept so this spec mirrors the source JSON 1:1 (F9). */ +/** + * Player-exclusion spec — committed, versioned config (F9). + * + * Owns the legacy `sg-replay-parser` `excludePlayers.json` constants: a small + * list of players whose per-game result rows are dropped from the aggregate, + * each optionally bounded to a date interval. Like the game-type spec + * (`game-type-config.ts`), these are spec values committed alongside the + * predicate and unit-tested — not a DB seed/table. + * + * Source of truth: legacy `excludePlayers.json` (ported 1:1). The legacy + * matcher strips the squad prefix from the in-game callsign and compares the + * bare name case-insensitively, excluding a game iff its date falls inside + * `[minDate ?? -inf, maxDate ?? +inf]` inclusive — replicated by + * `exclude-players.ts`. (Legacy clamps a null `maxDate` to `now()`; server-2 + * uses `+inf`, which is identical for any real, past-dated replay.) + */ + +export interface ExcludePlayer { + /** Upper bound (inclusive). `null` = no upper bound. */ + maxDate: string | null; + /** Lower bound (inclusive). `null` = no lower bound. */ + minDate: string | null; + /** Bare, squad-prefix-stripped callsign (matched case-insensitively). */ + name: string; +} + +/** + * Legacy `excludePlayers.json`, in source order. `exile`/`mooniverse`/`jm0t` + * are excluded for all dates (both bounds `null`); `scandal`/`mayson` are + * excluded only up to their `maxDate`. + */ +export const EXCLUDE_PLAYERS: readonly ExcludePlayer[] = [ + { maxDate: "2020-12-01T00:00:00.000Z", minDate: null, name: "scandal" }, + { maxDate: "2023-01-01T00:00:00.000Z", minDate: null, name: "mayson" }, + { maxDate: null, minDate: null, name: "exile" }, + { maxDate: null, minDate: null, name: "mooniverse" }, + { maxDate: null, minDate: null, name: "jm0t" }, +] as const; diff --git a/src/modules/statistics/exclude-players/exclude-players.ts b/src/modules/statistics/exclude-players/exclude-players.ts new file mode 100644 index 0000000..b6ad546 --- /dev/null +++ b/src/modules/statistics/exclude-players/exclude-players.ts @@ -0,0 +1,65 @@ +/** + * Pure player-exclusion predicate (F9) — Postgres-free, unit-testable. + * + * Ports the legacy `sg-replay-parser` `excludePlayers.json` filter 1:1. The + * legacy aggregator (`3 - statistics/global/add.ts`) drops a player's per-game + * result row when the squad-stripped callsign matches an excluded name + * (case-insensitive) and the game date falls inside the entry's interval. The + * repository applies this at the artifact→evidence seam (`resolvedPlayers`), so + * an excluded player contributes nothing to player/squad/commander/bounty + * aggregates — while deaths they inflicted on others (recorded on the victim's + * own row) are unaffected, matching the legacy skip-the-result-row semantics. + * + * This file MUST NOT import `pg` or touch the database. + */ +import { EXCLUDE_PLAYERS } from "./exclude-players-config.js"; + +/** Matches every `[...]` squad-prefix group in a callsign. */ +const SQUAD_PREFIX = /\[.*?\]/gu; +/** Matches any leftover lone bracket after the groups are removed. */ +const STRAY_BRACKET = /[[\]]/gu; + +/** + * Legacy `getPlayerName` normalization: strip every `[...]` squad-prefix group + * and any leftover lone `[`/`]`, then trim. Legacy compares the result + * case-insensitively, so this lowercases too (legacy lowercases at the + * comparison site — behaviorally identical). `"[ABC] scandal"` and `"Scandal"` + * both normalize to `"scandal"`. + */ +export function normalizeExcludeName(callsign: string): string { + return callsign + .replaceAll(SQUAD_PREFIX, "") + .replaceAll(STRAY_BRACKET, "") + .trim() + .toLowerCase(); +} + +/** + * Legacy inclusive interval `[minDate ?? -inf, maxDate ?? +inf]`. A `null` bound + * is unbounded (legacy used `1970-01-01` / `now`, both equivalent to the + * sentinels for real replay dates). Bounds are ISO strings; `time` is an epoch + * millisecond value. + */ +export function isWithinInterval( + time: number, + minDate: string | null, + maxDate: string | null, +): boolean { + const min = minDate === null ? Number.NEGATIVE_INFINITY : Date.parse(minDate), + max = maxDate === null ? Number.POSITIVE_INFINITY : Date.parse(maxDate); + return time >= min && time <= max; +} + +/** + * Whether the player named `callsign` is excluded for a game played at + * `replayDate` (squad-stripped, case-insensitive name match within the entry's + * inclusive date interval). + */ +export function isPlayerExcluded(callsign: string, replayDate: Date): boolean { + const name = normalizeExcludeName(callsign), + entry = EXCLUDE_PLAYERS.find((player) => player.name === name); + if (entry === undefined) { + return false; + } + return isWithinInterval(replayDate.getTime(), entry.minDate, entry.maxDate); +} diff --git a/src/modules/statistics/exclude-players/tests/exclude-players.test.ts b/src/modules/statistics/exclude-players/tests/exclude-players.test.ts new file mode 100644 index 0000000..6441e37 --- /dev/null +++ b/src/modules/statistics/exclude-players/tests/exclude-players.test.ts @@ -0,0 +1,121 @@ +/* eslint-disable unicorn/no-null -- null is the legacy bound sentinel the predicate accepts (F9). */ +import { describe, expect, it } from "vitest"; + +import { EXCLUDE_PLAYERS } from "../exclude-players-config.js"; +import { + isPlayerExcluded, + isWithinInterval, + normalizeExcludeName, +} from "../exclude-players.js"; + +const ms = (iso: string): number => new Date(iso).getTime(); + +describe("normalizeExcludeName", () => { + it("lowercases and trims a plain callsign", () => { + expect(normalizeExcludeName(" Scandal ")).toBe("scandal"); + }); + + it("strips every [...] squad prefix group", () => { + expect(normalizeExcludeName("[ABC] scandal")).toBe("scandal"); + expect(normalizeExcludeName("[A][B]jm0t")).toBe("jm0t"); + }); + + it("returns an empty string for a bracket-only callsign", () => { + expect(normalizeExcludeName("[ABC]")).toBe(""); + }); + + it("strips a leftover lone bracket (legacy getPlayerName parity)", () => { + expect(normalizeExcludeName("scandal]")).toBe("scandal"); + expect(normalizeExcludeName("[A scandal")).toBe("a scandal"); + }); +}); + +describe("isWithinInterval", () => { + const at = ms("2022-06-01T00:00:00.000Z"); + + it("is unbounded when both bounds are null", () => { + expect(isWithinInterval(at, null, null)).toBe(true); + }); + + it("respects a non-null upper bound inclusively", () => { + expect(isWithinInterval(at, null, "2022-06-01T00:00:00.000Z")).toBe(true); + expect(isWithinInterval(at, null, "2022-05-31T23:59:59.999Z")).toBe(false); + }); + + it("respects a non-null lower bound inclusively", () => { + expect(isWithinInterval(at, "2022-06-01T00:00:00.000Z", null)).toBe(true); + expect(isWithinInterval(at, "2022-06-01T00:00:00.001Z", null)).toBe(false); + }); + + it("requires both bounds when both are present", () => { + expect( + isWithinInterval( + at, + "2022-01-01T00:00:00.000Z", + "2022-12-31T00:00:00.000Z", + ), + ).toBe(true); + expect( + isWithinInterval( + at, + "2023-01-01T00:00:00.000Z", + "2023-12-31T00:00:00.000Z", + ), + ).toBe(false); + }); +}); + +describe("isPlayerExcluded", () => { + const anyDate = new Date("2021-06-01T00:00:00.000Z"); + + it("excludes an unbounded player at any date", () => { + expect( + isPlayerExcluded("exile", new Date("1999-01-01T00:00:00.000Z")), + ).toBe(true); + expect( + isPlayerExcluded("exile", new Date("2030-01-01T00:00:00.000Z")), + ).toBe(true); + }); + + it("returns false for a player not on the list", () => { + expect(isPlayerExcluded("somebodyelse", anyDate)).toBe(false); + }); + + it("matches case-insensitively and ignores the squad prefix", () => { + expect(isPlayerExcluded("[SQ] EXILE", anyDate)).toBe(true); + }); + + it("excludes a max-bounded player on or before maxDate (inclusive)", () => { + expect( + isPlayerExcluded("scandal", new Date("2019-01-01T00:00:00.000Z")), + ).toBe(true); + expect( + isPlayerExcluded("scandal", new Date("2020-12-01T00:00:00.000Z")), + ).toBe(true); + }); + + it("includes the maxDate boundary itself (inclusive interval)", () => { + // mayson: maxDate 2023-01-01T00:00:00.000Z — the boundary instant excludes. + expect( + isPlayerExcluded("mayson", new Date("2023-01-01T00:00:00.000Z")), + ).toBe(true); + }); + + it("does not exclude a max-bounded player after maxDate", () => { + expect( + isPlayerExcluded("mayson", new Date("2023-01-02T00:00:00.000Z")), + ).toBe(false); + }); +}); + +describe("EXCLUDE_PLAYERS config", () => { + // Legacy excludePlayers.json: scandal, mayson, exile, mooniverse, jm0t. + const LEGACY_ENTRY_COUNT = 5; + + it("carries the five legacy entries with lowercase names", () => { + expect(EXCLUDE_PLAYERS).toHaveLength(LEGACY_ENTRY_COUNT); + for (const entry of EXCLUDE_PLAYERS) { + expect(entry.name).toBe(entry.name.toLowerCase()); + } + }); +}); diff --git a/src/modules/statistics/repository/repository.ts b/src/modules/statistics/repository/repository.ts index c1187b0..1ac9dc5 100644 --- a/src/modules/statistics/repository/repository.ts +++ b/src/modules/statistics/repository/repository.ts @@ -6,6 +6,7 @@ import { type BountyPointRow, type PreviousBountyStats, } from "../bounty/bounty.js"; +import { isPlayerExcluded } from "../exclude-players/exclude-players.js"; import { computeIsShow } from "../game-type/is-show.js"; import { calculateCommanderSideAggregates, @@ -1002,12 +1003,18 @@ function resolvedPlayers(input: { // 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); + counterDeaths = artifactCounterDeaths(player.d, player.td), + // F9 excludePlayers: flag (do NOT drop) the player-game so the player + // leaderboard skips it while the squad aggregate still counts it (legacy + // excludes only the player aggregate). Matched on the artifact callsign + + // replay date, like legacy `addPlayerGameResultToGlobalStatistics`. + excluded = isPlayerExcluded(player.n, input.replayTimestamp); return [ { entityRef: String(player.eid), playerId: identity.player_id, ...(counterDeaths === undefined ? {} : { counterDeaths }), + ...(excluded ? { excluded } : {}), ...(squadId === undefined ? {} : { squadId }), }, ]; @@ -1147,8 +1154,13 @@ function previousBountyStats(stats: unknown): PreviousBountyStats | undefined { function bountyKillInputs(replays: AggregateReplayInput[]): BountyKillInput[] { return replays.flatMap((replay) => { + // F9: excluded player-games earn/award no bounty (player-attributed, like + // player_stats). Bounty is not a compared parity surface and legacy has no + // bounty, so this is a consistency choice, not a parity constraint. const playersByEntity = new Map( - replay.players.map((player) => [player.entityRef, player]), + replay.players + .filter((player) => player.excluded !== true) + .map((player) => [player.entityRef, player]), ); return replay.events.flatMap((event) => { if ( diff --git a/src/modules/statistics/repository/tests/postgres.test.ts b/src/modules/statistics/repository/tests/postgres.test.ts index 1e28a84..2e1340a 100644 --- a/src/modules/statistics/repository/tests/postgres.test.ts +++ b/src/modules/statistics/repository/tests/postgres.test.ts @@ -262,6 +262,97 @@ describe("PgStatisticsRepository", () => { }); }); + it("drops an excluded player's row but keeps the death they dealt to others (F9 excludePlayers)", async () => { + const rotationId = await seedRotation(), + // "exile" is unconditionally excluded (excludePlayers.json, both bounds + // null). Bravo (victim) and Charlie (control) are ordinary players. + excludedPlayer = await seedPlayer("exile", "steam-exile"), + victim = await seedPlayer("Bravo", "steam-b"), + control = await seedPlayer("Charlie", "steam-c"), + squad = await seedSquad("Squad X"), + parserResultId = await seedParserResult({ + rawSnapshot: { + contract_version: "3.0.0", + parser: {}, + players: [ + { eid: 101, n: "exile", sid: "steam-exile" }, + { eid: 202, n: "Bravo", sid: "steam-b" }, + { eid: 303, n: "Charlie", sid: "steam-c" }, + ], + replay: missionEnvelope("sg_assault"), + source: {}, + status: "success", + }, + replayTimestamp: "2026-02-01T12:00:00.000Z", + }); + + // The excluded player and the victim share a squad — legacy keeps excluded + // players in squad stats, so the squad row must still count both. + await seedMembership(squad, excludedPlayer); + await seedMembership(squad, victim); + await fullRunRepository.classifyGameTypesForCurrentReplays(); + // The excluded player teamkills the victim: the victim's death must survive + // (it is recorded on the victim's own row), but the excluded attacker must + // not appear at all and must not be credited the teamkill. + await repository.replaceParserEvents(parserResultId, [ + { + eventType: "teamkill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: { index: 0 }, + }, + ]); + + // Only the two non-excluded players are aggregated, across both the sg + // per-rotation and sg all-time scopes (2 players × 2 scopes = 4). + await expect( + repository.recalculatePlayerAndSquadStatsForParserResult(parserResultId), + ).resolves.toMatchObject({ playerStats: 4, rotationId }); + + 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], + ); + const byId = statsById(playerStats.rows); + + expect(byId[excludedPlayer]).toBeUndefined(); + expect(byId[victim]).toEqual({ + deaths: { by_teamkills: 1, total: 1 }, + kills: 0, + replay_count: 1, + teamkills: 0, + version: 1, + }); + expect(byId[control]).toEqual({ + deaths: { by_teamkills: 0, total: 0 }, + kills: 0, + replay_count: 1, + teamkills: 0, + version: 1, + }); + + // The squad still counts the excluded player: both members present + // (player_count 2), the excluded player's teamkill counts, and the victim's + // teamkill death counts — exactly as legacy squad stats (no exclude check). + const squadStats = await pool.query<{ squad_id: string; stats: StatsRow }>( + "select squad_id, stats from squad_stats where rotation_id = $1", + [rotationId], + ); + expect(statsById(squadStats.rows)).toEqual({ + [squad]: { + deaths: { by_teamkills: 1, total: 1 }, + kills: 0, + player_count: 2, + replay_count: 1, + teamkills: 1, + version: 1, + }, + }); + }); + 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, diff --git a/src/modules/statistics/service/service.ts b/src/modules/statistics/service/service.ts index 4f9d959..de87f74 100644 --- a/src/modules/statistics/service/service.ts +++ b/src/modules/statistics/service/service.ts @@ -22,6 +22,13 @@ export interface AggregatePlayerEvidence { // counts, instead of depending on a possibly-stale events table. counterDeaths?: DeathStats; entityRef: string; + // F9 excludePlayers: when true, this player-game is dropped from the player + // leaderboard (player_stats, all-time + per-rotation) because the callsign is + // an ambiguous identity in this replay's date interval. It STILL counts toward + // squad_stats — legacy excludes only the player aggregate, never squads — so + // the two aggregates read from different entity maps (see + // calculatePlayerAndSquadAggregates). + excluded?: boolean; playerId: string; squadId?: string; } @@ -90,7 +97,16 @@ export function calculatePlayerAndSquadAggregates( squadAggregates = new Map(); for (const replay of replays) { - const playersByEntity = new Map( + // Two entity maps: the player aggregate credits only non-excluded players + // (F9 — an excluded callsign's player-game is dropped from the leaderboard), + // while the squad aggregate credits EVERY player so squads keep their + // members exactly as legacy `getSquadInfo` does (no exclude check there). + const playerEntities = new Map( + replay.players + .filter((player) => player.excluded !== true) + .map((player) => [player.entityRef, player]), + ), + squadEntities = new Map( replay.players.map((player) => [player.entityRef, player]), ), // Per-replay death tallies. Solid Games are one-life: a player can die at @@ -109,14 +125,17 @@ export function calculatePlayerAndSquadAggregates( replayPlayerDeaths = new Map(), replaySquadDeaths = new Map(), eventContext = { - playersByEntity, + playerEntities, replayPlayerDeaths, replaySquadDeaths, + squadEntities, }; for (const player of replay.players) { - const aggregate = playerAggregate(playerAggregates, player.playerId); - aggregate.replayIds.add(replay.replayId); + if (player.excluded !== true) { + const aggregate = playerAggregate(playerAggregates, player.playerId); + aggregate.replayIds.add(replay.replayId); + } if (player.squadId !== undefined) { const squad = squadAggregate(squadAggregates, player.squadId); @@ -136,7 +155,7 @@ export function calculatePlayerAndSquadAggregates( applySquadCounterEvent(event, eventContext); continue; } - applyAttackerEvent(event, playersByEntity, playerAggregates); + applyAttackerEvent(event, playerEntities, playerAggregates); applyVictimDeath(event, eventContext); applySquadEvent(event, eventContext, squadAggregates); } @@ -186,12 +205,16 @@ interface MutableSquadAggregate extends MutablePlayerAggregate { } interface ReplayEventContext { - playersByEntity: Map; + // Non-excluded players only — credits the player aggregate (F9). + playerEntities: 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; + // All players (incl. excluded) — credits the squad aggregate, which legacy + // never filters (F9). + squadEntities: Map; } function playerAggregate( @@ -271,7 +294,7 @@ function applyVictimDeath( if (typeof victimEntityId !== "number") { return; } - const victim = context.playersByEntity.get(String(victimEntityId)); + const victim = context.playerEntities.get(String(victimEntityId)); if (victim === undefined) { return; } @@ -294,7 +317,7 @@ function applySquadEvent( return; } - const attacker = context.playersByEntity.get(event.observedPlayerRef); + const attacker = context.squadEntities.get(event.observedPlayerRef); if (attacker?.squadId !== undefined) { const aggregate = squadAggregate(aggregates, attacker.squadId); if (event.eventType === "kill") { @@ -309,7 +332,7 @@ function applySquadEvent( if (typeof victimEntityId !== "number") { return; } - const victim = context.playersByEntity.get(String(victimEntityId)); + const victim = context.squadEntities.get(String(victimEntityId)); if (victim?.squadId !== undefined) { incrementDeaths( replayDeaths(context.replaySquadDeaths, victim.squadId), @@ -322,7 +345,7 @@ function applyCounterEvent( event: PlayerCounterEvent, context: ReplayEventContext, ): void { - const player = context.playersByEntity.get(event.observedPlayerRef), + const player = context.playerEntities.get(event.observedPlayerRef), deaths = counterDeaths(event.payload); if (player === undefined || deaths === undefined) { return; @@ -337,7 +360,7 @@ function applySquadCounterEvent( event: PlayerCounterEvent, context: ReplayEventContext, ): void { - const player = context.playersByEntity.get(event.observedPlayerRef), + const player = context.squadEntities.get(event.observedPlayerRef), deaths = counterDeaths(event.payload); if (player?.squadId === undefined || deaths === undefined) { return; @@ -364,10 +387,12 @@ function tallyArtifactCounterDeaths( if (player.counterDeaths === undefined) { return; } - incrementDeathsByCounter( - replayDeaths(context.replayPlayerDeaths, player.playerId), - player.counterDeaths, - ); + if (player.excluded !== true) { + incrementDeathsByCounter( + replayDeaths(context.replayPlayerDeaths, player.playerId), + player.counterDeaths, + ); + } if (player.squadId !== undefined) { incrementDeathsByCounter( replayDeaths(context.replaySquadDeaths, player.squadId), diff --git a/src/modules/statistics/service/tests/aggregates.test.ts b/src/modules/statistics/service/tests/aggregates.test.ts index 8dfec43..44251e3 100644 --- a/src/modules/statistics/service/tests/aggregates.test.ts +++ b/src/modules/statistics/service/tests/aggregates.test.ts @@ -153,6 +153,113 @@ describe("calculatePlayerAndSquadAggregates", () => { ]); }); + it("drops an excluded player from the player leaderboard but keeps them in their squad (F9)", () => { + const result = calculatePlayerAndSquadAggregates([ + { + events: [ + // Excluded player-a kills player-b: the kill must NOT credit player-a + // (no leaderboard row) but MUST credit squad-a; player-b gets a death. + { + eventType: "kill", + observedPlayerRef: "101", + payload: { victim_entity_id: 202 }, + sourceRef: {}, + }, + // player-b teamkills the excluded player-a: player-a's death must NOT + // credit the player leaderboard but MUST credit squad-a's deaths. + { + eventType: "teamkill", + observedPlayerRef: "202", + payload: { victim_entity_id: 101 }, + sourceRef: {}, + }, + ], + players: [ + { + entityRef: "101", + excluded: true, + playerId: "player-a", + squadId: "squad-a", + }, + { entityRef: "202", playerId: "player-b", squadId: "squad-b" }, + ], + replayId: "replay-1", + }, + ]); + + // The excluded player has no leaderboard row; only player-b survives. + expect(result.playerStats).toEqual([ + { + playerId: "player-b", + stats: { + deaths: { by_teamkills: 0, total: 1 }, + kills: 0, + replay_count: 1, + teamkills: 1, + version: 1, + }, + }, + ]); + // Squads still count the excluded player's kill, death, and membership. + expect(result.squadStats).toEqual([ + { + squadId: "squad-a", + stats: { + deaths: { by_teamkills: 1, total: 1 }, + kills: 1, + player_count: 1, + replay_count: 1, + teamkills: 0, + version: 1, + }, + }, + { + squadId: "squad-b", + stats: { + deaths: { by_teamkills: 0, total: 1 }, + kills: 0, + player_count: 1, + replay_count: 1, + teamkills: 1, + version: 1, + }, + }, + ]); + }); + + it("skips an excluded player's artifact counter death for the leaderboard but keeps it for the squad (F9)", () => { + const result = calculatePlayerAndSquadAggregates([ + { + events: [], + players: [ + { + counterDeaths: { by_teamkills: 0, total: 1 }, + entityRef: "101", + excluded: true, + playerId: "player-a", + squadId: "squad-a", + }, + ], + replayId: "replay-1", + }, + ]); + + expect(result.playerStats).toEqual([]); + expect(result.squadStats).toEqual([ + { + squadId: "squad-a", + stats: { + deaths: { by_teamkills: 0, total: 1 }, + kills: 0, + player_count: 1, + replay_count: 1, + teamkills: 0, + version: 1, + }, + }, + ]); + }); + it("ignores unknown players, diagnostics, and missing squad evidence", () => { const result = calculatePlayerAndSquadAggregates([ {