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
100 changes: 100 additions & 0 deletions .planning/quick/F8-mission-name-envelope-SUMMARY.md
Original file line number Diff line number Diff line change
@@ -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: <string> }` → `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).
44 changes: 37 additions & 7 deletions src/modules/statistics/game-type/classify-game-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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: <string> }` 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<string, unknown> | null | undefined,
Expand All @@ -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;
}
}
Expand Down
61 changes: 61 additions & 0 deletions src/modules/statistics/game-type/tests/classify-game-type.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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");
});
});
Loading