cito-mcp 0.4.4 → 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 -6
- 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 -11
- 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 +0 -155
- package/dist/tools/meta.js +7 -52
- 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/resolve.js
CHANGED
|
@@ -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)
|
package/dist/tools/schedule.js
CHANGED
|
@@ -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',
|
package/dist/tools/standings.js
CHANGED
|
@@ -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;
|
|
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
|
}
|