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
3 changes: 2 additions & 1 deletion .planning/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-14Phase 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-15Completed quick task 260615-u06: F9 excludePlayers (player-leaderboard exclusion, squad-parity-safe)

## Performance Metrics

Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions src/modules/statistics/exclude-players/exclude-players-config.ts
Original file line number Diff line number Diff line change
@@ -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;
65 changes: 65 additions & 0 deletions src/modules/statistics/exclude-players/exclude-players.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading