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,41 +1,14 @@
1
- /**
2
- * resolve_entity + search_entities
3
- */
4
1
  import { clampInt, decodeCursor, encodeCursor, extractRows, fetchJson, gameNotIncludedHint, pickString, asRecord, } from '../client.js';
5
2
  import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
3
  import { editionYear, entityRef, rankScore } from './normalize.js';
7
4
  import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
8
- /**
9
- * Minimum relevance a search result must reach to be returned when a query was
10
- * given. rankScore returns 0 for "no part of the query appears anywhere in this
11
- * row", so 1 means "must have matched something". See the floor note in
12
- * searchEntities for the live evidence.
13
- */
14
5
  export const RELEVANCE_FLOOR = 1;
15
- /**
16
- * Ranking order whose tiebreak never depends on the order upstream happened to
17
- * return rows in.
18
- *
19
- * "Australian Open" is two rows with byte-identical names and identical fuzzy
20
- * scores, one ATP and one WTA. The old comparator ran out of criteria there and
21
- * left the winner to the source ordering, and event_card reads a different
22
- * source that orders them the other way round, so the two tools answered the
23
- * same question with different ids. Newest edition, then id, matching
24
- * chooseNamedEntity so both paths land on the same row.
25
- */
26
6
  function byRank(a, b) {
27
7
  return (b.score - a.score
28
8
  || a.name.localeCompare(b.name)
29
9
  || ((editionYear(b.id) ?? -1) - (editionYear(a.id) ?? -1))
30
10
  || a.id.localeCompare(b.id));
31
11
  }
32
- /**
33
- * Query aliases for orgs whose common name is not their slug. Kept deliberately
34
- * small and one-directional: these map what a person types onto the canonical
35
- * slug, and are only used to grant an exact-match boost, never to rewrite a
36
- * result. SK Telecom T1 became T1 in 2019; sk-telecom-t1-k and -s remain
37
- * separate historical rosters and are NOT folded in.
38
- */
39
12
  const normalizeQueryKey = (v) => String(v || '').toLowerCase().replace(/[^a-z0-9]/g, '');
40
13
  const ORG_ALIASES = {
41
14
  skt: 't1',
@@ -44,8 +17,6 @@ const ORG_ALIASES = {
44
17
  skt1: 't1',
45
18
  };
46
19
  function pushCandidates(out, game, type, rows, q, limit) {
47
- // Position within the upstream's own result list. That ordering is a real
48
- // signal we otherwise throw away.
49
20
  let index = -1;
50
21
  for (const row of rows) {
51
22
  index += 1;
@@ -53,29 +24,10 @@ function pushCandidates(out, game, type, rows, q, limit) {
53
24
  const r = asRecord(row) ?? {};
54
25
  const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname, ref.meta?.nickname);
55
26
  let score = rankScore(q, ref.name, ref.id, ref.slug, nickname);
56
- // Ranked-player tiebreaker: a surname query like "Alcaraz" fuzzy-ties the
57
- // world #2 with a 1991 journeyman; the row's current_rank breaks the tie
58
- // toward whoever is actually active/ranked (tennis search supplies it).
59
27
  const currentRank = Number(r.current_rank);
60
28
  if (score > 0 && Number.isFinite(currentRank) && currentRank > 0) {
61
29
  score += currentRank <= 100 ? 8 : currentRank <= 1000 ? 4 : 2;
62
30
  }
63
- /**
64
- * The upstream's FIRST row is allowed to match on something we cannot see.
65
- *
66
- * /tennis/players/search is a dedicated name search and it knows nicknames
67
- * and aliases that the payload does not carry: `q=Nole` returns Novak
68
- * Djokovic first, and nothing in the string "Novak Djokovic" resembles
69
- * "nole", so rankScore scores it 0. The old code floored every unmatched row
70
- * to 1 and then sorted, which let a mere substring collision win —
71
- * `resolve_entity { q: "Nole" }` answered **M Canoles** (a WTA player whose
72
- * name contains "noles") and search_entities dropped Djokovic entirely.
73
- *
74
- * So when our own scorer finds no name match at all but the dedicated search
75
- * put the row first, the row is kept as the alias answer and labelled. Only
76
- * the first row, and only when our scorer is silent, so this can never
77
- * outrank a query that genuinely matches a name.
78
- */
79
31
  let matchedBy;
80
32
  if (q && score <= 0 && index === 0) {
81
33
  score = 95;
@@ -105,7 +57,6 @@ function pushCandidates(out, game, type, rows, q, limit) {
105
57
  if (out.length > limit * 3)
106
58
  out.length = limit * 3;
107
59
  }
108
- /** Drop noise tokens so "UFC Fight Night Medic" can match "UFC Fight Night: Medic vs Rodriguez". */
109
60
  function ufcSearchQueries(q) {
110
61
  const raw = q.trim();
111
62
  if (!raw)
@@ -118,20 +69,16 @@ function ufcSearchQueries(q) {
118
69
  .split(/\s+/)
119
70
  .filter((t) => t.length >= 3 && !stop.has(t));
120
71
  if (tokens.length) {
121
- // distinctive tokens alone (e.g. "medic", "rodriguez")
122
72
  for (const t of tokens.slice(0, 4))
123
73
  queries.push(t);
124
- // pair of last two distinctive tokens
125
74
  if (tokens.length >= 2)
126
75
  queries.push(tokens.slice(-2).join(' '));
127
76
  }
128
- // slug-ish form
129
77
  const slugish = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
130
78
  if (slugish && slugish !== raw.toLowerCase())
131
79
  queries.push(slugish);
132
80
  return [...new Set(queries.map((s) => s.trim()).filter(Boolean))];
133
81
  }
134
- /** UFC dedicated search (list /fighters ignores `q`). Multi-query for event fuzzy match. */
135
82
  async function ufcSearch(ctx, q, type, limit) {
136
83
  const candidates = [];
137
84
  let calls = 0;
@@ -158,7 +105,6 @@ async function ufcSearch(ctx, q, type, limit) {
158
105
  const bouts = Array.isArray(payload.bouts)
159
106
  ? payload.bouts
160
107
  : extractRows(payload.bouts);
161
- // Always score against the *original* user query for ranking quality
162
108
  if (wantFighters)
163
109
  pushCandidates(candidates, 'ufc', 'fighter', fighters, q, limit);
164
110
  if (wantEvents)
@@ -166,7 +112,6 @@ async function ufcSearch(ctx, q, type, limit) {
166
112
  if (wantMatches)
167
113
  pushCandidates(candidates, 'ufc', 'match', bouts, q, limit);
168
114
  }
169
- // Event soft-miss fallback: scan upcoming + recent event lists client-side
170
115
  if (wantEvents && !candidates.some((c) => c.type === 'event' && c.score >= 40)) {
171
116
  for (const path of ['/ufc/events/upcoming', '/ufc/events/recent', '/ufc/events']) {
172
117
  const res = await fetchJson(ctx, path, { query: { limit: 40, page: 1 } });
@@ -177,7 +122,6 @@ async function ufcSearch(ctx, q, type, limit) {
177
122
  pushCandidates(candidates, 'ufc', 'event', extractRows(res.data), q, limit);
178
123
  }
179
124
  }
180
- // Dedupe by type+id
181
125
  const seen = new Set();
182
126
  const deduped = [];
183
127
  for (const c of candidates.sort(byRank)) {
@@ -212,7 +156,6 @@ async function searchGame(ctx, game, q, type, limit) {
212
156
  }),
213
157
  };
214
158
  }
215
- // Unwrap the { success, data, meta } envelope before reading buckets.
216
159
  const envelope = asRecord(res.data) ?? {};
217
160
  const data = asRecord(envelope.data) ?? envelope;
218
161
  const buckets = [
@@ -231,7 +174,6 @@ async function searchGame(ctx, game, q, type, limit) {
231
174
  if (candidates.length === 0) {
232
175
  const fallbackType = type === 'any' ? 'unknown' : type;
233
176
  let rows = extractRows(data);
234
- // extractRows prefers the `matches` bucket; never mislabel those rows as a non-match type.
235
177
  if (fallbackType !== 'match' && fallbackType !== 'unknown') {
236
178
  const matchRows = extractRows(data.matches);
237
179
  if (matchRows.length && rows[0] === matchRows[0])
@@ -254,7 +196,6 @@ async function searchGame(ctx, game, q, type, limit) {
254
196
  }),
255
197
  };
256
198
  }
257
- // Unwrap the { success, data, meta } envelope before reading buckets.
258
199
  const envelope = asRecord(res.data) ?? {};
259
200
  const data = asRecord(envelope.data) ?? envelope;
260
201
  for (const [t, key] of [
@@ -270,7 +211,6 @@ async function searchGame(ctx, game, q, type, limit) {
270
211
  if (candidates.length === 0) {
271
212
  const fallbackType = type === 'any' ? 'unknown' : type;
272
213
  let rows = extractRows(data);
273
- // extractRows prefers the `matches` bucket; never mislabel those rows as a non-match type.
274
214
  if (fallbackType !== 'match' && fallbackType !== 'unknown') {
275
215
  const matchRows = extractRows(data.matches);
276
216
  if (matchRows.length && rows[0] === matchRows[0])
@@ -299,7 +239,6 @@ async function searchGame(ctx, game, q, type, limit) {
299
239
  pushCandidates(candidates, game, type === 'any' ? 'unknown' : type, extractRows(res.data), q, limit);
300
240
  }
301
241
  else if (game === 'tennis') {
302
- // Tennis has no combined /search; players and competitions are separate lookups.
303
242
  const tasks = [];
304
243
  if (type === 'any' || type === 'player') {
305
244
  tasks.push((async () => {
@@ -329,19 +268,11 @@ async function searchGame(ctx, game, q, type, limit) {
329
268
  const tasks = [];
330
269
  if (type === 'any' || type === 'team') {
331
270
  tasks.push((async () => {
332
- // Over-fetch before ranking. The upstream returns search hits in
333
- // alphabetical order, so asking for `limit` rows put the exact
334
- // match outside the window: "T1" returned mt1-esports, mt1-vision
335
- // and two SK Telecom rosters while the real t1 sat sixth and was
336
- // never seen. Rank a wide window, then cut.
337
271
  const wide = String(Math.max(50, limit * 5));
338
272
  const res = await fetchJson(ctx, '/lol/teams', { query: { search: q, limit: wide } });
339
273
  calls += 1;
340
274
  if (res.ok)
341
275
  pushCandidates(candidates, game, 'team', extractRows(res.data), q, limit);
342
- // An alias is a name the API does not know. "SKT" returns no t1 row
343
- // at all, so ranking cannot help; search the canonical slug too and
344
- // score those rows against the canonical name.
345
276
  const aliasTarget = ORG_ALIASES[normalizeQueryKey(q)];
346
277
  if (aliasTarget) {
347
278
  const alt = await fetchJson(ctx, '/lol/teams', { query: { search: aliasTarget, limit: wide } });
@@ -390,7 +321,6 @@ async function searchGame(ctx, game, q, type, limit) {
390
321
  await Promise.all(tasks);
391
322
  }
392
323
  else if (game === 'ufc') {
393
- // /ufc/fighters ignores q (returns p4p/champ list). Always use /ufc/search when querying.
394
324
  if (q) {
395
325
  const ufc = await ufcSearch(ctx, q, type, limit);
396
326
  calls += ufc.calls;
@@ -523,11 +453,6 @@ Example: { "q": "T1", "game": "lol", "type": "team", "limit": 5 }`,
523
453
  all.sort(byRank);
524
454
  const candidates = all.slice(0, limit);
525
455
  const best = candidates[0] ?? null;
526
- // An exact identity hit is not ambiguous, whatever else scored near it.
527
- // "T1" ties with t1-rookies and t1-challengers on fuzzy score, which left
528
- // the caller with needsDisambiguation and no decision to act on even though
529
- // one candidate matched the query exactly. Aliases count: "SKT" resolves
530
- // through ORG_ALIASES to the canonical org.
531
456
  const qKey = normalizeQueryKey(q);
532
457
  const aliasKey = ORG_ALIASES[qKey] ? normalizeQueryKey(ORG_ALIASES[qKey]) : null;
533
458
  const bestIsExact = !!best &&
@@ -626,19 +551,7 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
626
551
  let items = [];
627
552
  let upstreamCalls = 0;
628
553
  let total = null;
629
- // pageQuery sends offset/page upstream, so what comes back IS the page and
630
- // must not be sliced by offset again. Doing both returned an EMPTY page two
631
- // while reporting total=1429 and hasMore=true. The UFC paths below fetch
632
- // page 1 deliberately and re-rank in memory, so they page here instead.
633
554
  let upstreamPaged = true;
634
- // Set for a game whose dedicated search endpoint already returns results
635
- // in the right order (e.g. tennis: /tennis/players/search ranks Carlos
636
- // Alcaraz first). The generic re-rank below fuzzy-scores every candidate
637
- // against the query string with no notion of who is actually ranked, so
638
- // "alcaraz" tied four players at nearly the same score and resorted the
639
- // world #3 to fourth — behind three players the upstream had already
640
- // correctly placed below him. Preserving push order keeps the upstream's
641
- // answer instead of a worse one computed from less information.
642
555
  let preserveUpstreamOrder = false;
643
556
  const pageQuery = (extra = {}) => ({
644
557
  limit,
@@ -681,7 +594,6 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
681
594
  const res = await fetchJson(ctx, '/cs2/search', { query: { q, limit } });
682
595
  upstreamCalls += 1;
683
596
  if (res.ok) {
684
- // Unwrap the { success, data, meta } envelope before reading buckets.
685
597
  const envelope = asRecord(res.data) ?? {};
686
598
  const data = asRecord(envelope.data) ?? envelope;
687
599
  if (type === 'any' || type === 'team') {
@@ -700,7 +612,6 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
700
612
  await listPath('/cs2/teams', 'team');
701
613
  if (type === 'player' || type === 'any') {
702
614
  if (q) {
703
- // /cs2/players list filters ignore q/search — use the dedicated search endpoint.
704
615
  await listPath('/cs2/players/search', 'player');
705
616
  }
706
617
  else {
@@ -742,9 +653,6 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
742
653
  const envelope = asRecord(res.data) ?? {};
743
654
  const data = asRecord(envelope.data) ?? envelope;
744
655
  items.push(...extractRows(data.items ?? data).map((r) => entityRef(r, 'player', game)));
745
- // /tennis/players/search already ranks the best-known match first
746
- // (it has current_rank to break ties this tool's fuzzy scorer
747
- // cannot see); do not let the generic re-sort below undo that.
748
656
  if (q)
749
657
  preserveUpstreamOrder = true;
750
658
  }
@@ -761,7 +669,6 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
761
669
  }
762
670
  else if (game === 'cod') {
763
671
  if (q) {
764
- // No offset goes upstream on this path, so page it here.
765
672
  upstreamPaged = false;
766
673
  const res = await fetchJson(ctx, '/cod/search', {
767
674
  query: { q, limit, ...(type !== 'any' ? { type: type === 'team' ? 'org' : type } : {}) },
@@ -781,8 +688,6 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
781
688
  }
782
689
  else if (game === 'ufc') {
783
690
  if (q) {
784
- // Dedicated search ranks name/slug/nickname; /fighters ignores q.
785
- // Returns the whole match set for client re-ranking; page it here.
786
691
  upstreamPaged = false;
787
692
  const res = await fetchJson(ctx, '/ufc/search', { query: { q } });
788
693
  upstreamCalls += 1;
@@ -861,7 +766,6 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
861
766
  rateLimit: e.headers,
862
767
  });
863
768
  }
864
- // de-dupe by game+type+id; when q present re-sort by score if available
865
769
  const seen = new Set();
866
770
  items = items.filter((it) => {
867
771
  const key = `${it.game}:${it.type}:${it.id}`;
@@ -870,51 +774,15 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
870
774
  seen.add(key);
871
775
  return true;
872
776
  });
873
- /**
874
- * Relevance floor.
875
- *
876
- * The upstream player/tournament search is a substring match with no floor
877
- * of its own: `q=Sinner` returns ten rows, of which only "Jannik Sinner" and
878
- * "Martin Sinner" have anything to do with the query. The tool scored them
879
- * all and then SORTED rather than filtered, so "A Winner", "J Skinner",
880
- * "E Skinner", "C Sinkler", "Dr Sinnett", "H C Skinner", "Mrs Skinner" and
881
- * "J J Sinnott" came back as equals — an agent asking for one player got
882
- * eight strangers to ignore, and a caller paging the results paid for them.
883
- *
884
- * rankScore already separates them cleanly (Jannik 100, Martin 48, every
885
- * other row 0), so the floor is simply "must have matched something at all".
886
- * It is applied only when a query was given: with no q this tool is a browse
887
- * list, and a floor there would delete the catalogue.
888
- */
889
777
  const scoreOf = (it) => typeof it.meta?.score === 'number'
890
778
  ? it.meta.score
891
779
  : rankScore(q, it.name, it.id, it.slug, it.meta?.nickname);
892
780
  const upstreamTotal = total;
893
781
  let droppedBelowFloor = [];
894
- /**
895
- * The upstream's own top ENTITY row is exempt from the floor.
896
- *
897
- * The upstream's dedicated search knows aliases the payload does not carry:
898
- * `q=Nole` returns Novak Djokovic first, and rankScore cannot see why, so a
899
- * pure floor would delete the correct answer and keep substring collisions
900
- * instead.
901
- *
902
- * This used to be the global items[0], which was wrong: search_entities
903
- * merges several sources CONCURRENTLY (for tennis, /players/search and
904
- * /competitions), so whichever promise settled first decided which row got
905
- * protected. The live gate caught it as a flaky "first result is Jannik
906
- * Sinner" — a competition could land at index 0 and the alias protection
907
- * would guard the wrong thing. Only entity rows (player/team/fighter) are
908
- * alias-matched at all; tournaments are matched by name and have no
909
- * nicknames, so taking the first entity row is both deterministic and the
910
- * row the rule was written for.
911
- */
912
782
  const ENTITY_TYPES = new Set(['player', 'team', 'fighter']);
913
783
  const protectedRow = q ? items.find((it) => ENTITY_TYPES.has(it.type)) ?? null : null;
914
784
  const protectedKey = protectedRow ? `${protectedRow.game}:${protectedRow.type}:${protectedRow.id}` : null;
915
785
  if (q) {
916
- // Publish the score on every row, not just the ones the scored branch
917
- // produced, so the floor is visible rather than mysterious.
918
786
  items = items.map((it) => {
919
787
  const key = `${it.game}:${it.type}:${it.id}`;
920
788
  const isProtected = protectedKey !== null && key === protectedKey;
@@ -935,9 +803,6 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
935
803
  if (droppedBelowFloor.length)
936
804
  items = items.filter(keep);
937
805
  }
938
- // An explicit (possibly rank-0) alias match beats a higher-scoring substring
939
- // collision: the upstream's own top hit is the answer to a query we cannot
940
- // score. This is what makes "Nole" resolve to Djokovic instead of M Canoles.
941
806
  if (q && !preserveUpstreamOrder) {
942
807
  items.sort((a, b) => {
943
808
  const am = a.meta?.matchedBy === 'upstream-first' ? 1 : 0;
@@ -945,7 +810,6 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
945
810
  return bm - am || scoreOf(b) - scoreOf(a) || a.name.localeCompare(b.name);
946
811
  });
947
812
  }
948
- // pagination over ranked result set
949
813
  const pageItems = upstreamPaged ? items.slice(0, limit) : items.slice(offset, offset + limit);
950
814
  const hasMore = upstreamPaged
951
815
  ? (total != null ? offset + pageItems.length < total : pageItems.length >= limit)
@@ -1,21 +1,6 @@
1
- /**
2
- * tennis_schedule — one day of tennis, scheduled and completed (TENNIS-26).
3
- *
4
- * WHY THIS EXISTS
5
- * /tennis/schedule and /tennis/matches/completed are the two routes that require
6
- * ?date=. They were the exact pair producing "route is broken" support tickets
7
- * from callers who omitted it, which is why the REST layer now returns a hint
8
- * with a working example URL. But neither route had a tool either — so an agent
9
- * driving the MCP had no way to ask "what tennis is on today?" at all.
10
- *
11
- * This tool owns the required date, defaults it to today, validates the format
12
- * before spending an upstream call, and on a bad date returns the same
13
- * paste-able example the REST hint does.
14
- */
15
1
  import { asRecord, clampInt, fetchJson, pickString, } from '../client.js';
16
2
  import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
17
3
  import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
18
- /** ISO date, no time component, already validated by the caller. */
19
4
  function todayIso() {
20
5
  return new Date().toISOString().slice(0, 10);
21
6
  }
@@ -33,13 +18,6 @@ function normalizeScheduled(row) {
33
18
  round: pickString(r.round) ?? null,
34
19
  roundName: pickString(r.round_name) ?? null,
35
20
  startsAt: pickString(r.starts_at, r.commence_time, r.match_date) ?? null,
36
- /**
37
- * Historical days report every match at T00:00:00 with
38
- * `start_time_known: false` — the date is real, the clock is a placeholder.
39
- * Emitting the midnight without this flag invites a caller to render "12:00
40
- * AM" for a semi-final that finished at 3pm. Null when the route does not
41
- * state it (the completed half does not).
42
- */
43
21
  startTimeKnown: typeof r.start_time_known === 'boolean' ? r.start_time_known : null,
44
22
  status: pickString(r.status, r.outcome) ?? null,
45
23
  player1: { id: pickString(p1.id, p1.player_id) ?? null, name: pickString(p1.name) ?? null },
@@ -47,7 +25,6 @@ function normalizeScheduled(row) {
47
25
  score: pickString(r.score, r.score_raw) ?? null,
48
26
  };
49
27
  }
50
- /** A row is "played" when the feed says so, in either spelling it uses. */
51
28
  function isPlayed(row) {
52
29
  const r = asRecord(row) ?? {};
53
30
  const s = (pickString(r.status, r.outcome) ?? '').toLowerCase();
@@ -114,8 +91,6 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
114
91
  }
115
92
  const rawDate = typeof args.date === 'string' ? args.date.trim() : '';
116
93
  const date = rawDate || todayIso();
117
- // Validate before spending an upstream call, and echo the same paste-able
118
- // example the REST layer returns so the two surfaces teach identically.
119
94
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
120
95
  return errorEnvelope({
121
96
  code: 'VALIDATION',
@@ -141,16 +116,6 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
141
116
  const limit = clampInt(args.limit, 20, 1, 50);
142
117
  const wantScheduled = includeRaw === 'both' || includeRaw === 'scheduled';
143
118
  const wantCompleted = includeRaw === 'both' || includeRaw === 'completed';
144
- /**
145
- * Ask for the whole day, then bound locally.
146
- *
147
- * /tennis/schedule honours ?limit= but /tennis/matches/completed ignores it,
148
- * so passing `limit` upstream made `scheduledCount` mean "rows this call
149
- * asked for" on one half and "rows the day held" on the other. Both halves
150
- * are now fetched at the API's own page ceiling and sliced here, which makes
151
- * scheduledCount/completedCount a fact about the day and *Returned a fact
152
- * about the response.
153
- */
154
119
  const upstreamPage = 200;
155
120
  const settled = await Promise.allSettled([
156
121
  wantScheduled
@@ -216,7 +181,6 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
216
181
  message: String(compRes.reason).slice(0, 200),
217
182
  });
218
183
  }
219
- // Both halves failed -> a real error, not a partial.
220
184
  if (rejected.length > 0 && scheduled.length === 0 && completed.length === 0) {
221
185
  const first = rejected[0];
222
186
  return errorEnvelope({
@@ -230,23 +194,8 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
230
194
  recover: ['Check the date is a real calendar day', 'Retry, or widen include to both'],
231
195
  });
232
196
  }
233
- /**
234
- * /tennis/matches/completed IGNORES ?limit= — verified: `limit=3` returned
235
- * all 13 rows of the day with page_size still 50. /tennis/schedule does
236
- * honour it, but both halves are bounded here so one call site cannot drift
237
- * from the other and `completedCount` stops being a number the caller asked
238
- * to be smaller.
239
- */
240
197
  const scheduledWindow = scheduled.slice(0, limit);
241
198
  const completedWindow = completed.slice(0, limit);
242
- /**
243
- * /tennis/schedule is the DAY's list, not an "upcoming" list: it carries
244
- * matches that have already finished, with status COMPLETED. So the two
245
- * halves of this response legitimately overlap, and the old key name
246
- * ("scheduled") plus a status of COMPLETED inside it read as a
247
- * contradiction. The overlap is now counted and the statuses summarised, so
248
- * a caller can join or de-duplicate deliberately instead of discovering it.
249
- */
250
199
  const completedBySchedule = scheduled.filter(isPlayed);
251
200
  const completedIds = new Set(completed
252
201
  .map((row) => pickString(asRecord(row)?.id, asRecord(row)?.match_id))
@@ -266,8 +215,6 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
266
215
  title: `Tennis — ${date}`,
267
216
  date,
268
217
  include: includeRaw,
269
- // Everything the day's list returned, then the bounded window. Both are
270
- // present so `scheduledCount` never silently means two different things.
271
218
  scheduled: scheduledWindow.map(normalizeScheduled),
272
219
  scheduledCount: scheduledTotal ?? scheduled.length,
273
220
  scheduledReturned: scheduledWindow.length,
@@ -290,8 +237,6 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
290
237
  : {}),
291
238
  };
292
239
  const upstreamCalls = (wantScheduled ? 1 : 0) + (wantCompleted ? 1 : 0);
293
- // A half that failed is reported alongside the half that worked rather than
294
- // being silently dropped: an empty scheduled[] means two different things.
295
240
  if (rejected.length > 0) {
296
241
  return successEnvelope({
297
242
  source: 'tennis_schedule',
@@ -1,16 +1,10 @@
1
- /**
2
- * standings
3
- */
4
1
  import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
5
2
  import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
6
3
  import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
7
- /** Exported for offline UFC rank mapping tests. */
8
4
  export function normalizeStandingRow(row, index) {
9
5
  const r = asRecord(row) ?? {};
10
6
  const entity = asRecord(r.team) ?? asRecord(r.fighter) ?? asRecord(r.org) ?? r;
11
7
  const name = pickString(asRecord(entity)?.name, r.teamName, r.name, r.orgName, r.fighterName, r.player_name, asRecord(entity)?.slug) ?? `row-${index + 1}`;
12
- // UFC official lists: champion has rank=null + rankText="C" (interim "IC"); contenders 1..15.
13
- // Never fall back to index+1 for explicit null ranks — that produced two "#1" rows (champ + #1).
14
8
  const championStatus = pickString(r.championStatus, asRecord(entity)?.championStatus);
15
9
  const rankText = pickString(r.rankText);
16
10
  const isInterim = championStatus === 'interim' || rankText === 'IC' || r.isInterimChampion === true;
@@ -21,9 +15,6 @@ export function normalizeStandingRow(row, index) {
21
15
  isInterim;
22
16
  let rank;
23
17
  if (isChampion) {
24
- // rank is numeric or null; the belt lives in rankText ("C"/"IC") and
25
- // championStatus. Mixing a string into a numeric column forced every
26
- // consumer to special-case it.
27
18
  rank = null;
28
19
  }
29
20
  else if (typeof r.rank === 'number') {
@@ -36,14 +27,13 @@ export function normalizeStandingRow(row, index) {
36
27
  rank = Number(rankText);
37
28
  }
38
29
  else if (r.rank === null) {
39
- // Explicit null (UFC champ without flags, or unranked) — never invent index+1
40
30
  rank = rankText ?? null;
41
31
  }
42
32
  else if (rankText) {
43
33
  rank = rankText;
44
34
  }
45
35
  else if (r.rank === undefined && r.position === undefined) {
46
- rank = index + 1; // last resort only when rank fields are absent entirely
36
+ rank = index + 1;
47
37
  }
48
38
  else {
49
39
  rank = null;
@@ -150,8 +140,6 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
150
140
  }
151
141
  const game = gameParse.game;
152
142
  const limit = clampInt(args.limit, 50, 1, 100);
153
- // Cursor is a plain offset. The whole table is in memory (UFC world scope
154
- // is 176 rows), so nothing fancier is needed to walk every division.
155
143
  const offset = Math.max(0, Number.parseInt(String(args.cursor ?? '0'), 10) || 0);
156
144
  const scope = typeof args.scope === 'string' ? args.scope : undefined;
157
145
  const leagueId = typeof args.leagueId === 'string' ? args.leagueId : undefined;
@@ -160,7 +148,6 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
160
148
  const season = typeof args.season === 'string' ? args.season : undefined;
161
149
  const stage = typeof args.stage === 'string' ? args.stage : undefined;
162
150
  const division = typeof args.division === 'string' ? args.division : undefined;
163
- // Tennis rankings are pageable now that the API reports the real list size.
164
151
  const tennisPage = typeof args.page === 'number' && Number.isFinite(args.page) && args.page >= 1
165
152
  ? Math.trunc(args.page)
166
153
  : 1;
@@ -252,8 +239,6 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
252
239
  }
253
240
  }
254
241
  else if (game === 'tennis') {
255
- // Tennis "standings" = the latest ATP/WTA singles rankings, with
256
- // rank_movement/previous_rank on every row. Pass tour via division.
257
242
  const tour = String(division ?? 'ATP').toUpperCase() === 'WTA' ? 'WTA' : 'ATP';
258
243
  path = '/tennis/standings';
259
244
  query = { tour, top_n: Math.min(limit, 100), page: tennisPage };
@@ -261,7 +246,6 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
261
246
  title = `${tour} singles rankings`;
262
247
  }
263
248
  else if (game === 'dota2') {
264
- // No first-class standings — try team list worldRanking
265
249
  const res = await fetchJson(ctx, '/dota2/teams', { query: { limit } });
266
250
  if (!res.ok) {
267
251
  return errorEnvelope({
@@ -332,12 +316,8 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
332
316
  rateLimit: res.headers,
333
317
  });
334
318
  }
335
- // Keep the unsliced count. UFC world scope is every division back to back,
336
- // so limit=50 stops a third of the way through flyweight; a consumer needs
337
- // to know that happened rather than receive total:null and guess.
338
319
  let allRows = extractRows(res.data).map((row, i) => normalizeStandingRow(row, i));
339
320
  let rows = allRows.slice(offset, offset + limit);
340
- // UFC rankings may be nested by division
341
321
  if (game === 'ufc' && rows.length === 0) {
342
322
  const obj = asRecord(res.data) ?? {};
343
323
  const nested = [];
@@ -366,20 +346,6 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
366
346
  }
367
347
  const obj = asRecord(res.data);
368
348
  const upstreamMeta = asRecord(obj?.meta) ?? {};
369
- /**
370
- * `total` is the size of the published table, NOT the size of this page.
371
- *
372
- * This read `total: allRows.length` — the rows that happened to arrive — so
373
- * a tennis table of 150 ranked players reported total:5, hasMore:false and
374
- * looked complete and unpaginated. The API now returns the real figure
375
- * (total/ranked_players/total_pages/has_next); this prefers it and only
376
- * falls back to the received count when the route does not state one.
377
- *
378
- * These are read BEFORE the pagination block, because that block had the
379
- * identical bug: it published total: allRows.length too, so fixing only the
380
- * data block left pagination.total disagreeing with data.total on the same
381
- * response. Two counts, one response, only one of them true.
382
- */
383
349
  const upstreamTotal = typeof obj?.total === 'number' ? obj.total : null;
384
350
  const rankedPlayers = typeof obj?.ranked_players === 'number' ? obj.ranked_players : null;
385
351
  const totalPages = typeof obj?.total_pages === 'number' ? obj.total_pages : null;
@@ -438,7 +404,6 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
438
404
  hasMore: effectiveHasMore,
439
405
  ...(game === 'tennis'
440
406
  ? {
441
- // How deep the published list is, independent of this page.
442
407
  rankedPlayers: rankedPlayers ?? effectiveTotal,
443
408
  totalPages,
444
409
  page: tennisPage,
@@ -446,15 +411,6 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
446
411
  : {}),
447
412
  ...(game === 'tennis'
448
413
  ? {
449
- /**
450
- * Tennis rankings are a dated weekly SNAPSHOT, and the route names
451
- * the date in `ranking_date`. updatedAt was read only from
452
- * updatedAt/lastUpdated/meta.syncedAt/meta.fetchedAt, none of which
453
- * the tennis route emits, so every tennis table claimed
454
- * updatedAt:null while the very date a caller needs sat in the
455
- * payload — and a consumer showing a stale-looking table had no
456
- * way to find out it was a week old.
457
- */
458
414
  rankingDate: pickString(obj?.ranking_date) ?? null,
459
415
  updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt, obj?.ranking_date) ?? null,
460
416
  }