From b0b4fc6000edb5ebb1cb30cddc0afb778732b659 Mon Sep 17 00:00:00 2001 From: Pavlov Alexandr Date: Sun, 14 Jun 2026 19:12:19 +0700 Subject: [PATCH 1/2] fix(statistics): read enveloped mission_name in game-type classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractMissionName read replay[key] only when typeof === 'string' and the candidate keys lacked the real snake_case 'mission_name'. In the parser artifact the field is at replay.mission_name wrapped in the present/value envelope ({state:'present',value:'mace@…'}), so classification returned null (excluded) for every replay → game_type NULL for all 23556 → empty aggregates on real data. Now reads mission_name (first) + world_name/map_name and unwraps the {state:'present',value} envelope (local unwrapMissionField, mirrors presentValue), still accepting plain strings. Unit tests add enveloped + absent cases (fail on old, pass on new); real-pg seeds use the faithful enveloped mission_name shape so the harness exercises the production extraction path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../game-type/classify-game-type.ts | 44 ++++++++++--- .../tests/classify-game-type.test.ts | 61 +++++++++++++++++++ .../repository/tests/postgres.test.ts | 45 +++++++++----- 3 files changed, 127 insertions(+), 23 deletions(-) diff --git a/src/modules/statistics/game-type/classify-game-type.ts b/src/modules/statistics/game-type/classify-game-type.ts index 745ab71..e261c8a 100644 --- a/src/modules/statistics/game-type/classify-game-type.ts +++ b/src/modules/statistics/game-type/classify-game-type.ts @@ -44,6 +44,13 @@ const SM_MIN_MONTH_EXCLUSIVE = 0; const GAME_TYPE_SPLIT_SYMBOL = "@"; const MISSION_CANDIDATE_KEYS = [ + // Real parser-artifact field (F8): the `replay` block uses snake_case keys, + // each wrapped in the present/value envelope. `mission_name` is the confirmed + // production field, so it is checked first. + "mission_name", + "world_name", + "map_name", + // Legacy/back-compat candidates (plain-string forms). "mission", "missionName", "world", @@ -52,13 +59,36 @@ const MISSION_CANDIDATE_KEYS = [ "mapName", ] as const; +/** + * Unwrap a raw_snapshot `replay`-block field. The real parser artifact wraps + * every replay-block field in the present/value envelope + * (`{ state: "present", value }`), the same shape `repository.ts`'s + * `presentValue` unwraps for commander/outcome facts. This mirrors those + * semantics locally so the classifier stays dependency-free and does not import + * across the public-stats / repository module boundary: + * - a plain `string` is returned as-is (back-compat with older snapshots), + * - `{ state: "present", value: }` yields `value`, + * - `{ state: "absent" }` / missing / a non-string value → `null` (not present). + */ +function unwrapMissionField(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + if (value === null || typeof value !== "object") { + return null; + } + const envelope = value as { state?: unknown; value?: unknown }; + if (envelope.state === "present" && typeof envelope.value === "string") { + return envelope.value; + } + return null; +} + /** * Extract the mission string from a raw_snapshot `replay` block by scanning the - * candidate keys in priority order — the SAME order as - * `public-stats/replay-mapper.ts` `extractMapName`, so classification and the - * public replay label agree. The list is intentionally duplicated here (rather - * than imported across the public-stats module boundary) to keep this classifier - * dependency-free and avoid a cross-module import. + * candidate keys in priority order, unwrapping the present/value envelope used + * by the real parser artifact (F8). Returns the first present string value, or + * null when no candidate is a present string. */ export function extractMissionName( replay: Record | null | undefined, @@ -67,8 +97,8 @@ export function extractMissionName( return null; } for (const key of MISSION_CANDIDATE_KEYS) { - const value = replay[key]; - if (typeof value === "string") { + const value = unwrapMissionField(replay[key]); + if (value !== null) { return value; } } diff --git a/src/modules/statistics/game-type/tests/classify-game-type.test.ts b/src/modules/statistics/game-type/tests/classify-game-type.test.ts index c504255..cda5d10 100644 --- a/src/modules/statistics/game-type/tests/classify-game-type.test.ts +++ b/src/modules/statistics/game-type/tests/classify-game-type.test.ts @@ -1,4 +1,5 @@ /* eslint-disable unicorn/no-null -- null is the contractual "excluded" value (D2). */ +/* eslint-disable camelcase -- mission_name/world_name/map_name are the real snake_case parser-artifact wire keys (F8). */ import { describe, expect, it } from "vitest"; import { @@ -175,4 +176,64 @@ describe("extractMissionName", () => { expect(extractMissionName(absent)).toBeNull(); expect(extractMissionName({})).toBeNull(); }); + + // F8 — the REAL parser artifact stores the mission at the snake_case key + // `mission_name`, wrapped in the present/value envelope. The old code only + // accepted a plain string under the camelCase keys, so it returned null for + // every real replay → game_type NULL → empty aggregates. These cases fail + // against the old extractor and pass against the envelope-aware one. + it("unwraps the present/value envelope at the real 'mission_name' key (F8)", () => { + expect( + extractMissionName({ + mission_name: { state: "present", value: "mace@x" }, + }), + ).toBe("mace@x"); + expect( + extractMissionName({ + mission_name: { state: "present", value: "sg_assault" }, + }), + ).toBe("sg_assault"); + expect( + extractMissionName({ + mission_name: { state: "present", value: "sm_clash" }, + }), + ).toBe("sm_clash"); + }); + + it("treats an 'absent' envelope (or non-string value) as not present (F8)", () => { + expect( + extractMissionName({ mission_name: { state: "absent" } }), + ).toBeNull(); + expect( + extractMissionName({ mission_name: { state: "present", value: 42 } }), + ).toBeNull(); + expect(extractMissionName({ mission_name: null })).toBeNull(); + }); + + it("prefers the real 'mission_name' envelope over legacy snake_case fields (F8)", () => { + expect( + extractMissionName({ + mission_name: { state: "present", value: "sg_real" }, + world_name: { state: "present", value: "sm_fallback" }, + }), + ).toBe("sg_real"); + }); + + it("falls back to legacy 'world_name'/'map_name' envelopes when mission_name is absent (F8)", () => { + expect( + extractMissionName({ + mission_name: { state: "absent" }, + world_name: { state: "present", value: "sm_world" }, + }), + ).toBe("sm_world"); + expect( + extractMissionName({ + map_name: { state: "present", value: "mace_map" }, + }), + ).toBe("mace_map"); + }); + + it("still accepts a plain-string mission for back-compat (F8)", () => { + expect(extractMissionName({ mission: "sg_legacy" })).toBe("sg_legacy"); + }); }); diff --git a/src/modules/statistics/repository/tests/postgres.test.ts b/src/modules/statistics/repository/tests/postgres.test.ts index 4b85566..65b04df 100644 --- a/src/modules/statistics/repository/tests/postgres.test.ts +++ b/src/modules/statistics/repository/tests/postgres.test.ts @@ -37,6 +37,19 @@ const env = { "../../../../infra/db/migrations", ); +/** + * Builds a faithful `raw_snapshot.replay` block for a mission name. The REAL + * parser artifact stores the mission at the snake_case key `mission_name`, + * wrapped in the present/value envelope (`{ state: "present", value }`) — every + * replay-block field is enveloped this way. Seeding this production shape (not a + * bare `{ mission: "..." }` string) forces the harness through the same + * `extractMissionName` envelope-unwrap path production uses, so a regression in + * that extraction (F8) is caught end-to-end by the parity/classification tests. + */ +function missionEnvelope(missionName: string): Record { + return { mission_name: { state: "present", value: missionName } }; +} + /** * The legacy 20 rotation `starts_at` windows (RESEARCH B.7), each snapped to the * ISO-week start (Monday 00:00:00 UTC) exactly as the legacy @@ -144,7 +157,7 @@ describe("PgStatisticsRepository", () => { { eid: 202, n: "Bravo", sid: "steam-b" }, { eid: 303, n: "Charlie", sid: "steam-c" }, ], - replay: { mission: "sg_assault" }, + replay: missionEnvelope("sg_assault"), source: {}, status: "success", }, @@ -260,7 +273,7 @@ describe("PgStatisticsRepository", () => { { eid: 202, n: "Target" }, { eid: 303, n: "Psycho" }, ], - replay: { mission: "sg_assault" }, + replay: missionEnvelope("sg_assault"), source: {}, status: "success", }, @@ -339,7 +352,7 @@ describe("PgStatisticsRepository", () => { contract_version: "3.0.0", parser: {}, players: [{ eid: 101, n: " " }], - replay: { mission: "sg_assault" }, + replay: missionEnvelope("sg_assault"), source: {}, status: "success", }, @@ -381,7 +394,7 @@ describe("PgStatisticsRepository", () => { contract_version: "3.0.0", parser: {}, players: [{ eid: 101, n: "Psycho" }], - replay: { mission: "sg_assault" }, + replay: missionEnvelope("sg_assault"), source: {}, status: "success", }, @@ -425,7 +438,7 @@ describe("PgStatisticsRepository", () => { contract_version: "3.0.0", parser: {}, players: [{ eid: 101, n: "Psycho" }], - replay: { mission: "sg_assault" }, + replay: missionEnvelope("sg_assault"), source: {}, status: "success", }, @@ -465,7 +478,7 @@ describe("PgStatisticsRepository", () => { contract_version: "3.0.0", parser: {}, players: [{ eid: 101, n: "Psycho" }], - replay: { mission: "sg_assault" }, + replay: missionEnvelope("sg_assault"), source: {}, status: "success", }, @@ -574,7 +587,7 @@ describe("PgStatisticsRepository", () => { winner_side: { state: "present", value: "west" }, }, }, - replay: { mission: "sg_assault" }, + replay: missionEnvelope("sg_assault"), source: {}, status: "success", }, @@ -643,7 +656,7 @@ describe("PgStatisticsRepository", () => { { eid: 101, n: "Alpha", sid: "steam-a" }, { eid: 202, n: "Bravo", sid: "steam-b" }, ], - replay: { mission: "sg_assault" }, + replay: missionEnvelope("sg_assault"), source: {}, status: "success", }, @@ -754,7 +767,7 @@ describe("PgStatisticsRepository", () => { winner_side: { state: "present", value: "west" }, }, }, - replay: { mission: "sg_one" }, + replay: missionEnvelope("sg_one"), source: {}, status: "success", }, @@ -784,7 +797,7 @@ describe("PgStatisticsRepository", () => { winner_side: { state: "present", value: "east" }, }, }, - replay: { mission: "sg_two" }, + replay: missionEnvelope("sg_two"), source: {}, status: "success", }, @@ -863,7 +876,7 @@ describe("PgStatisticsRepository", () => { { eid: 202, n: "Ghost" }, { eid: 303, n: "Wraith" }, ], - replay: { mission: "sg_one" }, + replay: missionEnvelope("sg_one"), source: {}, status: "success", }, @@ -878,7 +891,7 @@ describe("PgStatisticsRepository", () => { { eid: 101, n: "Alpha", sid: "steam-a" }, { eid: 404, n: "Ghost" }, ], - replay: { mission: "sg_two" }, + replay: missionEnvelope("sg_two"), source: {}, status: "success", }, @@ -2148,7 +2161,7 @@ async function seedClassifiableReplay( contract_version: "3.0.0", parser: {}, players, - replay: { mission: seed.missionName }, + replay: missionEnvelope(seed.missionName), source: {}, status: "success", }, @@ -2181,7 +2194,7 @@ async function seedKillReplay(seed: KillReplaySeed): Promise { ], ...(seed.missionName === undefined ? {} - : { replay: { mission: seed.missionName } }), + : { replay: missionEnvelope(seed.missionName) }), source: {}, status: "success", }, @@ -2229,7 +2242,7 @@ async function seedManyPlayerKillReplay( contract_version: "3.0.0", parser: {}, players, - replay: { mission: seed.missionName }, + replay: missionEnvelope(seed.missionName), source: {}, status: "success", }, @@ -2613,7 +2626,7 @@ async function seedCorpusReplay(seed: CorpusReplaySeed): Promise { contract_version: "3.0.0", parser: {}, players, - replay: { mission: seed.missionName }, + replay: missionEnvelope(seed.missionName), source: {}, status: "success", }, From dd2a609fb9bb2f18db49fd516abc451fa36fd28c Mon Sep 17 00:00:00 2001 From: Pavlov Alexandr Date: Sun, 14 Jun 2026 19:12:20 +0700 Subject: [PATCH 2/2] docs: F8 mission-name envelope fix summary Co-Authored-By: Claude Opus 4.8 (1M context) --- .../quick/F8-mission-name-envelope-SUMMARY.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .planning/quick/F8-mission-name-envelope-SUMMARY.md diff --git a/.planning/quick/F8-mission-name-envelope-SUMMARY.md b/.planning/quick/F8-mission-name-envelope-SUMMARY.md new file mode 100644 index 0000000..2fc449f --- /dev/null +++ b/.planning/quick/F8-mission-name-envelope-SUMMARY.md @@ -0,0 +1,100 @@ +# F8 — mission-name envelope extraction fix + +**Branch:** `fix/f8-mission-name-envelope` +**Severity:** CRITICAL — every replay had `game_type = NULL`, so all aggregates were empty. + +## Root cause + +`extractMissionName(replay)` in `src/modules/statistics/game-type/classify-game-type.ts` +read `replay[key]` and only accepted it when `typeof value === "string"`, scanning the +camelCase candidate keys `mission / missionName / world / worldName / map / mapName`. + +In the **real** parser artifact the mission name lives at the snake_case key +**`mission_name`** (not in the old candidate list) and is wrapped in the present/value +envelope — every `replay`-block field is enveloped this way: + +```jsonc +raw_snapshot.replay.mission_name = { "state": "present", "value": "mace@…" } +``` + +So for all 23 556 production replays `extractMissionName` returned `null` → +`classifyGameType` returned `null` (excluded) → `replays.game_type` stayed `NULL` → +zero aggregates. + +The existing unit test passed only because it fed a plain-string `missionName` +(`{ mission: "sg_x" }`), and the real-pg seeds in `postgres.test.ts` seeded the same +plain `replay: { mission: "..." }` shape — neither exercised the production envelope path. + +## Fix + +**1. `classify-game-type.ts` — `extractMissionName`** + +- Added the real snake_case keys to `MISSION_CANDIDATE_KEYS`, in priority order: + `mission_name` (confirmed production field, first), then `world_name`, `map_name`, + then the legacy camelCase fallbacks (`mission`, `missionName`, `world`, `worldName`, + `map`, `mapName`) kept for back-compat. +- Added a small local `unwrapMissionField(value)` that mirrors `repository.ts` + `presentValue` semantics without importing across the public-stats / repository + boundary (classifier stays dependency-free): + - plain `string` → returned as-is (back-compat), + - `{ state: "present", value: }` → `value`, + - `{ state: "absent" }` / missing / non-string value → `null` (try next key → null). + +Behavior is otherwise unchanged — classification spec (sgs exclusion, prefix match, +mace `<10`, sm month cutoff, includeReplays override, excludeReplays link, per-type / +all-time, `is_show`) is untouched. This is purely a mission-extraction fix. + +**2. Tests** + +Unit (`game-type/tests/classify-game-type.test.ts`): + +- `unwraps the present/value envelope at the real 'mission_name' key (F8)` — mace/sg/sm. +- `treats an 'absent' envelope (or non-string value) as not present (F8)` — + `{state:"absent"}`, `value: 42`, `null` → null. +- `prefers the real 'mission_name' envelope over legacy snake_case fields (F8)`. +- `falls back to legacy 'world_name'/'map_name' envelopes when mission_name is absent (F8)`. +- `still accepts a plain-string mission for back-compat (F8)`. + +These were verified to **FAIL against the old extractor** (3 enveloped cases returned +`null`) and **PASS against the fix** — proving the test catches this bug class. + +Real-pg (`repository/tests/postgres.test.ts`): + +- Added `missionEnvelope(missionName)` helper building the faithful production block + `{ mission_name: { state: "present", value } }`. +- Replaced all 16 `replay: { mission: "..." }` seed sites (literal sg_assault/sg_one/ + sg_two seeds + the `seedClassifiableReplay` / `seedKillReplay` / + `seedManyPlayerKillReplay` / `seedCorpusReplay` helpers) with + `replay: missionEnvelope(...)`, so the classification / per-type / all-time / parity + tests now drive the production envelope-unwrap path end-to-end. No parity assertion + weakened — only the seed shape was made faithful. + +Other test files (`readiness.test.ts`, `fixtures.ts`, `full-run.test.ts`) set no +`replay` mission block and don't exercise classification → left unchanged. + +## Verification + +Infra-free (green): + +- `prettier --check` — clean +- `eslint .` — clean (added file-level `eslint-disable camelcase` for the snake_case + wire keys in the test, matching repo convention) +- `tsc --noEmit` — clean +- unit `classify-game-type.test.ts` — 26/26 pass +- `openapi:check` — OpenAPI contract diff EMPTY + +PG-backed (`test:integration`, `postgres.test.ts`, `test:coverage`): pending — requires +the Docker stack (PG/RabbitMQ/MinIO) which could not be started in this environment +(user not in `docker` group; `sudo` needs a TTY). Run `sudo docker compose up -d` then +`pnpm verify` (`--no-file-parallelism`) to complete the mandated full green run. + +## Out of scope (deferred) + +`src/modules/public-stats/replay-mapper.ts` `extractMapName` has the **identical** bug +(plain-string only, no `mission_name` / envelope), so the public replay *label* is also +empty on real data. Not part of F8 (separate module / symptom, classifier must stay +dependency-free). Should be fixed in a follow-up. + +## Commits + +- _pending atomic commit on `fix/f8-mission-name-envelope`_ (hash recorded after commit).