cito-mcp 0.4.0 → 0.4.2

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.
@@ -62,6 +62,138 @@ function numOrNull(v) {
62
62
  return Number(v);
63
63
  return null;
64
64
  }
65
+ /**
66
+ * One set of a tennis score string ("6-4", "7-6(6)", "6-4, 3-6") as games won.
67
+ *
68
+ * A tiebreak is parenthesised after the games ("7-6(6)"), so the regex stops at
69
+ * the games. Anything else returns null rather than a guess: retirement rows
70
+ * carry "6-3 2-1 RET", and reading "RET" as games would invent a set.
71
+ */
72
+ export function parseTennisSetToken(token) {
73
+ const m = /^\s*(\d{1,2})\s*-\s*(\d{1,2})/.exec(token);
74
+ if (!m)
75
+ return null;
76
+ return { p1: Number(m[1]), p2: Number(m[2]) };
77
+ }
78
+ /**
79
+ * Sets won by one side, counted from the match-level game score
80
+ * ("4-6 6-3 6-3 7-5" → 3 for side 1, 1 for side 2).
81
+ *
82
+ * `score` orders every token as player1-player2 on this feed, which is verified
83
+ * against the same row's sets[] (set 1: score token "4-6", player1_games 4,
84
+ * player2_games 6). Ties are impossible in a completed set, so a token where
85
+ * neither side is ahead contributes to neither total.
86
+ */
87
+ export function tennisSetsWonFromScore(score, side) {
88
+ if (!score)
89
+ return null;
90
+ let won = 0;
91
+ let parsed = 0;
92
+ for (const token of score.split(/[\s,]+/)) {
93
+ if (!token)
94
+ continue;
95
+ const set = parseTennisSetToken(token);
96
+ if (!set)
97
+ continue;
98
+ parsed += 1;
99
+ const mine = side === 1 ? set.p1 : set.p2;
100
+ const theirs = side === 1 ? set.p2 : set.p1;
101
+ if (mine > theirs)
102
+ won += 1;
103
+ }
104
+ return parsed > 0 ? won : null;
105
+ }
106
+ /**
107
+ * Sets won by one side, counted from the structured sets[] array.
108
+ *
109
+ * player1_games/player2_games are the ONLY side-correct pair here. The sibling
110
+ * winner_games/loser_games are oriented to the match winner, not to side 1 —
111
+ * on s365_2026_4849786 set 1 is `winner_games: 4, loser_games: 6` while side 1
112
+ * (the winner, Shelton) actually lost that set 4-6. Reading those would have
113
+ * produced a confidently wrong 1-3 scoreline.
114
+ */
115
+ export function tennisSetsWonFromSets(sets, side) {
116
+ if (!Array.isArray(sets) || sets.length === 0)
117
+ return null;
118
+ let won = 0;
119
+ let parsed = 0;
120
+ for (const raw of sets) {
121
+ const s = asRecord(raw);
122
+ if (!s)
123
+ continue;
124
+ const p1 = numOrNull(s.player1_games);
125
+ const p2 = numOrNull(s.player2_games);
126
+ if (p1 === null || p2 === null)
127
+ continue;
128
+ parsed += 1;
129
+ const mine = side === 1 ? p1 : p2;
130
+ const theirs = side === 1 ? p2 : p1;
131
+ if (mine > theirs)
132
+ won += 1;
133
+ }
134
+ return parsed > 0 ? won : null;
135
+ }
136
+ /**
137
+ * Whether a set row is finished.
138
+ *
139
+ * The live route states `is_completed`; the archive route omits the key
140
+ * entirely, and reading only the flag marked all four sets of a COMPLETED US
141
+ * Open semi-final `completed: false`. When the flag is absent the games decide:
142
+ * a set is over at 6-x with a two-game margin, or at 7-6/7-5.
143
+ */
144
+ export function tennisSetCompleted(sr) {
145
+ if (typeof sr.is_completed === 'boolean')
146
+ return sr.is_completed;
147
+ const p1 = numOrNull(sr.player1_games);
148
+ const p2 = numOrNull(sr.player2_games);
149
+ if (p1 === null || p2 === null)
150
+ return false;
151
+ const hi = Math.max(p1, p2);
152
+ const lo = Math.min(p1, p2);
153
+ return (hi >= 6 && hi - lo >= 2) || (hi === 7 && lo === 6);
154
+ }
155
+ /**
156
+ * Sets won by one side of a tennis match, from whichever spelling the row uses.
157
+ *
158
+ * Four sources exist because three different endpoints describe the same match:
159
+ * 1. `sets_won` — the live route puts it on each player object
160
+ * 2. `winner_sets_won`/`loser_sets_won` — the archive route's own totals
161
+ * 3. `sets[]` — per-set games, present on both
162
+ * 4. the `score` string — "4-6 6-3 6-3 7-5", present on both
163
+ *
164
+ * The archive route (`/tennis/matches/{id}`) carries NO sets_won on its player
165
+ * objects at all, which is why a completed US Open semi-final rendered as
166
+ * "Ben Shelton - : - Frances Tiafoe" while the same response held the real
167
+ * score in `score` and in `sets[]`. Reading only source 1 is the bug; the order
168
+ * above is authority, not preference — a server-computed total beats a count we
169
+ * derive, and the structured array beats re-parsing a display string.
170
+ */
171
+ export function tennisSetsWon(opts) {
172
+ const explicit = numOrNull(opts.sideSetsWon);
173
+ if (explicit !== null)
174
+ return explicit;
175
+ // winner_sets_won / loser_sets_won belong to a side, and which side is known
176
+ // either from the row's own is_winner flag or by matching ids. Without one of
177
+ // those signals the pair is unusable and the structured sets below are safer
178
+ // than a coin flip.
179
+ const wsw = numOrNull(opts.winnerSetsWon);
180
+ const lsw = numOrNull(opts.loserSetsWon);
181
+ if (wsw !== null && lsw !== null) {
182
+ let isWinner = opts.isWinner;
183
+ if (isWinner === null || isWinner === undefined) {
184
+ if (opts.winnerId && opts.sideId)
185
+ isWinner = opts.winnerId === opts.sideId;
186
+ }
187
+ if (isWinner === true)
188
+ return wsw;
189
+ if (isWinner === false)
190
+ return lsw;
191
+ }
192
+ const fromSets = tennisSetsWonFromSets(Array.isArray(opts.sets) ? opts.sets : [], opts.side);
193
+ if (fromSets !== null)
194
+ return fromSets;
195
+ return tennisSetsWonFromScore(opts.score ?? undefined, opts.side);
196
+ }
65
197
  /**
66
198
  * Collect image URLs from a corner/team row and its nested profile. Returns
67
199
  * undefined when the upstream carried none, so lean rows stay lean; when any
@@ -82,9 +214,11 @@ function imagesFrom(game, ...sources) {
82
214
  return null;
83
215
  };
84
216
  const images = {
85
- headshotUrl: pick('headshotUrl', 'headshot'),
217
+ // portrait_url is the tennis feed's only likeness (see identityFrom in
218
+ // player.ts); without it a tennis side on every board carried no image.
219
+ headshotUrl: pick('headshotUrl', 'headshot', 'portrait_url'),
86
220
  bodyImageUrl: pick('bodyImageUrl', 'fullBodyImageUrl'),
87
- imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo'),
221
+ imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo', 'portrait_url'),
88
222
  proxiedImageUrl: pick('proxiedImageUrl', 'proxiedHeadshotUrl'),
89
223
  };
90
224
  if (!images.proxiedImageUrl) {
@@ -125,7 +259,9 @@ function sideExtras(o, game) {
125
259
  const rank = pickString(o.rankText, o.rank, profile?.rankText);
126
260
  const championStatus = pickString(o.championStatus, profile?.championStatus);
127
261
  const division = pickString(profile?.division, o.division, o.weightClass);
128
- const country = pickString(o.country, profile?.country);
262
+ // `ioc` is the tennis feed's country code ("ESP"); it was not read, so every
263
+ // tennis side on every board came back country-less.
264
+ const country = pickString(o.country, profile?.country, o.ioc);
129
265
  const flag = pickString(o.flag, profile?.flag);
130
266
  const outcome = pickString(o.outcome);
131
267
  return {
@@ -238,11 +374,38 @@ export function normalizeMatch(game, row, forcedStatus) {
238
374
  const p2obj = asRecord(r.player2);
239
375
  const p1 = pickString(p1obj?.name, r.player1_name);
240
376
  const p2 = pickString(p2obj?.name, r.player2_name);
377
+ // winner_sets_won/loser_sets_won and score/sets are match-level; only the
378
+ // side flags differ, so they are shared by both branches below.
379
+ const scoreText = pickString(r.score, r.score_raw) ?? null;
241
380
  if (p1 || p2) {
242
- const score1 = typeof p1obj?.sets_won === 'number' ? p1obj.sets_won : undefined;
243
- const score2 = typeof p2obj?.sets_won === 'number' ? p2obj.sets_won : undefined;
244
- team1 = sideFrom(p1, pickString(p1obj?.id, r.player1_id), undefined, score1);
245
- team2 = sideFrom(p2, pickString(p2obj?.id, r.player2_id), undefined, score2);
381
+ const p1Id = pickString(p1obj?.id, r.player1_id) ?? null;
382
+ const p2Id = pickString(p2obj?.id, r.player2_id) ?? null;
383
+ const winnerId = pickString(r.winner_id) ?? null;
384
+ const boolOrNull = (v) => (typeof v === 'boolean' ? v : null);
385
+ const score1 = tennisSetsWon({
386
+ sideSetsWon: p1obj?.sets_won,
387
+ winnerSetsWon: r.winner_sets_won,
388
+ loserSetsWon: r.loser_sets_won,
389
+ isWinner: boolOrNull(p1obj?.is_winner),
390
+ winnerId,
391
+ sideId: p1Id,
392
+ sets: r.sets,
393
+ score: scoreText,
394
+ side: 1,
395
+ });
396
+ const score2 = tennisSetsWon({
397
+ sideSetsWon: p2obj?.sets_won,
398
+ winnerSetsWon: r.winner_sets_won,
399
+ loserSetsWon: r.loser_sets_won,
400
+ isWinner: boolOrNull(p2obj?.is_winner),
401
+ winnerId,
402
+ sideId: p2Id,
403
+ sets: r.sets,
404
+ score: scoreText,
405
+ side: 2,
406
+ });
407
+ team1 = sideFrom(p1, p1Id ?? undefined, undefined, score1, sideExtras(p1obj ?? {}, game));
408
+ team2 = sideFrom(p2, p2Id ?? undefined, undefined, score2, sideExtras(p2obj ?? {}, game));
246
409
  }
247
410
  else {
248
411
  // Archive detail nests winner/loser objects with real names; list rows
@@ -254,8 +417,25 @@ export function normalizeMatch(game, row, forcedStatus) {
254
417
  const wId = pickString(wObj?.id, r.winner_id);
255
418
  const lId = pickString(lObj?.id, r.loser_id);
256
419
  if (wName || lName || wId || lId) {
257
- team1 = sideFrom(wName ?? wId, wId, undefined, undefined);
258
- team2 = sideFrom(lName ?? lId, lId, undefined, undefined);
420
+ // This shape IS winner-first, so side 1 is the winner by construction.
421
+ const wScore = tennisSetsWon({
422
+ winnerSetsWon: r.winner_sets_won,
423
+ loserSetsWon: r.loser_sets_won,
424
+ isWinner: true,
425
+ sets: r.sets,
426
+ score: scoreText,
427
+ side: 1,
428
+ });
429
+ const lScore = tennisSetsWon({
430
+ winnerSetsWon: r.winner_sets_won,
431
+ loserSetsWon: r.loser_sets_won,
432
+ isWinner: false,
433
+ sets: r.sets,
434
+ score: scoreText,
435
+ side: 2,
436
+ });
437
+ team1 = sideFrom(wName ?? wId, wId, undefined, wScore);
438
+ team2 = sideFrom(lName ?? lId, lId, undefined, lScore);
259
439
  }
260
440
  }
261
441
  }
@@ -440,9 +620,21 @@ export function normalizeMatch(game, row, forcedStatus) {
440
620
  ? undefined
441
621
  : explicitRaw;
442
622
  const explicitIsPlaceholder = explicit != null && /^\?\s*vs\s*\?$/i.test(explicit.trim());
623
+ // Tennis rows carry no `label`, but the live route does set `name` — and that
624
+ // name is the TOURNAMENT ("Challenger, Seville"), not the matchup. Preferring
625
+ // it made live_matches label a tennis row by its event while
626
+ // upcoming_schedule, off a route with no such field, labelled the same
627
+ // fixture "Max Alcala Gurri vs Dusan Lajovic". Two boards, one match, two
628
+ // labels. A "matchup" that is really an event name is not a matchup.
629
+ const explicitIsEventName = explicit != null &&
630
+ game === 'tennis' &&
631
+ Boolean(team1?.name || team2?.name) &&
632
+ [eventName, eventSlug, r.tournament_name, r.tournament]
633
+ .filter((v) => typeof v === 'string' && v.length > 0)
634
+ .some((v) => v.trim().toLowerCase() === explicit.trim().toLowerCase());
443
635
  const weightOrClass = pickString(r.weightClass, r.division, r.weight_class, r.boutClass);
444
636
  let label;
445
- if (explicit && !explicitIsPlaceholder) {
637
+ if (explicit && !explicitIsPlaceholder && !explicitIsEventName) {
446
638
  label = explicit;
447
639
  }
448
640
  else if (team1?.name || team2?.name) {
@@ -483,12 +675,19 @@ export function normalizeMatch(game, row, forcedStatus) {
483
675
  if (game === 'tennis' && Array.isArray(r.sets) && r.sets.length) {
484
676
  tennisSets = r.sets.map((s) => {
485
677
  const sr = asRecord(s) ?? {};
678
+ const p1g = numOrNull(sr.player1_games);
679
+ const p2g = numOrNull(sr.player2_games);
486
680
  return {
487
- setNumber: numOrNull(sr.set_num),
488
- player1Games: numOrNull(sr.player1_games),
489
- player2Games: numOrNull(sr.player2_games),
681
+ // The live route spells it set_num; the archive route set_number. Both
682
+ // are emitted by the same API for the same set.
683
+ setNumber: numOrNull(sr.set_num) ?? numOrNull(sr.set_number),
684
+ score: pickString(sr.score) ?? (p1g !== null && p2g !== null ? `${p1g}-${p2g}` : null),
685
+ player1Games: p1g,
686
+ player2Games: p2g,
490
687
  tiebreak: sr.tiebreak == null ? null : (pickString(sr.tiebreak) ?? String(sr.tiebreak)),
491
- completed: sr.is_completed === true,
688
+ // is_completed is a live-route field only; the archive route omits it
689
+ // and every set of a finished match read as completed:false.
690
+ completed: tennisSetCompleted(sr),
492
691
  };
493
692
  });
494
693
  tennisGameScore = pickString(r.game_score) ?? null;
@@ -624,7 +823,14 @@ export function rankScore(query, name, id, slug, ...extra) {
624
823
  best = Math.max(best, 92); // "jon jones" vs "Jon Jones"
625
824
  }
626
825
  else {
627
- const hits = qt.filter((t) => ct.some((x) => x.includes(t) || t.includes(x))).length;
826
+ // A hit is the candidate token containing the query token, or the query
827
+ // token containing a candidate token OF REAL LENGTH. The reverse
828
+ // direction used to accept any length, so the single letter "e" in
829
+ // "E Skinner" counted as a hit against the query "sinner" ("sinner"
830
+ // contains "e") and scored 32 — which is how eight strangers padded a
831
+ // search for one player. A one- or two-character fragment carries no
832
+ // identity, so it no longer counts.
833
+ const hits = qt.filter((t) => ct.some((x) => x.includes(t) || (t.includes(x) && x.length >= 3))).length;
628
834
  if (hits === qt.length && qt.length > 0)
629
835
  best = Math.max(best, 75);
630
836
  else if (hits)
@@ -53,6 +53,39 @@ function normalizeBookmaker(row) {
53
53
  markets,
54
54
  };
55
55
  }
56
+ /**
57
+ * The one tennis-odds projection, shared by `tennis_odds` and `match_details`.
58
+ *
59
+ * Both surfaces read the same REST payload, and before this the two disagreed:
60
+ * `tennis_odds scope=match` served FanDuel 1.105 / Matchbook 1.13 with implied
61
+ * probabilities while `match_details sections:["odds"]` answered
62
+ * NOT_IMPLEMENTED "UFC only today" for the same match id. A caller could not
63
+ * tell whether tennis odds existed. One shaper, one answer.
64
+ */
65
+ export function summarizeTennisOdds(data) {
66
+ const root = asRecord(data) ?? {};
67
+ // fetchJson hands back the whole REST envelope; the unit tests feed the inner
68
+ // object directly. Accept both.
69
+ const payload = asRecord(root.data) ?? root;
70
+ const coverage = asRecord(payload.coverage) ?? {};
71
+ const bookmakers = (Array.isArray(payload.bookmakers) ? payload.bookmakers : []).map(normalizeBookmaker);
72
+ const hasOdds = coverage.odds === true || bookmakers.length > 0;
73
+ return {
74
+ matchId: pickString(payload.match_id) ?? null,
75
+ oddsFormat: pickString(payload.odds_format) ?? 'decimal',
76
+ commenceTime: pickString(payload.commence_time) ?? null,
77
+ /** Upstream says the feed has since moved on; the prices are still real. */
78
+ stale: payload.stale === true,
79
+ bookmakers,
80
+ // Explicit, because an empty bookmakers[] is otherwise indistinguishable
81
+ // from a failed scrape.
82
+ oddsAvailable: hasOdds,
83
+ note: hasOdds
84
+ ? null
85
+ : 'No bookmaker is currently quoting this match. This is an upstream coverage gap, not an error; retry closer to the start time.',
86
+ fullBookPath: '/tennis/odds/{matchId}',
87
+ };
88
+ }
56
89
  function parseTennis(args, tool) {
57
90
  const gameParse = parseGame(args.game, { allowAll: false, required: true });
58
91
  if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
@@ -92,6 +125,10 @@ Do not use when: the match result → match_details; rankings → standings.
92
125
  Tennis-only. Odds are decimal. Coverage is partial upstream: a match with no
93
126
  priced bookmaker returns coverage.odds=false and an empty bookmakers[] rather
94
127
  than invented numbers, and this tool surfaces that distinction explicitly.
128
+ A match id that does not exist is a NOT_FOUND error, not a coverage gap: the
129
+ odds route answers 200/odds:false for every unknown id, so scope=match and
130
+ scope=live verify the match against /tennis/matches/{id} before reporting a gap,
131
+ and set matchExists.
95
132
 
96
133
  Parallel-safe: yes. Upstream cost: 1.`,
97
134
  inputSchema: {
@@ -166,15 +203,35 @@ Parallel-safe: yes. Upstream cost: 1.`,
166
203
  const r = asRecord(row) ?? {};
167
204
  const home = asRecord(r.home) ?? {};
168
205
  const away = asRecord(r.away) ?? {};
206
+ const homeId = pickString(home.player_id, home.id) ?? null;
207
+ const awayId = pickString(away.player_id, away.id) ?? null;
208
+ const matchId = pickString(r.match_id, r.id) ?? null;
209
+ const oddsEventId = pickString(r.odds_event_id) ?? null;
169
210
  return {
170
- matchId: pickString(r.match_id, r.id) ?? null,
171
- oddsEventId: pickString(r.odds_event_id) ?? null,
211
+ matchId,
212
+ oddsEventId,
172
213
  tournament: pickString(r.tournament) ?? null,
173
214
  commenceTime: pickString(r.commence_time, r.starts_at) ?? null,
174
- player1: { name: pickString(home.name) ?? null, id: pickString(home.player_id, home.id) ?? null },
175
- player2: { name: pickString(away.name) ?? null, id: pickString(away.player_id, away.id) ?? null },
215
+ player1: { name: pickString(home.name) ?? null, id: homeId },
216
+ player2: { name: pickString(away.name) ?? null, id: awayId },
176
217
  bookmakers: Array.isArray(r.bookmakers) ? r.bookmakers.map((b) => pickString(b) ?? null) : [],
177
218
  live: r.live === true,
219
+ /**
220
+ * The feed does not join its odds events to matches: every row of
221
+ * /tennis/odds/upcoming carries match_id:null (verified on all 94
222
+ * rows), so a caller could not go odds -> match and the two halves of
223
+ * a betting UI could not be stitched together. matchId itself is left
224
+ * exactly as the feed states it — null stays null, because inventing
225
+ * one would be worse — and the join key that DOES resolve is named
226
+ * alongside it.
227
+ */
228
+ joinKey: matchId
229
+ ? { kind: 'matchId', matchId }
230
+ : oddsEventId
231
+ ? { kind: 'oddsEventId', oddsEventId }
232
+ : null,
233
+ /** Player ids present on the row, which identify the fixture when matchId is null. */
234
+ playerIds: [homeId, awayId].filter((x) => Boolean(x)),
178
235
  };
179
236
  });
180
237
  const total = typeof body.total === 'number' ? body.total : items.length;
@@ -206,6 +263,8 @@ Parallel-safe: yes. Upstream cost: 1.`,
206
263
  ? `/tennis/odds/${encodeURIComponent(matchId)}`
207
264
  : `/tennis/odds/${encodeURIComponent(matchId)}/live`;
208
265
  const res = await fetchJson(ctx, path, {});
266
+ let upstreamCalls = 1;
267
+ let rateLimit = res.headers;
209
268
  if (!res.ok) {
210
269
  return errorEnvelope({
211
270
  code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
@@ -220,30 +279,73 @@ Parallel-safe: yes. Upstream cost: 1.`,
220
279
  recover: ['Check the match id with live_matches or match_details'],
221
280
  });
222
281
  }
223
- const root = asRecord(res.data) ?? {};
224
- const body = asRecord(root.data) ?? root;
225
- const coverage = asRecord(body.coverage) ?? {};
226
- const bookmakers = (Array.isArray(body.bookmakers) ? body.bookmakers : []).map(normalizeBookmaker);
227
- const hasOdds = coverage.odds === true || bookmakers.length > 0;
282
+ const summary = summarizeTennisOdds(res.data);
283
+ /**
284
+ * A match id that does not exist and a real match nobody is quoting look
285
+ * identical on the wire.
286
+ *
287
+ * /tennis/odds/{id} answers HTTP 200 with `coverage: { odds: false }` for
288
+ * EVERY id it does not know — verified for `s365_2026_0000000` (a plausible
289
+ * typo) and for the literal string `total-garbage-id`, both of which came
290
+ * back 200/odds:false. So the status code cannot tell them apart, and this
291
+ * tool used to report a typo as "upstream coverage gap, not an error" with
292
+ * ok:true — leaving the caller to conclude a real match has no prices.
293
+ *
294
+ * /tennis/matches/{id} DOES 404 for those same ids (verified: 404 NOT_FOUND
295
+ * for both, 200 for a real one), so that is the discriminator. It costs one
296
+ * extra call, and only on the no-odds path, where the answer is otherwise
297
+ * useless. player_stats already gets this right (404 -> NOT_FOUND); this
298
+ * makes odds agree with it.
299
+ */
300
+ let matchExists = null;
301
+ if (summary.oddsAvailable !== true) {
302
+ const probe = await fetchJson(ctx, `/tennis/matches/${encodeURIComponent(matchId)}`);
303
+ upstreamCalls += 1;
304
+ rateLimit = { ...rateLimit, ...probe.headers };
305
+ matchExists = probe.ok;
306
+ if (!matchExists) {
307
+ // Live ids live at /matches/live/{id} until the match archives.
308
+ const liveProbe = await fetchJson(ctx, `/tennis/matches/live/${encodeURIComponent(matchId)}`);
309
+ upstreamCalls += 1;
310
+ rateLimit = { ...rateLimit, ...liveProbe.headers };
311
+ matchExists = liveProbe.ok;
312
+ }
313
+ if (!matchExists) {
314
+ return errorEnvelope({
315
+ code: 'NOT_FOUND',
316
+ message: `No tennis match with id '${matchId}' — the odds feed has no event for it because the id itself does not exist`,
317
+ game,
318
+ source: 'tennis_odds',
319
+ requestId,
320
+ tookMs: Date.now() - started,
321
+ upstreamCalls,
322
+ rateLimit,
323
+ httpStatus: 200,
324
+ recover: [
325
+ 'Check the id with live_matches or tennis_schedule / match_summary',
326
+ 'Ids come from live_matches (s365_*) or the archive (s365_{year}_{gid})',
327
+ `For the pre-match odds list instead, call tennis_odds { game: "tennis", scope: "upcoming" }`,
328
+ ],
329
+ });
330
+ }
331
+ }
228
332
  return successEnvelope({
229
333
  source: 'tennis_odds',
230
334
  game,
231
335
  requestId,
232
336
  tookMs: Date.now() - started,
233
- upstreamCalls: 1,
234
- rateLimit: res.headers,
337
+ upstreamCalls,
338
+ rateLimit,
235
339
  data: {
236
340
  title: `Tennis ${scopeRaw} odds — ${matchId}`,
237
341
  scope: scopeRaw,
238
- matchId: pickString(body.match_id) ?? matchId,
239
- oddsFormat: pickString(body.odds_format) ?? 'decimal',
240
- bookmakers,
241
- // Explicit, because an empty bookmakers[] is otherwise indistinguishable
242
- // from a failed scrape.
243
- oddsAvailable: hasOdds,
244
- note: hasOdds
245
- ? null
246
- : 'No bookmaker is currently quoting this match. This is an upstream coverage gap, not an error; retry closer to the start time.',
342
+ // One projection, the same object match_details sections:["odds"]
343
+ // returns. See summarizeTennisOdds.
344
+ ...summary,
345
+ matchId: summary.matchId ?? matchId,
346
+ // true when the no-odds path verified the match exists; null when odds
347
+ // existed so no verification was needed.
348
+ matchExists,
247
349
  },
248
350
  });
249
351
  },
@@ -53,9 +53,16 @@ function identityFrom(game, raw, idHint, slugHint) {
53
53
  * own domain, so it works from a browser without hotlink/CORS trouble.
54
54
  */
55
55
  images: {
56
- headshotUrl: pickString(r.headshotUrl, r.headshot) ?? null,
56
+ // Tennis supplies exactly one likeness, `portrait_url` (a Wikimedia
57
+ // portrait), and no separate square headshot crop. Before this, the shaper
58
+ // read only headshotUrl/bodyImageUrl/imageUrl/photoUrl — none of which the
59
+ // tennis route emits — so every tennis profile shipped four explicit nulls
60
+ // while a perfectly good photo sat one key away, and builders concluded
61
+ // tennis had no images at all. The portrait fills the headshot slot too:
62
+ // a card with the real photo slightly cropped beats a faceless card.
63
+ headshotUrl: pickString(r.headshotUrl, r.headshot, r.portrait_url) ?? null,
57
64
  bodyImageUrl: pickString(r.bodyImageUrl, r.fullBodyImageUrl) ?? null,
58
- imageUrl: pickString(r.imageUrl, r.image, r.photoUrl) ?? null,
65
+ imageUrl: pickString(r.imageUrl, r.image, r.photoUrl, r.portrait_url) ?? null,
59
66
  proxiedImageUrl: pickString(r.proxiedImageUrl, r.proxiedHeadshotUrl) ?? null,
60
67
  },
61
68
  };
@@ -1064,16 +1071,85 @@ Upstream cost: 1.`,
1064
1071
  * Map an arbitrary {label: value} breakdown without assuming the upstream
1065
1072
  * key set: the endpoint has grown keys before and a hardcoded list would
1066
1073
  * silently drop new ones.
1074
+ *
1075
+ * Values that are OBJECTS are skipped rather than coerced to null. That
1076
+ * coercion is exactly what broke `bySurface`: the endpoint publishes
1077
+ * `surface_breakdown: { hard: { matches, won, lost, win_pct, titles }, ... }`
1078
+ * (one object per surface) and this ran each value through Number(), so
1079
+ * every tennis player's surface split came back
1080
+ * `{hard:null, clay:null, grass:null, carpet:null}` — permanently, on every
1081
+ * profile, while `player_profile` served the identical numbers from the
1082
+ * identical source. A key that is present and null reads as "this player
1083
+ * has no clay record", which is a worse lie than omitting it.
1084
+ */
1085
+ const camel = (key) => key.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
1086
+ const flatNumeric = (value) => {
1087
+ const rec = asRecord(value);
1088
+ if (!rec)
1089
+ return null;
1090
+ const out = {};
1091
+ for (const [k, v] of Object.entries(rec)) {
1092
+ if (asRecord(v))
1093
+ continue;
1094
+ out[camel(k)] = num(v);
1095
+ }
1096
+ return Object.keys(out).length > 0 ? out : null;
1097
+ };
1098
+ /**
1099
+ * One OBJECT per bucket (surface, level, …), with the bucket's own fields
1100
+ * renamed to stable camelCase keys. A bucket that arrives as a bare number
1101
+ * is preserved as { value }, so a shape change narrows the payload instead
1102
+ * of emptying it.
1067
1103
  */
1068
- const numericBlock = (value) => {
1104
+ const bucketBlock = (value, fields) => {
1069
1105
  const rec = asRecord(value);
1070
1106
  if (!rec)
1071
1107
  return null;
1072
1108
  const out = {};
1073
- for (const [k, v] of Object.entries(rec))
1074
- out[k] = num(v);
1109
+ for (const [bucket, raw] of Object.entries(rec)) {
1110
+ const inner = asRecord(raw);
1111
+ if (inner) {
1112
+ const mapped = {};
1113
+ for (const [outKey, srcKey] of fields) {
1114
+ if (srcKey in inner)
1115
+ mapped[outKey] = num(inner[srcKey]);
1116
+ }
1117
+ out[bucket] = Object.keys(mapped).length > 0 ? mapped : flatNumeric(inner) ?? {};
1118
+ }
1119
+ else {
1120
+ out[bucket] = { value: num(raw) };
1121
+ }
1122
+ }
1075
1123
  return Object.keys(out).length > 0 ? out : null;
1076
1124
  };
1125
+ const SURFACE_FIELDS = [
1126
+ ['matches', 'matches'],
1127
+ ['won', 'won'],
1128
+ ['lost', 'lost'],
1129
+ ['winPct', 'win_pct'],
1130
+ ['titles', 'titles'],
1131
+ ];
1132
+ const bySurface = bucketBlock(body.surface_breakdown ?? body.by_surface, SURFACE_FIELDS);
1133
+ const byLevel = bucketBlock(body.by_level ?? body.level_breakdown, SURFACE_FIELDS);
1134
+ const servingStats = flatNumeric(body.serving_stats);
1135
+ const returnStats = flatNumeric(body.return_stats);
1136
+ const clutchAndSituational = flatNumeric(body.clutch_and_situational);
1137
+ const grandSlamBreakdown = bucketBlock(body.grand_slam_breakdown, SURFACE_FIELDS);
1138
+ // Sections this endpoint declares but does not publish for this player.
1139
+ // Named explicitly so an absent key is not read as "no such data exists".
1140
+ const unavailable = {};
1141
+ if (!byLevel) {
1142
+ unavailable.byLevel =
1143
+ 'This endpoint publishes no per-level breakdown. Pass level= to a player_stats call, or use tournaments/event_card for level-scoped results.';
1144
+ }
1145
+ if (!returnStats)
1146
+ unavailable.returnStats = 'Not published for this player yet.';
1147
+ if (!clutchAndSituational) {
1148
+ unavailable.clutchAndSituational = 'Not published for this player yet.';
1149
+ }
1150
+ if (!grandSlamBreakdown) {
1151
+ unavailable.grandSlamBreakdown = 'Not published for this player yet.';
1152
+ }
1077
1153
  return successEnvelope({
1078
1154
  source: 'player_stats',
1079
1155
  game,
@@ -1102,8 +1178,13 @@ Upstream cost: 1.`,
1102
1178
  mastersTitles: num(summary.masters_titles),
1103
1179
  finalsReached: num(summary.finals_reached),
1104
1180
  },
1105
- bySurface: numericBlock(body.by_surface ?? body.surface_breakdown),
1106
- byLevel: numericBlock(body.by_level ?? body.level_breakdown),
1181
+ bySurface,
1182
+ byLevel,
1183
+ servingStats,
1184
+ returnStats,
1185
+ clutchAndSituational,
1186
+ grandSlamBreakdown,
1187
+ ...(Object.keys(unavailable).length > 0 ? { unavailable } : {}),
1107
1188
  },
1108
1189
  });
1109
1190
  },