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 +255 -0
- package/package.json +25 -0
- package/src/cli.js +128 -0
- package/src/extract/clock.js +75 -0
- package/src/extract/cumulative-stats.js +35 -0
- package/src/extract/depth-chart.js +56 -0
- package/src/extract/games.js +78 -0
- package/src/extract/payload.js +125 -0
- package/src/extract/player-game-stats.js +86 -0
- package/src/extract/player-week-status.js +189 -0
- package/src/extract/players.js +74 -0
- package/src/extract/team-game-stats.js +153 -0
- package/src/extract/teams.js +41 -0
- package/src/extract/transactions.js +105 -0
- package/src/index.js +4 -0
- package/src/lib/franchise-table.js +88 -0
- package/src/lib/identity.js +58 -0
- package/src/lib/incremental.js +90 -0
- package/src/lib/signing.js +48 -0
- package/src/lib/stat-categories.js +68 -0
- package/src/lib/state.js +30 -0
- package/test/helpers/fake-franchise.js +36 -0
- package/test/identity.test.js +61 -0
- package/test/incremental.test.js +147 -0
- package/test/player-game-stats.test.js +85 -0
- package/test/player-week-status.test.js +173 -0
- package/test/state.test.js +54 -0
- package/test/team-game-stats.test.js +119 -0
- package/test/transactions.test.js +118 -0
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
|
+
}
|