cito-mcp 0.2.4 → 0.2.6

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.
@@ -35,8 +35,55 @@ function matchCore(game, matchId, raw) {
35
35
  event: m.event,
36
36
  league: m.league,
37
37
  method: pickString(r.method, r.resultMethod) ?? null,
38
- winner: pickString(r.winnerFighterSlug, r.winnerSlug, r.winner) ?? null,
38
+ winner: (() => {
39
+ // UFC rows carry an explicit winner slug/fighter ref.
40
+ const explicit = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner);
41
+ if (explicit)
42
+ return explicit;
43
+ if (game === 'ufc')
44
+ return null;
45
+ // CS2 rows expose winnerTeamId instead; map it to a side, else fall back to scores.
46
+ const sideLabel = (side) => side ? pickString(side.slug, side.id, side.name) ?? null : null;
47
+ const winnerTeamId = pickString(r.winnerTeamId, r.winner_team_id);
48
+ if (winnerTeamId) {
49
+ const t1Ids = [m.team1?.id, m.team1?.slug, pickString(r.team1Id, r.team1_id)].filter(Boolean).map(String);
50
+ const t2Ids = [m.team2?.id, m.team2?.slug, pickString(r.team2Id, r.team2_id)].filter(Boolean).map(String);
51
+ if (t1Ids.includes(winnerTeamId))
52
+ return sideLabel(m.team1);
53
+ if (t2Ids.includes(winnerTeamId))
54
+ return sideLabel(m.team2);
55
+ }
56
+ if (s1 != null && s2 != null && s1 !== s2)
57
+ return sideLabel(s1 > s2 ? m.team1 : m.team2);
58
+ return null;
59
+ })(),
39
60
  weightClass: pickString(r.weightClass, r.division) ?? null,
61
+ referee: (() => {
62
+ const nested = asRecord(r.referee);
63
+ if (nested) {
64
+ const joined = [nested.firstName, nested.lastName].filter(Boolean).join(' ').trim();
65
+ const name = pickString(nested.name, joined || undefined) ?? null;
66
+ const id = pickString(nested.id, nested.refereeId) ?? null;
67
+ if (name || id) {
68
+ return {
69
+ id,
70
+ name,
71
+ firstName: pickString(nested.firstName) ?? null,
72
+ lastName: pickString(nested.lastName) ?? null,
73
+ };
74
+ }
75
+ }
76
+ const name = pickString(r.refereeName) ?? null;
77
+ const id = pickString(r.refereeId) ?? null;
78
+ if (!name && !id)
79
+ return null;
80
+ return {
81
+ id,
82
+ name,
83
+ firstName: pickString(r.refereeFirstName) ?? null,
84
+ lastName: pickString(r.refereeLastName) ?? null,
85
+ };
86
+ })(),
40
87
  rawStatus: pickString(r.status, r.state) ?? null,
41
88
  };
42
89
  }
@@ -56,18 +103,18 @@ function primaryPath(game, matchId) {
56
103
  }
57
104
  export const matchSummary = {
58
105
  name: 'match_summary',
59
- description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
60
-
61
- When to use:
62
- - Match recap / default match UI
63
- - After user selects a live or completed matchId
64
-
65
- Prefer over match_details for chat answers and default UIs.
66
- Prefer match_details for timelines, full map trees, live state, advanced packages.
67
-
68
- Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
69
-
70
- Parallel-safe: yes. Upstream cost: 2–5.
106
+ description: `COMPOSITE match card: scoreline, key context, player performances, and VOD/demo links when available.
107
+
108
+ When to use:
109
+ - Match recap / default match UI
110
+ - After user selects a live or completed matchId
111
+
112
+ Prefer over match_details for chat answers and default UIs.
113
+ Prefer match_details for timelines, full map trees, live state, advanced packages.
114
+
115
+ Do not use when: no matchId yet (resolve from live/schedule); pure pre-match → match_preview.
116
+
117
+ Parallel-safe: yes. Upstream cost: 2–5.
71
118
  Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includePlayerStats": true }`,
72
119
  inputSchema: {
73
120
  type: 'object',
@@ -251,22 +298,22 @@ Example: { "game": "cs2", "matchId": "cs2-match-123", "view": "summary", "includ
251
298
  };
252
299
  export const matchDetails = {
253
300
  name: 'match_details',
254
- description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
255
-
256
- When to use:
257
- - Analyst deep dive
258
- - Live in-game window (LoL/CS2/UFC)
259
- - Full demo list
260
-
261
- Prefer over match_summary only when summary is insufficient.
262
- Prefer match_summary for short answers and default cards.
263
-
264
- Do not use when: first-pass live board (use live_matches + match_summary).
265
-
266
- Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
267
- If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
268
-
269
- Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
301
+ description: `Deep match package: optional timelines, advanced stats, live state/snapshots, full map/game tree, media inventory.
302
+
303
+ When to use:
304
+ - Analyst deep dive
305
+ - Live in-game window (LoL/CS2/UFC)
306
+ - Full demo list
307
+
308
+ Prefer over match_summary only when summary is insufficient.
309
+ Prefer match_summary for short answers and default cards.
310
+
311
+ Do not use when: first-pass live board (use live_matches + match_summary).
312
+
313
+ Section selection: pass includeTimeline / includeLiveState / includeAdvanced booleans, OR an explicit sections[] list.
314
+ If sections[] is non-empty it wins (booleans are ignored). LoL liveState/advanced require gameId.
315
+
316
+ Parallel-safe: yes. Upstream cost: 1–8 (section-gated).
270
317
  Example: { "game": "lol", "matchId": "lol-match-1", "includeTimeline": true, "includeLiveState": false }`,
271
318
  inputSchema: {
272
319
  type: 'object',
@@ -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.4';
7
+ import { PACKAGE_VERSION as CATALOG_VERSION } from '../version.js';
8
8
  const TOOL_CATALOG = [
9
9
  {
10
10
  name: 'list_capabilities',
@@ -88,10 +88,10 @@ const TOOL_CATALOG = [
88
88
  },
89
89
  {
90
90
  name: 'player_profile',
91
- outcome: 'Player/fighter identity + recent form',
91
+ outcome: 'Player/fighter identity + recent form, including images (headshot, body, CORS-safe proxied)',
92
92
  parallelSafe: true,
93
93
  games: [...PRIMARY_GAMES],
94
- jobs: ['player_form'],
94
+ jobs: ['player_form', 'media'],
95
95
  exampleArgs: { game: 'cs2', playerId: 'cs2-player-1', recentLimit: 10 },
96
96
  preferOver: ['manual multi-call career/trends via call_api'],
97
97
  doNotUse: 'Full team roster → team_profile; unresolved name → resolve_entity',
@@ -138,14 +138,29 @@ const TOOL_CATALOG = [
138
138
  },
139
139
  {
140
140
  name: 'event_card',
141
- outcome: 'Event / fight-night card: identity + bout/match list (+ optional standings)',
141
+ outcome: 'Event / fight-night card: identity + bout list, card-ordered (main event first), ' +
142
+ 'each corner carrying photos, record, nickname, country and weight class (+ optional standings)',
142
143
  parallelSafe: true,
143
144
  games: [...PRIMARY_GAMES],
144
- jobs: ['event_card', 'schedule', 'app_scaffold'],
145
+ jobs: ['event_card', 'schedule', 'media', 'app_scaffold'],
145
146
  exampleArgs: { game: 'ufc', eventIdOrSlug: 'ufc-300', includeBouts: true },
146
- preferOver: ['call_api /ufc/events + bout expansion', 'N+1 match_summary for card list'],
147
+ preferOver: [
148
+ 'call_api /ufc/events + bout expansion',
149
+ 'N+1 match_summary for card list',
150
+ 'N+1 call_api per fighter for headshots — corners already include images',
151
+ ],
147
152
  doNotUse: 'Live-only strip → live_matches; single match recap → match_summary',
148
153
  },
154
+ {
155
+ name: 'list_routes',
156
+ outcome: 'Index of raw REST routes from the live OpenAPI spec (method, path, summary)',
157
+ parallelSafe: true,
158
+ games: [...PRIMARY_GAMES, 'fortnite'],
159
+ jobs: ['app_scaffold'],
160
+ exampleArgs: { game: 'ufc', q: 'rankings' },
161
+ preferOver: ['guessing a REST path for call_api', 'fetching the full 90KB spec by hand'],
162
+ doNotUse: 'A curated tool covers the outcome → list_capabilities',
163
+ },
149
164
  {
150
165
  name: 'call_api',
151
166
  outcome: 'Allowlisted raw REST escape hatch (unshaped data.raw)',
@@ -167,6 +182,13 @@ const JOBS = [
167
182
  { id: 'schedule', description: 'Upcoming fixtures/events', recommendedTools: ['upcoming_schedule', 'event_card'] },
168
183
  { id: 'preview', description: 'Pre-match briefing', recommendedTools: ['match_preview'] },
169
184
  { id: 'event_card', description: 'Event / fight-night card page', recommendedTools: ['event_card', 'resolve_entity', 'match_preview'] },
185
+ // Agents asked "does this API have photos?" and, finding no job for it,
186
+ // assumed no. Photos ship on every UFC corner and profile; make that findable.
187
+ {
188
+ id: 'media',
189
+ description: 'Headshots, full-body shots and team logos for visual UIs. UFC fighters carry headshotUrl / bodyImageUrl / imageUrl plus a CORS-safe proxiedImageUrl; use the proxied URL in a browser. Available on event_card corners and player_profile without any extra call.',
190
+ recommendedTools: ['event_card', 'player_profile', 'resolve_entity'],
191
+ },
170
192
  { id: 'app_scaffold', description: 'Design-time multi-screen prototype', recommendedTools: ['list_capabilities', 'api_health', 'live_matches'] },
171
193
  ];
172
194
  const RECIPES = [
@@ -450,7 +472,18 @@ Example: { "includeGameProbes": true }`,
450
472
  });
451
473
  },
452
474
  };
453
- const ALLOWLIST_PREFIXES = ['/health', '/lol', '/cs2', '/dota2', '/cod', '/ufc', '/fortnite'];
475
+ const ALLOWLIST_PREFIXES = [
476
+ '/health',
477
+ '/lol',
478
+ '/cs2',
479
+ '/dota2',
480
+ '/cod',
481
+ '/ufc',
482
+ '/fortnite',
483
+ // The spec describes the surface call_api is allowed to reach; refusing to
484
+ // serve it left route discovery impossible except by guessing.
485
+ '/openapi.json',
486
+ ];
454
487
  function pathAllowed(path) {
455
488
  if (!path.startsWith('/') || path.startsWith('//'))
456
489
  return false;
@@ -604,6 +637,147 @@ Example: { "method": "GET", "path": "/cs2/rankings/teams", "queryJson": "{\\"pag
604
637
  });
605
638
  },
606
639
  };
607
- export const metaTools = [listCapabilities, apiHealth, callApi];
640
+ /**
641
+ * Games whose routes the published spec does not describe.
642
+ *
643
+ * The spec has 123 paths and zero under /lol, yet every LoL route works —
644
+ * api_health itself answers partly from /lol/leagues. An agent that treats the
645
+ * spec as the whole surface concludes LoL is unsupported and stops, so say so
646
+ * explicitly rather than returning an empty list that reads as a verdict.
647
+ */
648
+ const SPEC_OMITS = {
649
+ lol: 'The published OpenAPI spec documents no /lol paths, but LoL routes exist and work. Use the curated LoL tools (live_matches, upcoming_schedule, team_profile, standings, player_profile); for raw access, /lol/* is allowlisted for call_api even though it is undocumented.',
650
+ };
651
+ export const listRoutes = {
652
+ name: 'list_routes',
653
+ description: `Index of raw REST routes from the live OpenAPI spec: method, path, summary, tag.
654
+
655
+ When to use:
656
+ - You need a long-tail path for call_api and do not want to guess
657
+ - Checking whether an endpoint exists before building around it
658
+ - Mapping what raw data backs a curated tool
659
+
660
+ Prefer curated tools for standard jobs — this indexes the escape hatch, it is not a replacement for list_capabilities.
661
+
662
+ Do not use when: a curated tool already covers the outcome (call list_capabilities instead).
663
+
664
+ Note: the spec omits /lol entirely, though LoL routes work. Filtering by game=lol returns that caveat rather than an empty list.
665
+
666
+ Parallel-safe: yes. Upstream cost: 1.
667
+ Example: { "game": "ufc", "q": "rankings" }`,
668
+ inputSchema: {
669
+ type: 'object',
670
+ additionalProperties: false,
671
+ properties: {
672
+ game: gameSchema({
673
+ allowAll: false,
674
+ description: 'Filter to one game prefix (also accepts fortnite). Omit for all routes.',
675
+ }),
676
+ q: stringSchema('Free-text filter over path and summary.', 'rankings'),
677
+ limit: {
678
+ type: 'integer',
679
+ minimum: 1,
680
+ maximum: 200,
681
+ default: 60,
682
+ description: 'Max routes to return (default 60, max 200).',
683
+ },
684
+ },
685
+ },
686
+ handler: async (args, ctx) => {
687
+ const started = Date.now();
688
+ const requestId = newRequestId();
689
+ // Accept fortnite here even though it is not a PRIMARY_GAME: it has 21
690
+ // documented paths and no curated tools, so it is exactly what call_api
691
+ // callers come looking for.
692
+ const gameRaw = typeof args.game === 'string' ? args.game.toLowerCase().trim() : '';
693
+ if (gameRaw && !gameRaw.match(/^(lol|cs2|dota2|cod|ufc|fortnite)$/)) {
694
+ return errorEnvelope({
695
+ code: 'UNSUPPORTED_GAME',
696
+ message: `unsupported game "${gameRaw}"; use lol|cs2|dota2|cod|ufc|fortnite`,
697
+ game: null,
698
+ source: 'list_routes',
699
+ requestId,
700
+ tookMs: Date.now() - started,
701
+ });
702
+ }
703
+ const q = typeof args.q === 'string' ? args.q.toLowerCase().trim() : '';
704
+ const limit = Math.min(Math.max(Number(args.limit) || 60, 1), 200);
705
+ const res = await fetchJson(ctx, '/openapi.json');
706
+ if (!res.ok) {
707
+ return errorEnvelope({
708
+ code: mapHttpToCode(res.status),
709
+ message: `openapi.json HTTP ${res.status}`,
710
+ game: gameRaw || null,
711
+ source: 'list_routes',
712
+ requestId,
713
+ tookMs: Date.now() - started,
714
+ upstreamCalls: 1,
715
+ rateLimit: res.headers,
716
+ httpStatus: res.status,
717
+ recover: [
718
+ 'Call list_capabilities for curated tools that need no route knowledge',
719
+ 'call_api accepts /health, /lol, /cs2, /dota2, /cod, /ufc, /fortnite',
720
+ ],
721
+ });
722
+ }
723
+ const spec = (res.data ?? {});
724
+ const paths = (spec.paths ?? {});
725
+ const rows = [];
726
+ for (const [path, ops] of Object.entries(paths)) {
727
+ if (!ops || typeof ops !== 'object')
728
+ continue;
729
+ for (const [method, opRaw] of Object.entries(ops)) {
730
+ if (!/^(get|post|put|patch|delete)$/i.test(method))
731
+ continue;
732
+ const op = (opRaw ?? {});
733
+ const tags = Array.isArray(op.tags) ? op.tags : [];
734
+ rows.push({
735
+ method: method.toUpperCase(),
736
+ path,
737
+ summary: typeof op.summary === 'string' ? op.summary : null,
738
+ tag: typeof tags[0] === 'string' ? tags[0] : null,
739
+ });
740
+ }
741
+ }
742
+ let filtered = rows;
743
+ if (gameRaw)
744
+ filtered = filtered.filter((r) => r.path.startsWith(`/${gameRaw}`));
745
+ if (q) {
746
+ filtered = filtered.filter((r) => r.path.toLowerCase().includes(q) || (r.summary ?? '').toLowerCase().includes(q));
747
+ }
748
+ filtered.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
749
+ const notes = [];
750
+ if (gameRaw && filtered.length === 0 && SPEC_OMITS[gameRaw])
751
+ notes.push(SPEC_OMITS[gameRaw]);
752
+ if (!gameRaw)
753
+ notes.push(SPEC_OMITS.lol);
754
+ if (filtered.length > limit) {
755
+ notes.push(`${filtered.length} routes matched; showing ${limit}. Narrow with game or q.`);
756
+ }
757
+ return successEnvelope({
758
+ data: {
759
+ specVersion: typeof spec.info?.version === 'string'
760
+ ? spec.info.version
761
+ : null,
762
+ totalDocumented: rows.length,
763
+ matched: filtered.length,
764
+ routes: filtered.slice(0, limit),
765
+ allowlistedPrefixes: ALLOWLIST_PREFIXES,
766
+ notes,
767
+ nextSteps: [
768
+ 'Prefer a curated tool when one covers the outcome (list_capabilities)',
769
+ 'call_api { path } for a route with no curated equivalent',
770
+ ],
771
+ },
772
+ game: gameRaw || null,
773
+ source: 'list_routes',
774
+ requestId,
775
+ tookMs: Date.now() - started,
776
+ upstreamCalls: 1,
777
+ rateLimit: res.headers,
778
+ });
779
+ },
780
+ };
781
+ export const metaTools = [listCapabilities, apiHealth, listRoutes, callApi];
608
782
  // silence unused import in case extractRows needed later
609
783
  void extractRows;
@@ -1,8 +1,34 @@
1
1
  /**
2
2
  * Cross-game row normalization for live/schedule boards and entity cards.
3
3
  */
4
- import { asRecord, pickString, unwrapPayload } from '../client.js';
5
- function sideFrom(name, id, slug, score) {
4
+ import { asRecord, pickString, unwrapPayload, DEFAULT_API_BASE } from '../client.js';
5
+ const IMAGE_PROXY_BASE = (process.env.CITO_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
6
+ /**
7
+ * Bout rows carry raw ufc.com URLs but no proxied variant (only the fighter
8
+ * detail endpoint includes one). ufc.com can hotlink-block and sends no CORS
9
+ * header, so a browser-side card built straight off those URLs shows broken
10
+ * images. The proxy token is base64url of the source URL — verified to
11
+ * round-trip and serve HTTP 200 — so derive it rather than making the caller
12
+ * N+1 the fighter endpoint just to get a loadable image.
13
+ *
14
+ * UFC only: /public/images/lol/<token> returns HTTP 400, so other games get
15
+ * their raw URL and a null proxy rather than a fabricated link.
16
+ */
17
+ function deriveProxiedImageUrl(rawUrl, game) {
18
+ if (!rawUrl || game !== 'ufc')
19
+ return null;
20
+ let host;
21
+ try {
22
+ host = new URL(rawUrl).hostname.toLowerCase();
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ if (host !== 'ufc.com' && !host.endsWith('.ufc.com'))
28
+ return null;
29
+ return `${IMAGE_PROXY_BASE}/public/images/ufc/${Buffer.from(rawUrl, 'utf8').toString('base64url')}`;
30
+ }
31
+ function sideFrom(name, id, slug, score, extra) {
6
32
  if (!name && !id && !slug)
7
33
  return null;
8
34
  return {
@@ -10,9 +36,96 @@ function sideFrom(name, id, slug, score) {
10
36
  ...(id ? { id } : {}),
11
37
  ...(slug ? { slug } : {}),
12
38
  ...(score !== undefined ? { score } : {}),
39
+ ...(extra ?? {}),
40
+ };
41
+ }
42
+ function numOrNull(v) {
43
+ if (typeof v === 'number' && Number.isFinite(v))
44
+ return v;
45
+ if (typeof v === 'string' && v !== '' && Number.isFinite(Number(v)))
46
+ return Number(v);
47
+ return null;
48
+ }
49
+ /**
50
+ * Collect image URLs from a corner/team row and its nested profile. Returns
51
+ * undefined when the upstream carried none, so lean rows stay lean; when any
52
+ * image exists every key is present (null where absent) — a missing key would
53
+ * read as "this API has no images" rather than "no image for this one".
54
+ */
55
+ function imagesFrom(game, ...sources) {
56
+ const pick = (...keys) => {
57
+ for (const src of sources) {
58
+ if (!src)
59
+ continue;
60
+ for (const k of keys) {
61
+ const v = pickString(src[k]);
62
+ if (v)
63
+ return v;
64
+ }
65
+ }
66
+ return null;
67
+ };
68
+ const images = {
69
+ headshotUrl: pick('headshotUrl', 'headshot'),
70
+ bodyImageUrl: pick('bodyImageUrl', 'fullBodyImageUrl'),
71
+ imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo'),
72
+ proxiedImageUrl: pick('proxiedImageUrl', 'proxiedHeadshotUrl'),
13
73
  };
74
+ if (!images.proxiedImageUrl) {
75
+ // Prefer the headshot for the proxied variant — it is the crop a card UI wants.
76
+ images.proxiedImageUrl =
77
+ deriveProxiedImageUrl(images.headshotUrl, game) ??
78
+ deriveProxiedImageUrl(images.imageUrl, game);
79
+ }
80
+ return Object.values(images).some(Boolean) ? images : undefined;
81
+ }
82
+ function recordFrom(...sources) {
83
+ for (const src of sources) {
84
+ if (!src)
85
+ continue;
86
+ const rec = asRecord(src.record);
87
+ const text = pickString(src.recordText, rec?.text);
88
+ if (!rec && !text)
89
+ continue;
90
+ const out = {
91
+ wins: numOrNull(rec?.wins),
92
+ losses: numOrNull(rec?.losses),
93
+ draws: numOrNull(rec?.draws),
94
+ noContest: numOrNull(rec?.noContest),
95
+ text: text ?? null,
96
+ };
97
+ if (Object.values(out).some((v) => v !== null))
98
+ return out;
99
+ }
100
+ return undefined;
14
101
  }
15
- function nestedSide(raw) {
102
+ /** Enrichment shared by UFC corners and (where upstream supplies it) team rows. */
103
+ function sideExtras(o, game) {
104
+ const profile = asRecord(o.profile) ?? undefined;
105
+ const fighter = asRecord(o.fighter) ?? undefined;
106
+ const images = imagesFrom(game, o, profile, fighter);
107
+ const record = recordFrom(o, profile, fighter);
108
+ const nickname = pickString(profile?.nickname, fighter?.nickname, o.nickname);
109
+ const rank = pickString(o.rankText, o.rank, profile?.rankText);
110
+ const championStatus = pickString(o.championStatus, profile?.championStatus);
111
+ const division = pickString(profile?.division, o.division, o.weightClass);
112
+ const country = pickString(o.country, profile?.country);
113
+ const flag = pickString(o.flag, profile?.flag);
114
+ const outcome = pickString(o.outcome);
115
+ return {
116
+ ...(nickname ? { nickname } : {}),
117
+ ...(images ? { images } : {}),
118
+ ...(record ? { record } : {}),
119
+ ...(rank ? { rank } : {}),
120
+ // "none" carries no signal for a page; only surface an actual belt state.
121
+ ...(championStatus && championStatus.toLowerCase() !== 'none' ? { championStatus } : {}),
122
+ ...(division ? { division } : {}),
123
+ ...(country ? { country } : {}),
124
+ ...(flag ? { flag } : {}),
125
+ ...(outcome ? { outcome } : {}),
126
+ };
127
+ }
128
+ function nestedSide(raw, game) {
16
129
  const o = asRecord(raw);
17
130
  if (!o) {
18
131
  if (typeof raw === 'string')
@@ -29,7 +142,7 @@ function nestedSide(raw) {
29
142
  : typeof scoreRaw === 'string' && scoreRaw !== ''
30
143
  ? Number(scoreRaw)
31
144
  : null;
32
- return sideFrom(name, id, slug, Number.isFinite(score) ? score : null);
145
+ return sideFrom(name, id, slug, Number.isFinite(score) ? score : null, sideExtras(o, game));
33
146
  }
34
147
  function scoreNum(v) {
35
148
  if (typeof v === 'number' && Number.isFinite(v))
@@ -42,10 +155,22 @@ export function normalizeMatch(game, row, forcedStatus) {
42
155
  // Always peel { success, data } so UFC bout fighters[] / status are visible.
43
156
  const r = asRecord(unwrapPayload(row)) ?? asRecord(row) ?? {};
44
157
  const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId, r.ufcFightId) ?? 'unknown';
45
- let team1 = nestedSide(r.team1) ??
158
+ let team1 = nestedSide(r.team1, game) ??
46
159
  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));
47
- let team2 = nestedSide(r.team2) ??
160
+ let team2 = nestedSide(r.team2, game) ??
48
161
  sideFrom(pickString(r.team2Name, r.team_b_name, r.blueName, r.fighter2Name, r.awayName), pickString(r.team2Id, r.team2_id, r.blueId, r.fighter2Id), pickString(r.team2Slug, r.blueSlug, r.fighter2Slug), scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore));
162
+ // CS2 rows nest team1/team2 objects that lack a score; a nested side must not
163
+ // shadow the flat score fields (team1Score/score1/team1Maps) with score:null.
164
+ if (team1 && team1.score == null) {
165
+ const s = scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore);
166
+ if (s != null)
167
+ team1 = { ...team1, score: s };
168
+ }
169
+ if (team2 && team2.score == null) {
170
+ const s = scoreNum(r.team2Score ?? r.score2 ?? r.team2Maps ?? r.blueScore);
171
+ if (s != null)
172
+ team2 = { ...team2, score: s };
173
+ }
49
174
  // UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
50
175
  // Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
51
176
  // Live tracking uses red/blue + fighters[] (not team1/team2).
@@ -64,7 +189,7 @@ export function normalizeMatch(game, row, forcedStatus) {
64
189
  const id = pickString(o.id, o.fighterId, profile?.id, fighter?.id);
65
190
  const slug = pickString(o.slug, o.fighterSlug, profile?.slug, fighter?.slug);
66
191
  const score = scoreNum(o.score ?? o.points);
67
- return sideFrom(name, id, slug, score);
192
+ return sideFrom(name, id, slug, score, sideExtras(o, game));
68
193
  };
69
194
  team1 =
70
195
  team1 ??
@@ -94,8 +219,8 @@ export function normalizeMatch(game, row, forcedStatus) {
94
219
  }
95
220
  // COD sometimes uses teams[]
96
221
  if ((!team1 || !team2) && Array.isArray(r.teams)) {
97
- team1 = team1 ?? nestedSide(r.teams[0]);
98
- team2 = team2 ?? nestedSide(r.teams[1]);
222
+ team1 = team1 ?? nestedSide(r.teams[0], game);
223
+ team2 = team2 ?? nestedSide(r.teams[1], game);
99
224
  }
100
225
  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
226
  const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus)?.toLowerCase() ?? '';
@@ -151,9 +276,12 @@ export function normalizeMatch(game, row, forcedStatus) {
151
276
  const leagueSlug = pickString(asRecord(r.league)?.slug, r.leagueSlug);
152
277
  // Prefer explicit title/label when present, but never keep the placeholder
153
278
  // "? vs ?" once fighter/team names were resolved (UFC fighters[] / red-blue).
279
+ // When both sides lack names, prefer event / weight / bout id over opaque "? vs ?"
280
+ // so agent boards (live_matches, upcoming_schedule, event_card) stay legible.
154
281
  const vsLabel = `${team1?.name ?? '?'} vs ${team2?.name ?? '?'}`;
155
282
  const explicit = pickString(r.label, r.title, r.name);
156
283
  const explicitIsPlaceholder = explicit != null && /^\?\s*vs\s*\?$/i.test(explicit.trim());
284
+ const weightOrClass = pickString(r.weightClass, r.division, r.weight_class, r.boutClass);
157
285
  let label;
158
286
  if (explicit && !explicitIsPlaceholder) {
159
287
  label = explicit;
@@ -162,8 +290,28 @@ export function normalizeMatch(game, row, forcedStatus) {
162
290
  label = vsLabel;
163
291
  }
164
292
  else {
165
- label = explicit ?? vsLabel;
293
+ // Identity fallbacks never invent fighter names, but avoid bare "? vs ?"
294
+ label =
295
+ pickString(eventName, eventSlug, weightOrClass) ??
296
+ (matchId && matchId !== 'unknown' ? `Bout ${matchId}` : null) ??
297
+ vsLabel;
166
298
  }
299
+ // Bout metadata already present on the upstream row. Passing it through here
300
+ // is what stops an agent from N+1'ing call_api to rebuild a fight card.
301
+ const cardSection = pickString(r.cardSection, r.cardSegment, r.segment);
302
+ const cardPosition = pickString(r.cardPosition);
303
+ const cardSectionOrder = numOrNull(r.cardSectionOrder);
304
+ const boutOrder = numOrNull(r.boutOrder);
305
+ const hasCard = cardSection != null || cardPosition != null || cardSectionOrder != null || boutOrder != null;
306
+ const method = pickString(r.method);
307
+ const methodDetails = pickString(r.methodDetails);
308
+ const resultTime = pickString(r.resultTime);
309
+ // UFC sends referee as { id, name, firstName, lastName } — not a string.
310
+ const referee = pickString(r.referee, asRecord(r.referee)?.name);
311
+ const winnerSlug = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner);
312
+ const resultRound = numOrNull(r.resultRound);
313
+ const hasResultDetail = method != null || resultRound != null || winnerSlug != null || resultTime != null;
314
+ const isCancelled = r.isCancelled === true;
167
315
  return {
168
316
  game,
169
317
  matchId,
@@ -174,8 +322,55 @@ export function normalizeMatch(game, row, forcedStatus) {
174
322
  team2,
175
323
  event: eventName || eventId || eventSlug ? { id: eventId, slug: eventSlug, name: eventName } : null,
176
324
  league: leagueName || leagueId || leagueSlug ? { id: leagueId, slug: leagueSlug, name: leagueName } : null,
325
+ ...(weightOrClass ? { weightClass: weightOrClass } : {}),
326
+ ...(r.titleBout != null ? { titleBout: Boolean(r.titleBout) } : {}),
327
+ ...(hasCard
328
+ ? {
329
+ card: {
330
+ section: cardSection ?? null,
331
+ sectionOrder: cardSectionOrder,
332
+ position: cardPosition ?? null,
333
+ order: boutOrder,
334
+ },
335
+ }
336
+ : {}),
337
+ ...(hasResultDetail
338
+ ? {
339
+ result: {
340
+ method: method ?? null,
341
+ methodDetails: methodDetails ?? null,
342
+ round: resultRound,
343
+ time: resultTime ?? null,
344
+ referee: referee ?? null,
345
+ winnerSlug: winnerSlug ?? null,
346
+ },
347
+ }
348
+ : {}),
349
+ ...(isCancelled
350
+ ? { cancelled: { isCancelled: true, reason: pickString(r.cancellationReason) ?? null } }
351
+ : {}),
177
352
  };
178
353
  }
354
+ /**
355
+ * Sort a fight card the way it is presented: main card before prelims, and the
356
+ * main event at the top of its section. Rows without placement keep their
357
+ * upstream order behind those that have it.
358
+ */
359
+ export function sortByCardOrder(rows) {
360
+ const rank = (x, i) => ({
361
+ section: x.card?.sectionOrder ?? Number.MAX_SAFE_INTEGER,
362
+ order: x.card?.order ?? Number.MAX_SAFE_INTEGER,
363
+ i,
364
+ });
365
+ return rows
366
+ .map((x, i) => ({ x, k: rank(x, i) }))
367
+ .sort((a, b) => a.k.section !== b.k.section
368
+ ? a.k.section - b.k.section
369
+ : a.k.order !== b.k.order
370
+ ? a.k.order - b.k.order
371
+ : a.k.i - b.k.i)
372
+ .map((e) => e.x);
373
+ }
179
374
  export function entityRef(row, type, game) {
180
375
  const r = asRecord(row) ?? {};
181
376
  const id = pickString(r.id, r.teamId, r.playerId, r.lolPlayerId, r.codPlayerId, r.matchId, r.boutId, r.eventId, r.tournamentId, r.leagueId) ??