madden-fantasy-extractor 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,255 @@
1
+ # madden-fantasy-extractor
2
+
3
+ Reads a Madden NFL franchise save and produces a signed `stat_import` JSON payload —
4
+ the ingest-ready snapshot consumed by the [`madden-fantasy`](https://github.com/patches822/madden-fantasy)
5
+ server monorepo. Schema and extraction-design context (`db/schema.sql`,
6
+ `docs/extraction-findings.md`) live in that repo, not here — this repo is standalone
7
+ and depends on it only through the payload/signing contract
8
+ (`payloadSchemaVersion`), not a code or file dependency.
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ npm install
14
+
15
+ # First run auto-generates a signing keypair under .keys/ (gitignored) and, since
16
+ # there's no capture watermark yet, runs in full/backfill mode automatically.
17
+ node src/cli.js "C:/Users/you/Documents/Madden NFL 26/saves/CAREER-WEEK1"
18
+
19
+ # A normal weekly run only includes games newly decided since the last successful
20
+ # run (tracked in .extractor-state.json, gitignored) — expect roughly one week's
21
+ # worth of games, not the whole season.
22
+ node src/cli.js "C:/Users/you/Documents/Madden NFL 26/saves/CAREER-WEEK2"
23
+
24
+ # Force everything in the save regardless of the watermark — first run, recovering
25
+ # from a missed week, or manual backfill:
26
+ node src/cli.js <savePath> --full
27
+ node src/cli.js <savePath> --backfill # same thing, alternate spelling
28
+
29
+ # Optional flags:
30
+ node src/cli.js <savePath> --out payload.json --key .keys/myfranchise --state .extractor-state.json
31
+
32
+ # Print a fresh public key + keyId to register as a franchise_signing_key row:
33
+ node src/cli.js keygen
34
+ ```
35
+
36
+ The output is a signed envelope:
37
+
38
+ ```jsonc
39
+ {
40
+ "payload": { /* the stat_import shape — see below */ },
41
+ "payloadHash": "sha256 hex, matches stat_import.payload_hash",
42
+ "signature": "base64 Ed25519 signature over the payload bytes",
43
+ "signingPublicKey": "PEM — the public key this was signed with",
44
+ "keyId": "sha256 hex of signingPublicKey — matches franchise_signing_key.key_id"
45
+ }
46
+ ```
47
+
48
+ ## Using this as a library
49
+
50
+ `buildPayload` is also exported directly, for callers that already have an opened
51
+ `madden-franchise` `Franchise` instance and want to run extraction themselves (e.g.
52
+ madden-fantasy's server-side save-upload feature, which downloads a save file and opens
53
+ it before calling this):
54
+
55
+ ```js
56
+ import Franchise from 'madden-franchise';
57
+ import { buildPayload } from 'madden-fantasy-extractor';
58
+
59
+ const franchise = await Franchise.create(localSaveFilePath);
60
+ const { payload, nextCapturedGameKeys, pendingCount } = await buildPayload(franchise, {
61
+ toolVersion: 'my-caller-version',
62
+ fullMode: false,
63
+ capturedGameKeys: new Set(), // or a previously-persisted set
64
+ });
65
+ ```
66
+
67
+ See `src/extract/payload.js` for `buildPayload`'s full return shape.
68
+
69
+ ## What it extracts
70
+
71
+ One run reads the save's CURRENT clock position and produces:
72
+
73
+ | Payload key | Schema table(s) | Source |
74
+ | --- | --- | --- |
75
+ | `clock` | `franchise_season` / `franchise_week` identity fields | `SeasonInfo` |
76
+ | `teams` | `sim_team` + `sim_team_season` | `Team` (capacity-37 instance, `TEAM_TYPE='Current'`) |
77
+ | `players` | `player` + `player_season_snapshot` | `Player` |
78
+ | `games` | `sim_game` | `SeasonGame` |
79
+ | `playerGameStats` | `player_game_stat` | `Player.GameStats[]` → `Game*Stats` |
80
+ | `teamGameStats` | `team_game_stat` | `SeasonGame.*TeamStatCache` → `TeamStats` |
81
+ | `playerWeekStatus` | `player_week_status` | `Player` injury/roster fields (live) + game presence (lagged) — TWO rows per player per run, see below |
82
+ | `transactions` | `sim_transaction` | `PlayerTransactionHistoryEntry` |
83
+ | `cumulativeStats` | `player_cumulative_stat` | `Player.CareerStats` (targets only) |
84
+ | `depthChart` | `depth_chart_entry` | `Team.DepthChart` |
85
+ | `pendingPlayerStatGameKeys` | — (extractor-only signal) | games included in `games` this run whose `player_game_stat` rows haven't landed yet — see "Incremental capture" below |
86
+ | `suggestedStatus` | hint for `stat_import.status` | `'partial'` if `pendingPlayerStatGameKeys` is non-empty, else `'accepted'` — not authoritative, ingest still decides |
87
+
88
+ `sequence` (the cross-regime week ordinal) is **not** assigned here — per
89
+ `db/schema.sql`'s migration notes, that's an ingest-side responsibility requiring
90
+ knowledge of previously observed weeks, which a single save read can't provide.
91
+
92
+ `games`, `playerGameStats`, and `teamGameStats` are **incremental by default** — see
93
+ "Incremental capture" below. `teams`, `players`, `playerWeekStatus`, `transactions`,
94
+ `cumulativeStats`, and `depthChart` always reflect the save's current full state;
95
+ incremental filtering only applies to the per-game historical arrays.
96
+
97
+ ## Key design points
98
+
99
+ **Reference resolution.** Almost everything in a Madden save is a reference field
100
+ (`{tableId, rowNumber}`) rather than an inline value. `src/lib/franchise-table.js`
101
+ centralizes that resolution, including the two non-obvious cases:
102
+
103
+ - `tableId: 0` means "nothing here" (an unfilled wrapper slot, e.g. a bye week in
104
+ `GameStats[]`), not a real table — `resolveRef` treats it as `null`.
105
+ - Wrapper tables (`GameStats[]`, `Player[]` depth-chart slots) expose a dynamic set of
106
+ numbered fields whose count is **not** a hard ceiling — confirmed 20+ filled
107
+ `GameStats` slots through a Super Bowl run. Always iterate present fields, never
108
+ assume a fixed range.
109
+
110
+ **Player identity.** `src/lib/identity.js` implements the decode-then-hash design
111
+ from `docs/extraction-findings.md` §1/§11: `YearDrafted` is relative to the save's
112
+ current calendar year and must be decoded (`CurrentSeasonYear + YearDrafted + 1`)
113
+ *before* hashing into `identity_key`, or the key rotates every season and orphans a
114
+ franchise's history. Verified stable across a season boundary and through a real
115
+ trade (James Cook III / Emanuel Wilson) against real save data.
116
+
117
+ **The week lag (discovered during this build, not in the original findings doc) — and
118
+ that it's CPU-sim-only.** `SeasonInfo.CurrentWeek` identifies the week about to be
119
+ played, not the last one finished — confirmed with a consistent 1-week lag across 5
120
+ saves spanning a full season. A player's `GameStats[]` wrapper only gains a slot once
121
+ a week is fully processed, even though that week's `SeasonGame` scores may already be
122
+ posted. Resolving "the most recently completed week" (`findCompletedWeek` in
123
+ `src/extract/player-week-status.js`) derives that week's actual `weekType` from the
124
+ schedule rather than assuming it matches the current week's type — necessary because
125
+ `SeasonGame.SeasonWeek` resets to 0 independently at the start of `RegularSeason` and
126
+ at each playoff round (`WildcardPlayoff` starts at index 18 while index 17 is still
127
+ `RegularSeason`).
128
+
129
+ Follow-up finding: the lag only applies to instant-CPU-simmed games (`IsSimmed=true`),
130
+ not to slow-simmed or manually-played ones. A save taken immediately after finishing a
131
+ slow-simmed game — confirmed with the save's owner it was slow-simmed, not manually
132
+ controller-played — showed nearly the entire roster on both sides (46/51 and 46/52
133
+ players) with that exact week's `GameStats` already populated, in the same save where
134
+ every other, instant-simmed game that week still had zero players with a stat row for
135
+ it (`docs/extraction-findings.md` §3.6). `TeamStats`/`SeasonGame` scores are always
136
+ immediate regardless of `IsSimmed`; only the player-level box score is deferred, and
137
+ only for instant-simmed games. This means a franchise that slow-sims (or plays) every
138
+ game rather than using instant/auto-sim should see no player-stat lag at all, though
139
+ that's an inference from one confirmed game, not a full season — check
140
+ `payload.pendingPlayerStatGameKeys` against real saves to confirm it holds for a given
141
+ franchise's workflow. No code change was needed for any of this either way — the
142
+ incremental watermark (below) already checks readiness empirically per game rather
143
+ than assuming a fixed delay, so it naturally clears a non-lagged game's stats on the
144
+ very next capture instead of waiting a full week like an instant-simmed one.
145
+
146
+ **Injury/roster status has NONE of the GameStats lag — `player-week-status.js` emits
147
+ two rows per player per run because of it.** `Player.InjuryStatus`/`InjuryType`/
148
+ `Min`/`MaxInjuryDuration`/`ContractStatus`/`TeamIndex` are direct fields, not resolved
149
+ through `GameStats[]` — confirmed directly (`docs/extraction-findings.md` §3.7): a
150
+ player injured in a slow-simmed game showed up injured in the very next save, with
151
+ zero other injury/roster changes anywhere else in the league in that same window.
152
+ Because this is live, bundling it under the same lagged week-label as the "did they
153
+ actually play" check (which genuinely can't be known before kickoff) would delay
154
+ injury visibility by a full week for no reason. So each run now emits:
155
+
156
+ - a **predictive** row for `clock.weekType`/`clock.weekIndex` (the week about to be
157
+ played) — injured/bye/free_agent read live and current; `active` here means
158
+ "nothing known against them," not "confirmed played" (a late healthy scratch is
159
+ unknowable ahead of time). This is what answers "which of my players are hurt or
160
+ not playing this week" for setting a lineup, with zero lag.
161
+ - the original **retrospective** row for the most recently completed week — unchanged
162
+ from the lag discussion above, and still the only row type that can distinguish a
163
+ confirmed healthy scratch (`inactive`) from a player whose stats simply haven't
164
+ landed yet.
165
+
166
+ Both target the same `UNIQUE(player_id, franchise_week_id)` once a week transitions
167
+ from "current" to "completed" across runs, so the predictive row for a given week is
168
+ naturally superseded by the retrospective one once it's captured — no explicit
169
+ "is this confirmed" flag needed, same "live now, frozen later" pattern the schema
170
+ already uses for fantasy scoring (`matchup.*_points_final`).
171
+
172
+ **Transaction classification.** `src/extract/transactions.js` extends the 5-pattern
173
+ table in `docs/extraction-findings.md` §7 with a 6th, `ps_sign` (`FreeAgent` →
174
+ `PracticeSquad` with no prior team) — real-save validation showed it's actually the
175
+ *single most common* transaction type (~41% of rows in a sampled save), not a rare
176
+ edge case. `db/schema.sql`'s `sim_transaction.classified_type` CHECK was widened to
177
+ match.
178
+
179
+ **Incremental capture (`src/lib/incremental.js`, `src/lib/state.js`).** A save
180
+ re-exposes the whole season's `GameStats` every time (they persist, per
181
+ `docs/extraction-findings.md` §2), so a naive weekly run re-emits everything it's
182
+ ever seen — 169k+ `playerGameStats` rows / ~43MB for a single week-14 capture of one
183
+ real franchise. A local, gitignored watermark (`.extractor-state.json`) tracks which
184
+ games have already been captured; a normal run only includes games newly decided
185
+ since then. `--full`/`--backfill` bypasses the watermark for first-run/recovery.
186
+
187
+ This is gated on **player-level readiness, not on the game being decided** — a game
188
+ is only added to the watermark once it actually produced a `playerGameStats` row this
189
+ run. That distinction matters because of the week lag above: verified directly
190
+ against a real save (`CAREER-WEEK18`), `SeasonGame` shows a final score *and*
191
+ `TeamStats` is already fully resolved for every game in the most recent week, a full
192
+ week before any player's `GameStats[]` wrapper gains a slot for that same week.
193
+ Marking a game captured as soon as it's merely decided would permanently skip its
194
+ `player_game_stat` rows once they do land, since a captured game is never
195
+ re-examined. `teamGameStats` has no such lag (same check), so gating on player-stat
196
+ presence is a safe superset — by the time player stats exist, team stats already do.
197
+
198
+ The watermark itself is not lag-length-dependent — it doesn't assume one week, or any
199
+ fixed number of runs. A decided game with no player stats yet simply stays out of the
200
+ watermark and gets re-selected by `selectGamesToCapture` on every subsequent run,
201
+ however many, until it actually produces a `player_game_stat` row. Verified against a
202
+ real three-save sequence (`CAREER-WEEK10` → `CAREER-WEEK10SIM` → `CAREER-WEEK11`): a
203
+ week's 14 games went 13 decided/0 player-ready → all 14 decided/1 player-ready (the
204
+ one the user played themselves) → the remaining 13 became player-ready a full save
205
+ later, at which point they were captured with zero duplication of the one already
206
+ captured earlier.
207
+
208
+ Any game still sitting in that retry state when a run completes is surfaced in the
209
+ payload as `pendingPlayerStatGameKeys`, so a consumer doesn't have to diff `games`
210
+ against `playerGameStats` to find out — and `suggestedStatus` flips to `'partial'`
211
+ whenever that list is non-empty, as a hint for what `stat_import.status` to assign at
212
+ ingest.
213
+
214
+ **Same risk applies on the ingest/finalize side — not fixed here, but flagged.**
215
+ Whatever eventually marks a `franchise_week` as `'final'` (freezing
216
+ `matchup.*_points_final` per `db/schema.sql`) needs to gate on the *same* signal this
217
+ watermark uses — "every `sim_game` in the week has produced `player_game_stat` rows"
218
+ — not on "every game in the week is decided." A week finalized the moment its games
219
+ are scheduled/scored, before the lagged player stats for a CPU-simmed game land, would
220
+ freeze that week's fantasy scores permanently wrong, with no error anywhere in the
221
+ pipeline to catch it. This can't be enforced from the extractor side — it depends on
222
+ logic that doesn't exist yet (the ingest/finalize service). `pendingPlayerStatGameKeys`
223
+ is exactly the signal that future code should check before flipping a week to `final`.
224
+
225
+ **Zero-value stat rows are skipped, except where zero is a real outcome.**
226
+ `v_player_game_points` (the only player-level scoring view) is linear-only — `SUM`
227
+ over zero matching rows and `SUM` over one row worth `0 × points_per_unit` are
228
+ identical, so an empty category (e.g. a QB's `rec_yards`) is written for no scoring
229
+ benefit. `src/extract/player-game-stats.js` skips these unconditionally. For
230
+ `team_game_stat`, most categories are the same story, but `dst_points_allowed` and
231
+ `dst_yards_allowed` are documented in the schema seed as "almost always tiered" —
232
+ tiered scoring is a bucket lookup, not a multiply, and 0 is typically the *best*
233
+ bucket (the seed ruleset pays a 10 pt shutout bonus for the 0-0 band). Dropping those
234
+ two rows at zero would silently score a shutout as 0 instead of the correct bonus, so
235
+ `src/extract/team-game-stats.js` always emits them regardless of value and only
236
+ zero-skips the genuinely linear categories (`dst_sacks`, `dst_takeaways`,
237
+ `two_point_conversions`, `dst_tds`, `dst_safeties`).
238
+
239
+ ## Testing
240
+
241
+ ```bash
242
+ npm test
243
+ ```
244
+
245
+ Unit tests (`test/*.test.js`) cover the pure-logic pieces — identity hashing, draft
246
+ year decoding, transaction classification, the week-lag/boundary-crossing logic, the
247
+ incremental watermark's selection and season-collision handling, the state file's
248
+ load/save round-trip, and the zero-skip rules (including the tiered-category
249
+ exception) — against fakes shaped like the real `madden-franchise` API
250
+ (`test/helpers/fake-franchise.js`), so they exercise the actual reference-resolution
251
+ code path rather than a reimplementation of it. There's no fixture-based save file in
252
+ the repo (real saves are personal franchise data and gitignored); the extractor has
253
+ been validated end-to-end against real Madden NFL 26 saves during development,
254
+ cross-checked line-by-line against `docs/extraction-findings.md`'s verified box score
255
+ and trade cases.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "madden-fantasy-extractor",
3
+ "version": "0.2.0",
4
+ "description": "Reads a Madden franchise save and produces a signed stat_import JSON payload for ingest.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./src/index.js"
10
+ }
11
+ },
12
+ "bin": {
13
+ "madden-extract": "src/cli.js"
14
+ },
15
+ "scripts": {
16
+ "extract": "node src/cli.js",
17
+ "test": "node --test"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "dependencies": {
23
+ "madden-franchise": "^4.3.1"
24
+ }
25
+ }
package/src/cli.js ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // CLI entry point. Reads a Madden franchise save, builds a stat_import payload,
3
+ // signs it, and writes the signed envelope to a JSON file — see README.md's
4
+ // "Getting started" for the ingest side this feeds.
5
+ //
6
+ // Usage:
7
+ // node src/cli.js <savePath> [--out <file>] [--key <keyPathPrefix>] [--state <file>] [--full|--backfill]
8
+ // node src/cli.js keygen [--key <keyPathPrefix>]
9
+ //
10
+ // By default, a run only includes games newly decided since the last successful run
11
+ // (tracked in a local, gitignored state file) — see src/lib/incremental.js. Pass
12
+ // --full (or --backfill) to include everything in the save regardless of the
13
+ // watermark; this is also the automatic behavior on a first run with no state file.
14
+
15
+ import Franchise from 'madden-franchise';
16
+ import { createHash } from 'node:crypto';
17
+ import { writeFileSync, readFileSync } from 'node:fs';
18
+ import { resolve } from 'node:path';
19
+ import { buildPayload } from './extract/payload.js';
20
+ import { loadOrCreateKeypair, generateKeypair, saveKeypair, signPayload } from './lib/signing.js';
21
+ import { loadState, saveState } from './lib/state.js';
22
+
23
+ const DEFAULT_KEY_PATH = resolve(import.meta.dirname, '..', '.keys', 'extractor');
24
+ const DEFAULT_STATE_PATH = resolve(import.meta.dirname, '..', '.extractor-state.json');
25
+
26
+ async function main() {
27
+ const args = process.argv.slice(2);
28
+
29
+ if (args[0] === 'keygen') {
30
+ const keyPath = readFlag(args, '--key') ?? DEFAULT_KEY_PATH;
31
+ const keypair = generateKeypair();
32
+ saveKeypair(keyPath, keypair);
33
+ const keyId = createHash('sha256').update(keypair.publicKeyPem).digest('hex');
34
+ console.log(`New keypair written to ${keyPath}.private.pem / ${keyPath}.public.pem`);
35
+ console.log(`\nkeyId: ${keyId}`);
36
+ console.log('\nRegister this as a franchise_signing_key row (public_key + key_id):\n');
37
+ console.log(keypair.publicKeyPem);
38
+ return;
39
+ }
40
+
41
+ const savePath = args[0];
42
+ if (!savePath) {
43
+ console.error('Usage: node src/cli.js <savePath> [--out <file>] [--key <keyPathPrefix>] [--state <file>] [--full|--backfill]');
44
+ console.error(' node src/cli.js keygen [--key <keyPathPrefix>]');
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+
49
+ const outPath = readFlag(args, '--out') ?? defaultOutPath(savePath);
50
+ const keyPath = readFlag(args, '--key') ?? DEFAULT_KEY_PATH;
51
+ const statePath = readFlag(args, '--state') ?? DEFAULT_STATE_PATH;
52
+ const toolVersion = readToolVersion();
53
+
54
+ const existingState = loadState(statePath);
55
+ const fullMode = args.includes('--full') || args.includes('--backfill') || existingState === null;
56
+ if (existingState === null) {
57
+ console.error(`No state file at ${statePath} — running full/backfill (expected on first run).`);
58
+ } else if (fullMode) {
59
+ console.error('Running --full: including every game in the save, ignoring the capture watermark.');
60
+ }
61
+
62
+ console.error(`Opening save: ${savePath}`);
63
+ const franchise = await Franchise.create(savePath);
64
+
65
+ console.error('Extracting...');
66
+ const { payload, nextCapturedGameKeys, gamesIncludedCount, gamesTotalCount, pendingCount } = await buildPayload(franchise, {
67
+ toolVersion,
68
+ fullMode,
69
+ capturedGameKeys: existingState?.capturedGameKeys ?? new Set(),
70
+ });
71
+
72
+ const canonicalBytes = Buffer.from(JSON.stringify(payload));
73
+ const payloadHash = createHash('sha256').update(canonicalBytes).digest('hex');
74
+
75
+ const { privateKeyPem, publicKeyPem } = loadOrCreateKeypair(keyPath);
76
+ const signature = signPayload(canonicalBytes, privateKeyPem);
77
+ const keyId = createHash('sha256').update(publicKeyPem).digest('hex');
78
+
79
+ const envelope = {
80
+ payload,
81
+ payloadHash,
82
+ signature,
83
+ signingPublicKey: publicKeyPem,
84
+ keyId,
85
+ };
86
+
87
+ writeFileSync(outPath, JSON.stringify(envelope, null, 2));
88
+ saveState(statePath, { capturedGameKeys: nextCapturedGameKeys, toolVersion, savePath });
89
+
90
+ console.error(`Wrote ${outPath}`);
91
+ console.error(` keyId: ${keyId}`);
92
+ console.error(` mode: ${fullMode ? 'full/backfill' : 'incremental'}`);
93
+ console.error(
94
+ ` clock: ${payload.clock.stage} / ${payload.clock.weekType} / week ${payload.clock.weekIndex}`,
95
+ );
96
+ console.error(` teams: ${payload.teams.length}`);
97
+ console.error(` players: ${payload.players.length}`);
98
+ console.error(` games: ${gamesIncludedCount} included (of ${gamesTotalCount} in the save)`);
99
+ console.error(` playerGameStats: ${payload.playerGameStats.length}`);
100
+ console.error(` teamGameStats: ${payload.teamGameStats.length}`);
101
+ console.error(` pendingPlayerStatGames: ${pendingCount} (suggestedStatus: ${payload.suggestedStatus})`);
102
+ console.error(` playerWeekStatus: ${payload.playerWeekStatus.length}`);
103
+ console.error(` transactions: ${payload.transactions.length}`);
104
+ console.error(` cumulativeStats: ${payload.cumulativeStats.length}`);
105
+ console.error(` depthChart: ${payload.depthChart.length}`);
106
+ console.error(` state: ${statePath} (${nextCapturedGameKeys.size} games watermarked)`);
107
+ }
108
+
109
+ function readFlag(args, name) {
110
+ const i = args.indexOf(name);
111
+ return i === -1 ? undefined : args[i + 1];
112
+ }
113
+
114
+ function defaultOutPath(savePath) {
115
+ const base = savePath.replace(/[/\\]+$/, '').split(/[/\\]/).pop();
116
+ return resolve(process.cwd(), `${base}.stat_import.json`);
117
+ }
118
+
119
+ function readToolVersion() {
120
+ const pkgPath = resolve(import.meta.dirname, '..', 'package.json');
121
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
122
+ return pkg.version ?? '0.0.0';
123
+ }
124
+
125
+ main().catch((err) => {
126
+ console.error(err);
127
+ process.exitCode = 1;
128
+ });
@@ -0,0 +1,75 @@
1
+ // Decodes the franchise clock from SeasonInfo (the singleton table; capacity 1).
2
+ //
3
+ // Two regimes that behave nothing alike — see docs/extraction-findings.md §3 and the
4
+ // comment block on franchise_week in db/schema.sql:
5
+ //
6
+ // stage='NFLSeason' -> CurrentWeek is monotonic 0..22 straight through the playoffs,
7
+ // disambiguated by CurrentWeekType. Trustworthy.
8
+ // stage='OffSeason' -> CurrentWeek resets to 0 and jumps non-uniformly. Decorative.
9
+ // CurrentOffseasonStage (0..10) is authoritative instead.
10
+ //
11
+ // `sequence` (the only safe cross-regime sort key) is NOT assigned here — schema.sql
12
+ // migration note 7 makes it explicit that sequence assignment is an ingest-side
13
+ // responsibility requiring knowledge of previously observed weeks, which a single
14
+ // stateless save read cannot provide. This module only decodes the clock as read.
15
+
16
+ import { getTableByNameAndCapacity, liveRecords } from '../lib/franchise-table.js';
17
+
18
+ const REGULAR_SEASON_WEEK_TYPES = new Set([
19
+ 'PreSeason',
20
+ 'RegularSeason',
21
+ 'WildcardPlayoff',
22
+ 'DivisionalPlayoff',
23
+ 'ConferencePlayoff',
24
+ 'ProBowl',
25
+ 'SuperBowl',
26
+ ]);
27
+
28
+ export async function extractClock(franchise) {
29
+ const table = await getTableByNameAndCapacity(franchise, 'SeasonInfo');
30
+ const info = liveRecords(table)[0];
31
+ if (!info) throw new Error('SeasonInfo table has no live record — cannot read the clock');
32
+
33
+ const stage = info.CurrentStage; // 'NFLSeason' | 'OffSeason'
34
+ const weekType = info.CurrentWeekType;
35
+ const weekIndex = info.CurrentWeek;
36
+ const offseasonStage = stage === 'OffSeason' ? info.CurrentOffseasonStage : null;
37
+
38
+ if (stage === 'NFLSeason' && !REGULAR_SEASON_WEEK_TYPES.has(weekType)) {
39
+ throw new Error(
40
+ `Unrecognized CurrentWeekType "${weekType}" during NFLSeason — widen ` +
41
+ 'REGULAR_SEASON_WEEK_TYPES (and the matching franchise_week.week_type CHECK in ' +
42
+ 'db/schema.sql) rather than coercing. A rejected import beats a silently wrong week.',
43
+ );
44
+ }
45
+
46
+ return {
47
+ // franchise_season identity
48
+ seasonIndex: info.CurrentYear, // 0-indexed franchise season number
49
+ calendarYear: info.CurrentSeasonYear, // NFL calendar year, e.g. 2025
50
+
51
+ // franchise_week identity
52
+ stage,
53
+ weekType: stage === 'OffSeason' ? 'OffSeason' : weekType,
54
+ weekIndex,
55
+ offseasonStage,
56
+ // Human-facing week: only meaningful for the regular season / preseason count-up.
57
+ // NULL in the offseason, where no meaningful week number exists (schema.sql comment).
58
+ displayWeek: stage === 'NFLSeason' ? weekIndex + 1 : null,
59
+
60
+ // Sub-phase booleans that drive the offseason lock calendar. Kept as a raw bag —
61
+ // franchise_week.phase_flags_json is deliberately JSON so EA can add phases without
62
+ // a migration.
63
+ phaseFlags: extractPhaseFlags(info),
64
+ };
65
+ }
66
+
67
+ function extractPhaseFlags(info) {
68
+ const flags = {};
69
+ for (const field of info.fieldsArray) {
70
+ if (field.key.startsWith('Is')) {
71
+ flags[field.key] = info[field.key];
72
+ }
73
+ }
74
+ return flags;
75
+ }
@@ -0,0 +1,35 @@
1
+ // Extracts player_cumulative_stat: raw cumulative baselines, captured per import so
2
+ // ingest can diff consecutive imports to recover categories the save only exposes as
3
+ // a running total. Currently that's exactly one category — rec_targets — per the
4
+ // schema seed (stat_category.default_source='derived_diff') and findings.md §2/§12
5
+ // rule 6: targets exist ONLY on CareerOffensiveStats.TARGETS, never per-game or
6
+ // per-season. This extractor stays scoped to that single verified case rather than
7
+ // speculatively capturing all 86 CareerOffensiveStats fields "just in case" — nothing
8
+ // else in the schema or findings calls for them, and CareerOffensiveStats is
9
+ // real-career-inclusive (imported NFL history + sim), so surfacing an unused raw
10
+ // field as if it meant something would be actively misleading (schema.sql comment).
11
+ //
12
+ // Player.CareerStats resolves DIRECTLY to one CareerOffensiveStats row (not a
13
+ // per-season wrapper like SeasonStats/GameStats — career is inherently one row).
14
+ // Only offensive-position players (WR/RB/TE/QB) have a meaningful CareerStats ref;
15
+ // this silently skips anyone whose ref doesn't resolve (e.g. pure defensive players,
16
+ // whose career stats live in a different position-bucketed table this doesn't need).
17
+
18
+ import { resolveFieldRef } from '../lib/franchise-table.js';
19
+
20
+ export async function extractCumulativeStats(franchise, { players }) {
21
+ const rows = [];
22
+ for (const player of players) {
23
+ const careerRow = await resolveFieldRef(franchise, player._record, 'CareerStats');
24
+ if (!careerRow) continue;
25
+ if (careerRow.TARGETS === undefined || careerRow.TARGETS === null) continue; // non-offensive bucket
26
+
27
+ rows.push({
28
+ externalPlayerId: player.externalPlayerId,
29
+ statCategoryKey: 'rec_targets',
30
+ scope: 'career',
31
+ value: careerRow.TARGETS,
32
+ });
33
+ }
34
+ return rows;
35
+ }
@@ -0,0 +1,56 @@
1
+ // Extracts depth_chart_entry: Team.DepthChart -> position slot -> Player[] wrapper,
2
+ // Player0=starter, Player1=backup, ... Not needed for scoring; drives "is my guy
3
+ // starting?" on waiver/draft day (schema.sql comment on depth_chart_entry).
4
+ //
5
+ // Position slot keys (QB, WR, SLWR, KR, PR, ...) are Madden's own depth-chart
6
+ // position taxonomy — finer-grained than Player.Position (e.g. WR splits into
7
+ // WR/SLWR for outside vs. slot). Stored as-is; not reconciled against
8
+ // player_season_snapshot.position, which is a separate, coarser field.
9
+
10
+ import { resolveFieldRef } from '../lib/franchise-table.js';
11
+
12
+ export async function extractDepthChart(franchise, { teams }) {
13
+ const rows = [];
14
+
15
+ for (const team of teams) {
16
+ const depthChart = await resolveFieldRef(franchise, team._record, 'DepthChart');
17
+ if (!depthChart) continue;
18
+
19
+ for (const field of depthChart.fieldsArray) {
20
+ if (field.key === 'LockedEntries') continue; // not a position slot
21
+ if (!field.isReference) continue;
22
+
23
+ const wrapper = await resolveFieldRef(franchise, depthChart, field.key);
24
+ if (!wrapper) continue;
25
+
26
+ const playerSlots = await resolveWrapperSlotsWithIndex(franchise, wrapper, 'Player');
27
+ for (const { record: playerRecord, depthOrder } of playerSlots) {
28
+ rows.push({
29
+ externalTeamId: team.externalTeamId,
30
+ positionSlot: field.key,
31
+ depthOrder,
32
+ externalPlayerId: playerRecord.PresentationId,
33
+ });
34
+ }
35
+ }
36
+ }
37
+
38
+ return rows;
39
+ }
40
+
41
+ // Like franchise-table.js's resolveWrapperSlots, but keeps each slot's numeric index
42
+ // (0 = starter) rather than discarding it — depth_order is part of the schema's
43
+ // UNIQUE constraint and the entire point of this table (schema.sql: depth_order
44
+ // INTEGER NOT NULL, 0=starter), so it can't be thrown away like GameStats[] does.
45
+ async function resolveWrapperSlotsWithIndex(franchise, wrapperRecord, slotPrefix) {
46
+ const slotFields = wrapperRecord.fieldsArray
47
+ .filter((f) => f.key.startsWith(slotPrefix) && /^\d+$/.test(f.key.slice(slotPrefix.length)))
48
+ .sort((a, b) => Number(a.key.slice(slotPrefix.length)) - Number(b.key.slice(slotPrefix.length)));
49
+
50
+ const resolved = [];
51
+ for (const field of slotFields) {
52
+ const record = await resolveFieldRef(franchise, wrapperRecord, field.key);
53
+ if (record) resolved.push({ record, depthOrder: Number(field.key.slice(slotPrefix.length)) });
54
+ }
55
+ return resolved;
56
+ }
@@ -0,0 +1,78 @@
1
+ // Extracts sim_game — the schedule + box score, and the anchor every stat row
2
+ // hangs off (via each stat row's own SeasonGame ref, resolved elsewhere).
3
+ //
4
+ // No stable game ID exists in the save (findings.md §4), so downstream identity is
5
+ // the natural key (season, week, home, away) per schema.sql's UNIQUE on sim_game.
6
+
7
+ import { getTableByNameAndCapacity, liveRecords, resolveFieldRef } from '../lib/franchise-table.js';
8
+
9
+ // db/schema.sql sim_game.game_status CHECK. The save also reports a template/junk
10
+ // status 'Invalid_' on one permanently-unscheduled row per save (score 0-0, no
11
+ // resolvable team refs, confirmed across multiple saves) — not a real game, dropped
12
+ // below rather than widening the CHECK to accept it.
13
+ const VALID_GAME_STATUSES = new Set(['Unscheduled', 'Unplayed', 'HomeWon', 'AwayWon', 'Tie']);
14
+ const UNDECIDED_STATUSES = new Set(['Unscheduled', 'Unplayed']);
15
+
16
+ export async function extractGames(franchise, { indexTeamsByTeamIndex }) {
17
+ const table = await getTableByNameAndCapacity(franchise, 'SeasonGame');
18
+ const records = liveRecords(table).filter((r) => VALID_GAME_STATUSES.has(r.GameStatus));
19
+
20
+ const games = [];
21
+ for (const r of records) {
22
+ const homeTeamRecord = await resolveFieldRef(franchise, r, 'HomeTeam');
23
+ const awayTeamRecord = await resolveFieldRef(franchise, r, 'AwayTeam');
24
+ // Unscheduled slots (playoff byes/TBD brackets) have no resolvable team refs yet.
25
+ if (!homeTeamRecord || !awayTeamRecord) continue;
26
+
27
+ const homeTeam = indexTeamsByTeamIndex.get(homeTeamRecord.TeamIndex);
28
+ const awayTeam = indexTeamsByTeamIndex.get(awayTeamRecord.TeamIndex);
29
+ if (!homeTeam || !awayTeam) continue;
30
+
31
+ const decided = !UNDECIDED_STATUSES.has(r.GameStatus);
32
+
33
+ games.push({
34
+ // natural key for sim_game.UNIQUE(franchise_season_id, franchise_week_id, home, away)
35
+ seasonWeek: r.SeasonWeek,
36
+ seasonWeekType: r.SeasonWeekType,
37
+ homeExternalTeamId: homeTeam.externalTeamId,
38
+ awayExternalTeamId: awayTeam.externalTeamId,
39
+
40
+ // Madden reports 0 for an unplayed game's score, not a null/absent value —
41
+ // normalize to NULL so "hasn't happened" is never confused with a real 0-0
42
+ // result once the game finalizes (schema.sql: home_score/away_score nullable).
43
+ homeScore: decided ? r.HomeScore : null,
44
+ awayScore: decided ? r.AwayScore : null,
45
+ gameStatus: r.GameStatus,
46
+ // IsSimmed=false => a human played it => footage exists (findings §4).
47
+ resultType: decided ? (r.IsSimmed ? 'cpu_simmed' : 'user_played') : null,
48
+
49
+ detail: {
50
+ homeScoreQuarter1: r.HomeScoreQuarter1,
51
+ homeScoreQuarter2: r.HomeScoreQuarter2,
52
+ homeScoreQuarter3: r.HomeScoreQuarter3,
53
+ homeScoreQuarter4: r.HomeScoreQuarter4,
54
+ homeScoreOT: r.HomeScoreOT,
55
+ awayScoreQuarter1: r.AwayScoreQuarter1,
56
+ awayScoreQuarter2: r.AwayScoreQuarter2,
57
+ awayScoreQuarter3: r.AwayScoreQuarter3,
58
+ awayScoreQuarter4: r.AwayScoreQuarter4,
59
+ awayScoreOT: r.AwayScoreOT,
60
+ },
61
+
62
+ // kept for the stats extractor to match Game*Stats rows back to this game via
63
+ // the identical (week, weekType, home, away) key, without re-resolving refs.
64
+ _record: r,
65
+ });
66
+ }
67
+ return games;
68
+ }
69
+
70
+ export function gameKey(g) {
71
+ return `${g.seasonWeekType}:${g.seasonWeek}:${g.homeExternalTeamId}:${g.awayExternalTeamId}`;
72
+ }
73
+
74
+ /** True once a game has a real result (not a future/unscheduled slot). Operates on
75
+ * the already-extracted game's own `gameStatus` field — no new payload field. */
76
+ export function isDecidedGame(g) {
77
+ return !UNDECIDED_STATUSES.has(g.gameStatus);
78
+ }