cito-mcp 0.4.3 → 0.4.5

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.
@@ -1,6 +1,3 @@
1
- /**
2
- * match_preview — pre-match briefing composite
3
- */
4
1
  import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
5
2
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
3
  import { chooseNamedEntity, normalizeMatch, presentSides, sortByCardOrder } from './normalize.js';
@@ -14,8 +11,6 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
14
11
  let recentForm = [];
15
12
  let keyPlayers = [];
16
13
  if (game === 'tennis') {
17
- // Profile + match log. Without this branch the preview fell to the generic
18
- // fallback and presented player ids where names belong.
19
14
  const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(side)}`);
20
15
  calls += 1;
21
16
  rateLimit = res.headers;
@@ -66,7 +61,6 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
66
61
  entity = {
67
62
  id: pickString(data.id, data.slug, side),
68
63
  slug: pickString(data.slug, side),
69
- // Prefer real display name; never leave entity.name as the slug when name exists
70
64
  name: pickString(data.name, data.displayName, data.nickname, side) ?? side,
71
65
  nickname: pickString(data.nickname) ?? null,
72
66
  division: pickString(data.division) ?? null,
@@ -81,7 +75,6 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
81
75
  httpStatus: res.status,
82
76
  }));
83
77
  }
84
- // Fight-by-fight form (not aggregate stats alone)
85
78
  const fights = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(side)}/fights`, {
86
79
  query: { limit: recentLimit },
87
80
  });
@@ -91,11 +84,6 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
91
84
  recentForm = extractRows(unwrapPayload(fights.data) ?? fights.data)
92
85
  .slice(0, recentLimit)
93
86
  .map((row) => {
94
- // History rows nest the bout and carry the fighter's own side as
95
- // fighterSlug/opponent/outcome. Merge so normalizeMatch sees both,
96
- // and do NOT force 'completed': the fighter's next scheduled bout is
97
- // in this list too, and stamping it completed put an unfought fight
98
- // at the top of the form with no result.
99
87
  const r = asRecord(row) ?? {};
100
88
  const bout = asRecord(r.bout) ?? {};
101
89
  return normalizeMatch('ufc', { ...bout, ...r });
@@ -105,11 +93,10 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
105
93
  calls += 1;
106
94
  rateLimit = { ...rateLimit, ...stats.headers };
107
95
  if (stats.ok && recentForm.length === 0) {
108
- recentForm = []; // keep empty rather than stuffing aggregate as "matches"
96
+ recentForm = [];
109
97
  keyPlayers = [];
110
98
  }
111
99
  if (stats.ok) {
112
- // stash aggregate on entity for talking points
113
100
  entity = { ...entity, stats: unwrapPayload(stats.data) };
114
101
  }
115
102
  return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
@@ -254,9 +241,6 @@ export function composeH2H(game, sideA, sideB, rows, limit) {
254
241
  const bl = sideB.toLowerCase();
255
242
  const meetings = rows
256
243
  .map((row) => normalizeMatch(game, row))
257
- // A meeting is a fight that happened. The upcoming bout between the two
258
- // sides sits in the same history list and must not count as a prior
259
- // meeting, or every preview reports the fight it is previewing as H2H.
260
244
  .filter((m) => m.status === 'completed')
261
245
  .filter((m) => {
262
246
  const s1 = [m.team1?.id, m.team1?.slug, m.team1?.name].filter(Boolean).map((x) => String(x).toLowerCase());
@@ -270,8 +254,6 @@ export function composeH2H(game, sideA, sideB, rows, limit) {
270
254
  .slice(0, limit);
271
255
  return {
272
256
  meetings: meetings.length,
273
- // Tennis has players, not teams: rename team1/team2 -> player1/player2 on
274
- // the way out. Every other game keeps team1/team2 unchanged.
275
257
  lastMeetings: meetings.slice(0, 5).map((m) => presentSides(m, game)),
276
258
  recordNote: meetings.length ? `${meetings.length} past meetings found in window` : 'No past H2H meetings in window',
277
259
  };
@@ -331,10 +313,6 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
331
313
  const includeH2H = args.includeH2H !== false;
332
314
  const includeRosters = args.includeRosters !== false;
333
315
  const recentLimit = clampInt(args.recentLimit, 5, 1, 15);
334
- // Optional tennis H2H surface filter (TENNIS-04 follow-through: the
335
- // /tennis/h2h endpoint accepts surface=Hard|Clay|Grass). Canonicalise
336
- // case so 'clay'/'CLAY' still hit; anything else is a validation error
337
- // rather than a silently ignored filter.
338
316
  const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
339
317
  let surface;
340
318
  if (surfaceRaw) {
@@ -390,7 +368,6 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
390
368
  rateLimit = res.headers;
391
369
  if (res.ok) {
392
370
  const entity = unwrapPayload(res.data);
393
- // Do not force "upcoming" — completed bouts (e.g. UFC 300) should keep completed.
394
371
  const m = normalizeMatch(game, entity);
395
372
  context = {
396
373
  matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
@@ -401,14 +378,10 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
401
378
  status: m.status,
402
379
  label: m.label,
403
380
  };
404
- // Prefer stable slugs for follow-up fighter fetches
405
381
  if (!teamA)
406
382
  teamA = m.team1?.slug || m.team1?.id || m.team1?.name || '';
407
383
  if (!teamB)
408
384
  teamB = m.team2?.slug || m.team2?.id || m.team2?.name || '';
409
- // Tennis archive detail carries winner/loser objects; if normalisation
410
- // did not surface them as sides, read the ids straight off the record
411
- // rather than failing the whole preview.
412
385
  if (game === 'tennis') {
413
386
  const rec = asRecord(entity) ?? {};
414
387
  const w = asRecord(rec.winner) ?? asRecord(rec.player1) ?? {};
@@ -488,9 +461,6 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
488
461
  rows = extractRows(res.data);
489
462
  }
490
463
  else if (game === 'tennis') {
491
- // Tennis has a first-class H2H endpoint with the full rivalry; use it
492
- // rather than composing from a short form window, which reported
493
- // Sinner vs Alcaraz as never having met.
494
464
  const h2hQuery = { player1_id: teamA, player2_id: teamB };
495
465
  if (game === 'tennis' && surface)
496
466
  h2hQuery.surface = surface;
@@ -499,9 +469,6 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
499
469
  rateLimit = { ...rateLimit, ...h2hRes.headers };
500
470
  if (h2hRes.ok) {
501
471
  const rec = asRecord(unwrapPayload(h2hRes.data)) ?? {};
502
- // The payload names both players once at the top; rows name only
503
- // the winner. Resolve the loser's name from the top-level blocks
504
- // so a meeting reads "Sinner vs Alcaraz", not "Sinner vs atp_207989".
505
472
  const p1 = asRecord(rec.player1) ?? {};
506
473
  const p2 = asRecord(rec.player2) ?? {};
507
474
  const nameOf = {
@@ -530,7 +497,6 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
530
497
  }
531
498
  }
532
499
  else if (game === 'ufc') {
533
- // Fighter fight history is the H2H source of truth (not global /bouts page 1).
534
500
  const hist = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(teamA)}/fights`, {
535
501
  query: { limit: 50 },
536
502
  });
@@ -538,8 +504,6 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
538
504
  rateLimit = { ...rateLimit, ...hist.headers };
539
505
  if (hist.ok) {
540
506
  rows = extractRows(unwrapPayload(hist.data) ?? hist.data).map((row) => {
541
- // Keep fighterSlug/opponent/outcome alongside the bout; they are
542
- // the only place the two corners exist on a history row.
543
507
  const r = asRecord(row) ?? {};
544
508
  return { ...(asRecord(r.bout) ?? {}), ...r };
545
509
  });
@@ -561,11 +525,6 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
561
525
  }));
562
526
  }
563
527
  }
564
- // Factual briefing lines only — never structural meta like "roster loaded".
565
- // Called with teamA + teamB and no matchId, context stayed empty
566
- // ({ matchId:null, event:null, startTime:null }) for a headliner whose
567
- // own form list had all three. Side A's history carries the booked bout
568
- // against side B; that IS the context.
569
528
  if (!matchId && context.matchId == null) {
570
529
  const bl = String(teamB ?? '').toLowerCase();
571
530
  const booked = sideARes.recentForm.find((m) => {
@@ -649,9 +608,6 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
649
608
  context,
650
609
  sideA: {
651
610
  entity: sideARes.entity,
652
- // A tennis player has no roster; the key never applied here, not
653
- // merely came back empty. UFC fighters get the same treatment
654
- // elsewhere in this surface — omit rather than ship a foreign key.
655
611
  ...(game === 'tennis' ? {} : { roster: sideARes.roster }),
656
612
  recentForm: sideARes.recentForm.map((m) => presentSides(m, game)),
657
613
  keyPlayers: sideARes.keyPlayers,
@@ -668,9 +624,6 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
668
624
  });
669
625
  },
670
626
  };
671
- /**
672
- * Resolve event id/slug from a free-text query via game event list.
673
- */
674
627
  async function resolveEventKey(ctx, game, q) {
675
628
  let calls = 0;
676
629
  let rateLimit = {};
@@ -678,11 +631,6 @@ async function resolveEventKey(ctx, game, q) {
678
631
  if (!needle)
679
632
  return { key: null, name: null, calls, rateLimit };
680
633
  if (game === 'tennis') {
681
- // No free-text search on tournaments upstream; the season calendar is the
682
- // complete list and is small enough to match locally.
683
- // This year's calendar first, then the two before it: the archive lags
684
- // the live season, so the current edition of an event may not exist yet
685
- // (no 2026 Wimbledon row while 2025 does).
686
634
  const thisYear = new Date().getUTCFullYear();
687
635
  let hit;
688
636
  for (const year of [thisYear, thisYear - 1, thisYear - 2]) {
@@ -690,11 +638,6 @@ async function resolveEventKey(ctx, game, q) {
690
638
  calls += 1;
691
639
  rateLimit = res.headers;
692
640
  const rows = extractRows(unwrapPayload(res.data) ?? res.data).map((r) => asRecord(r) ?? {});
693
- // .find() returned whatever the calendar happened to list first, and the
694
- // calendar lists the WTA row before the ATP one for a shared name, while
695
- // /tennis/competitions (which resolve_entity reads) lists them the other
696
- // way round. Same query, two tools, two ids. One shared rule now settles
697
- // both; see chooseNamedEntity.
698
641
  hit =
699
642
  chooseNamedEntity(rows, needle, (r) => ({
700
643
  id: pickString(r.id, r.tournament_id) ?? '',
@@ -776,7 +719,7 @@ Each bout carries weightClass, titleBout, card placement, and (once fought) resu
776
719
  { method, round, time, referee, winnerSlug }. Each corner carries images
777
720
  { headshotUrl, bodyImageUrl, imageUrl, proxiedImageUrl }, record, nickname, rank,
778
721
  championStatus, country and flag when upstream supplies them. Use proxiedImageUrl in
779
- browsers — ufc.com sends no CORS header. You do not need call_api per fighter for faces.
722
+ browsers — the image host sends no CORS header. You do not need call_api per fighter for faces.
780
723
 
781
724
  Prefer over: agent-side resolve + call_api /ufc/events + bout expansion; N+1 match_summary for the card list only; per-fighter call_api just to fetch headshots.
782
725
 
@@ -794,16 +737,11 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
794
737
  type: 'object',
795
738
  additionalProperties: false,
796
739
  required: ['game'],
797
- // The handler needs an event to look up, so `game` alone always fails.
798
- // Say so in the schema rather than letting a client believe game suffices.
799
740
  anyOf: [{ required: ['eventIdOrSlug'] }, { required: ['q'] }],
800
741
  properties: {
801
742
  game: gameSchema({ allowAll: false, required: true }),
802
743
  eventIdOrSlug: stringSchema('Event or tournament id/slug. Prefer over q when known. Required unless q is given. Example: "ufc-300".', 'ufc-300'),
803
744
  q: stringSchema('Free-text event name when id/slug unknown. Example: "UFC 300". Resolves within this tool — still prefer resolve_entity when disambiguating many hits.', 'UFC 300'),
804
- // Named for the general case (a card, a bracket, a match list), not
805
- // combat-sport vocabulary — "bout" has no meaning for a tennis draw or a
806
- // CS2 event hub, and this same parameter gates both.
807
745
  includeMatches: boolSchema('Include the bout/match list, or the tennis draw bracket (default true).', true),
808
746
  includeStandings: boolSchema('Include standings/rankings snippet when API supports event/tournament/division scope (default false). ' +
809
747
  'For UFC this also joins divisional rank and movement onto each bout corner as team.rank / team.rankMovement — ' +
@@ -882,17 +820,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
882
820
  });
883
821
  }
884
822
  }
885
- /**
886
- * Seeded with the key but explicitly NOT resolved.
887
- *
888
- * `name: q || eventKey` used to put the raw requested id in the name slot,
889
- * and the tennis branch then kept it whenever its detail fetch failed
890
- * (`pickString(d.name) ?? event.name`). event_card { eventIdOrSlug:
891
- * "atp_2026_999999" } therefore answered ok:true with
892
- * event.name = "atp_2026_999999" — an id presented as a tournament name,
893
- * over a 404 that the partial[] block reported correctly. A name is
894
- * something the API supplied or it is null; an identifier is not a name.
895
- */
896
823
  let event = {
897
824
  id: eventKey,
898
825
  slug: eventKey,
@@ -902,20 +829,8 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
902
829
  };
903
830
  let bouts = [];
904
831
  let standingsSnippet = null;
905
- // Tennis only: the draw bracket grouped by round, each round carrying the
906
- // upstream's own round code (Q1..R128, QF, SF, F) so an agent can render
907
- // the tournament tree rather than one flat, order-dependent match list.
908
832
  let drawRounds = null;
909
- /**
910
- * Draw completeness. A bracket is only useful if the caller knows whether it
911
- * is whole: atp_2026_560 returns R32 15/16, R64 31/32 and R128 45/64 (the
912
- * archived matches it actually holds), and the Final round is absent until
913
- * it is played. The old response gave no signal at all, so a partial
914
- * bracket silently looked like a complete one and a missing round looked
915
- * like a missing feature.
916
- */
917
833
  let drawSummary = null;
918
- /** Matches a round of this code must contain, or null when unrecognised. */
919
834
  const expectedMatchesForRound = (code, drawSize) => {
920
835
  const c = (code ?? '').toUpperCase();
921
836
  const rMatch = /^R(\d+)$/.exec(c);
@@ -928,7 +843,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
928
843
  if (c === 'QF')
929
844
  return 4;
930
845
  if (c === 'BR' || c === 'RR')
931
- return null; // round robin has no fixed size
846
+ return null;
932
847
  if (drawSize && drawSize > 0 && c.startsWith('Q'))
933
848
  return null;
934
849
  return null;
@@ -953,8 +868,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
953
868
  if (includeBouts) {
954
869
  const embedded = extractRows(inner.bouts ?? inner.fights ?? data.bouts);
955
870
  if (embedded.length) {
956
- // Order the full card before slicing — truncating upstream order
957
- // first can drop the main event and keep an early prelim.
958
871
  bouts = sortByCardOrder(embedded.map((row) => normalizeMatch('ufc', {
959
872
  ...(asRecord(row) ?? {}),
960
873
  eventName: event.name,
@@ -1014,9 +927,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1014
927
  }));
1015
928
  }
1016
929
  }
1017
- // Bout rows carry no start time of their own; inherit the card's. Without
1018
- // this the same bout said startTime:null here and the event time on
1019
- // upcoming_schedule, and a caller had to fetch both to learn when it is.
1020
930
  const cardStart = typeof event.date === 'string' ? event.date : null;
1021
931
  bouts = bouts.map((b) => ({ ...b, startTime: b.startTime ?? cardStart }));
1022
932
  if (includeStandings) {
@@ -1024,10 +934,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1024
934
  upstreamCalls += 1;
1025
935
  rateLimit = { ...rateLimit, ...ranks.headers };
1026
936
  if (ranks.ok) {
1027
- // Bout rows carry rankText: null, so a card built from them alone is
1028
- // rank-blind. The rankings payload is one row per fighter keyed by
1029
- // slug, so join it onto the corners we already have instead of
1030
- // handing back a raw blob the caller has to re-index itself.
1031
937
  const rankRows = extractRows(ranks.data)
1032
938
  .map((row) => asRecord(row))
1033
939
  .filter((r) => Boolean(r));
@@ -1052,14 +958,9 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1052
958
  };
1053
959
  };
1054
960
  bouts = bouts.map((b) => ({ ...b, team1: withRank(b.team1), team2: withRank(b.team2) }));
1055
- // Scope the table to divisions actually on this card. The full ladder
1056
- // is 176 rows (~42KB) and doubles the response; a card with no
1057
- // bantamweight bout has no use for the bantamweight ladder, and that
1058
- // context is not free for the agent reading it.
1059
961
  const cardDivisions = new Set(bouts
1060
962
  .flatMap((b) => [b.weightClass, b.team1?.division, b.team2?.division])
1061
963
  .filter((d) => Boolean(d))
1062
- // "Welterweight Title" must still match the Welterweight ladder.
1063
964
  .map((d) => d.toLowerCase().replace(/\s+title$/, '').trim()));
1064
965
  const inScope = (r) => {
1065
966
  if (cardDivisions.size === 0)
@@ -1074,8 +975,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1074
975
  scope: 'division',
1075
976
  note: 'UFC global rankings (not an event bracket), joined onto bout corners as team.rank / team.rankMovement. ' +
1076
977
  (scoped.length > 0 && scoped.length < rankRows.length
1077
- ? // Count the ladders actually returned, not the card's weight
1078
- // classes — catchweight bouts have no ranking ladder.
978
+ ?
1079
979
  `Rows filtered to the ${shownDivisions.length} ranked division(s) on this card; ${rankRows.length} rows exist across all divisions.`
1080
980
  : 'All divisions shown.'),
1081
981
  divisions: shownDivisions,
@@ -1308,9 +1208,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1308
1208
  }
1309
1209
  }
1310
1210
  else if (game === 'tennis') {
1311
- // Tournament detail plus the bracket. The draw is only populated once a
1312
- // tournament has results, so an event still in the future comes back
1313
- // with the detail and an empty bouts list, and says so.
1314
1211
  const detail = await fetchJson(ctx, `/tennis/tournaments/${encodeURIComponent(eventKey)}`);
1315
1212
  upstreamCalls += 1;
1316
1213
  rateLimit = { ...rateLimit, ...detail.headers };
@@ -1320,9 +1217,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1320
1217
  ...event,
1321
1218
  id: pickString(d.id) ?? eventKey,
1322
1219
  slug: pickString(d.id) ?? eventKey,
1323
- // Never fall back to event.name here: that is the requested key, and
1324
- // this branch only runs when the detail fetch SUCCEEDED, so the name
1325
- // comes from the payload or it is honestly absent.
1326
1220
  name: pickString(d.name, d.id) ?? null,
1327
1221
  resolved: true,
1328
1222
  tour: pickString(d.tour) ?? null,
@@ -1352,9 +1246,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1352
1246
  const rawRounds = Array.isArray(dd.rounds) ? dd.rounds : [];
1353
1247
  const drawSize = typeof dd.draw_size === 'number' ? dd.draw_size : null;
1354
1248
  const flat = [];
1355
- // Fill the bracket from the front (the upstream orders rounds from the
1356
- // business end backwards: SF, QF, R16, … R128), so a cap keeps the
1357
- // decisive rounds and drops the early ones rather than the reverse.
1358
1249
  let budget = drawMaxMatches ?? Number.POSITIVE_INFINITY;
1359
1250
  let omittedMatches = 0;
1360
1251
  drawRounds = rawRounds.map((rr) => {
@@ -1369,10 +1260,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1369
1260
  const mr = asRecord(mm) ?? {};
1370
1261
  const row = { ...mr, id: mr.match_id, round: roundName, tournament_name: event.name, status: 'completed' };
1371
1262
  flat.push(row);
1372
- // normalizeMatch gives named, id'd sides (player1/player2 once
1373
- // presented); the raw score string and bracket-progression
1374
- // fields are the only place the actual set score and next slot
1375
- // live, so carry them alongside rather than dropping them.
1376
1263
  return presentSides({
1377
1264
  ...normalizeMatch('tennis', row),
1378
1265
  score: pickString(mr.score) ?? null,
@@ -1400,17 +1287,12 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1400
1287
  const shortRounds = drawRounds
1401
1288
  .filter((r) => r.complete === false)
1402
1289
  .map((r) => `${r.roundCode ?? r.roundName ?? '?'} ${r.returned}/${r.expected}`);
1403
- // A round of 128 draw must have a Final. Its absence is not an error
1404
- // (the final may simply not be played yet) but it IS the difference
1405
- // between "in progress" and "the API lost a round".
1406
1290
  const haveCodes = new Set(drawRounds.map((r) => (r.roundCode ?? '').toUpperCase()));
1407
1291
  const missingRounds = ['F', 'SF', 'QF']
1408
1292
  .filter((c) => !haveCodes.has(c))
1409
1293
  .filter((c) => !(c === 'F' && shortRounds.length > 0 && drawSize !== null && returnedTotal < drawSize / 2));
1410
1294
  drawSummary = {
1411
1295
  drawSize,
1412
- // NOT `rounds`: that key holds the round ARRAY on the same object,
1413
- // and spreading a count over it replaced the bracket with a number.
1414
1296
  roundCount: drawRounds.length,
1415
1297
  matchesReturned: returnedTotal,
1416
1298
  matchesExpected: expectedTotal || null,
@@ -1441,7 +1323,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1441
1323
  }
1442
1324
  }
1443
1325
  else {
1444
- // dota2 — thin: tournament list + recent matches only
1445
1326
  warnings.push('Dota2 event_card is thin; expect partial bouts/standings');
1446
1327
  const detail = await fetchJson(ctx, `/dota2/tournaments/${encodeURIComponent(eventKey)}`);
1447
1328
  upstreamCalls += 1;
@@ -1497,15 +1378,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1497
1378
  }));
1498
1379
  }
1499
1380
  }
1500
- /**
1501
- * Last line of defence against an identifier posing as a name.
1502
- *
1503
- * Every game branch seeds `event.name` from `q`/`eventKey` so a partially
1504
- * resolved card still has something in the slot. Any branch that failed and
1505
- * left that seed in place would publish "atp_2026_999999" as a tournament
1506
- * name, and nothing downstream can tell an id from a name. It is nulled
1507
- * here; `resolved` records that the detail fetch never succeeded.
1508
- */
1509
1381
  if (event.resolved !== true && typeof event.name === 'string' && event.name === eventKey) {
1510
1382
  event = { ...event, name: null, nameResolved: false };
1511
1383
  }
@@ -1529,10 +1401,6 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
1529
1401
  event,
1530
1402
  bouts: includeBouts ? bouts.map((b) => presentSides(b, game)) : [],
1531
1403
  boutCount: includeBouts ? bouts.length : 0,
1532
- // Tennis only: the same matches grouped by round, with round codes
1533
- // (Q1..R128, QF, SF, F) and each match's score/bracket progression —
1534
- // this is what makes a bracket renderable; `bouts` above is the flat
1535
- // list every other game already gets.
1536
1404
  ...(game === 'tennis' ? { draw: drawRounds ? { rounds: drawRounds, ...(drawSummary ?? {}) } : null } : {}),
1537
1405
  standings: includeStandings ? standingsSnippet : null,
1538
1406
  nextSteps: [
@@ -1,12 +1,3 @@
1
- /**
2
- * leaderboard_aces (TENNIS-18 follow-up).
3
- *
4
- * Tennis-only: season aces leaderboard from real match_stats aggregates
5
- * (GET /tennis/leaderboards/aces). Upstream shape (verified 2026-09-09):
6
- * { season, tour, stat: 'aces', items: [{ player_id, full_name, aces, matches }], total }.
7
- * Upstream defaults: season=2026, tour=null (both tours combined), and it
8
- * 422s on a bad tour or a non-year season — both surface as VALIDATION here.
9
- */
10
1
  import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
11
2
  import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
12
3
  import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
@@ -156,16 +147,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
156
147
  const root = asRecord(res.data);
157
148
  const body = asRecord(root?.data) ?? root ?? {};
158
149
  const items = (Array.isArray(body.items) ? body.items : []).map(normalizeAceRow);
159
- /**
160
- * `total` is the QUALIFYING POPULATION, not the page.
161
- *
162
- * The API used to return len(items) AFTER applying .limit(limit), so limit=2
163
- * answered total:2 for a board with thousands of eligible players and the
164
- * caller had no way to tell a page from the whole board. It now carries
165
- * count(*) OVER (), the count of players passing the board's own threshold.
166
- * `returned` and `hasMore` are stated separately so the two can never be
167
- * confused again, and the threshold is surfaced so the number is explicable.
168
- */
169
150
  const total = typeof body.total === 'number' ? body.total : items.length;
170
151
  return successEnvelope({
171
152
  pagination: {
@@ -318,8 +299,6 @@ Parallel-safe: yes. Upstream cost: 1.`,
318
299
  const root = asRecord(res.data);
319
300
  const body = asRecord(root?.data) ?? root ?? {};
320
301
  const items = (Array.isArray(body.items) ? body.items : []).map(normalizeRow);
321
- // Same contract as leaderboard_aces: total is the qualifying population,
322
- // not the page. See the note there.
323
302
  const total = typeof body.total === 'number' ? body.total : items.length;
324
303
  return successEnvelope({
325
304
  pagination: {