madden-fantasy-extractor 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "madden-fantasy-extractor",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Reads a Madden franchise save and produces a signed stat_import JSON payload for ingest.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -11,7 +11,7 @@
11
11
  // week, independent of which games' stats have already been captured).
12
12
 
13
13
  import { extractClock } from './clock.js';
14
- import { extractTeams, indexTeamsByTeamIndex } from './teams.js';
14
+ import { extractTeams, extractDivisionConferenceByTeam, indexTeamsByTeamIndex } from './teams.js';
15
15
  import { extractPlayers, indexPlayersByTeamAndJersey } from './players.js';
16
16
  import { extractGames, gameKey } from './games.js';
17
17
  import { extractPlayerGameStats } from './player-game-stats.js';
@@ -24,11 +24,17 @@ import { extractFranchiseNews } from './franchise-news.js';
24
24
  import { extractLeagueAwards } from './league-awards.js';
25
25
  import { selectGamesToCapture, selectReadyGames, selectPendingGames, unionCapturedKeys } from '../lib/incremental.js';
26
26
 
27
- const PAYLOAD_SCHEMA_VERSION = '1.2.0';
27
+ const PAYLOAD_SCHEMA_VERSION = '1.4.0';
28
28
 
29
29
  export async function buildPayload(franchise, { toolVersion, fullMode = false, capturedGameKeys = new Set() }) {
30
30
  const clock = await extractClock(franchise);
31
31
  const teams = await extractTeams(franchise);
32
+ const divisionConferenceByTeam = await extractDivisionConferenceByTeam(franchise);
33
+ for (const team of teams) {
34
+ const dc = divisionConferenceByTeam.get(team.externalTeamId);
35
+ team.divisionName = dc?.divisionName ?? null;
36
+ team.conferenceName = dc?.conferenceName ?? null;
37
+ }
32
38
  const teamIdx = indexTeamsByTeamIndex(teams);
33
39
 
34
40
  const allGames = await extractGames(franchise, { indexTeamsByTeamIndex: teamIdx });
@@ -4,7 +4,7 @@
4
4
  // the real 32 teams live in the capacity-37 instance, filtered to TEAM_TYPE='Current'.
5
5
  // TeamIndex 32 is not a team, it's the FA/practice pool; we never create a row for it.
6
6
 
7
- import { getTableByNameAndCapacity, liveRecords } from '../lib/franchise-table.js';
7
+ import { getTableByNameAndCapacity, getAllLiveRecordsAcrossInstances, liveRecords, resolveFieldRef, resolveWrapperSlots } from '../lib/franchise-table.js';
8
8
 
9
9
  export async function extractTeams(franchise) {
10
10
  const table = await getTableByNameAndCapacity(franchise, 'Team');
@@ -35,6 +35,37 @@ export async function extractTeams(franchise) {
35
35
  }));
36
36
  }
37
37
 
38
+ /**
39
+ * Division/conference membership is NOT a field on Team -- it's the reverse
40
+ * relationship (Conference -> Divisions -> Teams), confirmed via real-save
41
+ * investigation (see docs/superpowers/specs/2026-07-26-conference-division-standings-
42
+ * design.md in the madden-fantasy repo). Conference itself exists as TWO SEPARATE
43
+ * table instances in the save (one AFC, one NFC), which is why this reads via
44
+ * getAllLiveRecordsAcrossInstances rather than getTableByNameAndCapacity -- the
45
+ * latter would silently return only one conference.
46
+ *
47
+ * Returns a lookup from externalTeamId (Team.PresentationId) to
48
+ * { divisionName, conferenceName }.
49
+ */
50
+ export async function extractDivisionConferenceByTeam(franchise) {
51
+ const conferences = await getAllLiveRecordsAcrossInstances(franchise, 'Conference');
52
+ const result = new Map();
53
+ for (const conf of conferences) {
54
+ const divWrapper = await resolveFieldRef(franchise, conf, 'Divisions');
55
+ if (!divWrapper) continue;
56
+ const divisions = await resolveWrapperSlots(franchise, divWrapper, 'Division');
57
+ for (const div of divisions) {
58
+ const teamWrapper = await resolveFieldRef(franchise, div, 'Teams');
59
+ if (!teamWrapper) continue;
60
+ const teams = await resolveWrapperSlots(franchise, teamWrapper, 'Team');
61
+ for (const team of teams) {
62
+ result.set(team.PresentationId, { divisionName: div.Name, conferenceName: conf.Name });
63
+ }
64
+ }
65
+ }
66
+ return result;
67
+ }
68
+
38
69
  /** TeamIndex (0..31) -> extracted team row, for resolving Player.TeamIndex and SeasonGame refs. */
39
70
  export function indexTeamsByTeamIndex(teams) {
40
71
  return new Map(teams.map((t) => [t.teamIndex, t]));
@@ -29,6 +29,25 @@ export async function getTableByNameAndCapacity(franchise, name, opts) {
29
29
  return table;
30
30
  }
31
31
 
32
+ /**
33
+ * Some tables exist as multiple real (non-decoy) instances rather than one real
34
+ * instance plus decoys -- confirmed for `Conference` (two capacity-1 instances, one
35
+ * holding AFC, one holding NFC; see docs/superpowers/specs/2026-07-26-conference-
36
+ * division-standings-design.md in the madden-fantasy repo). Unlike
37
+ * getTableByNameAndCapacity, this reads and returns every instance's live records
38
+ * merged into one array, rather than picking a single "best" instance.
39
+ */
40
+ export async function getAllLiveRecordsAcrossInstances(franchise, name) {
41
+ const matches = franchise.tables.filter((t) => t.name === name);
42
+ if (matches.length === 0) throw new Error(`No table named "${name}" in this save`);
43
+ const allRecords = [];
44
+ for (const table of matches) {
45
+ await table.readRecords();
46
+ allRecords.push(...liveRecords(table));
47
+ }
48
+ return allRecords;
49
+ }
50
+
32
51
  /**
33
52
  * Resolve a reference field to its target record, reading the target table first if needed.
34
53
  *
@@ -0,0 +1,37 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { getAllLiveRecordsAcrossInstances } from '../src/lib/franchise-table.js';
4
+ import { fakeTable, fakeRecord, fakeFranchise } from './helpers/fake-franchise.js';
5
+
6
+ test('getAllLiveRecordsAcrossInstances: merges live records across multiple instances of the same table name', async () => {
7
+ const afcRecord = fakeRecord({ Name: 'AFC' });
8
+ const afcTable = fakeTable('Conference', [afcRecord]);
9
+ const nfcRecord = fakeRecord({ Name: 'NFC' });
10
+ const nfcTable = fakeTable('Conference', [nfcRecord]);
11
+
12
+ const franchise = fakeFranchise([afcTable, nfcTable]);
13
+
14
+ const records = await getAllLiveRecordsAcrossInstances(franchise, 'Conference');
15
+ assert.equal(records.length, 2);
16
+ assert.deepEqual(records.map((r) => r.Name).sort(), ['AFC', 'NFC']);
17
+ });
18
+
19
+ test('getAllLiveRecordsAcrossInstances: skips empty records within each instance', async () => {
20
+ const liveRecord = fakeRecord({ Name: 'AFC' });
21
+ const emptyRecord = { ...fakeRecord({}), isEmpty: true };
22
+ const table = fakeTable('Conference', [liveRecord, emptyRecord]);
23
+
24
+ const franchise = fakeFranchise([table]);
25
+
26
+ const records = await getAllLiveRecordsAcrossInstances(franchise, 'Conference');
27
+ assert.equal(records.length, 1);
28
+ assert.equal(records[0].Name, 'AFC');
29
+ });
30
+
31
+ test('getAllLiveRecordsAcrossInstances: throws when no table with that name exists', async () => {
32
+ const franchise = fakeFranchise([]);
33
+ await assert.rejects(
34
+ () => getAllLiveRecordsAcrossInstances(franchise, 'Conference'),
35
+ /No table named "Conference"/,
36
+ );
37
+ });
@@ -0,0 +1,79 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { extractTeams, extractDivisionConferenceByTeam } from '../src/extract/teams.js';
4
+ import { fakeTable, fakeRecord, fakeFranchise } from './helpers/fake-franchise.js';
5
+
6
+ // Builds a minimal two-conference, one-division-each save fragment: AFC -> AFC East -> 2 teams,
7
+ // NFC -> NFC West -> 1 team. Enough to prove the two-instance Conference walk without needing
8
+ // all 8 real divisions.
9
+ function buildDivisionConferenceFixture() {
10
+ const dolphins = fakeRecord({ PresentationId: 12, DisplayName: 'Dolphins' });
11
+ const bills = fakeRecord({ PresentationId: 3, DisplayName: 'Bills' });
12
+ const teamTable = fakeTable('Team', [dolphins, bills]);
13
+
14
+ const afcEastTeamsWrapper = fakeRecord({}, {
15
+ Team0: { tableId: teamTable._id, rowNumber: 0 },
16
+ Team1: { tableId: teamTable._id, rowNumber: 1 },
17
+ });
18
+ const afcEastTeamsWrapperTable = fakeTable('DivisionTeamsWrapper', [afcEastTeamsWrapper]);
19
+ const afcEast = fakeRecord({ Name: 'AFC East' }, { Teams: { tableId: afcEastTeamsWrapperTable._id, rowNumber: 0 } });
20
+ const afcDivisionTable = fakeTable('Division', [afcEast]);
21
+
22
+ const afcDivisionsWrapper = fakeRecord({}, { Division0: { tableId: afcDivisionTable._id, rowNumber: 0 } });
23
+ const afcDivisionsWrapperTable = fakeTable('ConferenceDivisionsWrapper', [afcDivisionsWrapper]);
24
+ const afc = fakeRecord({ Name: 'AFC' }, { Divisions: { tableId: afcDivisionsWrapperTable._id, rowNumber: 0 } });
25
+ const afcConferenceTable = fakeTable('Conference', [afc]);
26
+
27
+ const rams = fakeRecord({ PresentationId: 20, DisplayName: 'Rams' });
28
+ const nfcTeamTable = fakeTable('Team', [rams]);
29
+ const nfcWestTeamsWrapper = fakeRecord({}, { Team0: { tableId: nfcTeamTable._id, rowNumber: 0 } });
30
+ const nfcWestTeamsWrapperTable = fakeTable('DivisionTeamsWrapper', [nfcWestTeamsWrapper]);
31
+ const nfcWest = fakeRecord({ Name: 'NFC West' }, { Teams: { tableId: nfcWestTeamsWrapperTable._id, rowNumber: 0 } });
32
+ const nfcDivisionTable = fakeTable('Division', [nfcWest]);
33
+
34
+ const nfcDivisionsWrapper = fakeRecord({}, { Division0: { tableId: nfcDivisionTable._id, rowNumber: 0 } });
35
+ const nfcDivisionsWrapperTable = fakeTable('ConferenceDivisionsWrapper', [nfcDivisionsWrapper]);
36
+ const nfc = fakeRecord({ Name: 'NFC' }, { Divisions: { tableId: nfcDivisionsWrapperTable._id, rowNumber: 0 } });
37
+ const nfcConferenceTable = fakeTable('Conference', [nfc]); // SEPARATE Conference table instance
38
+
39
+ return fakeFranchise([
40
+ teamTable, afcEastTeamsWrapperTable, afcDivisionTable, afcDivisionsWrapperTable, afcConferenceTable,
41
+ nfcTeamTable, nfcWestTeamsWrapperTable, nfcDivisionTable, nfcDivisionsWrapperTable, nfcConferenceTable,
42
+ ]);
43
+ }
44
+
45
+ test('extractDivisionConferenceByTeam: walks BOTH Conference table instances and merges results', async () => {
46
+ const franchise = buildDivisionConferenceFixture();
47
+ const result = await extractDivisionConferenceByTeam(franchise);
48
+
49
+ assert.equal(result.size, 3);
50
+ assert.deepEqual(result.get(12), { divisionName: 'AFC East', conferenceName: 'AFC' });
51
+ assert.deepEqual(result.get(3), { divisionName: 'AFC East', conferenceName: 'AFC' });
52
+ assert.deepEqual(result.get(20), { divisionName: 'NFC West', conferenceName: 'NFC' });
53
+ });
54
+
55
+ test('extractDivisionConferenceByTeam: a team not present in any division is simply absent from the map', async () => {
56
+ const franchise = buildDivisionConferenceFixture();
57
+ const result = await extractDivisionConferenceByTeam(franchise);
58
+ assert.equal(result.has(999), false);
59
+ });
60
+
61
+ test('extractTeams output can be merged with extractDivisionConferenceByTeam by externalTeamId', async () => {
62
+ // This proves the shape buildPayload's merge step (Step 12) relies on: extractTeams
63
+ // rows keyed by externalTeamId match extractDivisionConferenceByTeam's map keys.
64
+ const team = fakeRecord({
65
+ PresentationId: 12, TeamIndex: 0, TEAM_TYPE: 'Current',
66
+ DisplayName: 'Dolphins', NickName: 'Fins', ShortName: 'MIA',
67
+ HomeWin: 3, RoadWin: 2, HomeLoss: 1, RoadLoss: 1, HomeTie: 0, RoadTie: 0,
68
+ CurSeasonDivStanding: 1, CurSeasonConfStanding: 3, CurSeasonLeagStanding: 5,
69
+ });
70
+ const teamTable = fakeTable('Team', [team]);
71
+ const franchise = fakeFranchise([teamTable]);
72
+
73
+ const teams = await extractTeams(franchise);
74
+ assert.equal(teams.length, 1);
75
+ assert.equal(teams[0].externalTeamId, 12);
76
+ // divisionName/conferenceName are NOT set by extractTeams itself -- confirms the
77
+ // merge happens one level up, in buildPayload (Step 12), not inside this function.
78
+ assert.equal('divisionName' in teams[0], false);
79
+ });