cito-mcp 0.2.2 → 0.2.4
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 +23 -4
- package/dist/client.js +36 -0
- package/dist/index.js +1 -1
- package/dist/tools/insight.js +60 -8
- package/dist/tools/live.js +29 -6
- package/dist/tools/match.js +16 -5
- package/dist/tools/meta.js +1 -1
- package/dist/tools/normalize.js +105 -29
- package/dist/tools/player.js +54 -10
- package/dist/tools/resolve.js +216 -32
- package/dist/tools/standings.js +32 -9
- package/dist/tools/team.js +29 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Standalone [MCP](https://modelcontextprotocol.io) server for the [Cito esports API](https://api.citoapi.com) — **curated outcome tools** for agents building esports apps, dashboards, bots, and research flows.
|
|
4
4
|
|
|
5
|
-
**Version:** `0.2.
|
|
5
|
+
**Version:** `0.2.4` · **Node:** `>=20` · **Install:** `npx cito-mcp`
|
|
6
6
|
|
|
7
7
|
Primary games: **lol · cs2 · dota2 · cod · ufc**. Fortnite and long-tail REST stay available via `call_api`.
|
|
8
8
|
|
|
@@ -164,6 +164,16 @@ All tools are **read-only**. Names are `snake_case` with **no** `cito_` prefix (
|
|
|
164
164
|
| `cod` | medium | Org slugs; CDL standings; UUID match ids |
|
|
165
165
|
| `ufc` | medium | Fighter slugs; `boutId`; live + rankings |
|
|
166
166
|
|
|
167
|
+
### UFC projection notes (0.2.4)
|
|
168
|
+
|
|
169
|
+
Hardened agent-facing UFC shapes (offline-tested):
|
|
170
|
+
|
|
171
|
+
- **`normalizeMatch`** reads `fighters[]` (`corner` + `fighterName` / `profile.name`), live `red`/`blue`, and `boutId` / `dataId` / bare `id`. Never keeps label `? vs ?` when fighter names exist (including blue-first arrays).
|
|
172
|
+
- **`live_matches`** uses `extractLiveRows`: prefer `liveBouts` (empty array = honest empty board); never promote supervisor `events[]` shells into match rows.
|
|
173
|
+
- **`upcoming_schedule`** expands event → bout rows with fighter labels; applies client-side **`hours` / `from` / `to`** (API has no `hours`); event shells without bouts use the **event name**, not `? vs ?`.
|
|
174
|
+
- **`event_card`** bouts share the same `normalizeMatch` path.
|
|
175
|
+
- **`standings`** maps division champions to rank **`C`** (`rankText: "C"`, interim **`IC`**); contender `#1` stays numeric `1` — no dual numeric `#1`.
|
|
176
|
+
|
|
167
177
|
Fortnite: **`call_api` only** until promoted into the primary enum.
|
|
168
178
|
|
|
169
179
|
### `call_api` allowlist
|
|
@@ -398,7 +408,7 @@ src/
|
|
|
398
408
|
|
|
399
409
|
## Publish notes
|
|
400
410
|
|
|
401
|
-
Package: **`cito-mcp@0.2.
|
|
411
|
+
Package: **`cito-mcp@0.2.4`**
|
|
402
412
|
|
|
403
413
|
| Item | Value |
|
|
404
414
|
| --- | --- |
|
|
@@ -433,7 +443,7 @@ claude mcp add cito -e CITO_API_KEY=cito_… -- npx cito-mcp
|
|
|
433
443
|
|
|
434
444
|
### Semver expectations
|
|
435
445
|
|
|
436
|
-
- **0.2.
|
|
446
|
+
- **0.2.4** is a **major surface break** vs 0.1 (tool rename + removal of OpenAPI mass-generation).
|
|
437
447
|
- Further 0.2.x patches may refine envelopes and composite quality without renaming the 15 tools.
|
|
438
448
|
- Promoting Fortnite (or other titles) into the primary `game` enum would be a minor feature bump with catalog/docs updates.
|
|
439
449
|
|
|
@@ -441,7 +451,16 @@ claude mcp add cito -e CITO_API_KEY=cito_… -- npx cito-mcp
|
|
|
441
451
|
|
|
442
452
|
## Changelog (summary)
|
|
443
453
|
|
|
444
|
-
### 0.2.
|
|
454
|
+
### 0.2.4
|
|
455
|
+
|
|
456
|
+
- **UFC projection hardening** (offline-tested):
|
|
457
|
+
- `normalizeMatch`: `fighters[]` corners + profile, live red/blue, `boutId`/`dataId`; never keep `? vs ?` when names exist
|
|
458
|
+
- `extractRows` / `extractLiveRows`: prefer `liveBouts` (including empty); never fake match rows from supervisor `events[]`
|
|
459
|
+
- `upcoming_schedule`: client-side `hours`/`from`/`to`; bout expansion with fighter labels; event shells labeled by event name
|
|
460
|
+
- `event_card` bouts share the same normalize path
|
|
461
|
+
- `standings`: champion rank `C` / interim `IC`; no dual numeric `#1` for champ + contender
|
|
462
|
+
|
|
463
|
+
### 0.2.4
|
|
445
464
|
|
|
446
465
|
- **Breaking:** replaced OpenAPI mass-generated tools with **15 curated outcome tools**
|
|
447
466
|
- Stable JSON envelope + MCP server instructions
|
package/dist/client.js
CHANGED
|
@@ -172,6 +172,42 @@ export function asRecord(value) {
|
|
|
172
172
|
}
|
|
173
173
|
return null;
|
|
174
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Peel Cito `{ success, data: T }` (and one nested `data`) so tools see the entity,
|
|
177
|
+
* not the envelope. Arrays and primitives pass through. Critical for UFC bouts/fighters
|
|
178
|
+
* where fighters[] / name live under data, not the top-level response.
|
|
179
|
+
*/
|
|
180
|
+
export function unwrapPayload(value) {
|
|
181
|
+
let cur = value;
|
|
182
|
+
for (let depth = 0; depth < 3; depth += 1) {
|
|
183
|
+
const rec = asRecord(cur);
|
|
184
|
+
if (!rec)
|
|
185
|
+
return cur;
|
|
186
|
+
// Classic withMeta: { success: true, data: { ...entity } }
|
|
187
|
+
if ('data' in rec && rec.data != null && typeof rec.data === 'object' && !Array.isArray(rec.data)) {
|
|
188
|
+
const inner = rec.data;
|
|
189
|
+
// Prefer entity-shaped inner objects over list wrappers
|
|
190
|
+
const looksLikeEntity = 'id' in inner ||
|
|
191
|
+
'slug' in inner ||
|
|
192
|
+
'name' in inner ||
|
|
193
|
+
'title' in inner ||
|
|
194
|
+
'fighters' in inner ||
|
|
195
|
+
'boutId' in inner ||
|
|
196
|
+
'matchId' in inner ||
|
|
197
|
+
'team1' in inner ||
|
|
198
|
+
'red' in inner ||
|
|
199
|
+
'blue' in inner ||
|
|
200
|
+
'record' in inner ||
|
|
201
|
+
'nickname' in inner;
|
|
202
|
+
if (looksLikeEntity || rec.success === true) {
|
|
203
|
+
cur = rec.data;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
return cur;
|
|
210
|
+
}
|
|
175
211
|
export function pickString(...values) {
|
|
176
212
|
for (const v of values) {
|
|
177
213
|
if (typeof v === 'string' && v.length > 0)
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { errorEnvelope, toMcpResult } from './envelope.js';
|
|
|
18
18
|
import { SERVER_INSTRUCTIONS } from './instructions.js';
|
|
19
19
|
import { allTools, getTool } from './tools/index.js';
|
|
20
20
|
import { runTool } from './tools/types.js';
|
|
21
|
-
const PACKAGE_VERSION = '0.2.
|
|
21
|
+
const PACKAGE_VERSION = '0.2.4';
|
|
22
22
|
const API_KEY = process.env.CITO_API_KEY;
|
|
23
23
|
if (!API_KEY) {
|
|
24
24
|
console.error('[cito-mcp] CITO_API_KEY is required.\n' +
|
package/dist/tools/insight.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* match_preview — pre-match briefing composite
|
|
3
3
|
*/
|
|
4
|
-
import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
|
|
4
|
+
import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
|
|
5
5
|
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
6
6
|
import { normalizeMatch } from './normalize.js';
|
|
7
7
|
import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
@@ -18,11 +18,15 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
|
|
|
18
18
|
calls += 1;
|
|
19
19
|
rateLimit = res.headers;
|
|
20
20
|
if (res.ok) {
|
|
21
|
-
const data = asRecord(res.data) ?? {};
|
|
21
|
+
const data = asRecord(unwrapPayload(res.data)) ?? {};
|
|
22
22
|
entity = {
|
|
23
|
-
id: pickString(data.id, side),
|
|
23
|
+
id: pickString(data.id, data.slug, side),
|
|
24
24
|
slug: pickString(data.slug, side),
|
|
25
|
-
name
|
|
25
|
+
// Prefer real display name; never leave entity.name as the slug when name exists
|
|
26
|
+
name: pickString(data.name, data.displayName, data.nickname, side) ?? side,
|
|
27
|
+
nickname: pickString(data.nickname) ?? null,
|
|
28
|
+
division: pickString(data.division) ?? null,
|
|
29
|
+
recordText: pickString(data.recordText, asRecord(data.record)?.text) ?? null,
|
|
26
30
|
game,
|
|
27
31
|
};
|
|
28
32
|
}
|
|
@@ -33,11 +37,33 @@ async function loadSide(ctx, game, side, recentLimit, includeRosters) {
|
|
|
33
37
|
httpStatus: res.status,
|
|
34
38
|
}));
|
|
35
39
|
}
|
|
40
|
+
// Fight-by-fight form (not aggregate stats alone)
|
|
41
|
+
const fights = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(side)}/fights`, {
|
|
42
|
+
query: { limit: recentLimit },
|
|
43
|
+
});
|
|
44
|
+
calls += 1;
|
|
45
|
+
rateLimit = { ...rateLimit, ...fights.headers };
|
|
46
|
+
if (fights.ok) {
|
|
47
|
+
recentForm = extractRows(unwrapPayload(fights.data) ?? fights.data)
|
|
48
|
+
.slice(0, recentLimit)
|
|
49
|
+
.map((row) => {
|
|
50
|
+
// history rows nest bout
|
|
51
|
+
const r = asRecord(row) ?? {};
|
|
52
|
+
const bout = asRecord(r.bout) ?? r;
|
|
53
|
+
return normalizeMatch('ufc', { ...bout, ...(r.fighterName ? {} : {}) }, 'completed');
|
|
54
|
+
});
|
|
55
|
+
}
|
|
36
56
|
const stats = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(side)}/stats`);
|
|
37
57
|
calls += 1;
|
|
38
58
|
rateLimit = { ...rateLimit, ...stats.headers };
|
|
39
|
-
if (stats.ok)
|
|
40
|
-
recentForm = [
|
|
59
|
+
if (stats.ok && recentForm.length === 0) {
|
|
60
|
+
recentForm = []; // keep empty rather than stuffing aggregate as "matches"
|
|
61
|
+
keyPlayers = [];
|
|
62
|
+
}
|
|
63
|
+
if (stats.ok) {
|
|
64
|
+
// stash aggregate on entity for talking points
|
|
65
|
+
entity = { ...entity, stats: unwrapPayload(stats.data) };
|
|
66
|
+
}
|
|
41
67
|
return { entity, roster, recentForm, keyPlayers, calls, partial, rateLimit };
|
|
42
68
|
}
|
|
43
69
|
if (game === 'lol') {
|
|
@@ -285,14 +311,19 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
|
|
|
285
311
|
upstreamCalls += 1;
|
|
286
312
|
rateLimit = res.headers;
|
|
287
313
|
if (res.ok) {
|
|
288
|
-
const
|
|
314
|
+
const entity = unwrapPayload(res.data);
|
|
315
|
+
// Do not force "upcoming" — completed bouts (e.g. UFC 300) should keep completed.
|
|
316
|
+
const m = normalizeMatch(game, entity);
|
|
289
317
|
context = {
|
|
290
|
-
matchId: m.matchId,
|
|
318
|
+
matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
|
|
291
319
|
startTime: m.startTime,
|
|
292
320
|
event: m.event,
|
|
321
|
+
eventName: m.event?.name ?? null,
|
|
293
322
|
league: m.league,
|
|
294
323
|
status: m.status,
|
|
324
|
+
label: m.label,
|
|
295
325
|
};
|
|
326
|
+
// Prefer stable slugs for follow-up fighter fetches
|
|
296
327
|
if (!teamA)
|
|
297
328
|
teamA = m.team1?.slug || m.team1?.id || m.team1?.name || '';
|
|
298
329
|
if (!teamB)
|
|
@@ -366,6 +397,27 @@ Example: { "game": "lol", "teamA": "t1", "teamB": "gen-g", "includeH2H": true, "
|
|
|
366
397
|
if (res.ok)
|
|
367
398
|
rows = extractRows(res.data);
|
|
368
399
|
}
|
|
400
|
+
else if (game === 'ufc') {
|
|
401
|
+
// Fighter fight history is the H2H source of truth (not global /bouts page 1).
|
|
402
|
+
const hist = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(teamA)}/fights`, {
|
|
403
|
+
query: { limit: 50 },
|
|
404
|
+
});
|
|
405
|
+
upstreamCalls += 1;
|
|
406
|
+
rateLimit = { ...rateLimit, ...hist.headers };
|
|
407
|
+
if (hist.ok) {
|
|
408
|
+
rows = extractRows(unwrapPayload(hist.data) ?? hist.data).map((row) => {
|
|
409
|
+
const r = asRecord(row) ?? {};
|
|
410
|
+
return asRecord(r.bout) ?? row;
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
partial.push(partialFromRejection('h2h', {
|
|
415
|
+
code: mapHttpToCode(hist.status),
|
|
416
|
+
message: `fighter fights HTTP ${hist.status}`,
|
|
417
|
+
httpStatus: hist.status,
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
369
421
|
h2h = composeH2H(game, teamA, teamB, rows, 10);
|
|
370
422
|
}
|
|
371
423
|
catch (e) {
|
package/dist/tools/live.js
CHANGED
|
@@ -16,17 +16,39 @@ const LIVE_PATHS = {
|
|
|
16
16
|
* Extract live match/bout rows. UFC /ufc/live returns
|
|
17
17
|
* { liveBouts, events, ... } — events are supervisor shells without fighters.
|
|
18
18
|
* Prefer liveBouts (and nested tracking on events) over plain events[].
|
|
19
|
+
* Exported for offline UFC projection tests.
|
|
19
20
|
*/
|
|
20
|
-
function extractLiveRows(data, game) {
|
|
21
|
+
export function extractLiveRows(data, game) {
|
|
21
22
|
const root = asRecord(data);
|
|
22
23
|
const payload = asRecord(root?.data) ?? root;
|
|
23
24
|
if (!payload)
|
|
24
25
|
return extractRows(data);
|
|
25
26
|
if (game === 'ufc') {
|
|
26
|
-
|
|
27
|
-
if (liveBouts
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// Prefer liveBouts when the key is present (including empty [] — honest empty board).
|
|
28
|
+
if (Array.isArray(payload.liveBouts)) {
|
|
29
|
+
const liveBouts = payload.liveBouts;
|
|
30
|
+
if (liveBouts.length)
|
|
31
|
+
return liveBouts;
|
|
32
|
+
// Empty liveBouts: still check nested tracking, but never bare events[]
|
|
33
|
+
const eventsWithEmptyLive = Array.isArray(payload.events)
|
|
34
|
+
? payload.events
|
|
35
|
+
: [];
|
|
36
|
+
const nested = [];
|
|
37
|
+
for (const ev of eventsWithEmptyLive) {
|
|
38
|
+
const er = asRecord(ev);
|
|
39
|
+
const tracking = Array.isArray(er?.tracking) ? er.tracking : [];
|
|
40
|
+
for (const t of tracking) {
|
|
41
|
+
const tr = asRecord(t) ?? {};
|
|
42
|
+
nested.push({
|
|
43
|
+
...tr,
|
|
44
|
+
eventSlug: pickString(tr.eventSlug, er?.eventSlug, er?.slug),
|
|
45
|
+
eventName: pickString(er?.name, er?.eventName, er?.eventSlug),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return nested; // [] when only supervisor shells
|
|
50
|
+
}
|
|
51
|
+
// liveBouts key missing — try nested tracking under events
|
|
30
52
|
const events = Array.isArray(payload.events) ? payload.events : [];
|
|
31
53
|
const fromTracking = [];
|
|
32
54
|
for (const ev of events) {
|
|
@@ -192,7 +214,8 @@ Filter support (unsupported params are ignored with meta.warnings — do not ass
|
|
|
192
214
|
- lol: hours, team (slug), league (slug)
|
|
193
215
|
- cs2: team, from, to (ISO); hours not applied upstream
|
|
194
216
|
- cod: team, tournamentId
|
|
195
|
-
- dota2
|
|
217
|
+
- dota2: limit/cursor primarily; team may be client-filtered where data allows
|
|
218
|
+
- ufc: hours / from / to applied client-side after bout expansion (API has no hours); event shells labeled by event name; bouts use fighters[] corners
|
|
196
219
|
|
|
197
220
|
Parallel-safe: yes. Upstream cost: 1–2.
|
|
198
221
|
Example: { "game": "lol", "hours": 72, "team": "t1", "limit": 20 }`,
|
package/dist/tools/match.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* match_summary + match_details
|
|
3
3
|
*/
|
|
4
|
-
import { extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
|
|
4
|
+
import { extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
|
|
5
5
|
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
6
6
|
import { normalizeMatch } from './normalize.js';
|
|
7
7
|
import { boolSchema, gameSchema, isPrimaryGame, parseGame, stringSchema, } from './types.js';
|
|
@@ -9,8 +9,14 @@ async function getSection(ctx, path, query) {
|
|
|
9
9
|
return fetchJson(ctx, path, { query });
|
|
10
10
|
}
|
|
11
11
|
function matchCore(game, matchId, raw) {
|
|
12
|
-
const
|
|
13
|
-
const
|
|
12
|
+
const entity = unwrapPayload(raw);
|
|
13
|
+
const m = normalizeMatch(game, entity);
|
|
14
|
+
const r = asRecord(entity) ?? {};
|
|
15
|
+
const s1 = m.team1?.score ?? null;
|
|
16
|
+
const s2 = m.team2?.score ?? null;
|
|
17
|
+
const scoreline = m.team1 || m.team2
|
|
18
|
+
? `${m.team1?.name ?? '?'} ${s1 ?? '-'} : ${s2 ?? '-'} ${m.team2?.name ?? '?'}`
|
|
19
|
+
: null;
|
|
14
20
|
return {
|
|
15
21
|
matchId: m.matchId !== 'unknown' ? m.matchId : matchId,
|
|
16
22
|
game,
|
|
@@ -18,14 +24,19 @@ function matchCore(game, matchId, raw) {
|
|
|
18
24
|
startTime: m.startTime,
|
|
19
25
|
endTime: pickString(r.endTime, r.endedAt, r.finishedAt) ?? null,
|
|
20
26
|
bestOf: r.bestOf ?? r.bo ?? null,
|
|
27
|
+
label: m.label,
|
|
28
|
+
scoreline,
|
|
21
29
|
score: {
|
|
22
|
-
team1:
|
|
23
|
-
team2:
|
|
30
|
+
team1: s1,
|
|
31
|
+
team2: s2,
|
|
24
32
|
},
|
|
25
33
|
team1: m.team1,
|
|
26
34
|
team2: m.team2,
|
|
27
35
|
event: m.event,
|
|
28
36
|
league: m.league,
|
|
37
|
+
method: pickString(r.method, r.resultMethod) ?? null,
|
|
38
|
+
winner: pickString(r.winnerFighterSlug, r.winnerSlug, r.winner) ?? null,
|
|
39
|
+
weightClass: pickString(r.weightClass, r.division) ?? null,
|
|
29
40
|
rawStatus: pickString(r.status, r.state) ?? null,
|
|
30
41
|
};
|
|
31
42
|
}
|
package/dist/tools/meta.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { extractRows, fetchJson, gameNotIncludedHint, present } from '../client.js';
|
|
5
5
|
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
6
6
|
import { boolSchema, gameSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
|
|
7
|
-
const CATALOG_VERSION = '0.2.
|
|
7
|
+
const CATALOG_VERSION = '0.2.4';
|
|
8
8
|
const TOOL_CATALOG = [
|
|
9
9
|
{
|
|
10
10
|
name: 'list_capabilities',
|
package/dist/tools/normalize.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-game row normalization for live/schedule boards and entity cards.
|
|
3
3
|
*/
|
|
4
|
-
import { asRecord, pickString } from '../client.js';
|
|
4
|
+
import { asRecord, pickString, unwrapPayload } from '../client.js';
|
|
5
5
|
function sideFrom(name, id, slug, score) {
|
|
6
6
|
if (!name && !id && !slug)
|
|
7
7
|
return null;
|
|
@@ -39,8 +39,9 @@ function scoreNum(v) {
|
|
|
39
39
|
return null;
|
|
40
40
|
}
|
|
41
41
|
export function normalizeMatch(game, row, forcedStatus) {
|
|
42
|
-
|
|
43
|
-
const
|
|
42
|
+
// Always peel { success, data } so UFC bout fighters[] / status are visible.
|
|
43
|
+
const r = asRecord(unwrapPayload(row)) ?? asRecord(row) ?? {};
|
|
44
|
+
const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId, r.ufcFightId) ?? 'unknown';
|
|
44
45
|
let team1 = nestedSide(r.team1) ??
|
|
45
46
|
sideFrom(pickString(r.team1Name, r.team_a_name, r.redName, r.fighter1Name, r.homeName), pickString(r.team1Id, r.team1_id, r.redId, r.fighter1Id), pickString(r.team1Slug, r.redSlug, r.fighter1Slug), scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore));
|
|
46
47
|
let team2 = nestedSide(r.team2) ??
|
|
@@ -96,27 +97,73 @@ export function normalizeMatch(game, row, forcedStatus) {
|
|
|
96
97
|
team1 = team1 ?? nestedSide(r.teams[0]);
|
|
97
98
|
team2 = team2 ?? nestedSide(r.teams[1]);
|
|
98
99
|
}
|
|
99
|
-
const startTime = pickString(r.startTime, r.scheduledAt, r.startsAt, r.date, r.startDate, r.beginAt) ?? null;
|
|
100
|
-
const statusRaw = pickString(r.status, r.state, r.matchStatus)?.toLowerCase() ?? '';
|
|
100
|
+
const startTime = pickString(r.startTime, r.scheduledAt, r.startsAt, r.date, r.startDate, r.beginAt, asRecord(r.event)?.startsAt, asRecord(r.event)?.startTime, asRecord(r.event)?.date) ?? null;
|
|
101
|
+
const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus)?.toLowerCase() ?? '';
|
|
101
102
|
let status = forcedStatus ?? 'unknown';
|
|
102
103
|
if (!forcedStatus) {
|
|
103
|
-
|
|
104
|
+
const hasResult = Boolean(pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.method, r.result)) ||
|
|
105
|
+
r.resultRound != null ||
|
|
106
|
+
r.isComplete === true ||
|
|
107
|
+
r.completed === true;
|
|
108
|
+
if (/live|running|in_?progress|ongoing|started|watching/.test(statusRaw))
|
|
104
109
|
status = 'live';
|
|
105
|
-
else if (/
|
|
106
|
-
status = 'upcoming';
|
|
107
|
-
else if (/complete|finished|ended|final|closed|done/.test(statusRaw))
|
|
110
|
+
else if (/complete|finished|ended|final|closed|done|official|result/.test(statusRaw) || hasResult) {
|
|
108
111
|
status = 'completed';
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
}
|
|
113
|
+
else if (/upcoming|scheduled|not_started|pending|soon|booked|confirmed|announced/.test(statusRaw) ||
|
|
114
|
+
(game === 'ufc' && !hasResult && (startTime || statusRaw === '' || statusRaw === 'unknown'))) {
|
|
115
|
+
// UFC bouts often omit status or use sparse values; default upcoming when no result yet.
|
|
116
|
+
if (hasResult)
|
|
117
|
+
status = 'completed';
|
|
118
|
+
else if (startTime) {
|
|
119
|
+
const t = Date.parse(startTime);
|
|
120
|
+
status = Number.isFinite(t) && t < Date.now() - 3 * 3600_000 ? 'completed' : 'upcoming';
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
status = 'upcoming';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else if (hasResult)
|
|
127
|
+
status = 'completed';
|
|
128
|
+
}
|
|
129
|
+
// Winner → score 1-0 for UFC card display when numeric scores absent
|
|
130
|
+
if (game === 'ufc' && team1 && team2 && team1.score == null && team2.score == null) {
|
|
131
|
+
const winner = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner)?.toLowerCase();
|
|
132
|
+
if (winner) {
|
|
133
|
+
const t1hit = [team1.slug, team1.id, team1.name].some((x) => x && (String(x).toLowerCase() === winner || String(x).toLowerCase().includes(winner) || winner.includes(String(x).toLowerCase())));
|
|
134
|
+
const t2hit = [team2.slug, team2.id, team2.name].some((x) => x && (String(x).toLowerCase() === winner || String(x).toLowerCase().includes(winner) || winner.includes(String(x).toLowerCase())));
|
|
135
|
+
if (t1hit && !t2hit) {
|
|
136
|
+
team1 = { ...team1, score: 1 };
|
|
137
|
+
team2 = { ...team2, score: 0 };
|
|
138
|
+
}
|
|
139
|
+
else if (t2hit && !t1hit) {
|
|
140
|
+
team1 = { ...team1, score: 0 };
|
|
141
|
+
team2 = { ...team2, score: 1 };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
111
144
|
}
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
const
|
|
145
|
+
const ev = asRecord(r.event);
|
|
146
|
+
const eventName = pickString(ev?.name, ev?.title, r.eventName, typeof r.event === 'string' ? r.event : undefined, asRecord(r.tournament)?.name, asRecord(r.tournament)?.title, r.tournamentName);
|
|
147
|
+
const eventId = pickString(ev?.id, r.eventId, asRecord(r.tournament)?.id, r.tournamentId);
|
|
148
|
+
const eventSlug = pickString(ev?.slug, r.eventSlug, asRecord(r.tournament)?.slug);
|
|
115
149
|
const leagueName = pickString(asRecord(r.league)?.name, r.leagueName, r.league, r.leagueSlug);
|
|
116
150
|
const leagueId = pickString(asRecord(r.league)?.id, r.leagueId);
|
|
117
151
|
const leagueSlug = pickString(asRecord(r.league)?.slug, r.leagueSlug);
|
|
118
|
-
|
|
119
|
-
|
|
152
|
+
// Prefer explicit title/label when present, but never keep the placeholder
|
|
153
|
+
// "? vs ?" once fighter/team names were resolved (UFC fighters[] / red-blue).
|
|
154
|
+
const vsLabel = `${team1?.name ?? '?'} vs ${team2?.name ?? '?'}`;
|
|
155
|
+
const explicit = pickString(r.label, r.title, r.name);
|
|
156
|
+
const explicitIsPlaceholder = explicit != null && /^\?\s*vs\s*\?$/i.test(explicit.trim());
|
|
157
|
+
let label;
|
|
158
|
+
if (explicit && !explicitIsPlaceholder) {
|
|
159
|
+
label = explicit;
|
|
160
|
+
}
|
|
161
|
+
else if (team1?.name || team2?.name) {
|
|
162
|
+
label = vsLabel;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
label = explicit ?? vsLabel;
|
|
166
|
+
}
|
|
120
167
|
return {
|
|
121
168
|
game,
|
|
122
169
|
matchId,
|
|
@@ -135,7 +182,9 @@ export function entityRef(row, type, game) {
|
|
|
135
182
|
pickString(r.slug) ??
|
|
136
183
|
'unknown';
|
|
137
184
|
const slug = pickString(r.slug, r.orgSlug, r.teamSlug);
|
|
138
|
-
|
|
185
|
+
// Events often use `title` not `name` (UFC)
|
|
186
|
+
const name = pickString(r.name, r.title, r.nickname, r.displayName, r.tag, r.code, slug, id) ?? id;
|
|
187
|
+
const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname);
|
|
139
188
|
return {
|
|
140
189
|
game,
|
|
141
190
|
type,
|
|
@@ -143,34 +192,61 @@ export function entityRef(row, type, game) {
|
|
|
143
192
|
...(slug ? { slug } : {}),
|
|
144
193
|
name,
|
|
145
194
|
meta: {
|
|
195
|
+
...(nickname && nickname !== name ? { nickname } : {}),
|
|
146
196
|
...(r.region ? { region: r.region } : {}),
|
|
147
197
|
...(r.role ? { role: r.role } : {}),
|
|
148
198
|
...(r.nationality ? { nationality: r.nationality } : {}),
|
|
149
199
|
...(r.entity_type ? { entity_type: r.entity_type } : {}),
|
|
200
|
+
...(r.division ? { division: r.division } : {}),
|
|
201
|
+
...(r.p4pRank != null ? { p4pRank: r.p4pRank } : {}),
|
|
202
|
+
...(r.isChampion != null ? { isChampion: r.isChampion } : {}),
|
|
150
203
|
},
|
|
151
204
|
};
|
|
152
205
|
}
|
|
153
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Fuzzy score for entity resolution: exact > startsWith > includes > token overlap.
|
|
208
|
+
* Extra args (nickname, alt names) are scored as additional candidates.
|
|
209
|
+
* Multi-token person names ("jon jones") get a strong boost when every token hits.
|
|
210
|
+
*/
|
|
211
|
+
export function rankScore(query, name, id, slug, ...extra) {
|
|
212
|
+
const q = query.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
156
213
|
if (!q)
|
|
157
214
|
return 0;
|
|
158
|
-
const candidates = [name, id, slug]
|
|
215
|
+
const candidates = [name, id, slug, ...extra]
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
.map((s) => String(s).toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim());
|
|
159
218
|
let best = 0;
|
|
219
|
+
const qt = q.split(/\s+/).filter(Boolean);
|
|
160
220
|
for (const c of candidates) {
|
|
221
|
+
if (!c)
|
|
222
|
+
continue;
|
|
161
223
|
if (c === q)
|
|
162
224
|
best = Math.max(best, 100);
|
|
163
225
|
else if (c.startsWith(q))
|
|
164
|
-
best = Math.max(best,
|
|
226
|
+
best = Math.max(best, 85 - Math.min(15, c.length - q.length));
|
|
227
|
+
else if (q.startsWith(c) && c.length >= 3)
|
|
228
|
+
best = Math.max(best, 70);
|
|
165
229
|
else if (c.includes(q))
|
|
166
|
-
best = Math.max(best,
|
|
230
|
+
best = Math.max(best, 55 - Math.min(20, c.indexOf(q)));
|
|
167
231
|
else {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
best = Math.max(best,
|
|
232
|
+
const ct = c.split(/[\s]+/).filter(Boolean);
|
|
233
|
+
// all query tokens present as whole tokens (order-independent)
|
|
234
|
+
const allTokens = qt.length > 0 &&
|
|
235
|
+
qt.every((t) => ct.some((x) => x === t || x.startsWith(t) || t.startsWith(x)));
|
|
236
|
+
if (allTokens && qt.length >= 2) {
|
|
237
|
+
best = Math.max(best, 92); // "jon jones" vs "Jon Jones"
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
const hits = qt.filter((t) => ct.some((x) => x.includes(t) || t.includes(x))).length;
|
|
241
|
+
if (hits === qt.length && qt.length > 0)
|
|
242
|
+
best = Math.max(best, 75);
|
|
243
|
+
else if (hits)
|
|
244
|
+
best = Math.max(best, 20 + hits * 12);
|
|
245
|
+
}
|
|
246
|
+
// last-name only: query last token equals candidate last token
|
|
247
|
+
if (qt.length >= 2 && ct.length >= 1 && ct[ct.length - 1] === qt[qt.length - 1]) {
|
|
248
|
+
best = Math.max(best, 60);
|
|
249
|
+
}
|
|
174
250
|
}
|
|
175
251
|
}
|
|
176
252
|
return best;
|
package/dist/tools/player.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* player_profile
|
|
3
3
|
*/
|
|
4
|
-
import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
|
|
4
|
+
import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, unwrapPayload, } from '../client.js';
|
|
5
5
|
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
6
6
|
import { normalizeMatch } from './normalize.js';
|
|
7
7
|
import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
@@ -165,12 +165,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
165
165
|
httpStatus: res.status,
|
|
166
166
|
});
|
|
167
167
|
}
|
|
168
|
-
playerRaw = res.data;
|
|
169
|
-
// some APIs wrap in data
|
|
170
|
-
const obj = asRecord(res.data);
|
|
171
|
-
if (obj?.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) {
|
|
172
|
-
playerRaw = obj.data;
|
|
173
|
-
}
|
|
168
|
+
playerRaw = unwrapPayload(res.data);
|
|
174
169
|
}
|
|
175
170
|
const player = identityFrom(game, playerRaw, playerId || undefined, slug || undefined);
|
|
176
171
|
let currentTeam = player.team;
|
|
@@ -306,13 +301,15 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
306
301
|
})());
|
|
307
302
|
}
|
|
308
303
|
if (game === 'ufc') {
|
|
304
|
+
const fighterKey = slug || idOrSlug;
|
|
309
305
|
tasks.push((async () => {
|
|
310
|
-
const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(
|
|
306
|
+
const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(fighterKey)}/stats`);
|
|
311
307
|
upstreamCalls += 1;
|
|
312
308
|
rateLimit = { ...rateLimit, ...res.headers };
|
|
313
309
|
if (res.ok) {
|
|
314
|
-
|
|
315
|
-
|
|
310
|
+
const metrics = unwrapPayload(res.data);
|
|
311
|
+
form = { summary: 'UFC fighter stats', trend: null, window: null, metrics };
|
|
312
|
+
seasonStats = metrics;
|
|
316
313
|
}
|
|
317
314
|
else {
|
|
318
315
|
partial.push(partialFromRejection('form', {
|
|
@@ -322,6 +319,53 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
|
|
|
322
319
|
}));
|
|
323
320
|
}
|
|
324
321
|
})());
|
|
322
|
+
tasks.push((async () => {
|
|
323
|
+
// Fight-by-fight history — /fights and /history are aliases on the API
|
|
324
|
+
const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(fighterKey)}/fights`, {
|
|
325
|
+
query: { limit: recentLimit },
|
|
326
|
+
});
|
|
327
|
+
upstreamCalls += 1;
|
|
328
|
+
rateLimit = { ...rateLimit, ...res.headers };
|
|
329
|
+
if (res.ok) {
|
|
330
|
+
recentMatches = extractRows(res.data)
|
|
331
|
+
.slice(0, recentLimit)
|
|
332
|
+
.map((row) => {
|
|
333
|
+
const r = asRecord(row) ?? {};
|
|
334
|
+
const bout = asRecord(r.bout) ?? row;
|
|
335
|
+
const m = normalizeMatch('ufc', bout, 'completed');
|
|
336
|
+
return {
|
|
337
|
+
...m,
|
|
338
|
+
result: pickString(r.result, r.outcome, asRecord(bout)?.result) ?? null,
|
|
339
|
+
opponent: pickString(r.opponentName, asRecord(r.opponent)?.name) ?? null,
|
|
340
|
+
method: pickString(asRecord(bout)?.method, r.method) ?? null,
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
// fallback history path
|
|
346
|
+
const hist = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(fighterKey)}/history`, {
|
|
347
|
+
query: { limit: recentLimit },
|
|
348
|
+
});
|
|
349
|
+
upstreamCalls += 1;
|
|
350
|
+
rateLimit = { ...rateLimit, ...hist.headers };
|
|
351
|
+
if (hist.ok) {
|
|
352
|
+
recentMatches = extractRows(hist.data)
|
|
353
|
+
.slice(0, recentLimit)
|
|
354
|
+
.map((row) => {
|
|
355
|
+
const r = asRecord(row) ?? {};
|
|
356
|
+
const bout = asRecord(r.bout) ?? row;
|
|
357
|
+
return normalizeMatch('ufc', bout, 'completed');
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
partial.push(partialFromRejection('recentMatches', {
|
|
362
|
+
code: mapHttpToCode(res.status),
|
|
363
|
+
message: `fighter fights HTTP ${res.status}`,
|
|
364
|
+
httpStatus: res.status,
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
})());
|
|
325
369
|
}
|
|
326
370
|
await Promise.all(tasks);
|
|
327
371
|
if (view === 'summary' && recentMatches.length > recentLimit) {
|
package/dist/tools/resolve.js
CHANGED
|
@@ -8,31 +8,124 @@ import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_
|
|
|
8
8
|
function pushCandidates(out, game, type, rows, q, limit) {
|
|
9
9
|
for (const row of rows) {
|
|
10
10
|
const ref = entityRef(row, type, game);
|
|
11
|
-
const score = rankScore(q, ref.name, ref.id, ref.slug);
|
|
12
|
-
if (q && score <= 0 && type !== 'any') {
|
|
13
|
-
// still include search API hits even if local rank is weak
|
|
14
|
-
}
|
|
15
11
|
const r = asRecord(row) ?? {};
|
|
12
|
+
const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname, ref.meta?.nickname);
|
|
13
|
+
const score = rankScore(q, ref.name, ref.id, ref.slug, nickname);
|
|
14
|
+
// Prefer positive fuzzy hits; keep weak API hits at floor 1 so real search results are not wiped.
|
|
15
|
+
if (q && score <= 0) {
|
|
16
|
+
// still allow through at floor so dedicated search endpoints aren't empty on odd nicknames
|
|
17
|
+
}
|
|
16
18
|
const secondary = {};
|
|
17
19
|
for (const key of ['lolPlayerId', 'codPlayerId', 'matchId', 'boutId', 'tournamentId', 'eventId']) {
|
|
18
20
|
const v = pickString(r[key]);
|
|
19
21
|
if (v)
|
|
20
22
|
secondary[key] = v;
|
|
21
23
|
}
|
|
24
|
+
if (nickname)
|
|
25
|
+
secondary.nickname = nickname;
|
|
22
26
|
out.push({
|
|
23
27
|
game,
|
|
24
28
|
type: pickString(r.entity_type, r.type, type) ?? type,
|
|
25
29
|
id: ref.id,
|
|
26
30
|
slug: ref.slug,
|
|
27
31
|
name: ref.name,
|
|
28
|
-
score: q ? (score
|
|
32
|
+
score: q ? (score > 0 ? score : 1) : 10,
|
|
29
33
|
...(Object.keys(secondary).length ? { secondaryIds: secondary } : {}),
|
|
30
34
|
});
|
|
31
35
|
}
|
|
32
|
-
out.sort((a, b) => b.score - a.score);
|
|
36
|
+
out.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
33
37
|
if (out.length > limit * 3)
|
|
34
38
|
out.length = limit * 3;
|
|
35
39
|
}
|
|
40
|
+
/** Drop noise tokens so "UFC Fight Night Medic" can match "UFC Fight Night: Medic vs Rodriguez". */
|
|
41
|
+
function ufcSearchQueries(q) {
|
|
42
|
+
const raw = q.trim();
|
|
43
|
+
if (!raw)
|
|
44
|
+
return [];
|
|
45
|
+
const queries = [raw];
|
|
46
|
+
const stop = new Set(['ufc', 'fight', 'night', 'event', 'card', 'vs', 'the', 'and', 'mma']);
|
|
47
|
+
const tokens = raw
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
50
|
+
.split(/\s+/)
|
|
51
|
+
.filter((t) => t.length >= 3 && !stop.has(t));
|
|
52
|
+
if (tokens.length) {
|
|
53
|
+
// distinctive tokens alone (e.g. "medic", "rodriguez")
|
|
54
|
+
for (const t of tokens.slice(0, 4))
|
|
55
|
+
queries.push(t);
|
|
56
|
+
// pair of last two distinctive tokens
|
|
57
|
+
if (tokens.length >= 2)
|
|
58
|
+
queries.push(tokens.slice(-2).join(' '));
|
|
59
|
+
}
|
|
60
|
+
// slug-ish form
|
|
61
|
+
const slugish = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
62
|
+
if (slugish && slugish !== raw.toLowerCase())
|
|
63
|
+
queries.push(slugish);
|
|
64
|
+
return [...new Set(queries.map((s) => s.trim()).filter(Boolean))];
|
|
65
|
+
}
|
|
66
|
+
/** UFC dedicated search (list /fighters ignores `q`). Multi-query for event fuzzy match. */
|
|
67
|
+
async function ufcSearch(ctx, q, type, limit) {
|
|
68
|
+
const candidates = [];
|
|
69
|
+
let calls = 0;
|
|
70
|
+
let lastStatus = 200;
|
|
71
|
+
let anyOk = false;
|
|
72
|
+
const wantFighters = type === 'any' || type === 'fighter' || type === 'player';
|
|
73
|
+
const wantEvents = type === 'any' || type === 'event' || type === 'tournament';
|
|
74
|
+
const wantMatches = type === 'any' || type === 'match';
|
|
75
|
+
for (const query of ufcSearchQueries(q)) {
|
|
76
|
+
const res = await fetchJson(ctx, '/ufc/search', { query: { q: query } });
|
|
77
|
+
calls += 1;
|
|
78
|
+
lastStatus = res.status;
|
|
79
|
+
if (!res.ok)
|
|
80
|
+
continue;
|
|
81
|
+
anyOk = true;
|
|
82
|
+
const data = asRecord(res.data) ?? {};
|
|
83
|
+
const payload = asRecord(data.data) ?? data;
|
|
84
|
+
const fighters = Array.isArray(payload.fighters)
|
|
85
|
+
? payload.fighters
|
|
86
|
+
: extractRows(payload.fighters);
|
|
87
|
+
const events = Array.isArray(payload.events)
|
|
88
|
+
? payload.events
|
|
89
|
+
: extractRows(payload.events);
|
|
90
|
+
const bouts = Array.isArray(payload.bouts)
|
|
91
|
+
? payload.bouts
|
|
92
|
+
: extractRows(payload.bouts);
|
|
93
|
+
// Always score against the *original* user query for ranking quality
|
|
94
|
+
if (wantFighters)
|
|
95
|
+
pushCandidates(candidates, 'ufc', 'fighter', fighters, q, limit);
|
|
96
|
+
if (wantEvents)
|
|
97
|
+
pushCandidates(candidates, 'ufc', 'event', events, q, limit);
|
|
98
|
+
if (wantMatches)
|
|
99
|
+
pushCandidates(candidates, 'ufc', 'match', bouts, q, limit);
|
|
100
|
+
}
|
|
101
|
+
// Event soft-miss fallback: scan upcoming + recent event lists client-side
|
|
102
|
+
if (wantEvents && !candidates.some((c) => c.type === 'event' && c.score >= 40)) {
|
|
103
|
+
for (const path of ['/ufc/events/upcoming', '/ufc/events/recent', '/ufc/events']) {
|
|
104
|
+
const res = await fetchJson(ctx, path, { query: { limit: 40, page: 1 } });
|
|
105
|
+
calls += 1;
|
|
106
|
+
if (!res.ok)
|
|
107
|
+
continue;
|
|
108
|
+
anyOk = true;
|
|
109
|
+
pushCandidates(candidates, 'ufc', 'event', extractRows(res.data), q, limit);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Dedupe by type+id
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const deduped = [];
|
|
115
|
+
for (const c of candidates.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))) {
|
|
116
|
+
const key = `${c.type}:${c.id}:${c.slug ?? ''}`;
|
|
117
|
+
if (seen.has(key))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
deduped.push(c);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
candidates: deduped.slice(0, limit),
|
|
124
|
+
calls,
|
|
125
|
+
ok: anyOk || deduped.length > 0,
|
|
126
|
+
status: anyOk ? 200 : lastStatus,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
36
129
|
async function searchGame(ctx, game, q, type, limit) {
|
|
37
130
|
const candidates = [];
|
|
38
131
|
let calls = 0;
|
|
@@ -164,24 +257,45 @@ async function searchGame(ctx, game, q, type, limit) {
|
|
|
164
257
|
await Promise.all(tasks);
|
|
165
258
|
}
|
|
166
259
|
else if (game === 'ufc') {
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
260
|
+
// /ufc/fighters ignores q (returns p4p/champ list). Always use /ufc/search when querying.
|
|
261
|
+
if (q) {
|
|
262
|
+
const ufc = await ufcSearch(ctx, q, type, limit);
|
|
263
|
+
calls += ufc.calls;
|
|
264
|
+
if (ufc.ok) {
|
|
265
|
+
candidates.push(...ufc.candidates);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
return {
|
|
269
|
+
candidates,
|
|
270
|
+
calls,
|
|
271
|
+
error: partialFromRejection(`game:${game}`, {
|
|
272
|
+
code: mapHttpToCode(ufc.status ?? 0),
|
|
273
|
+
message: `ufc search HTTP ${ufc.status ?? 0}`,
|
|
274
|
+
httpStatus: ufc.status,
|
|
275
|
+
}),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
175
278
|
}
|
|
176
|
-
|
|
177
|
-
tasks
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
279
|
+
else {
|
|
280
|
+
const tasks = [];
|
|
281
|
+
if (type === 'any' || type === 'fighter' || type === 'player') {
|
|
282
|
+
tasks.push((async () => {
|
|
283
|
+
const res = await fetchJson(ctx, '/ufc/fighters', { query: { limit, page: 1 } });
|
|
284
|
+
calls += 1;
|
|
285
|
+
if (res.ok)
|
|
286
|
+
pushCandidates(candidates, game, 'fighter', extractRows(res.data), q, limit);
|
|
287
|
+
})());
|
|
288
|
+
}
|
|
289
|
+
if (type === 'any' || type === 'event' || type === 'tournament') {
|
|
290
|
+
tasks.push((async () => {
|
|
291
|
+
const res = await fetchJson(ctx, '/ufc/events', { query: { limit, page: 1 } });
|
|
292
|
+
calls += 1;
|
|
293
|
+
if (res.ok)
|
|
294
|
+
pushCandidates(candidates, game, 'event', extractRows(res.data), q, limit);
|
|
295
|
+
})());
|
|
296
|
+
}
|
|
297
|
+
await Promise.all(tasks);
|
|
183
298
|
}
|
|
184
|
-
await Promise.all(tasks);
|
|
185
299
|
}
|
|
186
300
|
}
|
|
187
301
|
catch (e) {
|
|
@@ -312,14 +426,17 @@ When to use:
|
|
|
312
426
|
- Typeahead / pickers
|
|
313
427
|
- "List teams matching…"
|
|
314
428
|
- Exploring entities without committing to one ID
|
|
429
|
+
- UFC fighter lookup by name/nickname (uses /ufc/search + client re-rank)
|
|
315
430
|
|
|
316
431
|
Prefer over resolve_entity when the user wants a list.
|
|
317
432
|
Prefer resolve_entity when chaining one name into a profile tool.
|
|
318
433
|
|
|
319
434
|
Do not use when: fetching a known entity profile — use team_profile or player_profile.
|
|
320
435
|
|
|
436
|
+
UFC: with q set, results are ranked (exact name > multi-token match > nickname). "Jon Jones" should return jon-jones first — never the generic P4P list.
|
|
437
|
+
|
|
321
438
|
Parallel-safe: yes. Upstream cost: 1–3.
|
|
322
|
-
Example: { "game": "
|
|
439
|
+
Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
323
440
|
inputSchema: {
|
|
324
441
|
type: 'object',
|
|
325
442
|
additionalProperties: false,
|
|
@@ -466,11 +583,69 @@ Example: { "game": "cs2", "q": "vitality", "type": "team", "limit": 20 }`,
|
|
|
466
583
|
}
|
|
467
584
|
}
|
|
468
585
|
else if (game === 'ufc') {
|
|
469
|
-
if (
|
|
470
|
-
|
|
586
|
+
if (q) {
|
|
587
|
+
// Dedicated search ranks name/slug/nickname; /fighters ignores q.
|
|
588
|
+
const res = await fetchJson(ctx, '/ufc/search', { query: { q } });
|
|
589
|
+
upstreamCalls += 1;
|
|
590
|
+
if (!res.ok) {
|
|
591
|
+
throw Object.assign(new Error(`HTTP ${res.status}`), {
|
|
592
|
+
status: res.status,
|
|
593
|
+
data: res.data,
|
|
594
|
+
headers: res.headers,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
const data = asRecord(res.data) ?? {};
|
|
598
|
+
const payload = asRecord(data.data) ?? data;
|
|
599
|
+
const scored = [];
|
|
600
|
+
if (type === 'fighter' || type === 'player' || type === 'any') {
|
|
601
|
+
const fighters = Array.isArray(payload.fighters)
|
|
602
|
+
? payload.fighters
|
|
603
|
+
: extractRows(payload.fighters);
|
|
604
|
+
for (const row of fighters) {
|
|
605
|
+
const ref = entityRef(row, 'fighter', game);
|
|
606
|
+
const r = asRecord(row) ?? {};
|
|
607
|
+
const nick = pickString(r.nickname);
|
|
608
|
+
const score = rankScore(q, ref.name, ref.id, ref.slug, nick);
|
|
609
|
+
if (score > 0)
|
|
610
|
+
scored.push({ ref, score });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (type === 'event' || type === 'tournament' || type === 'any') {
|
|
614
|
+
const events = Array.isArray(payload.events)
|
|
615
|
+
? payload.events
|
|
616
|
+
: extractRows(payload.events);
|
|
617
|
+
for (const row of events) {
|
|
618
|
+
const ref = entityRef(row, 'event', game);
|
|
619
|
+
const score = rankScore(q, ref.name, ref.id, ref.slug);
|
|
620
|
+
if (score > 0)
|
|
621
|
+
scored.push({ ref, score });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (type === 'match' || type === 'any') {
|
|
625
|
+
const bouts = Array.isArray(payload.bouts)
|
|
626
|
+
? payload.bouts
|
|
627
|
+
: extractRows(payload.bouts);
|
|
628
|
+
for (const row of bouts) {
|
|
629
|
+
const ref = entityRef(row, 'match', game);
|
|
630
|
+
const score = rankScore(q, ref.name, ref.id, ref.slug);
|
|
631
|
+
if (score > 0)
|
|
632
|
+
scored.push({ ref, score });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
scored.sort((a, b) => b.score - a.score || a.ref.name.localeCompare(b.ref.name));
|
|
636
|
+
items = scored.map((s) => ({
|
|
637
|
+
...s.ref,
|
|
638
|
+
meta: { ...s.ref.meta, score: s.score },
|
|
639
|
+
}));
|
|
640
|
+
total = items.length;
|
|
471
641
|
}
|
|
472
|
-
|
|
473
|
-
|
|
642
|
+
else {
|
|
643
|
+
if (type === 'fighter' || type === 'player' || type === 'any') {
|
|
644
|
+
await listPath('/ufc/fighters', 'fighter');
|
|
645
|
+
}
|
|
646
|
+
if (type === 'event' || type === 'tournament' || type === 'any') {
|
|
647
|
+
await listPath('/ufc/events', 'event');
|
|
648
|
+
}
|
|
474
649
|
}
|
|
475
650
|
}
|
|
476
651
|
}
|
|
@@ -487,7 +662,7 @@ Example: { "game": "cs2", "q": "vitality", "type": "team", "limit": 20 }`,
|
|
|
487
662
|
rateLimit: e.headers,
|
|
488
663
|
});
|
|
489
664
|
}
|
|
490
|
-
// de-dupe by game+type+id
|
|
665
|
+
// de-dupe by game+type+id; when q present re-sort by score if available
|
|
491
666
|
const seen = new Set();
|
|
492
667
|
items = items.filter((it) => {
|
|
493
668
|
const key = `${it.game}:${it.type}:${it.id}`;
|
|
@@ -495,8 +670,17 @@ Example: { "game": "cs2", "q": "vitality", "type": "team", "limit": 20 }`,
|
|
|
495
670
|
return false;
|
|
496
671
|
seen.add(key);
|
|
497
672
|
return true;
|
|
498
|
-
})
|
|
499
|
-
|
|
673
|
+
});
|
|
674
|
+
if (q) {
|
|
675
|
+
items.sort((a, b) => {
|
|
676
|
+
const sa = typeof a.meta?.score === 'number' ? a.meta.score : rankScore(q, a.name, a.id, a.slug, a.meta?.nickname);
|
|
677
|
+
const sb = typeof b.meta?.score === 'number' ? b.meta.score : rankScore(q, b.name, b.id, b.slug, b.meta?.nickname);
|
|
678
|
+
return sb - sa || a.name.localeCompare(b.name);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
// pagination over ranked result set
|
|
682
|
+
const pageItems = items.slice(offset, offset + limit);
|
|
683
|
+
const hasMore = offset + pageItems.length < items.length || (!q && items.length >= limit);
|
|
500
684
|
return successEnvelope({
|
|
501
685
|
source: 'search_entities',
|
|
502
686
|
game,
|
|
@@ -506,12 +690,12 @@ Example: { "game": "cs2", "q": "vitality", "type": "team", "limit": 20 }`,
|
|
|
506
690
|
pagination: {
|
|
507
691
|
limit,
|
|
508
692
|
offset,
|
|
509
|
-
total,
|
|
693
|
+
total: total ?? (q ? items.length : null),
|
|
510
694
|
hasMore,
|
|
511
695
|
nextCursor: hasMore ? encodeCursor({ offset: offset + limit }) : null,
|
|
512
696
|
prevCursor: offset > 0 ? encodeCursor({ offset: Math.max(0, offset - limit) }) : null,
|
|
513
697
|
},
|
|
514
|
-
data: { items },
|
|
698
|
+
data: { items: pageItems },
|
|
515
699
|
});
|
|
516
700
|
},
|
|
517
701
|
};
|
package/dist/tools/standings.js
CHANGED
|
@@ -4,21 +4,24 @@
|
|
|
4
4
|
import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
|
|
5
5
|
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
6
6
|
import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
7
|
-
|
|
7
|
+
/** Exported for offline UFC rank mapping tests. */
|
|
8
|
+
export function normalizeStandingRow(row, index) {
|
|
8
9
|
const r = asRecord(row) ?? {};
|
|
9
10
|
const entity = asRecord(r.team) ?? asRecord(r.fighter) ?? asRecord(r.org) ?? r;
|
|
10
11
|
const name = pickString(asRecord(entity)?.name, r.teamName, r.name, r.orgName, r.fighterName, asRecord(entity)?.slug) ?? `row-${index + 1}`;
|
|
11
|
-
// UFC official lists: champion has rank=null + rankText="C"; contenders 1..15.
|
|
12
|
-
// Never fall back to index+1 for null ranks — that produced two "#1" rows (champ + #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).
|
|
13
14
|
const championStatus = pickString(r.championStatus, asRecord(entity)?.championStatus);
|
|
14
15
|
const rankText = pickString(r.rankText);
|
|
16
|
+
const isInterim = championStatus === 'interim' || rankText === 'IC' || r.isInterimChampion === true;
|
|
15
17
|
const isChampion = championStatus === 'champion' ||
|
|
16
18
|
rankText === 'C' ||
|
|
17
19
|
r.isChampion === true ||
|
|
18
|
-
r.isTitleHolder === true
|
|
20
|
+
r.isTitleHolder === true ||
|
|
21
|
+
isInterim;
|
|
19
22
|
let rank;
|
|
20
23
|
if (isChampion) {
|
|
21
|
-
rank = 'C';
|
|
24
|
+
rank = isInterim || rankText === 'IC' ? 'IC' : 'C';
|
|
22
25
|
}
|
|
23
26
|
else if (typeof r.rank === 'number') {
|
|
24
27
|
rank = r.rank;
|
|
@@ -29,16 +32,35 @@ function normalizeStandingRow(row, index) {
|
|
|
29
32
|
else if (rankText && /^\d+$/.test(rankText)) {
|
|
30
33
|
rank = Number(rankText);
|
|
31
34
|
}
|
|
32
|
-
else if (r.rank === null
|
|
35
|
+
else if (r.rank === null) {
|
|
36
|
+
// Explicit null (UFC champ without flags, or unranked) — never invent index+1
|
|
37
|
+
rank = rankText ?? null;
|
|
38
|
+
}
|
|
39
|
+
else if (rankText) {
|
|
33
40
|
rank = rankText;
|
|
34
41
|
}
|
|
42
|
+
else if (r.rank === undefined && r.position === undefined) {
|
|
43
|
+
rank = index + 1; // last resort only when rank fields are absent entirely
|
|
44
|
+
}
|
|
35
45
|
else {
|
|
36
|
-
rank =
|
|
46
|
+
rank = null;
|
|
37
47
|
}
|
|
48
|
+
const resolvedChampionStatus = isInterim
|
|
49
|
+
? 'interim'
|
|
50
|
+
: isChampion
|
|
51
|
+
? 'champion'
|
|
52
|
+
: championStatus === 'interim'
|
|
53
|
+
? 'interim'
|
|
54
|
+
: 'none';
|
|
38
55
|
return {
|
|
39
56
|
rank,
|
|
40
|
-
rankText: rankText ??
|
|
41
|
-
|
|
57
|
+
rankText: rankText ??
|
|
58
|
+
(rank === 'C' || rank === 'IC'
|
|
59
|
+
? String(rank)
|
|
60
|
+
: typeof rank === 'number' || typeof rank === 'string'
|
|
61
|
+
? String(rank)
|
|
62
|
+
: null),
|
|
63
|
+
championStatus: resolvedChampionStatus,
|
|
42
64
|
teamOrFighter: {
|
|
43
65
|
id: pickString(asRecord(entity)?.id, r.teamId, r.fighterId, r.orgId, r.id),
|
|
44
66
|
slug: pickString(asRecord(entity)?.slug, r.orgSlug, r.teamSlug, r.slug, r.fighterSlug),
|
|
@@ -56,6 +78,7 @@ function normalizeStandingRow(row, index) {
|
|
|
56
78
|
...(r.normalizedDivision ? { normalizedDivision: r.normalizedDivision } : {}),
|
|
57
79
|
...(r.region ? { region: r.region } : {}),
|
|
58
80
|
...(isChampion ? { isChampion: true } : {}),
|
|
81
|
+
...(isInterim ? { isInterim: true } : {}),
|
|
59
82
|
},
|
|
60
83
|
};
|
|
61
84
|
}
|
package/dist/tools/team.js
CHANGED
|
@@ -627,26 +627,48 @@ Example: { "game": "cs2", "sideA": "faze", "sideB": "navi", "limit": 20 }`,
|
|
|
627
627
|
rows = extractRows(res.data);
|
|
628
628
|
}
|
|
629
629
|
else if (game === 'ufc') {
|
|
630
|
-
//
|
|
631
|
-
const [aRes, bRes,
|
|
630
|
+
// Use fighter fight history (not global /bouts page 1, which rarely contains both fighters).
|
|
631
|
+
const [aRes, bRes, histA, histB] = await Promise.all([
|
|
632
632
|
fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideA)}`),
|
|
633
633
|
fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideB)}`),
|
|
634
|
-
fetchJson(ctx,
|
|
634
|
+
fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideA)}/fights`, { query: { limit: 50 } }),
|
|
635
|
+
fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(sideB)}/fights`, { query: { limit: 50 } }),
|
|
635
636
|
]);
|
|
636
|
-
upstreamCalls +=
|
|
637
|
-
rateLimit = { ...aRes.headers, ...bRes.headers, ...
|
|
637
|
+
upstreamCalls += 4;
|
|
638
|
+
rateLimit = { ...aRes.headers, ...bRes.headers, ...histA.headers, ...histB.headers };
|
|
638
639
|
if (!aRes.ok || !bRes.ok) {
|
|
639
640
|
return errorEnvelope({
|
|
640
641
|
code: 'NOT_FOUND',
|
|
641
|
-
message: 'One or both fighters not found',
|
|
642
|
+
message: 'One or both fighters not found — pass fighter slugs from resolve_entity',
|
|
642
643
|
game,
|
|
643
644
|
source: 'head_to_head',
|
|
644
645
|
requestId,
|
|
645
646
|
tookMs: Date.now() - started,
|
|
646
647
|
upstreamCalls,
|
|
648
|
+
recover: [
|
|
649
|
+
'resolve_entity { game: "ufc", type: "fighter", q } for each name',
|
|
650
|
+
'Retry head_to_head with returned slugs as sideA/sideB',
|
|
651
|
+
],
|
|
647
652
|
});
|
|
648
653
|
}
|
|
649
|
-
|
|
654
|
+
const toBouts = (payload) => extractRows(payload).map((row) => {
|
|
655
|
+
const r = asRecord(row) ?? {};
|
|
656
|
+
return asRecord(r.bout) ?? row;
|
|
657
|
+
});
|
|
658
|
+
const fromA = histA.ok ? toBouts(histA.data) : [];
|
|
659
|
+
const fromB = histB.ok ? toBouts(histB.data) : [];
|
|
660
|
+
// Union by bout id
|
|
661
|
+
const byId = new Map();
|
|
662
|
+
for (const bout of [...fromA, ...fromB]) {
|
|
663
|
+
const m = normalizeMatch('ufc', bout);
|
|
664
|
+
const key = m.matchId !== 'unknown' ? m.matchId : JSON.stringify(bout).slice(0, 80);
|
|
665
|
+
if (!byId.has(key))
|
|
666
|
+
byId.set(key, bout);
|
|
667
|
+
}
|
|
668
|
+
rows = [...byId.values()];
|
|
669
|
+
if (rows.length === 0) {
|
|
670
|
+
warnings.push('No fight history rows returned for either fighter; H2H may be empty');
|
|
671
|
+
}
|
|
650
672
|
}
|
|
651
673
|
const meetings = rows
|
|
652
674
|
.map((row) => normalizeMatch(game, row))
|
package/package.json
CHANGED