cito-mcp 0.1.0 → 0.2.2
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 +396 -44
- package/dist/client.js +208 -0
- package/dist/envelope.js +210 -0
- package/dist/index.js +103 -201
- package/dist/instructions.js +80 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/insight.js +1013 -0
- package/dist/tools/live.js +447 -0
- package/dist/tools/match.js +464 -0
- package/dist/tools/meta.js +609 -0
- package/dist/tools/normalize.js +177 -0
- package/dist/tools/player.js +357 -0
- package/dist/tools/resolve.js +518 -0
- package/dist/tools/standings.js +319 -0
- package/dist/tools/team.js +704 -0
- package/dist/tools/types.js +85 -0
- package/package.json +3 -3
- package/dist/executor.js +0 -75
- package/dist/spec.js +0 -193
- package/dist/tools.js +0 -203
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* match_preview — pre-match briefing composite
|
|
3
|
+
*/
|
|
4
|
+
import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
|
|
5
|
+
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
6
|
+
import { normalizeMatch } from './normalize.js';
|
|
7
|
+
import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
8
|
+
async function loadSide(ctx, game, side, recentLimit, includeRosters) {
|
|
9
|
+
const partial = [];
|
|
10
|
+
let calls = 0;
|
|
11
|
+
let rateLimit = {};
|
|
12
|
+
let entity = { idOrSlug: side, name: side, game };
|
|
13
|
+
let roster = [];
|
|
14
|
+
let recentForm = [];
|
|
15
|
+
let keyPlayers = [];
|
|
16
|
+
if (game === 'ufc') {
|
|
17
|
+
const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(side)}`);
|
|
18
|
+
calls += 1;
|
|
19
|
+
rateLimit = res.headers;
|
|
20
|
+
if (res.ok) {
|
|
21
|
+
const data = asRecord(res.data) ?? {};
|
|
22
|
+
entity = {
|
|
23
|
+
id: pickString(data.id, side),
|
|
24
|
+
slug: pickString(data.slug, side),
|
|
25
|
+
name: pickString(data.name, side),
|
|
26
|
+
game,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
partial.push(partialFromRejection('side', {
|
|
31
|
+
code: mapHttpToCode(res.status),
|
|
32
|
+
message: `fighter ${side} HTTP ${res.status}`,
|
|
33
|
+
httpStatus: res.status,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
const stats = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(side)}/stats`);
|
|
37
|
+
calls += 1;
|
|
38
|
+
rateLimit = { ...rateLimit, ...stats.headers };
|
|
39
|
+
if (stats.ok)
|
|
40
|
+
recentForm = [stats.data];
|
|
41
|
+
return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
|
|
42
|
+
}
|
|
43
|
+
if (game === 'lol') {
|
|
44
|
+
const res = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(side)}`);
|
|
45
|
+
calls += 1;
|
|
46
|
+
rateLimit = res.headers;
|
|
47
|
+
if (res.ok) {
|
|
48
|
+
const data = asRecord(res.data) ?? {};
|
|
49
|
+
entity = {
|
|
50
|
+
id: pickString(data.id, side),
|
|
51
|
+
slug: pickString(data.slug, side),
|
|
52
|
+
name: pickString(data.name, side),
|
|
53
|
+
game,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
partial.push(partialFromRejection('side', {
|
|
58
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
59
|
+
message: `team ${side} HTTP ${res.status}`,
|
|
60
|
+
httpStatus: res.status,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
if (includeRosters) {
|
|
64
|
+
const rost = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(side)}/roster`);
|
|
65
|
+
calls += 1;
|
|
66
|
+
rateLimit = { ...rateLimit, ...rost.headers };
|
|
67
|
+
if (rost.ok) {
|
|
68
|
+
roster = extractRows(rost.data);
|
|
69
|
+
keyPlayers = roster.slice(0, 5);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
partial.push(partialFromRejection('roster', {
|
|
73
|
+
code: mapHttpToCode(rost.status),
|
|
74
|
+
message: `roster HTTP ${rost.status}`,
|
|
75
|
+
httpStatus: rost.status,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const sched = await fetchJson(ctx, '/lol/schedule', {
|
|
80
|
+
query: { teamSlug: side, completed: 'true', limit: String(recentLimit) },
|
|
81
|
+
});
|
|
82
|
+
calls += 1;
|
|
83
|
+
rateLimit = { ...rateLimit, ...sched.headers };
|
|
84
|
+
if (sched.ok) {
|
|
85
|
+
recentForm = extractRows(sched.data)
|
|
86
|
+
.slice(0, recentLimit)
|
|
87
|
+
.map((row) => normalizeMatch('lol', row, 'completed'));
|
|
88
|
+
}
|
|
89
|
+
return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
|
|
90
|
+
}
|
|
91
|
+
if (game === 'cs2') {
|
|
92
|
+
if (includeRosters) {
|
|
93
|
+
const rost = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(side)}/roster-history`);
|
|
94
|
+
calls += 1;
|
|
95
|
+
rateLimit = rost.headers;
|
|
96
|
+
if (rost.ok) {
|
|
97
|
+
roster = extractRows(rost.data).slice(0, 10);
|
|
98
|
+
keyPlayers = roster.slice(0, 5);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const trends = await fetchJson(ctx, `/cs2/teams/${encodeURIComponent(side)}/trends`);
|
|
102
|
+
calls += 1;
|
|
103
|
+
rateLimit = { ...rateLimit, ...trends.headers };
|
|
104
|
+
if (trends.ok)
|
|
105
|
+
recentForm = [trends.data];
|
|
106
|
+
const matches = await fetchJson(ctx, '/cs2/matches', {
|
|
107
|
+
query: { team: side, limit: recentLimit },
|
|
108
|
+
});
|
|
109
|
+
calls += 1;
|
|
110
|
+
rateLimit = { ...rateLimit, ...matches.headers };
|
|
111
|
+
if (matches.ok) {
|
|
112
|
+
const m = extractRows(matches.data)
|
|
113
|
+
.slice(0, recentLimit)
|
|
114
|
+
.map((row) => normalizeMatch('cs2', row));
|
|
115
|
+
recentForm = [...recentForm, ...m];
|
|
116
|
+
}
|
|
117
|
+
entity = { idOrSlug: side, name: side, game };
|
|
118
|
+
return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
|
|
119
|
+
}
|
|
120
|
+
if (game === 'cod') {
|
|
121
|
+
const res = await fetchJson(ctx, `/cod/orgs/${encodeURIComponent(side)}`);
|
|
122
|
+
calls += 1;
|
|
123
|
+
rateLimit = res.headers;
|
|
124
|
+
if (res.ok) {
|
|
125
|
+
const data = asRecord(res.data) ?? {};
|
|
126
|
+
entity = {
|
|
127
|
+
id: pickString(data.id, side),
|
|
128
|
+
slug: pickString(data.slug, side),
|
|
129
|
+
name: pickString(data.name, side),
|
|
130
|
+
game,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (includeRosters) {
|
|
134
|
+
const rost = await fetchJson(ctx, `/cod/orgs/${encodeURIComponent(side)}/roster`);
|
|
135
|
+
calls += 1;
|
|
136
|
+
rateLimit = { ...rateLimit, ...rost.headers };
|
|
137
|
+
if (rost.ok) {
|
|
138
|
+
roster = extractRows(rost.data);
|
|
139
|
+
keyPlayers = roster.slice(0, 5);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const matches = await fetchJson(ctx, '/cod/matches', {
|
|
143
|
+
query: { team: side, limit: recentLimit },
|
|
144
|
+
});
|
|
145
|
+
calls += 1;
|
|
146
|
+
rateLimit = { ...rateLimit, ...matches.headers };
|
|
147
|
+
if (matches.ok) {
|
|
148
|
+
recentForm = extractRows(matches.data)
|
|
149
|
+
.slice(0, recentLimit)
|
|
150
|
+
.map((row) => normalizeMatch('cod', row));
|
|
151
|
+
}
|
|
152
|
+
return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
|
|
153
|
+
}
|
|
154
|
+
if (game === 'dota2') {
|
|
155
|
+
const radar = await fetchJson(ctx, `/dota2/teams/${encodeURIComponent(side)}/radar`);
|
|
156
|
+
calls += 1;
|
|
157
|
+
rateLimit = radar.headers;
|
|
158
|
+
if (radar.ok)
|
|
159
|
+
recentForm = [radar.data];
|
|
160
|
+
else {
|
|
161
|
+
partial.push(partialFromRejection('form', {
|
|
162
|
+
code: mapHttpToCode(radar.status),
|
|
163
|
+
message: `radar HTTP ${radar.status}`,
|
|
164
|
+
httpStatus: radar.status,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
entity = { idOrSlug: side, name: side, game };
|
|
168
|
+
if (includeRosters) {
|
|
169
|
+
partial.push(partialFromRejection('roster', {
|
|
170
|
+
code: 'NOT_IMPLEMENTED',
|
|
171
|
+
message: 'Dota has no roster endpoint',
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
|
|
175
|
+
}
|
|
176
|
+
return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
|
|
177
|
+
}
|
|
178
|
+
function composeH2H(game, sideA, sideB, rows, limit) {
|
|
179
|
+
const al = sideA.toLowerCase();
|
|
180
|
+
const bl = sideB.toLowerCase();
|
|
181
|
+
const meetings = rows
|
|
182
|
+
.map((row) => normalizeMatch(game, row))
|
|
183
|
+
.filter((m) => {
|
|
184
|
+
const s1 = [m.team1?.id, m.team1?.slug, m.team1?.name].filter(Boolean).map((x) => String(x).toLowerCase());
|
|
185
|
+
const s2 = [m.team2?.id, m.team2?.slug, m.team2?.name].filter(Boolean).map((x) => String(x).toLowerCase());
|
|
186
|
+
const hasA1 = s1.some((x) => x.includes(al) || al.includes(x));
|
|
187
|
+
const hasB1 = s1.some((x) => x.includes(bl) || bl.includes(x));
|
|
188
|
+
const hasA2 = s2.some((x) => x.includes(al) || al.includes(x));
|
|
189
|
+
const hasB2 = s2.some((x) => x.includes(bl) || bl.includes(x));
|
|
190
|
+
return (hasA1 && hasB2) || (hasB1 && hasA2);
|
|
191
|
+
})
|
|
192
|
+
.slice(0, limit);
|
|
193
|
+
return {
|
|
194
|
+
meetings: meetings.length,
|
|
195
|
+
lastMeetings: meetings.slice(0, 5),
|
|
196
|
+
recordNote: meetings.length ? `${meetings.length} meetings found in window` : 'No H2H meetings in window',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
export const matchPreview = {
|
|
200
|
+
name: 'match_preview',
|
|
201
|
+
description: `COMPOSITE pre-match briefing: sides, roster/form snippets, H2H stub, event context — for pick'ems, articles, and match-page before state.
|
|
202
|
+
|
|
203
|
+
When to use:
|
|
204
|
+
- Upcoming match deep link
|
|
205
|
+
- "Who should I watch before this game?"
|
|
206
|
+
- App scaffold for preview cards
|
|
207
|
+
|
|
208
|
+
Prefer over: manually chaining team_profile ×2 + head_to_head + schedule.
|
|
209
|
+
Prefer match_summary when match is completed; match_details for live in-game.
|
|
210
|
+
|
|
211
|
+
Do not use when: user wants final score/recap of a finished match.
|
|
212
|
+
|
|
213
|
+
Parallel-safe: yes. Upstream cost: 4–8.
|
|
214
|
+
Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "recentLimit": 5 }`,
|
|
215
|
+
inputSchema: {
|
|
216
|
+
type: 'object',
|
|
217
|
+
additionalProperties: false,
|
|
218
|
+
required: ['game'],
|
|
219
|
+
properties: {
|
|
220
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
221
|
+
matchId: stringSchema('Pass matchId OR (teamA + teamB).'),
|
|
222
|
+
teamA: stringSchema('Id/slug; UFC fighter slug when applicable.', 't1'),
|
|
223
|
+
teamB: stringSchema('Id/slug for side B.', 'gen-g'),
|
|
224
|
+
eventId: stringSchema('Optional event context (UFC).'),
|
|
225
|
+
includeH2H: boolSchema('Include composed H2H stub.', true),
|
|
226
|
+
includeRosters: boolSchema('Include roster snippets when available.', true),
|
|
227
|
+
recentLimit: limitSchema({ default: 5, max: 15, description: 'Form window per side (default 5, max 15).' }),
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
handler: async (args, ctx) => {
|
|
231
|
+
const started = Date.now();
|
|
232
|
+
const requestId = newRequestId();
|
|
233
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
234
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
235
|
+
return errorEnvelope({
|
|
236
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
237
|
+
message: gameParse.error ?? 'game is required',
|
|
238
|
+
game: null,
|
|
239
|
+
source: 'match_preview',
|
|
240
|
+
requestId,
|
|
241
|
+
tookMs: Date.now() - started,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const game = gameParse.game;
|
|
245
|
+
let matchId = typeof args.matchId === 'string' ? args.matchId.trim() : '';
|
|
246
|
+
let teamA = typeof args.teamA === 'string' ? args.teamA.trim() : '';
|
|
247
|
+
let teamB = typeof args.teamB === 'string' ? args.teamB.trim() : '';
|
|
248
|
+
const eventId = typeof args.eventId === 'string' ? args.eventId : undefined;
|
|
249
|
+
const includeH2H = args.includeH2H !== false;
|
|
250
|
+
const includeRosters = args.includeRosters !== false;
|
|
251
|
+
const recentLimit = clampInt(args.recentLimit, 5, 1, 15);
|
|
252
|
+
if (!matchId && (!teamA || !teamB)) {
|
|
253
|
+
return errorEnvelope({
|
|
254
|
+
code: 'VALIDATION',
|
|
255
|
+
message: 'Pass matchId OR both teamA and teamB',
|
|
256
|
+
game,
|
|
257
|
+
source: 'match_preview',
|
|
258
|
+
requestId,
|
|
259
|
+
tookMs: Date.now() - started,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const partial = [];
|
|
263
|
+
let upstreamCalls = 0;
|
|
264
|
+
let rateLimit = {};
|
|
265
|
+
let context = {
|
|
266
|
+
matchId: matchId || null,
|
|
267
|
+
startTime: null,
|
|
268
|
+
event: eventId ? { id: eventId } : null,
|
|
269
|
+
league: null,
|
|
270
|
+
status: 'upcoming',
|
|
271
|
+
};
|
|
272
|
+
if (matchId) {
|
|
273
|
+
let path = '';
|
|
274
|
+
if (game === 'lol')
|
|
275
|
+
path = `/lol/matches/${encodeURIComponent(matchId)}`;
|
|
276
|
+
else if (game === 'cs2')
|
|
277
|
+
path = `/cs2/matches/${encodeURIComponent(matchId)}`;
|
|
278
|
+
else if (game === 'dota2')
|
|
279
|
+
path = `/dota2/matches/${encodeURIComponent(matchId)}`;
|
|
280
|
+
else if (game === 'cod')
|
|
281
|
+
path = `/cod/matches/${encodeURIComponent(matchId)}`;
|
|
282
|
+
else if (game === 'ufc')
|
|
283
|
+
path = `/ufc/bouts/${encodeURIComponent(matchId)}`;
|
|
284
|
+
const res = await fetchJson(ctx, path);
|
|
285
|
+
upstreamCalls += 1;
|
|
286
|
+
rateLimit = res.headers;
|
|
287
|
+
if (res.ok) {
|
|
288
|
+
const m = normalizeMatch(game, res.data, 'upcoming');
|
|
289
|
+
context = {
|
|
290
|
+
matchId: m.matchId,
|
|
291
|
+
startTime: m.startTime,
|
|
292
|
+
event: m.event,
|
|
293
|
+
league: m.league,
|
|
294
|
+
status: m.status,
|
|
295
|
+
};
|
|
296
|
+
if (!teamA)
|
|
297
|
+
teamA = m.team1?.slug || m.team1?.id || m.team1?.name || '';
|
|
298
|
+
if (!teamB)
|
|
299
|
+
teamB = m.team2?.slug || m.team2?.id || m.team2?.name || '';
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
partial.push(partialFromRejection('context', {
|
|
303
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
304
|
+
message: `match context HTTP ${res.status}`,
|
|
305
|
+
httpStatus: res.status,
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (!teamA || !teamB) {
|
|
310
|
+
return errorEnvelope({
|
|
311
|
+
code: 'VALIDATION',
|
|
312
|
+
message: 'Could not resolve both sides; pass teamA and teamB',
|
|
313
|
+
game,
|
|
314
|
+
source: 'match_preview',
|
|
315
|
+
requestId,
|
|
316
|
+
tookMs: Date.now() - started,
|
|
317
|
+
upstreamCalls,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
const [sideARes, sideBRes] = await Promise.all([
|
|
321
|
+
loadSide(ctx, game, teamA, recentLimit, includeRosters),
|
|
322
|
+
loadSide(ctx, game, teamB, recentLimit, includeRosters),
|
|
323
|
+
]);
|
|
324
|
+
upstreamCalls += sideARes.calls + sideBRes.calls;
|
|
325
|
+
rateLimit = { ...rateLimit, ...sideARes.rateLimit, ...sideBRes.rateLimit };
|
|
326
|
+
partial.push(...sideARes.partial.map((p) => ({ ...p, section: `sideA.${p.section}` })));
|
|
327
|
+
partial.push(...sideBRes.partial.map((p) => ({ ...p, section: `sideB.${p.section}` })));
|
|
328
|
+
let h2h = null;
|
|
329
|
+
if (includeH2H) {
|
|
330
|
+
try {
|
|
331
|
+
let rows = [];
|
|
332
|
+
if (game === 'cs2') {
|
|
333
|
+
const res = await fetchJson(ctx, '/cs2/matches', { query: { team: teamA, limit: 40 } });
|
|
334
|
+
upstreamCalls += 1;
|
|
335
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
336
|
+
if (res.ok)
|
|
337
|
+
rows = extractRows(res.data);
|
|
338
|
+
else {
|
|
339
|
+
partial.push(partialFromRejection('h2h', {
|
|
340
|
+
code: mapHttpToCode(res.status),
|
|
341
|
+
message: `h2h HTTP ${res.status}`,
|
|
342
|
+
httpStatus: res.status,
|
|
343
|
+
}));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else if (game === 'lol') {
|
|
347
|
+
const res = await fetchJson(ctx, '/lol/schedule', {
|
|
348
|
+
query: { teamSlug: teamA, completed: 'true', limit: '40' },
|
|
349
|
+
});
|
|
350
|
+
upstreamCalls += 1;
|
|
351
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
352
|
+
if (res.ok)
|
|
353
|
+
rows = extractRows(res.data);
|
|
354
|
+
}
|
|
355
|
+
else if (game === 'cod') {
|
|
356
|
+
const res = await fetchJson(ctx, '/cod/matches', { query: { team: teamA, limit: 40 } });
|
|
357
|
+
upstreamCalls += 1;
|
|
358
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
359
|
+
if (res.ok)
|
|
360
|
+
rows = extractRows(res.data);
|
|
361
|
+
}
|
|
362
|
+
else if (game === 'dota2') {
|
|
363
|
+
const res = await fetchJson(ctx, '/dota2/matches/recent', { query: { limit: 40 } });
|
|
364
|
+
upstreamCalls += 1;
|
|
365
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
366
|
+
if (res.ok)
|
|
367
|
+
rows = extractRows(res.data);
|
|
368
|
+
}
|
|
369
|
+
h2h = composeH2H(game, teamA, teamB, rows, 10);
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
partial.push(partialFromRejection('h2h', {
|
|
373
|
+
code: 'UPSTREAM',
|
|
374
|
+
message: e.message,
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Factual briefing lines only — never structural meta like "roster loaded".
|
|
379
|
+
const talkingPoints = [];
|
|
380
|
+
const sideAName = pickString(asRecord(sideARes.entity)?.name, teamA) ?? teamA;
|
|
381
|
+
const sideBName = pickString(asRecord(sideBRes.entity)?.name, teamB) ?? teamB;
|
|
382
|
+
if (context.startTime)
|
|
383
|
+
talkingPoints.push(`${sideAName} vs ${sideBName} scheduled for ${context.startTime}`);
|
|
384
|
+
if (context.eventName)
|
|
385
|
+
talkingPoints.push(`Event: ${context.eventName}`);
|
|
386
|
+
if (sideARes.recentForm.length) {
|
|
387
|
+
const w = sideARes.recentForm.filter((m) => asRecord(m)?.result === 'W' || asRecord(m)?.won === true).length;
|
|
388
|
+
talkingPoints.push(`${sideAName} recent sample: ${sideARes.recentForm.length} matches` + (w ? ` (~${w} marked wins in sample)` : ''));
|
|
389
|
+
}
|
|
390
|
+
if (sideBRes.recentForm.length) {
|
|
391
|
+
const w = sideBRes.recentForm.filter((m) => asRecord(m)?.result === 'W' || asRecord(m)?.won === true).length;
|
|
392
|
+
talkingPoints.push(`${sideBName} recent sample: ${sideBRes.recentForm.length} matches` + (w ? ` (~${w} marked wins in sample)` : ''));
|
|
393
|
+
}
|
|
394
|
+
if (sideARes.keyPlayers.length) {
|
|
395
|
+
const names = sideARes.keyPlayers
|
|
396
|
+
.slice(0, 3)
|
|
397
|
+
.map((p) => pickString(asRecord(p)?.name, asRecord(p)?.nickname, asRecord(p)?.handle))
|
|
398
|
+
.filter(Boolean);
|
|
399
|
+
if (names.length)
|
|
400
|
+
talkingPoints.push(`${sideAName} key names: ${names.join(', ')}`);
|
|
401
|
+
}
|
|
402
|
+
if (sideBRes.keyPlayers.length) {
|
|
403
|
+
const names = sideBRes.keyPlayers
|
|
404
|
+
.slice(0, 3)
|
|
405
|
+
.map((p) => pickString(asRecord(p)?.name, asRecord(p)?.nickname, asRecord(p)?.handle))
|
|
406
|
+
.filter(Boolean);
|
|
407
|
+
if (names.length)
|
|
408
|
+
talkingPoints.push(`${sideBName} key names: ${names.join(', ')}`);
|
|
409
|
+
}
|
|
410
|
+
const h2hRec = asRecord(h2h);
|
|
411
|
+
if (h2hRec?.recordNote)
|
|
412
|
+
talkingPoints.push(String(h2hRec.recordNote));
|
|
413
|
+
else if (Array.isArray(h2hRec?.meetings) && h2hRec.meetings.length) {
|
|
414
|
+
talkingPoints.push(`H2H sample includes ${h2hRec.meetings.length} prior meeting(s)`);
|
|
415
|
+
}
|
|
416
|
+
if (!talkingPoints.length) {
|
|
417
|
+
talkingPoints.push('Limited preview data — expand with head_to_head or team_profile for deeper form.');
|
|
418
|
+
}
|
|
419
|
+
return successEnvelope({
|
|
420
|
+
source: 'match_preview',
|
|
421
|
+
game,
|
|
422
|
+
requestId,
|
|
423
|
+
tookMs: Date.now() - started,
|
|
424
|
+
upstreamCalls,
|
|
425
|
+
rateLimit,
|
|
426
|
+
partial: partial.length ? partial : undefined,
|
|
427
|
+
entities: {
|
|
428
|
+
games: [game],
|
|
429
|
+
ids: {
|
|
430
|
+
...(matchId ? { matchId } : {}),
|
|
431
|
+
teamA,
|
|
432
|
+
teamB,
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
data: {
|
|
436
|
+
context,
|
|
437
|
+
sideA: {
|
|
438
|
+
entity: sideARes.entity,
|
|
439
|
+
roster: sideARes.roster,
|
|
440
|
+
recentForm: sideARes.recentForm,
|
|
441
|
+
keyPlayers: sideARes.keyPlayers,
|
|
442
|
+
},
|
|
443
|
+
sideB: {
|
|
444
|
+
entity: sideBRes.entity,
|
|
445
|
+
roster: sideBRes.roster,
|
|
446
|
+
recentForm: sideBRes.recentForm,
|
|
447
|
+
keyPlayers: sideBRes.keyPlayers,
|
|
448
|
+
},
|
|
449
|
+
h2h,
|
|
450
|
+
talkingPoints,
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
/**
|
|
456
|
+
* Resolve event id/slug from a free-text query via game event list.
|
|
457
|
+
*/
|
|
458
|
+
async function resolveEventKey(ctx, game, q) {
|
|
459
|
+
let calls = 0;
|
|
460
|
+
let rateLimit = {};
|
|
461
|
+
const needle = q.toLowerCase().trim();
|
|
462
|
+
if (!needle)
|
|
463
|
+
return { key: null, name: null, calls, rateLimit };
|
|
464
|
+
let path = '';
|
|
465
|
+
let query = { limit: 25, page: 1 };
|
|
466
|
+
if (game === 'ufc') {
|
|
467
|
+
path = '/ufc/events';
|
|
468
|
+
query = { q, limit: 25, page: 1 };
|
|
469
|
+
}
|
|
470
|
+
else if (game === 'cs2') {
|
|
471
|
+
path = '/cs2/events';
|
|
472
|
+
query = { q, limit: 25, page: 1 };
|
|
473
|
+
}
|
|
474
|
+
else if (game === 'lol') {
|
|
475
|
+
path = '/lol/tournaments';
|
|
476
|
+
query = { limit: '25' };
|
|
477
|
+
}
|
|
478
|
+
else if (game === 'cod') {
|
|
479
|
+
path = '/cod/tournaments';
|
|
480
|
+
query = { limit: 25 };
|
|
481
|
+
}
|
|
482
|
+
else if (game === 'dota2') {
|
|
483
|
+
path = '/dota2/tournaments';
|
|
484
|
+
query = { limit: 25 };
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
return { key: null, name: null, calls, rateLimit };
|
|
488
|
+
}
|
|
489
|
+
const res = await fetchJson(ctx, path, { query });
|
|
490
|
+
calls += 1;
|
|
491
|
+
rateLimit = res.headers;
|
|
492
|
+
if (!res.ok)
|
|
493
|
+
return { key: null, name: null, calls, rateLimit };
|
|
494
|
+
const rows = extractRows(res.data);
|
|
495
|
+
let best = null;
|
|
496
|
+
for (const row of rows) {
|
|
497
|
+
const r = asRecord(row) ?? {};
|
|
498
|
+
const id = pickString(r.id, r.eventId, r.tournamentId) ?? '';
|
|
499
|
+
const slug = pickString(r.slug) ?? '';
|
|
500
|
+
const name = pickString(r.name, r.title, r.code, slug, id) ?? '';
|
|
501
|
+
const hay = `${name} ${slug} ${id}`.toLowerCase();
|
|
502
|
+
let score = 0;
|
|
503
|
+
if (hay === needle || slug.toLowerCase() === needle || id.toLowerCase() === needle)
|
|
504
|
+
score = 100;
|
|
505
|
+
else if (hay.includes(needle))
|
|
506
|
+
score = 50 + Math.min(20, needle.length);
|
|
507
|
+
else if (needle.split(/\s+/).every((t) => t && hay.includes(t)))
|
|
508
|
+
score = 30;
|
|
509
|
+
if (score > 0 && (!best || score > best.score)) {
|
|
510
|
+
best = { key: slug || id, name: name || slug || id, score };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return { key: best?.key ?? null, name: best?.name ?? null, calls, rateLimit };
|
|
514
|
+
}
|
|
515
|
+
export const eventCard = {
|
|
516
|
+
name: 'event_card',
|
|
517
|
+
description: `COMPOSITE event / fight-night card: identity, bout or match list, optional standings snippet.
|
|
518
|
+
|
|
519
|
+
When to use:
|
|
520
|
+
- UFC fight night / numbered event page ("UFC 300 card", "Fight Night")
|
|
521
|
+
- CS2 event hub with match list
|
|
522
|
+
- Tournament/event overview before match_preview drill-down
|
|
523
|
+
|
|
524
|
+
Prefer over: agent-side resolve + call_api /ufc/events + bout expansion; N+1 match_summary for the card list only.
|
|
525
|
+
|
|
526
|
+
Prefer match_preview for one bout/match briefing; match_summary for completed recaps; live_matches for live-only strips; standings alone for pure tables.
|
|
527
|
+
|
|
528
|
+
Do not use when: you only need live scores (live_matches); single finished match recap (match_summary); no event name/id yet and game unknown.
|
|
529
|
+
|
|
530
|
+
Parallel-safe: yes. Upstream cost: 1–4.
|
|
531
|
+
Example: { "game": "ufc", "eventIdOrSlug": "ufc-300", "includeBouts": true, "includeStandings": false }`,
|
|
532
|
+
inputSchema: {
|
|
533
|
+
type: 'object',
|
|
534
|
+
additionalProperties: false,
|
|
535
|
+
required: ['game'],
|
|
536
|
+
properties: {
|
|
537
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
538
|
+
eventIdOrSlug: stringSchema('Event or tournament id/slug. Prefer over q when known. Example: "ufc-300".', 'ufc-300'),
|
|
539
|
+
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'),
|
|
540
|
+
includeBouts: boolSchema('Include bout/match list (default true).', true),
|
|
541
|
+
includeStandings: boolSchema('Include standings/rankings snippet when API supports event/tournament/division scope (default false).', false),
|
|
542
|
+
limit: limitSchema({
|
|
543
|
+
default: 20,
|
|
544
|
+
max: 50,
|
|
545
|
+
description: 'Max bouts/matches on the card (default 20, max 50).',
|
|
546
|
+
}),
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
handler: async (args, ctx) => {
|
|
550
|
+
const started = Date.now();
|
|
551
|
+
const requestId = newRequestId();
|
|
552
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
553
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
554
|
+
return errorEnvelope({
|
|
555
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
556
|
+
message: gameParse.error ?? 'game is required',
|
|
557
|
+
game: null,
|
|
558
|
+
source: 'event_card',
|
|
559
|
+
requestId,
|
|
560
|
+
tookMs: Date.now() - started,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const game = gameParse.game;
|
|
564
|
+
let eventKey = typeof args.eventIdOrSlug === 'string' ? args.eventIdOrSlug.trim() : '';
|
|
565
|
+
const q = typeof args.q === 'string' ? args.q.trim() : '';
|
|
566
|
+
const includeBouts = args.includeBouts !== false;
|
|
567
|
+
const includeStandings = args.includeStandings === true;
|
|
568
|
+
const limit = clampInt(args.limit, 20, 1, 50);
|
|
569
|
+
if (!eventKey && !q) {
|
|
570
|
+
return errorEnvelope({
|
|
571
|
+
code: 'VALIDATION',
|
|
572
|
+
message: 'Pass eventIdOrSlug or q (event name)',
|
|
573
|
+
game,
|
|
574
|
+
source: 'event_card',
|
|
575
|
+
requestId,
|
|
576
|
+
tookMs: Date.now() - started,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
const partial = [];
|
|
580
|
+
let upstreamCalls = 0;
|
|
581
|
+
let rateLimit = {};
|
|
582
|
+
const warnings = [];
|
|
583
|
+
if (!eventKey && q) {
|
|
584
|
+
const resolved = await resolveEventKey(ctx, game, q);
|
|
585
|
+
upstreamCalls += resolved.calls;
|
|
586
|
+
rateLimit = { ...rateLimit, ...resolved.rateLimit };
|
|
587
|
+
if (resolved.key) {
|
|
588
|
+
eventKey = resolved.key;
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
return errorEnvelope({
|
|
592
|
+
code: 'NOT_FOUND',
|
|
593
|
+
message: `No event matched q="${q}" for ${game}`,
|
|
594
|
+
game,
|
|
595
|
+
source: 'event_card',
|
|
596
|
+
requestId,
|
|
597
|
+
tookMs: Date.now() - started,
|
|
598
|
+
upstreamCalls,
|
|
599
|
+
rateLimit,
|
|
600
|
+
recover: [
|
|
601
|
+
'Call resolve_entity { type: "event", game, q } or search_entities, then retry with eventIdOrSlug',
|
|
602
|
+
'For next fight night without a name, use upcoming_schedule { game: "ufc" }',
|
|
603
|
+
],
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
let event = {
|
|
608
|
+
id: eventKey,
|
|
609
|
+
slug: eventKey,
|
|
610
|
+
name: q || eventKey,
|
|
611
|
+
game,
|
|
612
|
+
};
|
|
613
|
+
let bouts = [];
|
|
614
|
+
let standingsSnippet = null;
|
|
615
|
+
if (game === 'ufc') {
|
|
616
|
+
const detail = await fetchJson(ctx, `/ufc/events/${encodeURIComponent(eventKey)}`);
|
|
617
|
+
upstreamCalls += 1;
|
|
618
|
+
rateLimit = { ...rateLimit, ...detail.headers };
|
|
619
|
+
if (detail.ok) {
|
|
620
|
+
const data = asRecord(detail.data) ?? {};
|
|
621
|
+
const inner = asRecord(data.data) ?? data;
|
|
622
|
+
event = {
|
|
623
|
+
id: pickString(inner.id, eventKey),
|
|
624
|
+
slug: pickString(inner.slug, eventKey),
|
|
625
|
+
name: pickString(inner.name, inner.title, q, eventKey),
|
|
626
|
+
date: pickString(inner.date, inner.startTime, inner.startsAt),
|
|
627
|
+
venue: pickString(inner.venue, asRecord(inner.location)?.name),
|
|
628
|
+
location: pickString(inner.location, inner.city, inner.country),
|
|
629
|
+
status: pickString(inner.status),
|
|
630
|
+
game,
|
|
631
|
+
};
|
|
632
|
+
if (includeBouts) {
|
|
633
|
+
const embedded = extractRows(inner.bouts ?? inner.fights ?? data.bouts);
|
|
634
|
+
if (embedded.length) {
|
|
635
|
+
bouts = embedded.slice(0, limit).map((row) => normalizeMatch('ufc', {
|
|
636
|
+
...(asRecord(row) ?? {}),
|
|
637
|
+
eventName: event.name,
|
|
638
|
+
eventId: event.id,
|
|
639
|
+
eventSlug: event.slug,
|
|
640
|
+
}, undefined));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
else if (detail.status === 404) {
|
|
645
|
+
return errorEnvelope({
|
|
646
|
+
code: 'NOT_FOUND',
|
|
647
|
+
message: `UFC event not found: ${eventKey}`,
|
|
648
|
+
game,
|
|
649
|
+
source: 'event_card',
|
|
650
|
+
requestId,
|
|
651
|
+
tookMs: Date.now() - started,
|
|
652
|
+
upstreamCalls,
|
|
653
|
+
rateLimit,
|
|
654
|
+
httpStatus: 404,
|
|
655
|
+
recover: [
|
|
656
|
+
'resolve_entity { game: "ufc", type: "event", q } then retry with returned id/slug',
|
|
657
|
+
'upcoming_schedule { game: "ufc" } for the next fight nights',
|
|
658
|
+
],
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
return errorEnvelope({
|
|
663
|
+
code: mapHttpToCode(detail.status, { gameNotIncluded: gameNotIncludedHint(detail.data) }),
|
|
664
|
+
message: `UFC event detail HTTP ${detail.status}`,
|
|
665
|
+
game,
|
|
666
|
+
source: 'event_card',
|
|
667
|
+
requestId,
|
|
668
|
+
tookMs: Date.now() - started,
|
|
669
|
+
upstreamCalls,
|
|
670
|
+
rateLimit,
|
|
671
|
+
httpStatus: detail.status,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
if (includeBouts && bouts.length === 0) {
|
|
675
|
+
const boutsRes = await fetchJson(ctx, `/ufc/events/${encodeURIComponent(String(event.slug ?? eventKey))}/bouts`);
|
|
676
|
+
upstreamCalls += 1;
|
|
677
|
+
rateLimit = { ...rateLimit, ...boutsRes.headers };
|
|
678
|
+
if (boutsRes.ok) {
|
|
679
|
+
bouts = extractRows(boutsRes.data)
|
|
680
|
+
.slice(0, limit)
|
|
681
|
+
.map((row) => normalizeMatch('ufc', {
|
|
682
|
+
...(asRecord(row) ?? {}),
|
|
683
|
+
eventName: event.name,
|
|
684
|
+
eventId: event.id,
|
|
685
|
+
eventSlug: event.slug,
|
|
686
|
+
}, undefined));
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
partial.push(partialFromRejection('bouts', {
|
|
690
|
+
code: mapHttpToCode(boutsRes.status),
|
|
691
|
+
message: `event bouts HTTP ${boutsRes.status}`,
|
|
692
|
+
httpStatus: boutsRes.status,
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (includeStandings) {
|
|
697
|
+
const ranks = await fetchJson(ctx, '/ufc/rankings');
|
|
698
|
+
upstreamCalls += 1;
|
|
699
|
+
rateLimit = { ...rateLimit, ...ranks.headers };
|
|
700
|
+
if (ranks.ok) {
|
|
701
|
+
standingsSnippet = {
|
|
702
|
+
scope: 'division',
|
|
703
|
+
note: 'UFC global rankings (not event-specific bracket)',
|
|
704
|
+
raw: ranks.data,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
partial.push(partialFromRejection('standings', {
|
|
709
|
+
code: mapHttpToCode(ranks.status),
|
|
710
|
+
message: `rankings HTTP ${ranks.status}`,
|
|
711
|
+
httpStatus: ranks.status,
|
|
712
|
+
}));
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
else if (game === 'cs2') {
|
|
717
|
+
const detail = await fetchJson(ctx, `/cs2/events/${encodeURIComponent(eventKey)}`);
|
|
718
|
+
upstreamCalls += 1;
|
|
719
|
+
rateLimit = { ...rateLimit, ...detail.headers };
|
|
720
|
+
if (detail.ok) {
|
|
721
|
+
const data = asRecord(detail.data) ?? {};
|
|
722
|
+
const inner = asRecord(data.data) ?? data;
|
|
723
|
+
event = {
|
|
724
|
+
id: pickString(inner.id, eventKey),
|
|
725
|
+
slug: pickString(inner.slug, eventKey),
|
|
726
|
+
name: pickString(inner.name, inner.title, q, eventKey),
|
|
727
|
+
date: pickString(inner.startDate, inner.date, inner.startTime),
|
|
728
|
+
status: pickString(inner.status),
|
|
729
|
+
game,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
else if (detail.status === 404) {
|
|
733
|
+
return errorEnvelope({
|
|
734
|
+
code: 'NOT_FOUND',
|
|
735
|
+
message: `CS2 event not found: ${eventKey}`,
|
|
736
|
+
game,
|
|
737
|
+
source: 'event_card',
|
|
738
|
+
requestId,
|
|
739
|
+
tookMs: Date.now() - started,
|
|
740
|
+
upstreamCalls,
|
|
741
|
+
rateLimit,
|
|
742
|
+
httpStatus: 404,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
partial.push(partialFromRejection('event', {
|
|
747
|
+
code: mapHttpToCode(detail.status, { gameNotIncluded: gameNotIncludedHint(detail.data) }),
|
|
748
|
+
message: `event detail HTTP ${detail.status}`,
|
|
749
|
+
httpStatus: detail.status,
|
|
750
|
+
}));
|
|
751
|
+
warnings.push('Event detail failed; attempting matches list with event filter');
|
|
752
|
+
}
|
|
753
|
+
if (includeBouts) {
|
|
754
|
+
const matchesRes = await fetchJson(ctx, '/cs2/matches', {
|
|
755
|
+
query: { event: eventKey, limit, page: 1 },
|
|
756
|
+
});
|
|
757
|
+
upstreamCalls += 1;
|
|
758
|
+
rateLimit = { ...rateLimit, ...matchesRes.headers };
|
|
759
|
+
if (matchesRes.ok) {
|
|
760
|
+
bouts = extractRows(matchesRes.data)
|
|
761
|
+
.slice(0, limit)
|
|
762
|
+
.map((row) => normalizeMatch('cs2', row, undefined));
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
partial.push(partialFromRejection('bouts', {
|
|
766
|
+
code: mapHttpToCode(matchesRes.status),
|
|
767
|
+
message: `cs2 matches HTTP ${matchesRes.status}`,
|
|
768
|
+
httpStatus: matchesRes.status,
|
|
769
|
+
}));
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (includeStandings) {
|
|
773
|
+
const st = await fetchJson(ctx, `/cs2/events/${encodeURIComponent(eventKey)}/standings`);
|
|
774
|
+
upstreamCalls += 1;
|
|
775
|
+
rateLimit = { ...rateLimit, ...st.headers };
|
|
776
|
+
if (st.ok)
|
|
777
|
+
standingsSnippet = { scope: 'event', raw: st.data };
|
|
778
|
+
else {
|
|
779
|
+
partial.push(partialFromRejection('standings', {
|
|
780
|
+
code: mapHttpToCode(st.status),
|
|
781
|
+
message: `event standings HTTP ${st.status}`,
|
|
782
|
+
httpStatus: st.status,
|
|
783
|
+
}));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
else if (game === 'lol') {
|
|
788
|
+
const detail = await fetchJson(ctx, `/lol/tournaments/${encodeURIComponent(eventKey)}`);
|
|
789
|
+
upstreamCalls += 1;
|
|
790
|
+
rateLimit = { ...rateLimit, ...detail.headers };
|
|
791
|
+
if (detail.ok) {
|
|
792
|
+
const data = asRecord(detail.data) ?? {};
|
|
793
|
+
const inner = asRecord(data.data) ?? data;
|
|
794
|
+
event = {
|
|
795
|
+
id: pickString(inner.id, eventKey),
|
|
796
|
+
slug: pickString(inner.slug, eventKey),
|
|
797
|
+
name: pickString(inner.name, inner.title, q, eventKey),
|
|
798
|
+
date: pickString(inner.startDate, inner.date),
|
|
799
|
+
status: pickString(inner.status),
|
|
800
|
+
game,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
partial.push(partialFromRejection('event', {
|
|
805
|
+
code: mapHttpToCode(detail.status, { gameNotIncluded: gameNotIncludedHint(detail.data) }),
|
|
806
|
+
message: `tournament HTTP ${detail.status}`,
|
|
807
|
+
httpStatus: detail.status,
|
|
808
|
+
}));
|
|
809
|
+
if (detail.status === 404) {
|
|
810
|
+
return errorEnvelope({
|
|
811
|
+
code: 'NOT_FOUND',
|
|
812
|
+
message: `LoL tournament not found: ${eventKey}`,
|
|
813
|
+
game,
|
|
814
|
+
source: 'event_card',
|
|
815
|
+
requestId,
|
|
816
|
+
tookMs: Date.now() - started,
|
|
817
|
+
upstreamCalls,
|
|
818
|
+
rateLimit,
|
|
819
|
+
httpStatus: 404,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (includeBouts) {
|
|
824
|
+
const sched = await fetchJson(ctx, '/lol/schedule', {
|
|
825
|
+
query: { tournamentId: eventKey, limit: String(limit) },
|
|
826
|
+
});
|
|
827
|
+
upstreamCalls += 1;
|
|
828
|
+
rateLimit = { ...rateLimit, ...sched.headers };
|
|
829
|
+
if (sched.ok) {
|
|
830
|
+
bouts = extractRows(sched.data)
|
|
831
|
+
.slice(0, limit)
|
|
832
|
+
.map((row) => normalizeMatch('lol', row, undefined));
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
partial.push(partialFromRejection('bouts', {
|
|
836
|
+
code: mapHttpToCode(sched.status),
|
|
837
|
+
message: `schedule HTTP ${sched.status}`,
|
|
838
|
+
httpStatus: sched.status,
|
|
839
|
+
}));
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (includeStandings) {
|
|
843
|
+
const st = await fetchJson(ctx, `/lol/tournaments/${encodeURIComponent(eventKey)}/standings`);
|
|
844
|
+
upstreamCalls += 1;
|
|
845
|
+
rateLimit = { ...rateLimit, ...st.headers };
|
|
846
|
+
if (st.ok)
|
|
847
|
+
standingsSnippet = { scope: 'tournament', raw: st.data };
|
|
848
|
+
else {
|
|
849
|
+
partial.push(partialFromRejection('standings', {
|
|
850
|
+
code: mapHttpToCode(st.status),
|
|
851
|
+
message: `tournament standings HTTP ${st.status}`,
|
|
852
|
+
httpStatus: st.status,
|
|
853
|
+
}));
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
else if (game === 'cod') {
|
|
858
|
+
const detail = await fetchJson(ctx, `/cod/tournaments/${encodeURIComponent(eventKey)}`);
|
|
859
|
+
upstreamCalls += 1;
|
|
860
|
+
rateLimit = { ...rateLimit, ...detail.headers };
|
|
861
|
+
if (detail.ok) {
|
|
862
|
+
const data = asRecord(detail.data) ?? {};
|
|
863
|
+
const inner = asRecord(data.data) ?? data;
|
|
864
|
+
event = {
|
|
865
|
+
id: pickString(inner.id, eventKey),
|
|
866
|
+
slug: pickString(inner.slug, eventKey),
|
|
867
|
+
name: pickString(inner.name, inner.title, q, eventKey),
|
|
868
|
+
game,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
else if (detail.status === 404) {
|
|
872
|
+
return errorEnvelope({
|
|
873
|
+
code: 'NOT_FOUND',
|
|
874
|
+
message: `COD tournament not found: ${eventKey}`,
|
|
875
|
+
game,
|
|
876
|
+
source: 'event_card',
|
|
877
|
+
requestId,
|
|
878
|
+
tookMs: Date.now() - started,
|
|
879
|
+
upstreamCalls,
|
|
880
|
+
rateLimit,
|
|
881
|
+
httpStatus: 404,
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
partial.push(partialFromRejection('event', {
|
|
886
|
+
code: mapHttpToCode(detail.status, { gameNotIncluded: gameNotIncludedHint(detail.data) }),
|
|
887
|
+
message: `tournament HTTP ${detail.status}`,
|
|
888
|
+
httpStatus: detail.status,
|
|
889
|
+
}));
|
|
890
|
+
}
|
|
891
|
+
if (includeBouts) {
|
|
892
|
+
const matchesRes = await fetchJson(ctx, '/cod/matches', {
|
|
893
|
+
query: { tournamentId: eventKey, limit },
|
|
894
|
+
});
|
|
895
|
+
upstreamCalls += 1;
|
|
896
|
+
rateLimit = { ...rateLimit, ...matchesRes.headers };
|
|
897
|
+
if (matchesRes.ok) {
|
|
898
|
+
bouts = extractRows(matchesRes.data)
|
|
899
|
+
.slice(0, limit)
|
|
900
|
+
.map((row) => normalizeMatch('cod', row, undefined));
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
partial.push(partialFromRejection('bouts', {
|
|
904
|
+
code: mapHttpToCode(matchesRes.status),
|
|
905
|
+
message: `cod matches HTTP ${matchesRes.status}`,
|
|
906
|
+
httpStatus: matchesRes.status,
|
|
907
|
+
}));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (includeStandings) {
|
|
911
|
+
const st = await fetchJson(ctx, '/cod/standings', { query: { tournamentId: eventKey } });
|
|
912
|
+
upstreamCalls += 1;
|
|
913
|
+
rateLimit = { ...rateLimit, ...st.headers };
|
|
914
|
+
if (st.ok)
|
|
915
|
+
standingsSnippet = { scope: 'tournament', raw: st.data };
|
|
916
|
+
else {
|
|
917
|
+
partial.push(partialFromRejection('standings', {
|
|
918
|
+
code: mapHttpToCode(st.status),
|
|
919
|
+
message: `cod standings HTTP ${st.status}`,
|
|
920
|
+
httpStatus: st.status,
|
|
921
|
+
}));
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
// dota2 — thin: tournament list + recent matches only
|
|
927
|
+
warnings.push('Dota2 event_card is thin; expect partial bouts/standings');
|
|
928
|
+
const detail = await fetchJson(ctx, `/dota2/tournaments/${encodeURIComponent(eventKey)}`);
|
|
929
|
+
upstreamCalls += 1;
|
|
930
|
+
rateLimit = { ...rateLimit, ...detail.headers };
|
|
931
|
+
if (detail.ok) {
|
|
932
|
+
const data = asRecord(detail.data) ?? {};
|
|
933
|
+
const inner = asRecord(data.data) ?? data;
|
|
934
|
+
event = {
|
|
935
|
+
id: pickString(inner.id, eventKey),
|
|
936
|
+
slug: pickString(inner.slug, eventKey),
|
|
937
|
+
name: pickString(inner.name, inner.title, q, eventKey),
|
|
938
|
+
game,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
else {
|
|
942
|
+
partial.push(partialFromRejection('event', {
|
|
943
|
+
code: mapHttpToCode(detail.status, { gameNotIncluded: gameNotIncludedHint(detail.data) }),
|
|
944
|
+
message: `dota tournament HTTP ${detail.status}`,
|
|
945
|
+
httpStatus: detail.status,
|
|
946
|
+
}));
|
|
947
|
+
event = { id: eventKey, slug: eventKey, name: q || eventKey, game };
|
|
948
|
+
}
|
|
949
|
+
if (includeBouts) {
|
|
950
|
+
const matchesRes = await fetchJson(ctx, '/dota2/matches/upcoming', { query: { limit } });
|
|
951
|
+
upstreamCalls += 1;
|
|
952
|
+
rateLimit = { ...rateLimit, ...matchesRes.headers };
|
|
953
|
+
if (matchesRes.ok) {
|
|
954
|
+
const key = eventKey.toLowerCase();
|
|
955
|
+
bouts = extractRows(matchesRes.data)
|
|
956
|
+
.map((row) => normalizeMatch('dota2', row, 'upcoming'))
|
|
957
|
+
.filter((m) => {
|
|
958
|
+
const hay = `${m.event?.id ?? ''} ${m.event?.slug ?? ''} ${m.event?.name ?? ''}`.toLowerCase();
|
|
959
|
+
return hay.includes(key) || !key;
|
|
960
|
+
})
|
|
961
|
+
.slice(0, limit);
|
|
962
|
+
if (!bouts.length) {
|
|
963
|
+
warnings.push('No dota upcoming matches matched this tournament filter');
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
partial.push(partialFromRejection('bouts', {
|
|
968
|
+
code: mapHttpToCode(matchesRes.status),
|
|
969
|
+
message: `dota upcoming HTTP ${matchesRes.status}`,
|
|
970
|
+
httpStatus: matchesRes.status,
|
|
971
|
+
}));
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (includeStandings) {
|
|
975
|
+
warnings.push('Dota standings often NOT_IMPLEMENTED');
|
|
976
|
+
partial.push(partialFromRejection('standings', {
|
|
977
|
+
code: 'NOT_IMPLEMENTED',
|
|
978
|
+
message: 'Dota has no first-class event standings in curated surface',
|
|
979
|
+
}));
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return successEnvelope({
|
|
983
|
+
source: 'event_card',
|
|
984
|
+
game,
|
|
985
|
+
requestId,
|
|
986
|
+
tookMs: Date.now() - started,
|
|
987
|
+
upstreamCalls,
|
|
988
|
+
rateLimit,
|
|
989
|
+
warnings: warnings.length ? warnings : undefined,
|
|
990
|
+
partial: partial.length ? partial : undefined,
|
|
991
|
+
entities: {
|
|
992
|
+
games: [game],
|
|
993
|
+
ids: {
|
|
994
|
+
eventId: String(event.id ?? eventKey),
|
|
995
|
+
...(event.slug ? { eventSlug: String(event.slug) } : {}),
|
|
996
|
+
},
|
|
997
|
+
},
|
|
998
|
+
data: {
|
|
999
|
+
event,
|
|
1000
|
+
bouts: includeBouts ? bouts : [],
|
|
1001
|
+
boutCount: includeBouts ? bouts.length : 0,
|
|
1002
|
+
standings: includeStandings ? standingsSnippet : null,
|
|
1003
|
+
nextSteps: [
|
|
1004
|
+
'For a featured bout/match: match_preview { game, matchId } or teamA+teamB',
|
|
1005
|
+
'When live: live_matches then match_summary / match_details',
|
|
1006
|
+
'Completed recap: match_summary { game, matchId: boutId }',
|
|
1007
|
+
],
|
|
1008
|
+
...(warnings.length ? { warnings } : {}),
|
|
1009
|
+
},
|
|
1010
|
+
});
|
|
1011
|
+
},
|
|
1012
|
+
};
|
|
1013
|
+
export const insightTools = [matchPreview, eventCard];
|