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 +1 -1
- package/src/extract/payload.js +8 -2
- package/src/extract/teams.js +32 -1
- package/src/lib/franchise-table.js +19 -0
- package/test/franchise-table.test.js +37 -0
- package/test/teams.test.js +79 -0
package/package.json
CHANGED
package/src/extract/payload.js
CHANGED
|
@@ -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.
|
|
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 });
|
package/src/extract/teams.js
CHANGED
|
@@ -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
|
+
});
|