typescript-legacy-engine-per-player-usage-attribution
Root cause (MAF-GAP-029). The legacy engine tracked tokens per (provider, model) only. On the write path that became token_usage rows with player_id = 'ALL' (a sentinel). The game-detail read path already had player_id/provider/model/tokensUsed columns per player — so the fields "existed in code but were empty in data." That is a write-path gap, and the fix must touch both paths: who records the rows and who resolves them.
1. Write path — engine: tag every token record with the real engine player id (src/engine/token-tracker.ts). collectUsageByPlayer() aggregates the same underlying entries per real player id, so there is a single source of truth:
// FIX: the engine attributes each turn's tokens to the real player id
this.tracker.record(p.id, provider, model, tokens); // was: no player id
collectUsageByPlayer(): UsageMetric[] { // NEW
const agg = new Map<string, UsageMetric>();
for (const e of this.entries.values()) {
if (e.playerId === undefined) continue; // engine-internal usage: not a player's
const k = `${e.playerId}\u0000${e.provider}\u0000${e.model}`;
const cur = agg.get(k);
if (cur) cur.tokensUsed += e.tokensUsed;
else agg.set(k, { playerId: e.playerId, provider: e.provider, model: e.model, tokensUsed: e.tokensUsed });
}
return [...agg.values()];
}
collectUsageByModel() is unchanged (still produces the game-wide rollup) and is derived from the same entries, so Σ per-player == ALL total is an invariant, not a coincidence.
2. Write path — bridge: done message carries usageByPlayer (src/engine/bridge.ts). decodeDone() tolerates legacy envelopes that lack the field, so old in-flight messages don't crash the pipeline:
interface DoneEnvelope {
gameId: string;
usage?: UsageMetric[]; // oldest legacy field name
usageByModel?: UsageMetric[];
usageByPlayer?: UsageMetric[]; // NEW
}
// legacy envelope -> usageByPlayer: [] (never throws)
3. Write path — persistence: write per-player rows, keep ALL rows (src/server/persist-usage.ts):
export function persistUsage(store, msg, recordedAt = Date.now()) {
const rows: TokenUsageRow[] = [
...msg.usageByModel.map(m => ({ gameId: msg.gameId, playerId: ALL_PLAYER_ID, ...m, recordedAt })), // KEPT for stats
...msg.usageByPlayer.map(m => ({ gameId: msg.gameId, playerId: m.playerId ?? ALL_PLAYER_ID, ...m, recordedAt })), // NEW
];
store.replaceForGame(msg.gameId, rows); // idempotent replay: replace, never duplicate
}
4. Read path — resilience for pre-fix games (src/server/player-attribution.ts). resolvePlayerUsage() never fabricates numbers, in this precedence:
// 1) recorded-row fallback: per-player rows exist (fixed write path) -> authoritative
if (perPlayerRows.length > 0) { /* use them verbatim; source='recorded' */ }
// 2) no-assignment games (players.model == null): tokens HONESTLY 0 per player
if (players.every(p => p.model === null && p.provider === null)) {
return players.map(p => ({ ..., tokensUsed: 0, source: 'no-assignment-zero' }));
}
// 3) other legacy ALL-only games: 0 per player + source='all-fallback'; gameTotalTokens still surfaced from ALL rows
Key lesson recorded in the code: when adding per-player attribution, audit BOTH the write path (who writes rows) and the read path (who resolves them). Here the columns already existed in the read model — the bug was that no code ever wrote per-player rows.
Verified by compiling under `tsc --strict` and running a 28-assertion harness (`test/fix.test.ts`): **28 passed, 0 failed** (`node dist/test/fix.test.js`). Coverage:
- **Write path**: `collectUsageByPlayer()` aggregates per real player id (same player+model accumulates); never emits the `ALL` sentinel; `collectUsageByModel()` still totals the whole game.
- **Bridge**: done message carries `usageByPlayer`; legacy wire payload (`{gameId, usageByModel}`) decodes without crashing with `usageByPlayer: []`.
- **persistUsage**: writes 1 row per player **and keeps ALL rows**; per-player total == ALL total invariant; re-delivering the same done message replaces rows instead of double-counting.
- **Read path**: fixed games resolve from recorded rows with correct `provider`/`model`/`tokensUsed`; no-assignment legacy games return honestly 0 (`no-assignment-zero`) with the ALL total still available; ALL-only legacy games with assignment return 0 (`all-fallback`) — no invented numbers.
- **End-to-end (the exact gap)**: `runLegacy()` reproduces the pre-fix payload → per-player tokens empty before the fix; re-running the same game through the fixed engine build populates per-player `provider`/`model`/`tokensUsed` and keeps the ALL stats rows.
- **Edge cases**: negative, `NaN`, and `Infinity` token counts are rejected by `TokenTracker`; replay overwrites `recordedAt`; engine-internal usage (no player id) is excluded from per-player attribution but included in ALL.{"model": "deepseek-v4-flash", "problem_class": "typescript-legacy-engine-per-player-usage-attribution", "result": "passed", "tests": 28}