@semanticintent/semantic-chirp-intelligence-mcp 3.1.0 → 4.0.1

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,431 @@
1
+ /**
2
+ * 🎯 Draft Pick Analysis — ICE at the draft table
3
+ *
4
+ * Answers one question: with pick N on the clock, who should you take?
5
+ *
6
+ * A public draft board ranks players in the abstract. This ranks them against
7
+ * *your* draft: who is already gone, what your roster is still missing, what
8
+ * your league's categories reward, and what each player's club schedule is
9
+ * worth in the weeks your league plays its playoffs.
10
+ *
11
+ * Value is measured as ADP delta — Yahoo's own average draft position minus
12
+ * the pick actually on the clock — so "value" means the market is wrong here,
13
+ * not "this player is good".
14
+ *
15
+ * Semantic Identity: ICE - Intent Chirp Engine (Draft)
16
+ * Intent: I tell you who to take, and why the board is wrong
17
+ * Chirp Style: ice_cold_truth
18
+ */
19
+ import { AnalysisTemplate } from '../template/AnalysisTemplate.js';
20
+ import { ChirpIntelligence } from '../services/ChirpIntelligence.js';
21
+ import { NHL_SCHEDULE, NhlScheduleService } from '../services/NhlScheduleService.js';
22
+ import { toNhlTricode } from '../domain/nhl-teams.js';
23
+ import { LEAGUE_DATA, LeagueDataService } from '../services/LeagueDataService.js';
24
+ import { NHL_STATS } from '../services/NhlStatsService.js';
25
+ /** Skater and goalie slots this analysis reasons about. */
26
+ const TRACKED_POSITIONS = ['C', 'LW', 'RW', 'D', 'G'];
27
+ export class DraftPickAnalysis extends AnalysisTemplate {
28
+ constructor() {
29
+ super('chirp_draft_pick', 'draft_pick');
30
+ }
31
+ // ==========================================
32
+ // Hook 1: Fetch
33
+ // ==========================================
34
+ async fetchData(args) {
35
+ await Promise.all([NHL_STATS.load(), NHL_SCHEDULE.load(), NHL_SCHEDULE.loadStandings()]);
36
+ // v4: the board is every NHL player, ranked on last season's production.
37
+ // Yahoo's ADP is gone, so "value" is measured against production rank
38
+ // rather than against where a market drafts a player.
39
+ return {
40
+ pool: LEAGUE_DATA.getPlayerPool({ limit: Math.min(args.pool_size ?? 250, 400) }),
41
+ roster: LEAGUE_DATA.getRoster(),
42
+ pool_caveat: LeagueDataService.POOL_CAVEAT
43
+ };
44
+ }
45
+ // ==========================================
46
+ // Hook 2: Prepare
47
+ // ==========================================
48
+ async prepareData(rawData, args) {
49
+ // Board state comes entirely from what the user tells us — there is no
50
+ // platform to ask. `already_drafted` is therefore the source, not a
51
+ // fallback, and the pick number follows from it unless stated.
52
+ const draftedNames = new Set((args.already_drafted ?? []).map(n => this.normalizeName(n)));
53
+ const rosterPositions = {};
54
+ for (const p of rawData.roster?.players ?? []) {
55
+ for (const pos of String(p.position ?? '').split(',')) {
56
+ const clean = pos.trim().toUpperCase();
57
+ if (TRACKED_POSITIONS.includes(clean)) {
58
+ rosterPositions[clean] = (rosterPositions[clean] ?? 0) + 1;
59
+ }
60
+ }
61
+ }
62
+ // Rank the pool by production; that rank stands in for a draft board.
63
+ const pool = (rawData.pool ?? []).map((p, index) => ({
64
+ player_id: p.player_id,
65
+ name: p.name,
66
+ position: p.position,
67
+ positions: String(p.position ?? '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean),
68
+ team: p.team,
69
+ // Production rank is the board position: the Nth best producer is,
70
+ // absent a market, the Nth pick worth making.
71
+ average_pick: index + 1,
72
+ average_round: null,
73
+ percent_drafted: null,
74
+ stats: p.stats ?? null
75
+ }));
76
+ return {
77
+ draftPool: pool,
78
+ draftedIds: new Set(),
79
+ draftedNames,
80
+ draftedCount: draftedNames.size,
81
+ draftResultsAvailable: false,
82
+ manualDraftedCount: draftedNames.size,
83
+ rosterPositions,
84
+ playoffWindow: this.resolvePlayoffWindow(null),
85
+ poolCaveat: rawData.pool_caveat
86
+ };
87
+ }
88
+ // ==========================================
89
+ // Hook 3: Analyze
90
+ // ==========================================
91
+ async analyzeData(data, args) {
92
+ const d = data;
93
+ const maxResults = args.max_results ?? 8;
94
+ // Pick on the clock: explicit, else inferred from picks already made.
95
+ const pickNumber = args.pick_number ?? (Math.max(d.draftedCount, d.draftedNames.size) + 1);
96
+ const needs = args.roster_needs?.length
97
+ ? args.roster_needs.map(p => p.toUpperCase())
98
+ : this.inferNeeds(d.rosterPositions);
99
+ // Yahoo's REST draft results can lag a fast live draft, so `already_drafted`
100
+ // is a first-class second source rather than a fallback: a player is off
101
+ // the board if either source says so.
102
+ const available = d.draftPool.filter((p) => !d.draftedIds.has(String(p.player_id)) &&
103
+ !d.draftedNames.has(this.normalizeName(p.name)));
104
+ const candidates = available
105
+ .map((p) => this.rateCandidate(p, pickNumber, needs, d.playoffWindow))
106
+ .sort((a, b) => b.draft_score - a.draft_score)
107
+ .slice(0, maxResults);
108
+ return {
109
+ pick_number: pickNumber,
110
+ pick_number_source: args.pick_number ? 'explicit' : 'inferred from draft results',
111
+ roster_needs: needs,
112
+ pool_size: d.draftPool.length,
113
+ available_count: available.length,
114
+ players_off_board: d.draftedIds.size + d.draftedNames.size,
115
+ draft_results_available: d.draftResultsAvailable,
116
+ manual_drafted_count: d.manualDraftedCount,
117
+ playoff_window: d.playoffWindow,
118
+ pool_caveat: d.poolCaveat,
119
+ schedule_available: NHL_SCHEDULE.isAvailable(),
120
+ candidates
121
+ };
122
+ }
123
+ // ==========================================
124
+ // Hook 4: Chirp
125
+ // ==========================================
126
+ async generateChirp(analysisResults, semanticContract, data) {
127
+ const contract = this.mergeContractWithDefaults(semanticContract);
128
+ const enhanced = ChirpIntelligence.enhance(this.toolName, analysisResults, contract);
129
+ const top = analysisResults.candidates[0];
130
+ let chirp;
131
+ if (!top) {
132
+ chirp = 'Nobody left worth chirping about. Either the pool is empty or everyone is drafted.';
133
+ }
134
+ else if (top.adp_delta !== null && top.adp_delta >= 12) {
135
+ chirp =
136
+ `${top.name} is still sitting there at ${analysisResults.pick_number} and the room ` +
137
+ `usually takes him at ${top.average_pick}. That is ${Math.round(top.adp_delta)} picks of ` +
138
+ `free value. Take him before someone wakes up.`;
139
+ }
140
+ else if (top.fills_need) {
141
+ chirp =
142
+ `${top.name} fills the hole you actually have (${top.position}), and ` +
143
+ `${top.team} plays ${top.playoff_games ?? '?'} games in your playoff window. ` +
144
+ `Best available is a luxury; a full lineup is not.`;
145
+ }
146
+ else {
147
+ chirp =
148
+ `${top.name} is the pick. No bargain, no drama — just the best board-and-schedule ` +
149
+ `combination left at ${analysisResults.pick_number}.`;
150
+ }
151
+ if (analysisResults.players_off_board === 0) {
152
+ chirp +=
153
+ ' ⚠️ Nobody is marked as drafted yet — pass `already_drafted` as picks go by ' +
154
+ 'and this board stays accurate.';
155
+ }
156
+ return {
157
+ ...enhanced,
158
+ chirp_intelligence: {
159
+ ...enhanced.chirp_intelligence,
160
+ analysis_chirp: chirp
161
+ }
162
+ };
163
+ }
164
+ // ==========================================
165
+ // Hook 5: Format
166
+ // ==========================================
167
+ async formatResponse(chirpEnhanced, data) {
168
+ const candidates = chirpEnhanced.candidates ?? [];
169
+ const recommendations = candidates.map((c, index) => ({
170
+ priority: index === 0 ? 'CRITICAL' : index < 3 ? 'HIGH' : 'MEDIUM',
171
+ action: 'draft',
172
+ reasoning: c.reasoning
173
+ }));
174
+ const analysisInsights = {
175
+ pick_number: chirpEnhanced.pick_number,
176
+ pick_number_source: chirpEnhanced.pick_number_source,
177
+ roster_needs: chirpEnhanced.roster_needs,
178
+ players_off_board: chirpEnhanced.players_off_board,
179
+ board_state: `${chirpEnhanced.players_off_board} player(s) marked as already drafted. ` +
180
+ 'Board state comes from `already_drafted` — there is no platform to read it from, ' +
181
+ 'so tell me who has gone and the board updates.',
182
+ board_note: chirpEnhanced.pool_caveat,
183
+ schedule_source: chirpEnhanced.schedule_available
184
+ ? `NHL public API (season ${NHL_SCHEDULE.getSeason()})`
185
+ : `UNAVAILABLE - ${NHL_SCHEDULE.getUnavailableReason()}; schedule value excluded from scoring`,
186
+ playoff_window: chirpEnhanced.playoff_window,
187
+ top_candidates: candidates
188
+ };
189
+ const metadata = {
190
+ analysis_type: this.analysisType,
191
+ timestamp: new Date().toISOString(),
192
+ team_context: { team_name: 'Draft Board' },
193
+ semantic_contract_applied: true
194
+ };
195
+ return {
196
+ analysis_insights: analysisInsights,
197
+ recommendations,
198
+ chirp_intelligence: chirpEnhanced.chirp_intelligence,
199
+ metadata
200
+ };
201
+ }
202
+ // ==========================================
203
+ // 🎯 Scoring
204
+ // ==========================================
205
+ rateCandidate(player, pickNumber, needs, window) {
206
+ const averagePick = player.average_pick;
207
+ const adpDelta = averagePick !== null ? pickNumber - averagePick : null;
208
+ const tricode = toNhlTricode(player.team);
209
+ const profile = tricode && NHL_SCHEDULE.isAvailable()
210
+ ? NHL_SCHEDULE.getTeamProfile(tricode)
211
+ : null;
212
+ const playoffGames = profile && window?.start && window?.end
213
+ ? NHL_SCHEDULE.countGamesInRange(tricode, window.start, window.end)
214
+ : null;
215
+ const fillsNeed = needs.some(need => player.positions.includes(need));
216
+ // --- components, each 0-1 ---
217
+ // Value: how far past his usual draft slot he has fallen. A player going
218
+ // 20 picks later than the market takes him is the whole point.
219
+ const valueComponent = adpDelta === null
220
+ ? 0.5
221
+ : Math.max(0, Math.min(1, (adpDelta + 10) / 40));
222
+ // Board rank: earlier average pick is a better player, all else equal.
223
+ const rankComponent = averagePick === null
224
+ ? 0.3
225
+ : Math.max(0, Math.min(1, 1 - (averagePick / 250)));
226
+ // Schedule: playoff-window volume, normalized against a 4-game week.
227
+ const weekCount = window?.weeks?.length ?? 0;
228
+ const scheduleComponent = playoffGames !== null && weekCount > 0
229
+ ? Math.min(1, (playoffGames / weekCount) / 4)
230
+ : 0.5;
231
+ const needComponent = fillsNeed ? 1 : 0;
232
+ const score = (0.40 * valueComponent) +
233
+ (0.30 * rankComponent) +
234
+ (0.18 * needComponent) +
235
+ (0.12 * scheduleComponent);
236
+ return {
237
+ player_id: player.player_id,
238
+ name: player.name,
239
+ position: player.position,
240
+ team: player.team,
241
+ average_pick: averagePick,
242
+ percent_drafted: player.percent_drafted,
243
+ adp_delta: adpDelta === null ? null : Number(adpDelta.toFixed(1)),
244
+ playoff_games: playoffGames,
245
+ four_game_weeks: profile?.weeks_with_4_plus ?? null,
246
+ fills_need: fillsNeed,
247
+ draft_score: Math.round(score * 100),
248
+ verdict: this.verdictFor(adpDelta),
249
+ reasoning: this.reasoningFor(player, adpDelta, playoffGames, weekCount, fillsNeed)
250
+ };
251
+ }
252
+ verdictFor(adpDelta) {
253
+ if (adpDelta === null)
254
+ return 'FAIR';
255
+ if (adpDelta >= 20)
256
+ return 'STEAL';
257
+ if (adpDelta >= 8)
258
+ return 'VALUE';
259
+ if (adpDelta <= -12)
260
+ return 'REACH';
261
+ return 'FAIR';
262
+ }
263
+ reasoningFor(player, adpDelta, playoffGames, weekCount, fillsNeed) {
264
+ const parts = [`${player.name} (${player.position}, ${player.team})`];
265
+ if (adpDelta === null) {
266
+ parts.push('no Yahoo ADP available');
267
+ }
268
+ else if (adpDelta > 0) {
269
+ parts.push(`${Math.round(adpDelta)} picks past his ADP of ${player.average_pick}`);
270
+ }
271
+ else if (adpDelta < 0) {
272
+ parts.push(`${Math.abs(Math.round(adpDelta))} picks ahead of his ADP of ${player.average_pick}`);
273
+ }
274
+ else {
275
+ parts.push(`right at his ADP of ${player.average_pick}`);
276
+ }
277
+ if (playoffGames !== null && weekCount > 0) {
278
+ parts.push(`${playoffGames} games across your ${weekCount} playoff weeks`);
279
+ }
280
+ if (fillsNeed)
281
+ parts.push('fills a roster hole');
282
+ return parts.join(' — ') + '.';
283
+ }
284
+ // ==========================================
285
+ // 🎯 Roster needs
286
+ // ==========================================
287
+ /** Positions with the thinnest coverage on the current roster. */
288
+ inferNeeds(rosterPositions) {
289
+ const counts = TRACKED_POSITIONS.map(pos => ({ pos, count: rosterPositions[pos] ?? 0 }));
290
+ const minimum = Math.min(...counts.map(c => c.count));
291
+ return counts.filter(c => c.count === minimum).map(c => c.pos);
292
+ }
293
+ // ==========================================
294
+ // 🎯 Yahoo parsing (defensive)
295
+ // ==========================================
296
+ /**
297
+ * Yahoo's fantasy JSON alternates between arrays and count-keyed objects
298
+ * depending on the resource and, in places, the request. Everything below
299
+ * accepts either shape and returns empty rather than throwing, so a shape
300
+ * change degrades one field instead of the whole draft tool.
301
+ */
302
+ keyedEntries(container) {
303
+ if (!container)
304
+ return [];
305
+ if (Array.isArray(container))
306
+ return container;
307
+ return Object.keys(container)
308
+ .filter(k => k !== 'count')
309
+ .map(k => container[k]);
310
+ }
311
+ parsePlayerPool(poolPages) {
312
+ const players = [];
313
+ for (const page of poolPages ?? []) {
314
+ if (!page)
315
+ continue;
316
+ const container = page?.fantasy_content?.league?.[1]?.players ??
317
+ page?.fantasy_content?.league?.players;
318
+ for (const entry of this.keyedEntries(container)) {
319
+ const player = entry?.player;
320
+ if (!player)
321
+ continue;
322
+ const identity = Array.isArray(player[0]) ? player[0] : [];
323
+ const find = (key) => identity.find((item) => item?.[key])?.[key];
324
+ const name = find('name')?.full;
325
+ const playerId = find('player_id');
326
+ if (!name || !playerId)
327
+ continue;
328
+ // draft_analysis rides on the second element, occasionally nested.
329
+ const analysis = player[1]?.draft_analysis ??
330
+ player[1]?.[0]?.draft_analysis ??
331
+ (Array.isArray(player[1])
332
+ ? player[1].find((item) => item?.draft_analysis)?.draft_analysis
333
+ : undefined);
334
+ const displayPosition = find('display_position') ?? '';
335
+ players.push({
336
+ player_id: String(playerId),
337
+ name,
338
+ position: displayPosition,
339
+ positions: String(displayPosition)
340
+ .split(',')
341
+ .map((p) => p.trim().toUpperCase())
342
+ .filter(Boolean),
343
+ team: find('editorial_team_abbr') ?? '',
344
+ average_pick: this.toNumberOrNull(analysis?.average_pick),
345
+ average_round: this.toNumberOrNull(analysis?.average_round),
346
+ percent_drafted: this.toNumberOrNull(analysis?.percent_drafted)
347
+ });
348
+ }
349
+ }
350
+ // Same player can appear across pages; keep one.
351
+ return Array.from(new Map(players.map(p => [p.player_id, p])).values());
352
+ }
353
+ parseDraftResults(payload) {
354
+ const container = payload?.fantasy_content?.league?.[1]?.draft_results ??
355
+ payload?.fantasy_content?.league?.draft_results;
356
+ const entries = this.keyedEntries(container);
357
+ if (entries.length === 0) {
358
+ return { available: false, count: 0, ids: new Set() };
359
+ }
360
+ const ids = new Set();
361
+ let count = 0;
362
+ for (const entry of entries) {
363
+ const result = entry?.draft_result ?? entry;
364
+ if (!result?.player_key)
365
+ continue;
366
+ count++;
367
+ // Draft results carry player_key (`nhl.p.1234`), never a name, so the
368
+ // board state is matched to the pool by id.
369
+ const id = String(result.player_key).split('.').pop();
370
+ if (id)
371
+ ids.add(id);
372
+ }
373
+ return { available: count > 0, count, ids };
374
+ }
375
+ parseRosterPositions(payload) {
376
+ const counts = {};
377
+ const container = payload?.fantasy_content?.team?.[1]?.roster?.['0']?.players ??
378
+ payload?.fantasy_content?.team?.[1]?.roster?.players;
379
+ for (const entry of this.keyedEntries(container)) {
380
+ const player = entry?.player;
381
+ if (!player)
382
+ continue;
383
+ const identity = Array.isArray(player[0]) ? player[0] : [];
384
+ const displayPosition = identity.find((item) => item?.display_position)?.display_position;
385
+ for (const pos of String(displayPosition ?? '').split(',')) {
386
+ const clean = pos.trim().toUpperCase();
387
+ if (TRACKED_POSITIONS.includes(clean)) {
388
+ counts[clean] = (counts[clean] ?? 0) + 1;
389
+ }
390
+ }
391
+ }
392
+ return counts;
393
+ }
394
+ resolvePlayoffWindow(settings) {
395
+ const leagueMeta = settings?.fantasy_content?.league?.[0] ?? {};
396
+ const leagueSettings = settings?.fantasy_content?.league?.[1]?.settings?.[0] ?? {};
397
+ const startDate = leagueMeta.start_date;
398
+ const endWeek = Number(leagueMeta.end_week ?? leagueSettings.end_week ?? 0);
399
+ const playoffStartWeek = Number(leagueSettings.playoff_start_week ?? 0);
400
+ if (!startDate || !playoffStartWeek || !endWeek || playoffStartWeek > endWeek) {
401
+ return { resolved: false, start: null, end: null, weeks: [] };
402
+ }
403
+ const week1Monday = NhlScheduleService.weekStart(startDate);
404
+ const weeks = [];
405
+ for (let week = playoffStartWeek; week <= endWeek; week++) {
406
+ weeks.push(NhlScheduleService.addDays(week1Monday, (week - 1) * 7));
407
+ }
408
+ return {
409
+ resolved: true,
410
+ playoff_start_week: playoffStartWeek,
411
+ end_week: endWeek,
412
+ start: NhlScheduleService.addDays(week1Monday, (playoffStartWeek - 1) * 7),
413
+ end: NhlScheduleService.addDays(week1Monday, endWeek * 7 - 1),
414
+ weeks
415
+ };
416
+ }
417
+ toNumberOrNull(value) {
418
+ if (value === undefined || value === null || value === '' || value === '-')
419
+ return null;
420
+ const parsed = parseFloat(value);
421
+ return Number.isFinite(parsed) ? parsed : null;
422
+ }
423
+ /** Case- and punctuation-insensitive name key for matching drafted players. */
424
+ normalizeName(name) {
425
+ return String(name)
426
+ .toLowerCase()
427
+ .normalize('NFD')
428
+ .replace(/[̀-ͯ]/g, '')
429
+ .replace(/[^a-z0-9]/g, '');
430
+ }
431
+ }
@@ -7,133 +7,36 @@
7
7
  */
8
8
  import { AnalysisTemplate } from '../template/AnalysisTemplate.js';
9
9
  import { ChirpIntelligence } from '../services/ChirpIntelligence.js';
10
+ import { NHL_SCHEDULE, NhlScheduleService } from '../services/NhlScheduleService.js';
11
+ import { LEAGUE_DATA, NO_ROSTER_MESSAGE, NO_OPPONENT_MESSAGE } from '../services/LeagueDataService.js';
12
+ import { NHL_STATS } from '../services/NhlStatsService.js';
10
13
  export class GamesInHandAnalysis extends AnalysisTemplate {
11
- apiClient;
12
- leagueId;
13
- teamId;
14
- constructor(apiClient, leagueId, teamId) {
14
+ constructor() {
15
15
  super("get_games_in_hand", "schedule_advantage");
16
- this.apiClient = apiClient;
17
- this.leagueId = leagueId;
18
- this.teamId = teamId;
19
16
  }
20
17
  /**
21
18
  * Hook 1: Fetch raw data from Yahoo API
22
19
  */
23
20
  async fetchData(args) {
24
- try {
25
- // Fetch current matchup data
26
- const matchupData = await this.apiClient.getTeamMatchup(this.leagueId, this.teamId);
27
- // Fetch league scoreboard for schedule data
28
- const scoreboardData = await this.apiClient.getLeagueScoreboard(this.leagueId);
29
- return {
30
- matchup: matchupData,
31
- scoreboard: scoreboardData
32
- };
33
- }
34
- catch (error) {
35
- throw new Error(`Failed to fetch games in hand data: ${error}`);
36
- }
21
+ await Promise.all([NHL_STATS.load(), NHL_SCHEDULE.load()]);
22
+ const roster = LEAGUE_DATA.getRoster();
23
+ if (!roster)
24
+ throw new Error(NO_ROSTER_MESSAGE);
25
+ const opponent = LEAGUE_DATA.getOpponentRoster();
26
+ if (!opponent)
27
+ throw new Error(NO_OPPONENT_MESSAGE);
28
+ return { roster, opponent };
37
29
  }
38
30
  /**
39
31
  * Hook 2: Prepare data into FantasyData structure
40
32
  */
41
33
  async prepareData(rawData, args) {
42
- const { matchup, scoreboard } = rawData;
43
- // Parse matchup to find opponent - try multiple potential structures
44
- let teams = matchup.fantasy_content?.team?.[1]?.matchup?.['0']?.teams?.['0']?.team || [];
45
- // If teams is empty, try alternative structure (matchups array)
46
- if (teams.length === 0) {
47
- const matchups = matchup.fantasy_content?.team?.[1]?.matchups;
48
- if (matchups && matchups.count !== '0') {
49
- // Find the current matchup where status === "midevent"
50
- const matchupKeys = Object.keys(matchups).filter(key => key !== 'count');
51
- const currentMatchupKey = matchupKeys.find(key => {
52
- const matchupData = matchups[key]?.matchup?.[0] || matchups[key]?.matchup;
53
- return matchupData?.status === 'midevent';
54
- });
55
- // Use current matchup if found, otherwise fallback to last matchup
56
- const selectedKey = currentMatchupKey || matchupKeys[matchupKeys.length - 1];
57
- if (selectedKey && matchups[selectedKey]?.matchup?.[0]) {
58
- teams = matchups[selectedKey].matchup[0].teams?.['0']?.team || [];
59
- }
60
- }
61
- }
62
- // Handle both full team IDs (nhl.l.123.t.1) and partial (just the number)
63
- const cleanLeagueId = this.leagueId.replace(/^nhl\.l\./, '');
64
- const cleanTeamId = this.teamId.replace(/^.*\.t\./, '');
65
- const fullTeamKey = `nhl.l.${cleanLeagueId}.t.${cleanTeamId}`;
66
- const yourTeam = teams.find((t) => {
67
- // Team data can be nested as an array or direct object
68
- const teamData = Array.isArray(t) ? t[0] : t;
69
- const teamKey = teamData?.team_key;
70
- return teamKey === fullTeamKey || teamKey === this.teamId || teamKey?.endsWith(`.t.${cleanTeamId}`);
71
- });
72
- const opponentTeam = teams.find((t) => {
73
- const teamData = Array.isArray(t) ? t[0] : t;
74
- const teamKey = teamData?.team_key;
75
- return teamKey && teamKey !== fullTeamKey && !teamKey?.endsWith(`.t.${cleanTeamId}`);
76
- });
77
- if (!yourTeam || !opponentTeam) {
78
- throw new Error(`Could not find matchup data. Teams found: ${teams.length}, Structure: ${JSON.stringify(matchup.fantasy_content?.team?.[1], null, 2).substring(0, 500)}`);
79
- }
80
- // Extract team data (handle both array and object structures)
81
- const yourTeamData = Array.isArray(yourTeam) ? yourTeam[0] : yourTeam;
82
- const opponentTeamData = Array.isArray(opponentTeam) ? opponentTeam[0] : opponentTeam;
83
- // Extract team rosters
84
- const yourRoster = yourTeamData.roster?.players || [];
85
- const opponentRoster = opponentTeamData.roster?.players || [];
86
- // Parse player data using .find() pattern like IceAnalysis and LineupAnalysis
87
- const parsePlayer = (playerData) => {
88
- const player = playerData.player?.[0] || playerData;
89
- // Use .find() pattern since player is an array of property objects
90
- const player_id = Array.isArray(player)
91
- ? player.find((item) => item.player_id)?.player_id
92
- : player.player_id;
93
- const name = Array.isArray(player)
94
- ? player.find((item) => item.name)?.name?.full
95
- : player.name?.full;
96
- const position = Array.isArray(player)
97
- ? player.find((item) => item.display_position)?.display_position ||
98
- player.find((item) => item.primary_positions)?.primary_positions?.[0]
99
- : player.display_position || player.primary_positions?.[0];
100
- const team = Array.isArray(player)
101
- ? player.find((item) => item.editorial_team_abbr)?.editorial_team_abbr
102
- : player.editorial_team_abbr;
103
- const status = Array.isArray(player)
104
- ? player.find((item) => item.status)?.status
105
- : player.status;
106
- const selected_position = Array.isArray(player)
107
- ? player.find((item) => item.selected_position)?.selected_position
108
- : player.selected_position;
109
- return {
110
- player_id: player_id || '',
111
- name: name || 'Unknown',
112
- position: position || 'Unknown',
113
- team: team || '',
114
- selected_position: selected_position || [],
115
- status: status || ''
116
- };
117
- };
118
- const yourPlayers = yourRoster.map(parsePlayer);
119
- const opponentPlayers = opponentRoster.map(parsePlayer);
120
34
  return {
121
- roster: {
122
- team_key: yourTeamData.team_key || '',
123
- team_name: yourTeamData.name || 'Your Team',
124
- players: yourPlayers
125
- },
35
+ roster: rawData.roster,
126
36
  opponent: {
127
- team_key: opponentTeamData.team_key || '',
128
- team_name: opponentTeamData.name || 'Opponent',
129
- players: opponentPlayers
130
- },
131
- matchup: {
132
- week: matchup.fantasy_content?.team?.[0]?.matchups?.['0']?.matchup?.week || 'current',
133
- your_team_key: yourTeamData.team_key || '',
134
- opponent_team_key: opponentTeamData.team_key || ''
135
- },
136
- scoreboard: scoreboard
37
+ team_name: rawData.opponent.team_name,
38
+ players: rawData.opponent.players
39
+ }
137
40
  };
138
41
  }
139
42
  /**
@@ -141,9 +44,29 @@ export class GamesInHandAnalysis extends AnalysisTemplate {
141
44
  */
142
45
  async analyzeData(data, args) {
143
46
  const lookAheadDays = args.look_ahead_days || 7;
144
- // Calculate games remaining for each team
47
+ // Calculate games remaining for each team from the real NHL schedule
145
48
  const yourGamesRemaining = this.calculateGamesRemaining(data.roster.players, lookAheadDays);
146
49
  const opponentGamesRemaining = this.calculateGamesRemaining(data.opponent.players || [], lookAheadDays);
50
+ // 🏛️ Rule 3: without the schedule there is no schedule advantage to report.
51
+ // Say so plainly instead of inventing a differential.
52
+ if (yourGamesRemaining === null || opponentGamesRemaining === null) {
53
+ return {
54
+ schedule_available: false,
55
+ schedule_note: NHL_SCHEDULE.getUnavailableReason() ?? 'NHL schedule unavailable',
56
+ your_team: {
57
+ team_name: data.roster.team_name,
58
+ games_remaining: null,
59
+ players_with_games: data.roster.players.filter(p => !p.selected_position.includes('IR'))
60
+ },
61
+ opponent: {
62
+ team_name: data.opponent.team_name || 'Opponent',
63
+ games_remaining: null,
64
+ players_with_games: (data.opponent.players || []).filter((p) => !p.selected_position.includes('IR'))
65
+ },
66
+ advantage: null,
67
+ strategic_recommendation: 'SCHEDULE UNAVAILABLE: could not reach the NHL schedule API, so no games-in-hand edge can be calculated. Retry shortly.'
68
+ };
69
+ }
147
70
  const advantage = yourGamesRemaining - opponentGamesRemaining;
148
71
  // Generate strategic recommendation based on advantage
149
72
  let strategicRecommendation;
@@ -169,6 +92,8 @@ export class GamesInHandAnalysis extends AnalysisTemplate {
169
92
  strategicRecommendation = "MASSIVE DISADVANTAGE: Quality over quantity - pick your spots carefully";
170
93
  }
171
94
  return {
95
+ schedule_available: true,
96
+ schedule_source: `NHL public API (season ${NHL_SCHEDULE.getSeason()})`,
172
97
  your_team: {
173
98
  team_name: data.roster.team_name,
174
99
  games_remaining: yourGamesRemaining,
@@ -242,16 +167,22 @@ export class GamesInHandAnalysis extends AnalysisTemplate {
242
167
  };
243
168
  }
244
169
  /**
245
- * Helper: Calculate total games remaining for a roster
170
+ * Helper: Count real games remaining for a roster over the look-ahead window.
171
+ *
172
+ * Sums each active player's club games from the NHL schedule. A roster of
173
+ * eight MTL skaters and a roster of eight SEA skaters are not the same
174
+ * number of games, which is precisely the edge this tool exists to find.
175
+ *
176
+ * Returns null when the schedule is unavailable — callers must report that
177
+ * rather than fall back to an estimate.
246
178
  */
247
179
  calculateGamesRemaining(players, lookAheadDays) {
248
- // This is a simplified calculation
249
- // In production, would parse actual NHL schedule data from scoreboard
250
- // Active players (not on IR) typically play 3-4 games per week
180
+ if (!NHL_SCHEDULE.isAvailable())
181
+ return null;
182
+ const start = NhlScheduleService.today();
183
+ const end = NhlScheduleService.addDays(start, Math.max(0, lookAheadDays - 1));
251
184
  const activePlayers = players.filter(p => !p.selected_position.includes('IR') &&
252
185
  !p.selected_position.includes('BN'));
253
- // Rough estimate: 3.5 games per active player per week
254
- const weeksInLookAhead = lookAheadDays / 7;
255
- return Math.round(activePlayers.length * 3.5 * weeksInLookAhead);
186
+ return activePlayers.reduce((total, player) => total + NHL_SCHEDULE.countGamesInRange(player.team, start, end), 0);
256
187
  }
257
188
  }