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.
- package/README.md +6 -7
- package/dist/client.js +0 -35
- package/dist/envelope.js +3 -13
- package/dist/http.js +0 -16
- package/dist/index.js +0 -39
- package/dist/install.js +0 -44
- package/dist/instructions.js +0 -4
- package/dist/scrub.js +67 -0
- package/dist/tools/cs2.js +1 -48
- package/dist/tools/index.js +0 -6
- package/dist/tools/insight.js +4 -136
- package/dist/tools/leaderboard.js +0 -21
- package/dist/tools/live.js +4 -95
- package/dist/tools/match.js +94 -123
- package/dist/tools/meta.js +7 -62
- package/dist/tools/normalize.js +6 -229
- package/dist/tools/odds.js +0 -66
- package/dist/tools/player.js +1 -135
- package/dist/tools/rankings.js +0 -38
- package/dist/tools/resolve.js +0 -136
- package/dist/tools/schedule.js +0 -55
- package/dist/tools/standings.js +1 -45
- package/dist/tools/team.js +39 -120
- package/dist/tools/tournaments.js +0 -37
- package/dist/tools/types.js +0 -17
- package/dist/version.js +0 -17
- package/package.json +1 -1
package/dist/tools/insight.js
CHANGED
|
@@ -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 = [];
|
|
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 —
|
|
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;
|
|
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
|
-
?
|
|
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: {
|