cito-mcp 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/dist/tools/insight.js +95 -4
- package/dist/tools/match.js +75 -5
- package/dist/tools/meta.js +6 -6
- package/dist/tools/normalize.js +221 -15
- package/dist/tools/odds.js +62 -18
- package/dist/tools/player.js +88 -7
- package/dist/tools/rankings.js +75 -10
- package/dist/tools/resolve.js +67 -7
- package/dist/tools/schedule.js +101 -12
- package/dist/tools/standings.js +18 -2
- package/dist/tools/tournaments.js +70 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,19 @@ Composites multi-fetch server-side, return a **stable JSON envelope**, and isola
|
|
|
42
42
|
|
|
43
43
|
**MCP is design-time / agent assistance.** Ship production backends with Cito REST + the user’s API key. Do not put multi-tenant traffic through this process.
|
|
44
44
|
|
|
45
|
+
### Honest gaps, in-band
|
|
46
|
+
|
|
47
|
+
Where the source data is partial, the tool says so instead of shipping a plausible-looking wrong value:
|
|
48
|
+
|
|
49
|
+
| Situation | What the tool returns |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| The two ranking lists compared by `rankings_movers` are not a week apart | `gapDays` + `comparisonWindow` (`"12-week gap"`), `comparisonsAreWeekly: false`, and a warning naming both dates |
|
|
52
|
+
| A tennis tournament draw is missing matches | `draw.shortRounds` (`["R128 45/64"]`), `draw.missingRounds`, `draw.complete` |
|
|
53
|
+
| A stats section the endpoint does not publish | `player_stats.unavailable` names the section and why, rather than a block of nulls |
|
|
54
|
+
| No bookmaker is quoting a match | `oddsAvailable: false` plus a note that it is a coverage gap, not an error |
|
|
55
|
+
| The odds feed leaves `match_id` null | `joinKey` (`{kind:"oddsEventId", …}`) and `playerIds`, so the row is still joinable |
|
|
56
|
+
| `search_entities` results that do not match at all | dropped, counted under `data.relevance.droppedBelowFloor`, with a warning naming them |
|
|
57
|
+
|
|
45
58
|
---
|
|
46
59
|
|
|
47
60
|
## Install
|
|
@@ -167,7 +180,7 @@ All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (
|
|
|
167
180
|
| `head_to_head` | Composed H2H (no first-class REST H2H) | Rivalry / series record; preview context | Single-side form only; live scores; standings | `game`, `sideA`, `sideB`, `entityType?`, `limit?`, `from?`, `to?` |
|
|
168
181
|
| `standings` | League/event tables or world/division rankings | Playoff picture; UFC rankings; CDL / CS2 tables | Team form; live scores; match recaps | `game`, `scope?`, `leagueId?`, `tournamentId?`, `eventId?`, `season?`, `stage?`, `division?`, `limit?` |
|
|
169
182
|
| `match_preview` | Pre-match briefing: sides, rosters/form, H2H stub | Upcoming deep link; pick’ems; preview cards | Completed recaps; deep live state | `game`, `matchId` **or** (`teamA` + `teamB`), `eventId?`, `includeH2H?`, `includeRosters?`, `recentLimit?` |
|
|
170
|
-
| `event_card` | Event / fight-night card: identity + bouts in card order (main event first), each corner with photos, record, nickname, weight class; optional rankings | UFC card, CS2 event hub, tournament overview | Live-only strip; single match recap | `game`, `eventIdOrSlug` **or** `q`, `includeBouts?`, `includeStandings?`, `limit?` |
|
|
183
|
+
| `event_card` | Event / fight-night card: identity + bouts in card order (main event first), each corner with photos, record, nickname, weight class; optional rankings. Tennis returns the draw bracket with per-round completeness | UFC card, CS2 event hub, tournament overview | Live-only strip; single match recap | `game`, `eventIdOrSlug` **or** `q`, `includeBouts?`, `includeStandings?`, `limit?`, `drawMaxMatches?` |
|
|
171
184
|
| `list_routes` | Index of raw REST routes from the live OpenAPI spec (method, path, summary, tag) | Finding a long-tail path before `call_api`; checking an endpoint exists | A curated tool covers the outcome | `game?`, `q?`, `limit?` |
|
|
172
185
|
| `call_api` | Allowlisted raw REST (`data.raw`) | Fortnite / long-tail paths; payload debugging | Any job covered by a curated tool | `path`, `method?`, `queryJson?`, `bodyJson?` |
|
|
173
186
|
|
|
@@ -199,6 +212,11 @@ When a key is `null` the image does not exist for that entity; when the whole
|
|
|
199
212
|
`images` object is absent, upstream sent nothing for that side. The object is
|
|
200
213
|
never partially shaped — if any image exists, all four keys are present.
|
|
201
214
|
|
|
215
|
+
Tennis is the exception on purpose: the feed supplies a single `portrait_url`
|
|
216
|
+
(Wikimedia) and no square crop, so that one URL fills both `headshotUrl` and
|
|
217
|
+
`imageUrl`, and `proxiedImageUrl` stays `null` because only `ufc.com` URLs have
|
|
218
|
+
a proxy. A card with the real photo slightly cropped beats a faceless card.
|
|
219
|
+
|
|
202
220
|
### Games
|
|
203
221
|
|
|
204
222
|
| Game | Depth | Notes |
|
|
@@ -507,6 +525,22 @@ claude mcp add cito -e CITO_API_KEY=cito_… "--" npx -y cito-mcp
|
|
|
507
525
|
|
|
508
526
|
## Changelog (summary)
|
|
509
527
|
|
|
528
|
+
### 0.4.1
|
|
529
|
+
|
|
530
|
+
Tennis correctness pass, driven by the 2026-09-12 sweep (`reports/tennis-mcp-sweep-2026-09-12.md`). Every item was reproduced against live ids and re-verified after the fix.
|
|
531
|
+
|
|
532
|
+
- **`match_summary` tennis scoreline was always `- : -`.** `/tennis/matches/{id}` puts no `sets_won` on its player objects, so the sets-won score was null for every completed match while the same payload held the real score in `score` and `sets[]`. Sets won are now derived (explicit → `winner_sets_won` → `sets[]` → `score` string), `score.detail` carries the game score, and a set's `completed` is inferred when the archive omits `is_completed`. The winner-oriented `winner_games`/`loser_games` pair is deliberately not used as a side score. `bestOf` now reads `best_of`.
|
|
533
|
+
- **`match_details` denied tennis odds existed.** The `odds` section was gated to UFC; the same match returned FanDuel/Matchbook prices from `tennis_odds`. Both surfaces now share one projection (`summarizeTennisOdds`), and `playerStats` (previously always `null` for tennis) is filled from `/tennis/matches/{id}/stats`.
|
|
534
|
+
- **`player_stats.bySurface` was always four nulls.** The endpoint publishes one object per surface and the projector coerced each to a number. Real per-surface blocks, `servingStats`, and an explicit `unavailable` map for sections the endpoint does not publish.
|
|
535
|
+
- **`limit` was ignored** by `player_rankings_history` (asked 4, got 342) and by `/tennis/matches/completed`. Both are bounded locally now, with `*Returned` versus `*Count` so the day total is never confused with the page.
|
|
536
|
+
- **`tournaments level="WTA 1000"` returned ATP Masters events.** The API's level reverse-map is not tour-aware (`_TIER_WTA` maps both `PM` and `M` to "WTA 1000"), so the tool now sends and enforces the tour the level name states, and drops + counts contradicting rows. Fixed at the source too — the same widening also leaked `WTA 125`, `Challenger`, `Davis Cup` and `Billie Jean King Cup`.
|
|
537
|
+
- **`rankings_movers` called an 84-day gap "week-over-week".** `gapDays`/`comparisonWindow`/`comparisonsAreWeekly` now state the real interval, with a warning. `standings` reports the tennis `ranking_date` as `updatedAt` instead of `null`.
|
|
538
|
+
- **`tennis_odds upcoming` rows carried `matchId: null`** with no alternative key. `joinKey` and `playerIds` make the row joinable without inventing an id.
|
|
539
|
+
- **`tennis_schedule`** now counts the day's list/completed overlap, summarises statuses, and flags date-only `startsAt` placeholders.
|
|
540
|
+
- **`live_matches` labelled tennis rows by tournament**, not players, because the live route's `name` is the event.
|
|
541
|
+
- **Tennis players had no images** although `/tennis/players/{id}` carries `portrait_url`. Now surfaced (plus `ioc` as the side country on boards).
|
|
542
|
+
- **`search_entities` had no relevance floor**: `q=Sinner` returned eight unrelated names. Rows that match nothing are dropped and reported, and a one- or two-character fragment no longer counts as a match.
|
|
543
|
+
|
|
510
544
|
### 0.2.4
|
|
511
545
|
|
|
512
546
|
- **UFC projection hardening** (offline-tested):
|
package/dist/tools/insight.js
CHANGED
|
@@ -811,8 +811,14 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
811
811
|
limit: limitSchema({
|
|
812
812
|
default: 20,
|
|
813
813
|
max: 50,
|
|
814
|
-
description: 'Max bouts/matches on the card (default 20, max 50).',
|
|
814
|
+
description: 'Max bouts/matches on the card (default 20, max 50). For a tennis draw this bounds the flat `bouts` list; the bracket in `draw.rounds` is returned whole by default, because a truncated bracket is not a bracket. Use drawMaxMatches to bound it.',
|
|
815
815
|
}),
|
|
816
|
+
drawMaxMatches: {
|
|
817
|
+
type: 'integer',
|
|
818
|
+
minimum: 1,
|
|
819
|
+
maximum: 500,
|
|
820
|
+
description: 'Tennis only. Cap the total matches carried in draw.rounds. The round list is filled from the business end backwards (Final, SF, QF, …) so a small cap keeps the decisive rounds and drops the early ones; draw.truncated and draw.omitted report what happened. Omit for the complete bracket.',
|
|
821
|
+
},
|
|
816
822
|
},
|
|
817
823
|
},
|
|
818
824
|
handler: async (args, ctx) => {
|
|
@@ -835,6 +841,9 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
835
841
|
const includeBouts = args.includeMatches !== false;
|
|
836
842
|
const includeStandings = args.includeStandings === true;
|
|
837
843
|
const limit = clampInt(args.limit, 20, 1, 50);
|
|
844
|
+
const drawMaxMatches = typeof args.drawMaxMatches === 'number' && Number.isFinite(args.drawMaxMatches)
|
|
845
|
+
? Math.max(1, Math.min(500, Math.trunc(args.drawMaxMatches)))
|
|
846
|
+
: null;
|
|
838
847
|
if (!eventKey && !q) {
|
|
839
848
|
return errorEnvelope({
|
|
840
849
|
code: 'VALIDATION',
|
|
@@ -885,6 +894,33 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
885
894
|
// upstream's own round code (Q1..R128, QF, SF, F) so an agent can render
|
|
886
895
|
// the tournament tree rather than one flat, order-dependent match list.
|
|
887
896
|
let drawRounds = null;
|
|
897
|
+
/**
|
|
898
|
+
* Draw completeness. A bracket is only useful if the caller knows whether it
|
|
899
|
+
* is whole: atp_2026_560 returns R32 15/16, R64 31/32 and R128 45/64 (the
|
|
900
|
+
* archived matches it actually holds), and the Final round is absent until
|
|
901
|
+
* it is played. The old response gave no signal at all, so a partial
|
|
902
|
+
* bracket silently looked like a complete one and a missing round looked
|
|
903
|
+
* like a missing feature.
|
|
904
|
+
*/
|
|
905
|
+
let drawSummary = null;
|
|
906
|
+
/** Matches a round of this code must contain, or null when unrecognised. */
|
|
907
|
+
const expectedMatchesForRound = (code, drawSize) => {
|
|
908
|
+
const c = (code ?? '').toUpperCase();
|
|
909
|
+
const rMatch = /^R(\d+)$/.exec(c);
|
|
910
|
+
if (rMatch)
|
|
911
|
+
return Math.max(1, Math.floor(Number(rMatch[1]) / 2));
|
|
912
|
+
if (c === 'F' || c === 'FINAL')
|
|
913
|
+
return 1;
|
|
914
|
+
if (c === 'SF')
|
|
915
|
+
return 2;
|
|
916
|
+
if (c === 'QF')
|
|
917
|
+
return 4;
|
|
918
|
+
if (c === 'BR' || c === 'RR')
|
|
919
|
+
return null; // round robin has no fixed size
|
|
920
|
+
if (drawSize && drawSize > 0 && c.startsWith('Q'))
|
|
921
|
+
return null;
|
|
922
|
+
return null;
|
|
923
|
+
};
|
|
888
924
|
if (game === 'ufc') {
|
|
889
925
|
const detail = await fetchJson(ctx, `/ufc/events/${encodeURIComponent(eventKey)}`);
|
|
890
926
|
upstreamCalls += 1;
|
|
@@ -1298,12 +1334,22 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
1298
1334
|
if (draw.ok) {
|
|
1299
1335
|
const dd = asRecord(unwrapPayload(draw.data)) ?? {};
|
|
1300
1336
|
const rawRounds = Array.isArray(dd.rounds) ? dd.rounds : [];
|
|
1337
|
+
const drawSize = typeof dd.draw_size === 'number' ? dd.draw_size : null;
|
|
1301
1338
|
const flat = [];
|
|
1339
|
+
// Fill the bracket from the front (the upstream orders rounds from the
|
|
1340
|
+
// business end backwards: SF, QF, R16, … R128), so a cap keeps the
|
|
1341
|
+
// decisive rounds and drops the early ones rather than the reverse.
|
|
1342
|
+
let budget = drawMaxMatches ?? Number.POSITIVE_INFINITY;
|
|
1343
|
+
let omittedMatches = 0;
|
|
1302
1344
|
drawRounds = rawRounds.map((rr) => {
|
|
1303
1345
|
const rec = asRecord(rr) ?? {};
|
|
1304
1346
|
const roundCode = pickString(rec.round_code, rec.roundCode) ?? null;
|
|
1305
1347
|
const roundName = pickString(rec.round_name, rec.name, rec.round) ?? roundCode;
|
|
1306
|
-
const
|
|
1348
|
+
const all = Array.isArray(rec.matches) ? rec.matches : [];
|
|
1349
|
+
const kept = all.slice(0, Math.max(0, budget));
|
|
1350
|
+
omittedMatches += all.length - kept.length;
|
|
1351
|
+
budget -= kept.length;
|
|
1352
|
+
const matches = kept.map((mm) => {
|
|
1307
1353
|
const mr = asRecord(mm) ?? {};
|
|
1308
1354
|
const row = { ...mr, id: mr.match_id, round: roundName, tournament_name: event.name, status: 'completed' };
|
|
1309
1355
|
flat.push(row);
|
|
@@ -1318,11 +1364,56 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
1318
1364
|
nextMatchId: pickString(mr.next_match_id) ?? null,
|
|
1319
1365
|
}, 'tennis');
|
|
1320
1366
|
});
|
|
1321
|
-
return {
|
|
1367
|
+
return {
|
|
1368
|
+
roundCode,
|
|
1369
|
+
roundName,
|
|
1370
|
+
matches,
|
|
1371
|
+
returned: all.length,
|
|
1372
|
+
expected: expectedMatchesForRound(roundCode, drawSize),
|
|
1373
|
+
complete: (() => {
|
|
1374
|
+
const expected = expectedMatchesForRound(roundCode, drawSize);
|
|
1375
|
+
return expected === null ? null : all.length >= expected;
|
|
1376
|
+
})(),
|
|
1377
|
+
};
|
|
1322
1378
|
});
|
|
1323
1379
|
bouts = flat.slice(0, limit).map((row) => normalizeMatch('tennis', row));
|
|
1324
1380
|
if (!bouts.length)
|
|
1325
1381
|
warnings.push('No draw yet: the tournament has no archived results');
|
|
1382
|
+
const expectedTotal = drawRounds.reduce((sum, r) => sum + (r.expected ?? 0), 0);
|
|
1383
|
+
const returnedTotal = drawRounds.reduce((sum, r) => sum + r.returned, 0);
|
|
1384
|
+
const shortRounds = drawRounds
|
|
1385
|
+
.filter((r) => r.complete === false)
|
|
1386
|
+
.map((r) => `${r.roundCode ?? r.roundName ?? '?'} ${r.returned}/${r.expected}`);
|
|
1387
|
+
// A round of 128 draw must have a Final. Its absence is not an error
|
|
1388
|
+
// (the final may simply not be played yet) but it IS the difference
|
|
1389
|
+
// between "in progress" and "the API lost a round".
|
|
1390
|
+
const haveCodes = new Set(drawRounds.map((r) => (r.roundCode ?? '').toUpperCase()));
|
|
1391
|
+
const missingRounds = ['F', 'SF', 'QF']
|
|
1392
|
+
.filter((c) => !haveCodes.has(c))
|
|
1393
|
+
.filter((c) => !(c === 'F' && shortRounds.length > 0 && drawSize !== null && returnedTotal < drawSize / 2));
|
|
1394
|
+
drawSummary = {
|
|
1395
|
+
drawSize,
|
|
1396
|
+
// NOT `rounds`: that key holds the round ARRAY on the same object,
|
|
1397
|
+
// and spreading a count over it replaced the bracket with a number.
|
|
1398
|
+
roundCount: drawRounds.length,
|
|
1399
|
+
matchesReturned: returnedTotal,
|
|
1400
|
+
matchesExpected: expectedTotal || null,
|
|
1401
|
+
complete: expectedTotal > 0 ? returnedTotal >= expectedTotal : null,
|
|
1402
|
+
shortRounds,
|
|
1403
|
+
missingRounds,
|
|
1404
|
+
totalMatchesInCard: flat.length,
|
|
1405
|
+
returnedInBouts: bouts.length,
|
|
1406
|
+
truncated: omittedMatches > 0,
|
|
1407
|
+
omittedMatches,
|
|
1408
|
+
maxMatches: drawMaxMatches,
|
|
1409
|
+
note: 'matchesReturned counts what the archive holds for this tournament; matchesExpected counts a full draw of drawSize. shortRounds names every round that is short, and missingRounds names a round the bracket should contain but does not.',
|
|
1410
|
+
};
|
|
1411
|
+
if (shortRounds.length) {
|
|
1412
|
+
warnings.push(`Draw is partial: ${shortRounds.join(', ')}. The archive does not hold every match of this tournament yet.`);
|
|
1413
|
+
}
|
|
1414
|
+
if (omittedMatches > 0) {
|
|
1415
|
+
warnings.push(`drawMaxMatches=${drawMaxMatches} dropped ${omittedMatches} early-round match(es) from draw.rounds; raise or omit it for the complete bracket.`);
|
|
1416
|
+
}
|
|
1326
1417
|
}
|
|
1327
1418
|
else {
|
|
1328
1419
|
partial.push(partialFromRejection('bouts', {
|
|
@@ -1414,7 +1505,7 @@ Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeMatches": true, "i
|
|
|
1414
1505
|
// (Q1..R128, QF, SF, F) and each match's score/bracket progression —
|
|
1415
1506
|
// this is what makes a bracket renderable; `bouts` above is the flat
|
|
1416
1507
|
// list every other game already gets.
|
|
1417
|
-
...(game === 'tennis' ? { draw: drawRounds ? { rounds: drawRounds } : null } : {}),
|
|
1508
|
+
...(game === 'tennis' ? { draw: drawRounds ? { rounds: drawRounds, ...(drawSummary ?? {}) } : null } : {}),
|
|
1418
1509
|
standings: includeStandings ? standingsSnippet : null,
|
|
1419
1510
|
nextSteps: [
|
|
1420
1511
|
'For a featured bout/match: match_preview { game, matchId } or teamA+teamB',
|
package/dist/tools/match.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
|
|
5
5
|
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
6
|
-
import { normalizeMatch, normalizeUfcMethod, presentSides } from './normalize.js';
|
|
6
|
+
import { normalizeMatch, normalizeUfcMethod, presentSides, tennisSetCompleted } from './normalize.js';
|
|
7
|
+
import { summarizeTennisOdds } from './odds.js';
|
|
7
8
|
import { boolSchema, gameSchema, isPrimaryGame, parseGame, stringSchema, } from './types.js';
|
|
8
9
|
async function getSection(ctx, path, query) {
|
|
9
10
|
return fetchJson(ctx, path, { query });
|
|
@@ -20,7 +21,7 @@ export function parseBestOf(r) {
|
|
|
20
21
|
const n = Number(v);
|
|
21
22
|
return Number.isFinite(n) && [1, 3, 5, 7, 9].includes(n) ? n : null;
|
|
22
23
|
};
|
|
23
|
-
const direct = fromNumber(r.bestOf) ?? fromNumber(r.bo);
|
|
24
|
+
const direct = fromNumber(r.bestOf) ?? fromNumber(r.bo) ?? fromNumber(r.best_of);
|
|
24
25
|
if (direct)
|
|
25
26
|
return direct;
|
|
26
27
|
const strat = r.strategy;
|
|
@@ -61,6 +62,12 @@ function matchCore(game, matchId, raw) {
|
|
|
61
62
|
const scoreline = m.team1 || m.team2
|
|
62
63
|
? `${m.team1?.name ?? '?'} ${s1 ?? '-'} : ${s2 ?? '-'} ${m.team2?.name ?? '?'}`
|
|
63
64
|
: null;
|
|
65
|
+
/**
|
|
66
|
+
* Tennis: `score` is the set-by-set game score ("4-6 6-3 6-3 7-5"), which is
|
|
67
|
+
* a DIFFERENT fact from the sets-won scoreline. "3 : 1" is the result; it
|
|
68
|
+
* cannot distinguish 6-0 6-0 from three tiebreaks. Both are carried.
|
|
69
|
+
*/
|
|
70
|
+
const scoreDetail = game === 'tennis' ? (pickString(r.score, r.score_raw) ?? null) : null;
|
|
64
71
|
const core = {
|
|
65
72
|
matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
|
|
66
73
|
game,
|
|
@@ -73,6 +80,7 @@ function matchCore(game, matchId, raw) {
|
|
|
73
80
|
score: {
|
|
74
81
|
team1: s1,
|
|
75
82
|
team2: s2,
|
|
83
|
+
...(scoreDetail ? { detail: scoreDetail } : {}),
|
|
76
84
|
},
|
|
77
85
|
team1: m.team1,
|
|
78
86
|
team2: m.team2,
|
|
@@ -385,7 +393,10 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
385
393
|
player1Games: p1g,
|
|
386
394
|
player2Games: p2g,
|
|
387
395
|
tiebreak: sr.tiebreak ?? null,
|
|
388
|
-
|
|
396
|
+
// is_completed is a LIVE-route field. The archive route omits it,
|
|
397
|
+
// so every set of a finished match read completed:false. Fall
|
|
398
|
+
// back to the games, which always decide whether a set is over.
|
|
399
|
+
completed: tennisSetCompleted(sr),
|
|
389
400
|
};
|
|
390
401
|
});
|
|
391
402
|
return;
|
|
@@ -420,6 +431,7 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
|
|
|
420
431
|
display: `${match.team1?.name ?? '?'} ${match.score.team1 ?? '-'} : ${match.score.team2 ?? '-'} ${match.team2?.name ?? '?'}`,
|
|
421
432
|
team1: match.score.team1,
|
|
422
433
|
team2: match.score.team2,
|
|
434
|
+
...(game === 'tennis' && match.score.detail ? { detail: match.score.detail } : {}),
|
|
423
435
|
}, game);
|
|
424
436
|
if (view === 'summary' && playerPerformances.length > 10) {
|
|
425
437
|
playerPerformances = playerPerformances.slice(0, 10);
|
|
@@ -540,6 +552,52 @@ export function summarizeUfcOdds(data) {
|
|
|
540
552
|
fullBookPath: '/ufc/bouts/{boutId}/odds',
|
|
541
553
|
};
|
|
542
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* Tennis per-match stats projection.
|
|
557
|
+
*
|
|
558
|
+
* `/tennis/matches/{id}/stats` does NOT return a row per player the way every
|
|
559
|
+
* other game's player-stats route does. It returns
|
|
560
|
+
* `{ stats: { winner: {...}, loser: {...} }, sets: [...], score }` — two objects
|
|
561
|
+
* keyed by OUTCOME. `match_details sections:["playerStats"]` had no tennis arm
|
|
562
|
+
* at all, so it answered `playerStats: null` while `match_summary` was filling
|
|
563
|
+
* `playerPerformances` from the same route.
|
|
564
|
+
*
|
|
565
|
+
* The sides stay keyed by outcome rather than being mislabelled as player1 /
|
|
566
|
+
* player2, because the payload carries no names and the ordering is not
|
|
567
|
+
* guaranteed to match the base row. `match.player1.is_winner` on the base
|
|
568
|
+
* section is what maps a name onto these numbers, and `sidesKeyedBy` says so.
|
|
569
|
+
*/
|
|
570
|
+
export function summarizeTennisMatchStats(data) {
|
|
571
|
+
const root = asRecord(data) ?? {};
|
|
572
|
+
const payload = asRecord(root.data) ?? root;
|
|
573
|
+
const stats = asRecord(payload.stats);
|
|
574
|
+
const sets = Array.isArray(payload.sets) ? payload.sets : [];
|
|
575
|
+
const toNum = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
576
|
+
return {
|
|
577
|
+
matchId: pickString(payload.match_id) ?? null,
|
|
578
|
+
score: pickString(payload.score) ?? null,
|
|
579
|
+
sidesKeyedBy: 'outcome',
|
|
580
|
+
players: stats
|
|
581
|
+
? {
|
|
582
|
+
winner: asRecord(stats.winner) ?? null,
|
|
583
|
+
loser: asRecord(stats.loser) ?? null,
|
|
584
|
+
}
|
|
585
|
+
: null,
|
|
586
|
+
sets: sets.map((s) => {
|
|
587
|
+
const sr = asRecord(s) ?? {};
|
|
588
|
+
return {
|
|
589
|
+
setNumber: toNum(sr.set_num) ?? toNum(sr.set_number),
|
|
590
|
+
winnerGames: toNum(sr.winner_games),
|
|
591
|
+
loserGames: toNum(sr.loser_games),
|
|
592
|
+
player1Games: toNum(sr.player1_games),
|
|
593
|
+
player2Games: toNum(sr.player2_games),
|
|
594
|
+
tiebreak: sr.tiebreak ?? null,
|
|
595
|
+
completed: tennisSetCompleted(sr),
|
|
596
|
+
};
|
|
597
|
+
}),
|
|
598
|
+
rawPath: '/tennis/matches/{matchId}/stats',
|
|
599
|
+
};
|
|
600
|
+
}
|
|
543
601
|
export const matchDetails = {
|
|
544
602
|
name: 'match_details',
|
|
545
603
|
description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
|
|
@@ -574,7 +632,7 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
|
|
|
574
632
|
type: 'string',
|
|
575
633
|
enum: ['base', 'playerStats', 'gamesOrMaps', 'timeline', 'liveState', 'media', 'advanced', 'odds'],
|
|
576
634
|
},
|
|
577
|
-
description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds"
|
|
635
|
+
description: 'Explicit section list; defaults to base+playerStats+gamesOrMaps+media. "odds" is opt-in: UFC returns moneyline summarised per fighter with bookmaker count, best and median American price and implied probability plus a count of every other market (closing lines for a finished fight come back with currentlyOffered=false rather than being omitted); tennis returns the same bookmaker/market/outcome projection that tennis_odds serves, including oddsAvailable=false when no book is quoting. Odds exist for UFC and tennis only.',
|
|
578
636
|
},
|
|
579
637
|
includeTimeline: boolSchema('Include timeline section (heavy).', false),
|
|
580
638
|
includeLiveState: boolSchema('Include live state/snapshots.', false),
|
|
@@ -726,6 +784,11 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
|
|
|
726
784
|
load('playerStats', `/cod/matches/${encodeURIComponent(matchId)}/player-stats`);
|
|
727
785
|
if (game === 'ufc')
|
|
728
786
|
load('playerStats', `/ufc/bouts/${encodeURIComponent(matchId)}/stats`);
|
|
787
|
+
// Tennis has a real per-match stats route; the section simply had no arm
|
|
788
|
+
// for it and shipped playerStats:null forever.
|
|
789
|
+
if (game === 'tennis') {
|
|
790
|
+
loadWith('playerStats', `/tennis/matches/${encodeURIComponent(matchId)}/stats`, summarizeTennisMatchStats);
|
|
791
|
+
}
|
|
729
792
|
}
|
|
730
793
|
if (wanted.has('gamesOrMaps')) {
|
|
731
794
|
if (game === 'lol')
|
|
@@ -772,10 +835,17 @@ Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "in
|
|
|
772
835
|
if (game === 'ufc') {
|
|
773
836
|
loadWith('odds', `/ufc/bouts/${encodeURIComponent(matchId)}/odds`, summarizeUfcOdds);
|
|
774
837
|
}
|
|
838
|
+
else if (game === 'tennis') {
|
|
839
|
+
// The gate that produced "Odds not curated for tennis; UFC only today"
|
|
840
|
+
// was written before /tennis/odds/{id} existed. It does: the same match
|
|
841
|
+
// returned FanDuel 1.105 / Matchbook 1.13 from tennis_odds while this
|
|
842
|
+
// section denied odds existed. Both surfaces now share one projection.
|
|
843
|
+
loadWith('odds', `/tennis/odds/${encodeURIComponent(matchId)}`, summarizeTennisOdds);
|
|
844
|
+
}
|
|
775
845
|
else {
|
|
776
846
|
partial.push(partialFromRejection('odds', {
|
|
777
847
|
code: 'NOT_IMPLEMENTED',
|
|
778
|
-
message: `Odds not curated for ${game}
|
|
848
|
+
message: `Odds are not curated for ${game}. Available: UFC (/ufc/bouts/{id}/odds) and tennis (/tennis/odds/{id}).`,
|
|
779
849
|
}));
|
|
780
850
|
}
|
|
781
851
|
}
|
package/dist/tools/meta.js
CHANGED
|
@@ -38,7 +38,7 @@ const TOOL_CATALOG = [
|
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
name: 'search_entities',
|
|
41
|
-
outcome: 'Browse/search entities with type filter and pagination',
|
|
41
|
+
outcome: 'Browse/search entities with type filter and pagination; when q is given, results below the relevance floor are dropped and reported under data.relevance',
|
|
42
42
|
parallelSafe: true,
|
|
43
43
|
games: [...PRIMARY_GAMES],
|
|
44
44
|
jobs: ['team_page', 'player_form'],
|
|
@@ -138,7 +138,7 @@ const TOOL_CATALOG = [
|
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
name: 'rankings_movers',
|
|
141
|
-
outcome: 'Tennis ranking
|
|
141
|
+
outcome: 'Tennis ranking climbers/fallers plus new entries and drop-outs, with gapDays/comparisonWindow stating the real interval between the two lists',
|
|
142
142
|
parallelSafe: true,
|
|
143
143
|
games: ['tennis'],
|
|
144
144
|
jobs: ['standings'],
|
|
@@ -168,7 +168,7 @@ const TOOL_CATALOG = [
|
|
|
168
168
|
},
|
|
169
169
|
{
|
|
170
170
|
name: 'player_stats',
|
|
171
|
-
outcome: 'Tennis career statistics: W/L, win%, titles,
|
|
171
|
+
outcome: 'Tennis career statistics: W/L, win%, titles, per-surface breakdown, serving stats, and named gaps',
|
|
172
172
|
parallelSafe: true,
|
|
173
173
|
games: ['tennis'],
|
|
174
174
|
jobs: ['player_form'],
|
|
@@ -188,7 +188,7 @@ const TOOL_CATALOG = [
|
|
|
188
188
|
},
|
|
189
189
|
{
|
|
190
190
|
name: 'tennis_odds',
|
|
191
|
-
outcome: 'Tennis betting odds: upcoming matches with prices, plus pre-match and in-play for one match',
|
|
191
|
+
outcome: 'Tennis betting odds: upcoming matches with prices, plus pre-match and in-play for one match; upcoming rows carry a joinKey and playerIds because the feed leaves match_id null',
|
|
192
192
|
parallelSafe: true,
|
|
193
193
|
games: ['tennis'],
|
|
194
194
|
jobs: ['preview', 'match_page', 'odds'],
|
|
@@ -198,7 +198,7 @@ const TOOL_CATALOG = [
|
|
|
198
198
|
},
|
|
199
199
|
{
|
|
200
200
|
name: 'tournaments',
|
|
201
|
-
outcome: 'Tennis tournament catalog: filter by year, tour, level, surface or country',
|
|
201
|
+
outcome: 'Tennis tournament catalog: filter by year, tour, level, surface or country; a level name that names a tour also fixes it',
|
|
202
202
|
parallelSafe: true,
|
|
203
203
|
games: ['tennis'],
|
|
204
204
|
jobs: ['preview', 'standings'],
|
|
@@ -208,7 +208,7 @@ const TOOL_CATALOG = [
|
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
name: 'tennis_schedule',
|
|
211
|
-
outcome: "One day of tennis: that day's schedule and/or completed matches",
|
|
211
|
+
outcome: "One day of tennis: that day's schedule and/or completed matches, with limit applied to each half and the two halves' overlap counted",
|
|
212
212
|
parallelSafe: true,
|
|
213
213
|
games: ['tennis'],
|
|
214
214
|
jobs: ['schedule', 'match_page'],
|