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.
@@ -0,0 +1,125 @@
1
+ // Assembles the full stat_import payload by running every extractor against one
2
+ // opened franchise save. Mirrors db/schema.sql's table shapes closely enough that
3
+ // ingest can map each array 1:1 onto an INSERT, but stays in "extracted" shape
4
+ // (external_*_id, not surrogate ids — those don't exist until ingest mints them).
5
+ //
6
+ // Incremental capture: `games` (and the playerGameStats/teamGameStats keyed off it)
7
+ // are restricted, by default, to games newly decided since the last successful run —
8
+ // see src/lib/incremental.js for why that's a different set than "every decided game
9
+ // in the save," and why player_week_status deliberately does NOT go through the same
10
+ // filter (it needs the full schedule for bye detection and to resolve the completed
11
+ // week, independent of which games' stats have already been captured).
12
+
13
+ import { extractClock } from './clock.js';
14
+ import { extractTeams, indexTeamsByTeamIndex } from './teams.js';
15
+ import { extractPlayers } from './players.js';
16
+ import { extractGames, gameKey } from './games.js';
17
+ import { extractPlayerGameStats } from './player-game-stats.js';
18
+ import { extractTeamGameStats, indexPlayerGameStatsByGameAndTeam } from './team-game-stats.js';
19
+ import { extractPlayerWeekStatus } from './player-week-status.js';
20
+ import { extractTransactions } from './transactions.js';
21
+ import { extractCumulativeStats } from './cumulative-stats.js';
22
+ import { extractDepthChart } from './depth-chart.js';
23
+ import { selectGamesToCapture, selectReadyGames, selectPendingGames, unionCapturedKeys } from '../lib/incremental.js';
24
+
25
+ const PAYLOAD_SCHEMA_VERSION = '1.0.0';
26
+
27
+ export async function buildPayload(franchise, { toolVersion, fullMode = false, capturedGameKeys = new Set() }) {
28
+ const clock = await extractClock(franchise);
29
+ const teams = await extractTeams(franchise);
30
+ const teamIdx = indexTeamsByTeamIndex(teams);
31
+
32
+ const allGames = await extractGames(franchise, { indexTeamsByTeamIndex: teamIdx });
33
+
34
+ const gamesToCapture = selectGamesToCapture(allGames, {
35
+ fullMode,
36
+ seasonIndex: clock.seasonIndex,
37
+ alreadyCapturedKeys: capturedGameKeys,
38
+ });
39
+ const gamesByKey = new Map(gamesToCapture.map((g) => [gameKey(g), g]));
40
+
41
+ const players = await extractPlayers(franchise, { currentSeasonYear: clock.calendarYear });
42
+
43
+ const playerGameStats = await extractPlayerGameStats(franchise, {
44
+ players,
45
+ gamesByKey,
46
+ indexTeamsByTeamIndex: teamIdx,
47
+ });
48
+
49
+ const pgsIndex = indexPlayerGameStatsByGameAndTeam(playerGameStats, players, teamIdx);
50
+ const teamGameStats = await extractTeamGameStats(franchise, {
51
+ games: gamesToCapture,
52
+ playerGameStatsByGameAndTeam: pgsIndex,
53
+ indexTeamsByTeamIndex: teamIdx,
54
+ });
55
+
56
+ // Deliberately allGames, not gamesToCapture: bye detection and resolving "the most
57
+ // recently completed week" both need the full schedule regardless of the
58
+ // incremental watermark — see this module's header and player-week-status.js's own.
59
+ const playerWeekStatus = await extractPlayerWeekStatus(franchise, {
60
+ players,
61
+ games: allGames,
62
+ clock,
63
+ indexTeamsByTeamIndex: teamIdx,
64
+ });
65
+
66
+ const transactions = await extractTransactions(franchise, { indexTeamsByTeamIndex: teamIdx });
67
+ const cumulativeStats = await extractCumulativeStats(franchise, { players });
68
+ const depthChart = await extractDepthChart(franchise, { teams });
69
+
70
+ // Only games that ACTUALLY produced player-level stats this run get added to the
71
+ // watermark (PreSeason excepted — see incremental.js's selectReadyGames) — "decided"
72
+ // (schedule/TeamStats) is not a safe readiness signal for player_game_stat. Anything
73
+ // else included this run is still sitting in the retry queue; surfaced below as
74
+ // pendingPlayerStatGameKeys so it doesn't take a diff against playerGameStats to see.
75
+ const readyGames = selectReadyGames(gamesToCapture, playerGameStats);
76
+ const pendingGames = selectPendingGames(gamesToCapture, playerGameStats);
77
+ const pendingPlayerStatGameKeys = pendingGames.map((g) => gameKey(g));
78
+
79
+ const payload = {
80
+ payloadSchemaVersion: PAYLOAD_SCHEMA_VERSION,
81
+ toolVersion,
82
+ gameSchemaVersion: formatGameSchemaVersion(franchise),
83
+ capturedAt: new Date().toISOString(),
84
+ // A hint for ingest's stat_import.status, not authoritative — ingest still owns
85
+ // that column and may reject or otherwise override this. 'partial' means: every
86
+ // game below is real and safe to upsert, but pendingPlayerStatGameKeys lists ones
87
+ // whose player_game_stat rows aren't in THIS payload yet and will arrive in a
88
+ // later import once the lag resolves (see docs/extraction-findings.md §3.5/§3.6).
89
+ suggestedStatus: pendingPlayerStatGameKeys.length > 0 ? 'partial' : 'accepted',
90
+
91
+ clock,
92
+
93
+ teams: teams.map(stripRecord),
94
+ players: players.map(({ _record, ...rest }) => rest),
95
+ games: gamesToCapture.map(({ _record, ...rest }) => ({ ...rest, gameKey: gameKey(rest) })),
96
+
97
+ playerGameStats,
98
+ teamGameStats,
99
+ pendingPlayerStatGameKeys,
100
+ playerWeekStatus,
101
+ transactions,
102
+ cumulativeStats,
103
+ depthChart,
104
+ };
105
+
106
+ const nextCapturedGameKeys = unionCapturedKeys(capturedGameKeys, readyGames, clock.seasonIndex);
107
+
108
+ return {
109
+ payload,
110
+ nextCapturedGameKeys,
111
+ gamesIncludedCount: gamesToCapture.length,
112
+ gamesTotalCount: allGames.length,
113
+ pendingCount: pendingGames.length,
114
+ };
115
+ }
116
+
117
+ function stripRecord({ _record, ...rest }) {
118
+ return rest;
119
+ }
120
+
121
+ function formatGameSchemaVersion(franchise) {
122
+ const v = franchise.expectedSchemaVersion;
123
+ if (!v) return null;
124
+ return `madden${franchise.gameYear}-${v.major}.${v.minor}`;
125
+ }
@@ -0,0 +1,86 @@
1
+ // Extracts player_game_stat: one row per (player, game, stat_category).
2
+ //
3
+ // Player.GameStats -> GameStats[] wrapper -> N slots, each a reference into ONE of six
4
+ // position-bucketed tables (GameOffensiveStats, GameDefensiveStats, GameKickingStats,
5
+ // GameOLineStats, GameOffensiveKPReturnStats, GameDefensiveKPReturnStats). The wrapper
6
+ // doesn't say which table a slot resolves to — resolveWrapperSlots just returns the
7
+ // record, so we key the field map lookup off the field names actually present.
8
+ //
9
+ // findings.md §2: a row exists iff the player played; GameStats[]'s nominal slot count
10
+ // is not a ceiling (confirmed 20+ filled through a Super Bowl run) — iterate whatever's
11
+ // there. Some rows have a null SeasonGame ref (preseason/template) — dropped below.
12
+
13
+ import { resolveGameStatRows, resolveFieldRef } from '../lib/franchise-table.js';
14
+ import { PLAYER_STAT_FIELD_MAP } from '../lib/stat-categories.js';
15
+ import { gameKey } from './games.js';
16
+
17
+ export async function extractPlayerGameStats(franchise, { players, gamesByKey, indexTeamsByTeamIndex }) {
18
+ const rows = [];
19
+ // A game's ~46 rostered players all resolve the same SeasonGame record instance
20
+ // (the underlying table caches records[]) — memoize the team-ref resolution per
21
+ // record rather than repeating two more reference lookups per player.
22
+ const keyCache = new WeakMap();
23
+
24
+ for (const player of players) {
25
+ const statRecords = await resolveGameStatRows(franchise, player._record);
26
+
27
+ for (const statRecord of statRecords) {
28
+ const seasonGame = await resolveFieldRef(franchise, statRecord, 'SeasonGame');
29
+ if (!seasonGame) continue; // null SeasonGame ref — template/preseason junk row
30
+
31
+ let key = keyCache.get(seasonGame);
32
+ if (key === undefined) {
33
+ key = await seasonGameKeyFromRecord(franchise, seasonGame, indexTeamsByTeamIndex);
34
+ keyCache.set(seasonGame, key);
35
+ }
36
+ if (!key) continue; // unresolvable team refs on this SeasonGame row
37
+ const game = gamesByKey.get(key);
38
+ if (!game) continue; // e.g. dropped Unscheduled/Invalid_ game
39
+
40
+ for (const field of statRecord.fieldsArray) {
41
+ const categoryKey = PLAYER_STAT_FIELD_MAP[field.key];
42
+ if (!categoryKey) continue;
43
+ const value = statRecord[field.key];
44
+ // Zero-skip is safe here: v_player_game_points (the only player-level scoring
45
+ // view — db/schema.sql) only ever joins rule_type='linear' rules, where
46
+ // points = value * points_per_unit. SUM over zero matching rows and SUM over
47
+ // one row worth 0 points are identical, so an all-zero category (e.g. a QB's
48
+ // rec_yards) costs storage for no scoring difference. There is no tiered
49
+ // player-level scoring path in this schema — tiered is a team/D-ST-only
50
+ // concept (see the exception carved out in team-game-stats.js) — so this
51
+ // check can be unconditional here, unlike the team-stat extractor.
52
+ if (!value) continue; // undefined, null, or 0
53
+
54
+ rows.push({
55
+ externalPlayerId: player.externalPlayerId,
56
+ gameKey: key,
57
+ statCategoryKey: categoryKey,
58
+ value,
59
+ source: 'native',
60
+ });
61
+ }
62
+ }
63
+ }
64
+
65
+ return rows;
66
+ }
67
+
68
+ // Same natural key as games.gameKey, but derived directly from a stat row's own
69
+ // SeasonGame ref (not an already-extracted game object) — this is how a
70
+ // player_game_stat row finds its sim_game without a second player_id-shaped join.
71
+ async function seasonGameKeyFromRecord(franchise, seasonGameRecord, indexTeamsByTeamIndex) {
72
+ const homeTeamRecord = await resolveFieldRef(franchise, seasonGameRecord, 'HomeTeam');
73
+ const awayTeamRecord = await resolveFieldRef(franchise, seasonGameRecord, 'AwayTeam');
74
+ if (!homeTeamRecord || !awayTeamRecord) return null;
75
+
76
+ const homeTeam = indexTeamsByTeamIndex.get(homeTeamRecord.TeamIndex);
77
+ const awayTeam = indexTeamsByTeamIndex.get(awayTeamRecord.TeamIndex);
78
+ if (!homeTeam || !awayTeam) return null;
79
+
80
+ return gameKey({
81
+ seasonWeekType: seasonGameRecord.SeasonWeekType,
82
+ seasonWeek: seasonGameRecord.SeasonWeek,
83
+ homeExternalTeamId: homeTeam.externalTeamId,
84
+ awayExternalTeamId: awayTeam.externalTeamId,
85
+ });
86
+ }
@@ -0,0 +1,189 @@
1
+ // Extracts player_week_status for TWO weeks every run, not one — a forward-looking
2
+ // row for the week about to be played, and a backward-looking row for the most
3
+ // recently COMPLETED week. They answer different questions and neither can stand in
4
+ // for the other:
5
+ //
6
+ // predictive (week = clock.weekType/weekIndex, i.e. "now"):
7
+ // "Is this player available for THIS week's lineup decisions?" InjuryStatus,
8
+ // ContractStatus, and the schedule are all live Player/SeasonGame fields with NO
9
+ // GameStats-style lag — confirmed directly against real saves (a slow-simmed
10
+ // game's newly-injured player appeared in the very next save, zero delay; see
11
+ // docs/extraction-findings.md §3.7). So injured/bye/free_agent are exactly as
12
+ // fresh as the save itself. What's NOT knowable yet is whether a healthy,
13
+ // rostered player will actually take the field (a late healthy scratch) — that's
14
+ // inherently a post-game fact. This mode therefore never reports 'inactive';
15
+ // healthy + rostered + team-has-a-game-this-week reads as 'active' by default,
16
+ // same spirit as the schema's "fantasy points computed live, frozen only at
17
+ // finalize" philosophy (db/schema.sql invariant on matchup.*_points_final) — this
18
+ // row is provisional until the retrospective pass for the SAME week (below,
19
+ // emitted once this week becomes the completed one) upserts over it with
20
+ // confirmed data. UNIQUE(player_id, franchise_week_id) makes that upsert automatic.
21
+ //
22
+ // retrospective (week = clock.weekIndex - 1, i.e. "last week, confirmed"):
23
+ // "Did this player actually play last week?" This is the ONLY mode that can
24
+ // distinguish a genuine healthy scratch ('inactive') from a player who simply
25
+ // hasn't had their stats attributed yet, which matters because of the OTHER
26
+ // discovery below: SeasonInfo.CurrentWeek identifies the week about to be played,
27
+ // not the last one finished, and a player's GameStats[] wrapper only gains a slot
28
+ // once that week is fully processed — confirmed with a consistent 1-week lag
29
+ // across 5 saves spanning a full season (worse for instant-CPU-simmed games
30
+ // specifically; see docs/extraction-findings.md §3.5/§3.6). By the time a save
31
+ // reports CurrentWeek=N, the schedule for week N may already show decided scores,
32
+ // but per-player GameStats rows only go up to week N-1. This mode reports status
33
+ // as of clock.weekIndex - 1 accordingly, and derives that week's actual weekType
34
+ // from the schedule rather than assuming it matches the current week's type —
35
+ // necessary because SeasonGame.SeasonWeek resets to 0 independently at the start
36
+ // of RegularSeason and at each playoff round (WildcardPlayoff starts at index 18
37
+ // while index 17 is still RegularSeason).
38
+ //
39
+ // All non-derived fields live directly on Player (findings.md §5).
40
+ //
41
+ // status derivation (schema.sql comment on player_week_status):
42
+ // injured = InjuryStatus='Injured'
43
+ // bye = team had no SeasonGame this week
44
+ // inactive = healthy, no GameStats row for the week (healthy scratch) — RETROSPECTIVE ONLY
45
+ // active = played (retrospective) / expected to play, nothing known against it (predictive)
46
+ // free_agent = ContractStatus='FreeAgent' (TeamIndex=32 pool)
47
+
48
+ import { resolveFieldRef, resolveGameStatRows } from '../lib/franchise-table.js';
49
+
50
+ export async function extractPlayerWeekStatus(franchise, { players, games, clock, indexTeamsByTeamIndex }) {
51
+ if (clock.stage !== 'NFLSeason') return []; // schema.sql: no meaningful "played this week" for OffSeason rows
52
+
53
+ const rows = [];
54
+
55
+ // Predictive: the week about to be played. No ambiguity in resolving it (unlike
56
+ // completedWeek below) — clock.weekType/weekIndex ARE SeasonInfo.CurrentWeekType/
57
+ // CurrentWeek directly, nothing to derive. Also computable at weekIndex 0, unlike
58
+ // the retrospective pass, since it has no "prior week" dependency.
59
+ const currentWeek = { weekType: clock.weekType, weekIndex: clock.weekIndex };
60
+ for (const player of players) {
61
+ rows.push(buildPredictiveRow(player, games, currentWeek, indexTeamsByTeamIndex));
62
+ }
63
+
64
+ // Retrospective: the most recently completed week, only resolvable once one exists.
65
+ if (clock.weekIndex > 0) {
66
+ const completedWeek = findCompletedWeek(games, clock);
67
+ if (completedWeek) {
68
+ for (const player of players) {
69
+ rows.push(await buildRetrospectiveRow(franchise, player, games, completedWeek, indexTeamsByTeamIndex));
70
+ }
71
+ }
72
+ }
73
+
74
+ return rows;
75
+ }
76
+
77
+ function teamsPlayingWeek(games, week) {
78
+ const teams = new Set();
79
+ for (const g of games) {
80
+ if (g.seasonWeekType !== week.weekType || g.seasonWeek !== week.weekIndex) continue;
81
+ teams.add(g.homeExternalTeamId);
82
+ teams.add(g.awayExternalTeamId);
83
+ }
84
+ return teams;
85
+ }
86
+
87
+ function buildPredictiveRow(player, games, week, indexTeamsByTeamIndex) {
88
+ const r = player._record;
89
+ const isFreeAgent = r.ContractStatus === 'FreeAgent';
90
+ const isInjured = r.InjuryStatus === 'Injured';
91
+ const team = indexTeamsByTeamIndex.get(player.teamIndex);
92
+
93
+ let status;
94
+ if (isFreeAgent) {
95
+ status = 'free_agent';
96
+ } else if (isInjured) {
97
+ status = 'injured';
98
+ } else if (!team || !teamsPlayingWeek(games, week).has(team.externalTeamId)) {
99
+ status = 'bye';
100
+ } else {
101
+ // Can't know "played" ahead of kickoff — a late healthy scratch is a post-game
102
+ // fact. Healthy + rostered + scheduled reads as active; see module header.
103
+ status = 'active';
104
+ }
105
+
106
+ return statusRow(player, week, status, isInjured, r);
107
+ }
108
+
109
+ async function buildRetrospectiveRow(franchise, player, games, week, indexTeamsByTeamIndex) {
110
+ const r = player._record;
111
+ const isFreeAgent = r.ContractStatus === 'FreeAgent';
112
+ const isInjured = r.InjuryStatus === 'Injured';
113
+ const team = indexTeamsByTeamIndex.get(player.teamIndex);
114
+
115
+ let status;
116
+ if (isFreeAgent) {
117
+ status = 'free_agent';
118
+ } else if (isInjured) {
119
+ status = 'injured';
120
+ } else if (!team || !teamsPlayingWeek(games, week).has(team.externalTeamId)) {
121
+ status = 'bye';
122
+ } else {
123
+ status = (await hasStatRowForWeek(franchise, r, week)) ? 'active' : 'inactive';
124
+ }
125
+
126
+ return statusRow(player, week, status, isInjured, r);
127
+ }
128
+
129
+ function statusRow(player, week, status, isInjured, r) {
130
+ return {
131
+ externalPlayerId: player.externalPlayerId,
132
+ weekType: week.weekType,
133
+ weekIndex: week.weekIndex,
134
+ status,
135
+ injuryType: isInjured ? r.InjuryType : null,
136
+ injurySeverity: isInjured ? r.InjurySeverity : null,
137
+ // Min can go negative in the save — clamp at 0 (schema.sql comment).
138
+ minWeeksOut: isInjured ? Math.max(0, r.MinInjuryDuration) : null,
139
+ maxWeeksOut: isInjured ? Math.max(0, r.MaxInjuryDuration) : null,
140
+ isInjuredReserve: Boolean(r.IsInjuredReserve),
141
+ // 30 is the sentinel for "not season-ending" — but it's also the DEFAULT value
142
+ // on players who were never injured at all, so gate on isInjured first or every
143
+ // healthy player's stale 0 reads as a season-ender.
144
+ isSeasonEnding: isInjured && r.CurrentYearSeasonEndingInjuryWeek !== 30,
145
+ };
146
+ }
147
+
148
+ // clock.weekIndex is monotonic from RegularSeason through the playoffs (confirmed:
149
+ // RegularSeason runs 0..17, WildcardPlayoff starts at 18 and continues the SAME
150
+ // counter rather than resetting — findings.md §3). But it is NOT safely monotonic
151
+ // across the PreSeason -> RegularSeason boundary: both stages independently index
152
+ // SeasonGame.SeasonWeek from 0 (confirmed directly: a save's PreSeason games occupy
153
+ // SeasonWeek 0-2 while RegularSeason ALSO occupies 0-17 in the same save), so
154
+ // "index - 1" is genuinely ambiguous exactly at RegularSeason week 0 — it could mean
155
+ // PreSeason's last week or nothing at all, and this repo has no save evidence of
156
+ // CurrentWeek's behavior during the PreSeason stage to disambiguate it.
157
+ //
158
+ // Strategy: prefer a game at (currentWeekType, targetIndex) — the common case, and
159
+ // also what correctly resolves the RegularSeason -> WildcardPlayoff transition
160
+ // (targetIndex won't exist under WildcardPlayoff, forcing the fallback below to find
161
+ // the true RegularSeason week 17). Only fall back to a same-index search across
162
+ // OTHER types when the current type has no such week.
163
+ //
164
+ // The one confirmed-ambiguous case — RegularSeason week 0 rolling back into
165
+ // PreSeason — never reaches this function: extractPlayerWeekStatus only calls it
166
+ // when clock.weekIndex > 0.
167
+ function findCompletedWeek(games, clock) {
168
+ const targetIndex = clock.weekIndex - 1;
169
+
170
+ const sameType = games.find((g) => g.seasonWeekType === clock.weekType && g.seasonWeek === targetIndex);
171
+ if (sameType) return { weekType: clock.weekType, weekIndex: targetIndex };
172
+
173
+ const otherType = games.find((g) => g.seasonWeekType !== clock.weekType && g.seasonWeek === targetIndex);
174
+ if (otherType) return { weekType: otherType.seasonWeekType, weekIndex: targetIndex };
175
+
176
+ return null;
177
+ }
178
+
179
+ async function hasStatRowForWeek(franchise, playerRecord, week) {
180
+ const statRecords = await resolveGameStatRows(franchise, playerRecord);
181
+ for (const statRecord of statRecords) {
182
+ const seasonGame = await resolveFieldRef(franchise, statRecord, 'SeasonGame');
183
+ if (!seasonGame) continue;
184
+ if (seasonGame.SeasonWeekType === week.weekType && seasonGame.SeasonWeek === week.weekIndex) {
185
+ return true;
186
+ }
187
+ }
188
+ return false;
189
+ }
@@ -0,0 +1,74 @@
1
+ // Extracts `player` (immutable identity) and `player_season_snapshot` (mutable
2
+ // per-season attributes) rows. See docs/extraction-findings.md §1, §9 and
3
+ // src/lib/identity.js for the identity_key decode-then-hash design.
4
+
5
+ import { getTableByNameAndCapacity, liveRecords } from '../lib/franchise-table.js';
6
+ import { decodeDraftYear, computeIdentityKey, isIngestiblePlayer } from '../lib/identity.js';
7
+
8
+ export async function extractPlayers(franchise, { currentSeasonYear }) {
9
+ const table = await getTableByNameAndCapacity(franchise, 'Player');
10
+ const records = liveRecords(table).filter(isIngestiblePlayer);
11
+
12
+ const players = [];
13
+ for (const r of records) {
14
+ const draftYear = decodeDraftYear(r.YearDrafted, currentSeasonYear);
15
+ const { identityKey, identityBasis } = computeIdentityKey({
16
+ firstName: r.FirstName,
17
+ lastName: r.LastName,
18
+ draftYear,
19
+ draftRound: r.PLYR_DRAFTROUND,
20
+ draftPick: r.PLYR_DRAFTPICK,
21
+ });
22
+
23
+ players.push({
24
+ externalPlayerId: r.PresentationId,
25
+ identityKey,
26
+ identityBasis,
27
+ firstName: r.FirstName,
28
+ lastName: r.LastName,
29
+ draftYear,
30
+ draftRound: r.PLYR_DRAFTROUND,
31
+ draftPick: r.PLYR_DRAFTPICK,
32
+ assetName: r.PLYR_ASSETNAME || null,
33
+
34
+ // carried through for downstream extraction (team membership, snapshot, stats)
35
+ // without re-scanning the Player table — not part of the `player` table shape.
36
+ teamIndex: r.TeamIndex,
37
+ contractStatus: r.ContractStatus,
38
+
39
+ snapshot: extractSnapshot(r),
40
+
41
+ // raw record kept for other extractors (stats, injuries, transactions) that
42
+ // need to walk this same player's reference fields (GameStats, etc.) without
43
+ // re-reading the Player table.
44
+ _record: r,
45
+ });
46
+ }
47
+ return players;
48
+ }
49
+
50
+ function extractSnapshot(r) {
51
+ return {
52
+ position: r.Position,
53
+ overallRating: r.OverallRating,
54
+ age: r.Age,
55
+ yearsPro: r.YearsPro,
56
+ devTrait: r.TraitDevelopment,
57
+ careerPhase: r.PLYR_CAREERPHASE,
58
+ isLegend: Boolean(r.IsLegend),
59
+ ratingsJson: extractRatings(r),
60
+ };
61
+ }
62
+
63
+ // Current ratings only (excludes OriginalXRating baseline fields — those are the
64
+ // player's rating before this season's progression, not needed for a snapshot of
65
+ // "what they are right now").
66
+ function extractRatings(r) {
67
+ const ratings = {};
68
+ for (const field of r.fieldsArray) {
69
+ if (field.key.endsWith('Rating') && !field.key.startsWith('Original')) {
70
+ ratings[field.key] = r[field.key];
71
+ }
72
+ }
73
+ return ratings;
74
+ }
@@ -0,0 +1,153 @@
1
+ // Extracts team_game_stat: one row per (team, game, stat_category), covering D/ST
2
+ // scoring inputs. findings.md §8: reach per-game TeamStats rows via
3
+ // SeasonGame.Home/AwayTeamStatCache — never scan TeamStats blind (it mixes
4
+ // per-game and aggregate/all-time rows).
5
+ //
6
+ // Native categories (dst_sacks, dst_takeaways, two_point_conversions) come straight
7
+ // off TeamStats. The derived_calc categories in the schema seed (dst_points_allowed,
8
+ // dst_yards_allowed, dst_tds, dst_safeties) are computed here rather than deferred to
9
+ // ingest, because the extractor already has every input in hand and a self-contained
10
+ // payload is more useful to ingest than a partial one:
11
+ // - dst_points_allowed / dst_yards_allowed: read straight off the opponent's side
12
+ // of the same SeasonGame / TeamStats rows already resolved for this game.
13
+ // - dst_tds / dst_safeties: a defense's TDs/safeties are scored BY that team's own
14
+ // players on defense/special teams, so this sums def_int_tds + def_fumble_tds +
15
+ // kick_return_tds + punt_return_tds (+ def_safeties) across every player who
16
+ // fielded a stat row in this game AND belongs to that team.
17
+ // CAVEAT: attribution uses each player's CURRENT roster TeamIndex (as of this
18
+ // save read), not their team at the time that historical game was played — the
19
+ // save carries no per-game roster snapshot, only Player.TeamIndex "as of now"
20
+ // (see docs/extraction-findings.md §7). A player traded mid-season will have
21
+ // their pre-trade defensive TDs attributed to their POST-trade team once this
22
+ // runs against a later save. This only affects backfilled history for players
23
+ // who changed teams; weekly imports captured before a trade are unaffected
24
+ // because the player's TeamIndex was still correct at that read. Acceptable
25
+ // given how rare defensive-player trades with return/INT TDs are, but a known
26
+ // gap — fixing it needs sim_transaction-based team-at-time-of-game resolution,
27
+ // which is a fantasy_league_season/ingest-side concern, not this extractor's.
28
+
29
+ import { resolveFieldRef } from '../lib/franchise-table.js';
30
+ import { TEAM_STAT_FIELD_MAP } from '../lib/stat-categories.js';
31
+
32
+ const DEFENSIVE_TD_KEYS = new Set(['def_int_tds', 'def_fumble_tds', 'kick_return_tds', 'punt_return_tds']);
33
+
34
+ export async function extractTeamGameStats(franchise, { games, playerGameStatsByGameAndTeam, indexTeamsByTeamIndex }) {
35
+ const rows = [];
36
+
37
+ for (const game of games) {
38
+ const homeTeamRecord = await resolveFieldRef(franchise, game._record, 'HomeTeam');
39
+ const awayTeamRecord = await resolveFieldRef(franchise, game._record, 'AwayTeam');
40
+ if (!homeTeamRecord || !awayTeamRecord) continue;
41
+
42
+ const home = indexTeamsByTeamIndex.get(homeTeamRecord.TeamIndex);
43
+ const away = indexTeamsByTeamIndex.get(awayTeamRecord.TeamIndex);
44
+ if (!home || !away) continue;
45
+
46
+ const homeStatCache = await resolveFieldRef(franchise, game._record, 'HomeTeamStatCache');
47
+ const awayStatCache = await resolveFieldRef(franchise, game._record, 'AwayTeamStatCache');
48
+ const key = gameKeyOf(game);
49
+
50
+ if (homeStatCache) {
51
+ rows.push(
52
+ ...buildTeamRows({
53
+ team: home,
54
+ key,
55
+ ownStatCache: homeStatCache,
56
+ opponentScore: game.awayScore,
57
+ playerStats: playerGameStatsByGameAndTeam.get(`${key}:${home.externalTeamId}`) ?? [],
58
+ }),
59
+ );
60
+ }
61
+ if (awayStatCache) {
62
+ rows.push(
63
+ ...buildTeamRows({
64
+ team: away,
65
+ key,
66
+ ownStatCache: awayStatCache,
67
+ opponentScore: game.homeScore,
68
+ playerStats: playerGameStatsByGameAndTeam.get(`${key}:${away.externalTeamId}`) ?? [],
69
+ }),
70
+ );
71
+ }
72
+ }
73
+
74
+ return rows;
75
+ }
76
+
77
+ function buildTeamRows({ team, key, ownStatCache, opponentScore, playerStats }) {
78
+ const out = [];
79
+
80
+ // TEAM_STAT_FIELD_MAP (dst_sacks, dst_takeaways, two_point_conversions) are counting
81
+ // stats scored LINEAR in the seed ruleset (value * points_per_unit) — same
82
+ // zero-skip-is-safe reasoning as player-game-stats.js: a missing row and a
83
+ // value=0 row sum to the same points.
84
+ for (const [field, categoryKey] of Object.entries(TEAM_STAT_FIELD_MAP)) {
85
+ const value = ownStatCache[field];
86
+ if (!value) continue; // undefined, null, or 0
87
+ out.push(teamRow(team, key, categoryKey, value, 'native'));
88
+ }
89
+
90
+ // dst_yards_allowed / dst_points_allowed are NOT safe to zero-skip. The schema seed
91
+ // documents both as "almost always TIERED, not linear" (db/schema.sql) — tiered
92
+ // scoring (v_team_game_tier_points) is a bucket lookup via an INNER JOIN on
93
+ // scoring_rule_tier, not a multiply, and 0 is typically the BEST bucket (the seed
94
+ // ruleset's own points-allowed table pays a 10 pt bonus for the 0-0 band — a
95
+ // shutout). Skipping the row on a shutout would mean v_fantasy_slot_points's
96
+ // COALESCE(...,0) fallback silently scores it as 0 instead of the correct bonus.
97
+ // These two are therefore always emitted whenever the underlying value is present,
98
+ // regardless of whether that value is zero.
99
+ if (ownStatCache.DEFPASSYARDS != null && ownStatCache.DEFRUSHYARDS != null) {
100
+ out.push(
101
+ teamRow(team, key, 'dst_yards_allowed', ownStatCache.DEFPASSYARDS + ownStatCache.DEFRUSHYARDS, 'derived_calc'),
102
+ );
103
+ }
104
+
105
+ if (opponentScore != null) {
106
+ out.push(teamRow(team, key, 'dst_points_allowed', opponentScore, 'derived_calc'));
107
+ }
108
+
109
+ // dst_tds / dst_safeties are summed counts, scored LINEAR in the seed ruleset —
110
+ // safe to zero-skip same as the native categories above.
111
+ let tds = 0;
112
+ let safeties = 0;
113
+ for (const stat of playerStats) {
114
+ if (DEFENSIVE_TD_KEYS.has(stat.statCategoryKey)) tds += stat.value;
115
+ if (stat.statCategoryKey === 'def_safeties') safeties += stat.value;
116
+ }
117
+ if (tds) out.push(teamRow(team, key, 'dst_tds', tds, 'derived_calc'));
118
+ if (safeties) out.push(teamRow(team, key, 'dst_safeties', safeties, 'derived_calc'));
119
+
120
+ return out;
121
+ }
122
+
123
+ function teamRow(team, gameKey, statCategoryKey, value, source) {
124
+ return { externalTeamId: team.externalTeamId, gameKey, statCategoryKey, value, source };
125
+ }
126
+
127
+ function gameKeyOf(game) {
128
+ return `${game.seasonWeekType}:${game.seasonWeek}:${game.homeExternalTeamId}:${game.awayExternalTeamId}`;
129
+ }
130
+
131
+ /**
132
+ * Groups already-extracted player_game_stat rows by (gameKey, externalTeamId) so
133
+ * extractTeamGameStats can sum defensive TDs/safeties without re-walking every
134
+ * player's GameStats[] wrapper a second time. Built from the same players list
135
+ * extractPlayerGameStats consumed, keyed by each player's CURRENT roster TeamIndex
136
+ * (see the module-level caveat on why this is the only attribution available).
137
+ */
138
+ export function indexPlayerGameStatsByGameAndTeam(playerGameStats, players, indexTeamsByTeamIndex) {
139
+ const teamByExternalPlayerId = new Map(
140
+ players.map((p) => [p.externalPlayerId, indexTeamsByTeamIndex.get(p.teamIndex)]),
141
+ );
142
+ const index = new Map();
143
+
144
+ for (const stat of playerGameStats) {
145
+ const team = teamByExternalPlayerId.get(stat.externalPlayerId);
146
+ if (!team) continue;
147
+ const mapKey = `${stat.gameKey}:${team.externalTeamId}`;
148
+ if (!index.has(mapKey)) index.set(mapKey, []);
149
+ index.get(mapKey).push(stat);
150
+ }
151
+
152
+ return index;
153
+ }
@@ -0,0 +1,41 @@
1
+ // Extracts sim_team (stable identity) and sim_team_season (per-season naming/standings).
2
+ //
3
+ // findings.md §6: getTableByName('Team') can return a 1-row decoy (Practice team) —
4
+ // the real 32 teams live in the capacity-37 instance, filtered to TEAM_TYPE='Current'.
5
+ // TeamIndex 32 is not a team, it's the FA/practice pool; we never create a row for it.
6
+
7
+ import { getTableByNameAndCapacity, liveRecords } from '../lib/franchise-table.js';
8
+
9
+ export async function extractTeams(franchise) {
10
+ const table = await getTableByNameAndCapacity(franchise, 'Team');
11
+ const teams = liveRecords(table).filter((r) => r.TEAM_TYPE === 'Current');
12
+
13
+ return teams.map((t) => ({
14
+ // sim_team — stable across weeks and season boundaries (findings §6).
15
+ externalTeamId: t.PresentationId,
16
+ teamIndex: t.TeamIndex, // 0..31, what Player.TeamIndex points at. Non-alphabetical, fixed.
17
+
18
+ // sim_team_season — season-scoped because relocation/rebrand renames teams mid-franchise.
19
+ // IsRelocated is unreliable (findings §6: Vikings ship with it true) — not read here;
20
+ // rebrand detection is a diff-across-imports concern for the ingest side, not extraction.
21
+ displayName: t.DisplayName,
22
+ nickName: t.NickName,
23
+ shortName: t.ShortName,
24
+
25
+ wins: (t.HomeWin ?? 0) + (t.RoadWin ?? 0),
26
+ losses: (t.HomeLoss ?? 0) + (t.RoadLoss ?? 0),
27
+ ties: (t.HomeTie ?? 0) + (t.RoadTie ?? 0),
28
+ divStanding: t.CurSeasonDivStanding,
29
+ confStanding: t.CurSeasonConfStanding,
30
+ leagueStanding: t.CurSeasonLeagStanding,
31
+
32
+ // raw record kept for other extractors (depth chart) that need to walk this
33
+ // same team's reference fields without re-reading/re-filtering the Team table.
34
+ _record: t,
35
+ }));
36
+ }
37
+
38
+ /** TeamIndex (0..31) -> extracted team row, for resolving Player.TeamIndex and SeasonGame refs. */
39
+ export function indexTeamsByTeamIndex(teams) {
40
+ return new Map(teams.map((t) => [t.teamIndex, t]));
41
+ }