cito-mcp 0.4.4 → 0.4.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.
@@ -1,19 +1,5 @@
1
- /**
2
- * Cross-game row normalization for live/schedule boards and entity cards.
3
- */
4
1
  import { asRecord, pickString, unwrapPayload, DEFAULT_API_BASE } from '../client.js';
5
2
  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
3
  function deriveProxiedImageUrl(rawUrl, game) {
18
4
  if (!rawUrl || game !== 'ufc')
19
5
  return null;
@@ -28,14 +14,6 @@ function deriveProxiedImageUrl(rawUrl, game) {
28
14
  return null;
29
15
  return `${IMAGE_PROXY_BASE}/public/images/ufc/${Buffer.from(rawUrl, 'utf8').toString('base64url')}`;
30
16
  }
31
- /**
32
- * Tennis has players, not teams. The cross-game NormalizedMatch type keeps
33
- * team1/team2 internally for every game so the rest of this module stays
34
- * uniform (nestedSide, sidesMatch, sortByCardOrder, …); this renames those two
35
- * keys — and, when present, a nested score sub-object shaped the same way —
36
- * at the JSON boundary for tennis only. Every other game passes through
37
- * unchanged.
38
- */
39
17
  export function presentSides(obj, game) {
40
18
  if (game !== 'tennis' || !obj || typeof obj !== 'object')
41
19
  return obj;
@@ -62,28 +40,12 @@ function numOrNull(v) {
62
40
  return Number(v);
63
41
  return null;
64
42
  }
65
- /**
66
- * One set of a tennis score string ("6-4", "7-6(6)", "6-4, 3-6") as games won.
67
- *
68
- * A tiebreak is parenthesised after the games ("7-6(6)"), so the regex stops at
69
- * the games. Anything else returns null rather than a guess: retirement rows
70
- * carry "6-3 2-1 RET", and reading "RET" as games would invent a set.
71
- */
72
43
  export function parseTennisSetToken(token) {
73
44
  const m = /^\s*(\d{1,2})\s*-\s*(\d{1,2})/.exec(token);
74
45
  if (!m)
75
46
  return null;
76
47
  return { p1: Number(m[1]), p2: Number(m[2]) };
77
48
  }
78
- /**
79
- * Sets won by one side, counted from the match-level game score
80
- * ("4-6 6-3 6-3 7-5" → 3 for side 1, 1 for side 2).
81
- *
82
- * `score` orders every token as player1-player2 on this feed, which is verified
83
- * against the same row's sets[] (set 1: score token "4-6", player1_games 4,
84
- * player2_games 6). Ties are impossible in a completed set, so a token where
85
- * neither side is ahead contributes to neither total.
86
- */
87
49
  export function tennisSetsWonFromScore(score, side) {
88
50
  if (!score)
89
51
  return null;
@@ -103,15 +65,6 @@ export function tennisSetsWonFromScore(score, side) {
103
65
  }
104
66
  return parsed > 0 ? won : null;
105
67
  }
106
- /**
107
- * Sets won by one side, counted from the structured sets[] array.
108
- *
109
- * player1_games/player2_games are the ONLY side-correct pair here. The sibling
110
- * winner_games/loser_games are oriented to the match winner, not to side 1 —
111
- * on s365_2026_4849786 set 1 is `winner_games: 4, loser_games: 6` while side 1
112
- * (the winner, Shelton) actually lost that set 4-6. Reading those would have
113
- * produced a confidently wrong 1-3 scoreline.
114
- */
115
68
  export function tennisSetsWonFromSets(sets, side) {
116
69
  if (!Array.isArray(sets) || sets.length === 0)
117
70
  return null;
@@ -133,14 +86,6 @@ export function tennisSetsWonFromSets(sets, side) {
133
86
  }
134
87
  return parsed > 0 ? won : null;
135
88
  }
136
- /**
137
- * Whether a set row is finished.
138
- *
139
- * The live route states `is_completed`; the archive route omits the key
140
- * entirely, and reading only the flag marked all four sets of a COMPLETED US
141
- * Open semi-final `completed: false`. When the flag is absent the games decide:
142
- * a set is over at 6-x with a two-game margin, or at 7-6/7-5.
143
- */
144
89
  export function tennisSetCompleted(sr) {
145
90
  if (typeof sr.is_completed === 'boolean')
146
91
  return sr.is_completed;
@@ -152,30 +97,10 @@ export function tennisSetCompleted(sr) {
152
97
  const lo = Math.min(p1, p2);
153
98
  return (hi >= 6 && hi - lo >= 2) || (hi === 7 && lo === 6);
154
99
  }
155
- /**
156
- * Sets won by one side of a tennis match, from whichever spelling the row uses.
157
- *
158
- * Four sources exist because three different endpoints describe the same match:
159
- * 1. `sets_won` — the live route puts it on each player object
160
- * 2. `winner_sets_won`/`loser_sets_won` — the archive route's own totals
161
- * 3. `sets[]` — per-set games, present on both
162
- * 4. the `score` string — "4-6 6-3 6-3 7-5", present on both
163
- *
164
- * The archive route (`/tennis/matches/{id}`) carries NO sets_won on its player
165
- * objects at all, which is why a completed US Open semi-final rendered as
166
- * "Ben Shelton - : - Frances Tiafoe" while the same response held the real
167
- * score in `score` and in `sets[]`. Reading only source 1 is the bug; the order
168
- * above is authority, not preference — a server-computed total beats a count we
169
- * derive, and the structured array beats re-parsing a display string.
170
- */
171
100
  export function tennisSetsWon(opts) {
172
101
  const explicit = numOrNull(opts.sideSetsWon);
173
102
  if (explicit !== null)
174
103
  return explicit;
175
- // winner_sets_won / loser_sets_won belong to a side, and which side is known
176
- // either from the row's own is_winner flag or by matching ids. Without one of
177
- // those signals the pair is unusable and the structured sets below are safer
178
- // than a coin flip.
179
104
  const wsw = numOrNull(opts.winnerSetsWon);
180
105
  const lsw = numOrNull(opts.loserSetsWon);
181
106
  if (wsw !== null && lsw !== null) {
@@ -194,12 +119,6 @@ export function tennisSetsWon(opts) {
194
119
  return fromSets;
195
120
  return tennisSetsWonFromScore(opts.score ?? undefined, opts.side);
196
121
  }
197
- /**
198
- * Collect image URLs from a corner/team row and its nested profile. Returns
199
- * undefined when the upstream carried none, so lean rows stay lean; when any
200
- * image exists every key is present (null where absent) — a missing key would
201
- * read as "this API has no images" rather than "no image for this one".
202
- */
203
122
  function imagesFrom(game, ...sources) {
204
123
  const pick = (...keys) => {
205
124
  for (const src of sources) {
@@ -214,15 +133,12 @@ function imagesFrom(game, ...sources) {
214
133
  return null;
215
134
  };
216
135
  const images = {
217
- // portrait_url is the tennis feed's only likeness (see identityFrom in
218
- // player.ts); without it a tennis side on every board carried no image.
219
136
  headshotUrl: pick('headshotUrl', 'headshot', 'portrait_url'),
220
137
  bodyImageUrl: pick('bodyImageUrl', 'fullBodyImageUrl'),
221
138
  imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo', 'portrait_url'),
222
139
  proxiedImageUrl: pick('proxiedImageUrl', 'proxiedHeadshotUrl'),
223
140
  };
224
141
  if (!images.proxiedImageUrl) {
225
- // Prefer the headshot for the proxied variant — it is the crop a card UI wants.
226
142
  images.proxiedImageUrl =
227
143
  deriveProxiedImageUrl(images.headshotUrl, game) ??
228
144
  deriveProxiedImageUrl(images.imageUrl, game);
@@ -249,7 +165,6 @@ function recordFrom(...sources) {
249
165
  }
250
166
  return undefined;
251
167
  }
252
- /** Enrichment shared by UFC corners and (where upstream supplies it) team rows. */
253
168
  function sideExtras(o, game) {
254
169
  const profile = asRecord(o.profile) ?? undefined;
255
170
  const fighter = asRecord(o.fighter) ?? undefined;
@@ -259,8 +174,6 @@ function sideExtras(o, game) {
259
174
  const rank = pickString(o.rankText, o.rank, profile?.rankText);
260
175
  const championStatus = pickString(o.championStatus, profile?.championStatus);
261
176
  const division = pickString(profile?.division, o.division, o.weightClass);
262
- // `ioc` is the tennis feed's country code ("ESP"); it was not read, so every
263
- // tennis side on every board came back country-less.
264
177
  const country = pickString(o.country, profile?.country, o.ioc);
265
178
  const flag = pickString(o.flag, profile?.flag);
266
179
  const outcome = pickString(o.outcome);
@@ -269,12 +182,7 @@ function sideExtras(o, game) {
269
182
  ...(images ? { images } : {}),
270
183
  ...(record ? { record } : {}),
271
184
  ...(rank ? { rank } : {}),
272
- // "none" carries no signal for a page; only surface an actual belt state.
273
185
  ...(championStatus && championStatus.toLowerCase() !== 'none' ? { championStatus } : {}),
274
- // UFC corners always carry these keys. A debutant with no ranked division
275
- // was shipping with the key ABSENT while every other corner had it, and a
276
- // fighter from a country with no flag mapping did the same, which breaks
277
- // strict parsers. Null says "not known"; absence says nothing.
278
186
  ...(game === 'ufc'
279
187
  ? { division: division ?? null, country: country ?? null, flag: flag ?? null }
280
188
  : {
@@ -311,12 +219,6 @@ function scoreNum(v) {
311
219
  return Number(v);
312
220
  return null;
313
221
  }
314
- /**
315
- * Collapse the six spellings one outcome arrives in ("SUB" / "Submission",
316
- * "U-DEC" / "Decision - Unanimous", "KO/TKO" / "TKO") onto one enum. The raw
317
- * string is kept alongside as methodRaw; this is a stable key for consumers,
318
- * not a rewrite of the source.
319
- */
320
222
  export function normalizeUfcMethod(raw) {
321
223
  if (!raw)
322
224
  return undefined;
@@ -346,15 +248,12 @@ export function normalizeUfcMethod(raw) {
346
248
  return raw.trim();
347
249
  }
348
250
  export function normalizeMatch(game, row, forcedStatus) {
349
- // Always peel { success, data } so UFC bout fighters[] / status are visible.
350
251
  const r = asRecord(unwrapPayload(row)) ?? asRecord(row) ?? {};
351
- const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId, r.ufcFightId, r.live_match_id) ?? 'unknown';
252
+ const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.live_match_id) ?? 'unknown';
352
253
  let team1 = nestedSide(r.team1, game) ??
353
254
  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));
354
255
  let team2 = nestedSide(r.team2, game) ??
355
256
  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));
356
- // CS2 rows nest team1/team2 objects that lack a score; a nested side must not
357
- // shadow the flat score fields (team1Score/score1/team1Maps) with score:null.
358
257
  if (team1 && team1.score == null) {
359
258
  const s = scoreNum(r.team1Score ?? r.score1 ?? r.team1Maps ?? r.redScore);
360
259
  if (s != null)
@@ -365,17 +264,11 @@ export function normalizeMatch(game, row, forcedStatus) {
365
264
  if (s != null)
366
265
  team2 = { ...team2, score: s };
367
266
  }
368
- // Tennis: the live board nests player1/player2 objects ({id,name,sets_won});
369
- // raw live rows carry player1_name/player2_name; archive rows only carry
370
- // winner_id/loser_id (ids, no names). Map all three onto sides so labels are
371
- // never "? vs ?" and live scores surface as sets won.
372
267
  if (game === 'tennis' && !team1 && !team2) {
373
268
  const p1obj = asRecord(r.player1);
374
269
  const p2obj = asRecord(r.player2);
375
270
  const p1 = pickString(p1obj?.name, r.player1_name);
376
271
  const p2 = pickString(p2obj?.name, r.player2_name);
377
- // winner_sets_won/loser_sets_won and score/sets are match-level; only the
378
- // side flags differ, so they are shared by both branches below.
379
272
  const scoreText = pickString(r.score, r.score_raw) ?? null;
380
273
  if (p1 || p2) {
381
274
  const p1Id = pickString(p1obj?.id, r.player1_id) ?? null;
@@ -408,8 +301,6 @@ export function normalizeMatch(game, row, forcedStatus) {
408
301
  team2 = sideFrom(p2, p2Id ?? undefined, undefined, score2, sideExtras(p2obj ?? {}, game));
409
302
  }
410
303
  else {
411
- // Archive detail nests winner/loser objects with real names; list rows
412
- // may only carry winner_id/loser_id (ids double as last-resort labels).
413
304
  const wObj = asRecord(r.winner);
414
305
  const lObj = asRecord(r.loser);
415
306
  const wName = pickString(wObj?.name, r.winner_name);
@@ -417,7 +308,6 @@ export function normalizeMatch(game, row, forcedStatus) {
417
308
  const wId = pickString(wObj?.id, r.winner_id);
418
309
  const lId = pickString(lObj?.id, r.loser_id);
419
310
  if (wName || lName || wId || lId) {
420
- // This shape IS winner-first, so side 1 is the winner by construction.
421
311
  const wScore = tennisSetsWon({
422
312
  winnerSetsWon: r.winner_sets_won,
423
313
  loserSetsWon: r.loser_sets_won,
@@ -439,9 +329,6 @@ export function normalizeMatch(game, row, forcedStatus) {
439
329
  }
440
330
  }
441
331
  }
442
- // UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
443
- // Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
444
- // Live tracking uses red/blue + fighters[] (not team1/team2).
445
332
  if (game === 'ufc' || (!team1 && !team2 && (r.red || r.blue || Array.isArray(r.fighters)))) {
446
333
  const fromCornerObj = (raw) => {
447
334
  const o = asRecord(raw);
@@ -450,14 +337,9 @@ export function normalizeMatch(game, row, forcedStatus) {
450
337
  return { name: raw.trim() };
451
338
  return null;
452
339
  }
453
- // Nested profile from serializeBoutFighter
454
340
  const profile = asRecord(o.profile);
455
341
  const fighter = asRecord(o.fighter);
456
342
  const name = pickString(o.name, o.fighterName, o.displayName, o.nickname, profile?.name, profile?.nickname, fighter?.name, fighter?.nickname);
457
- // o.id on a serialized corner is the ufc_bout_fighters ROW id, unique per
458
- // bout, so the same human got a different "id" on every card. Prefer the
459
- // fighter's own id wherever the API supplies it; fall back to the row id
460
- // only when nothing better exists.
461
343
  const id = pickString(o.fighterId, profile?.id, fighter?.id, o.id);
462
344
  const slug = pickString(o.slug, o.fighterSlug, profile?.slug, fighter?.slug);
463
345
  const score = scoreNum(o.score ?? o.points);
@@ -479,7 +361,7 @@ export function normalizeMatch(game, row, forcedStatus) {
479
361
  if (fightersList.length) {
480
362
  const byCorner = (want) => fightersList.find((f) => {
481
363
  const c = pickString(asRecord(f)?.corner, asRecord(f)?.side)?.toLowerCase();
482
- return c === want || c === want[0]; // "red" / "r"
364
+ return c === want || c === want[0];
483
365
  });
484
366
  const redF = byCorner('red') ?? byCorner('r') ?? fightersList[0];
485
367
  const blueF = byCorner('blue') ??
@@ -489,13 +371,6 @@ export function normalizeMatch(game, row, forcedStatus) {
489
371
  team2 = team2 ?? fromCornerObj(blueF);
490
372
  }
491
373
  }
492
- // UFC fighter-history rows (/ufc/fighters/{slug}/fights). These carry the
493
- // fighter as fighterSlug/fighterName, the other corner under opponent, and
494
- // the result as outcome, with NO fighters[] and no team1/team2. Every reader
495
- // was left with two null sides: head_to_head filtered on those nulls and
496
- // reported Jones vs Cormier as never having met, the profile showed
497
- // team1:null with the division as the label, and the preview form could not
498
- // say who won. One shape, three symptoms.
499
374
  if (game === 'ufc' && !team1 && !team2 && typeof r.fighterSlug === 'string') {
500
375
  const opp = asRecord(r.opponent);
501
376
  const selfCorner = pickString(r.corner)?.toLowerCase();
@@ -503,8 +378,6 @@ export function normalizeMatch(game, row, forcedStatus) {
503
378
  const other = opp
504
379
  ? sideFrom(pickString(opp.name) ?? pickString(opp.slug), undefined, pickString(opp.slug), undefined, sideExtras(opp, game))
505
380
  : null;
506
- // Keep red on team1 when we know the corner, so a card and a history row
507
- // agree on which side is which.
508
381
  if (selfCorner === 'blue' && other) {
509
382
  team1 = other;
510
383
  team2 = self;
@@ -514,19 +387,11 @@ export function normalizeMatch(game, row, forcedStatus) {
514
387
  team2 = other;
515
388
  }
516
389
  }
517
- // COD sometimes uses teams[]
518
390
  if ((!team1 || !team2) && Array.isArray(r.teams)) {
519
391
  team1 = team1 ?? nestedSide(r.teams[0], game);
520
392
  team2 = team2 ?? nestedSide(r.teams[1], game);
521
393
  }
522
- const startTime = pickString(r.startTime, r.scheduledAt,
523
- // Tennis emits scheduled_at; startTime was the API's only camelCase key
524
- // and was removed. Without this the upcoming board loses its clock.
525
- r.scheduled_at,
526
- // A live row reports when it went in-play, not when it was scheduled.
527
- r.started_at, r.startsAt, r.date, r.startDate, r.beginAt, asRecord(r.event)?.startsAt, asRecord(r.event)?.startTime, asRecord(r.event)?.date) ?? null;
528
- // Tennis archive rows carry the state as outcome (COMPLETED / RETIREMENT /
529
- // WALKOVER); read it, or every finished match reported status unknown.
394
+ const startTime = pickString(r.startTime, r.scheduledAt, r.scheduled_at, r.started_at, r.startsAt, r.date, r.startDate, r.beginAt, asRecord(r.event)?.startsAt, asRecord(r.event)?.startTime, asRecord(r.event)?.date) ?? null;
530
395
  const statusRaw = pickString(r.status, r.state, r.matchStatus, r.boutStatus, r.outcome)?.toLowerCase() ?? '';
531
396
  let status = forcedStatus ?? 'unknown';
532
397
  if (!forcedStatus) {
@@ -534,16 +399,11 @@ export function normalizeMatch(game, row, forcedStatus) {
534
399
  r.resultRound != null ||
535
400
  r.isComplete === true ||
536
401
  r.completed === true;
537
- // "started" as a bare substring also matches "not_started", which is how a
538
- // fixture that has not begun was being served to customers as live. Require
539
- // word boundaries, refuse the explicit not-started spellings, and refuse any
540
- // match whose kickoff is still in the future: a scheduled match cannot be
541
- // in progress no matter what the upstream status string says.
542
402
  const unstarted = /not[_\s-]?started|unstarted|not[_\s-]?begun|yet[_\s-]?to[_\s-]?start/.test(statusRaw);
543
403
  const looksLive = !unstarted && /\b(live|running|in[_\s-]?progress|ongoing|started|watching)\b/.test(statusRaw);
544
404
  const kickoffPassed = (() => {
545
405
  if (!startTime)
546
- return true; // no clock to contradict the feed
406
+ return true;
547
407
  const t = Date.parse(startTime);
548
408
  return !Number.isFinite(t) || t <= Date.now();
549
409
  })();
@@ -556,7 +416,6 @@ export function normalizeMatch(game, row, forcedStatus) {
556
416
  }
557
417
  else if (/upcoming|scheduled|not_?started|unstarted|pending|soon|booked|confirmed|announced|tbd/.test(statusRaw) ||
558
418
  (game === 'ufc' && !hasResult && (startTime || statusRaw === '' || statusRaw === 'unknown'))) {
559
- // UFC bouts often omit status or use sparse values; default upcoming when no result yet.
560
419
  if (hasResult)
561
420
  status = 'completed';
562
421
  else if (startTime) {
@@ -570,20 +429,12 @@ export function normalizeMatch(game, row, forcedStatus) {
570
429
  else if (hasResult)
571
430
  status = 'completed';
572
431
  }
573
- // The winner, resolved once. Explicit winner fields first; otherwise a
574
- // history row states the result from the fighter's own side (outcome: win /
575
- // loss), which translates back into a slug. Both the score below and the
576
- // result block use this, so a fight the profile shows as a win also counts
577
- // as a win in head_to_head's tally, which reads the scores.
578
432
  const historyOutcome = pickString(r.outcome)?.toLowerCase();
579
433
  const historyOpponent = pickString(asRecord(r.opponent)?.slug);
580
- const resolvedWinnerSlug =
581
- // winner_id is the tennis archive's spelling; winner there is an object.
582
- pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.winner_id) ??
434
+ const resolvedWinnerSlug = pickString(r.winnerFighterSlug, r.winnerSlug, r.winner, r.winner_id) ??
583
435
  (historyOutcome === 'win' && typeof r.fighterSlug === 'string' ? r.fighterSlug
584
436
  : historyOutcome === 'loss' && historyOpponent ? historyOpponent
585
437
  : undefined);
586
- // Winner → score 1-0 for UFC card display when numeric scores absent
587
438
  if (game === 'ufc' && team1 && team2 && team1.score == null && team2.score == null) {
588
439
  const winner = resolvedWinnerSlug?.toLowerCase();
589
440
  if (winner) {
@@ -606,26 +457,13 @@ export function normalizeMatch(game, row, forcedStatus) {
606
457
  const leagueName = pickString(asRecord(r.league)?.name, r.leagueName, r.league, r.leagueSlug);
607
458
  const leagueId = pickString(asRecord(r.league)?.id, r.leagueId);
608
459
  const leagueSlug = pickString(asRecord(r.league)?.slug, r.leagueSlug);
609
- // Prefer explicit title/label when present, but never keep the placeholder
610
- // "? vs ?" once fighter/team names were resolved (UFC fighters[] / red-blue).
611
- // When both sides lack names, prefer event / weight / bout id over opaque "? vs ?"
612
- // so agent boards (live_matches, upcoming_schedule, event_card) stay legible.
613
460
  const vsLabel = `${team1?.name ?? '?'} vs ${team2?.name ?? '?'}`;
614
461
  const explicitRaw = pickString(r.label, r.title, r.name);
615
- // UFC history rows put the division where a title would go ("Heavyweight"),
616
- // which then displaced "Jon Jones vs Stipe Miocic" as the label. A label that
617
- // merely repeats the weight class is not a matchup; use the sides instead.
618
462
  const explicit = game === 'ufc' && explicitRaw && (team1?.name || team2?.name) &&
619
463
  explicitRaw.toLowerCase() === (pickString(r.weightClass, r.division) ?? '').toLowerCase()
620
464
  ? undefined
621
465
  : explicitRaw;
622
466
  const explicitIsPlaceholder = explicit != null && /^\?\s*vs\s*\?$/i.test(explicit.trim());
623
- // Tennis rows carry no `label`, but the live route does set `name` — and that
624
- // name is the TOURNAMENT ("Challenger, Seville"), not the matchup. Preferring
625
- // it made live_matches label a tennis row by its event while
626
- // upcoming_schedule, off a route with no such field, labelled the same
627
- // fixture "Max Alcala Gurri vs Dusan Lajovic". Two boards, one match, two
628
- // labels. A "matchup" that is really an event name is not a matchup.
629
467
  const explicitIsEventName = explicit != null &&
630
468
  game === 'tennis' &&
631
469
  Boolean(team1?.name || team2?.name) &&
@@ -641,14 +479,11 @@ export function normalizeMatch(game, row, forcedStatus) {
641
479
  label = vsLabel;
642
480
  }
643
481
  else {
644
- // Identity fallbacks — never invent fighter names, but avoid bare "? vs ?"
645
482
  label =
646
483
  pickString(eventName, eventSlug, weightOrClass) ??
647
484
  (matchId && matchId !== 'unknown' ? `Bout ${matchId}` : null) ??
648
485
  vsLabel;
649
486
  }
650
- // Bout metadata already present on the upstream row. Passing it through here
651
- // is what stops an agent from N+1'ing call_api to rebuild a fight card.
652
487
  const cardSection = pickString(r.cardSection, r.cardSegment, r.segment);
653
488
  const cardPosition = pickString(r.cardPosition);
654
489
  const cardSectionOrder = numOrNull(r.cardSectionOrder);
@@ -658,16 +493,11 @@ export function normalizeMatch(game, row, forcedStatus) {
658
493
  const method = normalizeUfcMethod(methodRaw);
659
494
  const methodDetails = pickString(r.methodDetails);
660
495
  const resultTime = pickString(r.resultTime);
661
- // UFC sends referee as { id, name, firstName, lastName } — not a string.
662
496
  const referee = pickString(r.referee, asRecord(r.referee)?.name);
663
497
  const winnerSlug = resolvedWinnerSlug;
664
498
  const resultRound = numOrNull(r.resultRound);
665
499
  const hasResultDetail = method != null || resultRound != null || winnerSlug != null || resultTime != null;
666
500
  const isCancelled = r.isCancelled === true;
667
- // Tennis live/detail rows carry a top-level sets[] (set_num/player*_games/
668
- // tiebreak/is_completed) plus game_score/server/current_set. A live board
669
- // that only reports "score: 1" cannot tell 6-0 6-0 from a 7-6 in progress;
670
- // carry the real breakdown through instead of collapsing it to an integer.
671
501
  let tennisSets;
672
502
  let tennisGameScore = null;
673
503
  let tennisServer = null;
@@ -678,15 +508,11 @@ export function normalizeMatch(game, row, forcedStatus) {
678
508
  const p1g = numOrNull(sr.player1_games);
679
509
  const p2g = numOrNull(sr.player2_games);
680
510
  return {
681
- // The live route spells it set_num; the archive route set_number. Both
682
- // are emitted by the same API for the same set.
683
511
  setNumber: numOrNull(sr.set_num) ?? numOrNull(sr.set_number),
684
512
  score: pickString(sr.score) ?? (p1g !== null && p2g !== null ? `${p1g}-${p2g}` : null),
685
513
  player1Games: p1g,
686
514
  player2Games: p2g,
687
515
  tiebreak: sr.tiebreak == null ? null : (pickString(sr.tiebreak) ?? String(sr.tiebreak)),
688
- // is_completed is a live-route field only; the archive route omits it
689
- // and every set of a finished match read as completed:false.
690
516
  completed: tennisSetCompleted(sr),
691
517
  };
692
518
  });
@@ -704,8 +530,6 @@ export function normalizeMatch(game, row, forcedStatus) {
704
530
  team2,
705
531
  event: eventName || eventId || eventSlug ? { id: eventId, slug: eventSlug, name: eventName } : null,
706
532
  league: leagueName || leagueId || leagueSlug ? { id: leagueId, slug: leagueSlug, name: leagueName } : null,
707
- // weightClass is UFC vocabulary; other games never set it, but never lie
708
- // it into existence from a tennis/LoL row that merely shares a field name.
709
533
  ...(weightOrClass && game === 'ufc' ? { weightClass: weightOrClass } : {}),
710
534
  ...(r.titleBout != null ? { titleBout: Boolean(r.titleBout) } : {}),
711
535
  ...(hasCard
@@ -721,10 +545,6 @@ export function normalizeMatch(game, row, forcedStatus) {
721
545
  ...(hasResultDetail
722
546
  ? {
723
547
  result: {
724
- // method/referee are combat-sport vocabulary with no tennis
725
- // meaning; a tennis history row otherwise picks up a
726
- // result.method:null / result.referee:null pair from this same
727
- // object shape, which reads as "render a fight" to an agent.
728
548
  ...(game === 'ufc' ? { method: method ?? null, referee: referee ?? null } : {}),
729
549
  methodRaw: methodRaw ?? null,
730
550
  methodDetails: methodDetails ?? null,
@@ -742,11 +562,6 @@ export function normalizeMatch(game, row, forcedStatus) {
742
562
  : {}),
743
563
  };
744
564
  }
745
- /**
746
- * Sort a fight card the way it is presented: main card before prelims, and the
747
- * main event at the top of its section. Rows without placement keep their
748
- * upstream order behind those that have it.
749
- */
750
565
  export function sortByCardOrder(rows) {
751
566
  const rank = (x, i) => ({
752
567
  section: x.card?.sectionOrder ?? Number.MAX_SAFE_INTEGER,
@@ -768,7 +583,6 @@ export function entityRef(row, type, game) {
768
583
  pickString(r.slug) ??
769
584
  'unknown';
770
585
  const slug = pickString(r.slug, r.orgSlug, r.teamSlug);
771
- // Events often use `title` not `name` (UFC)
772
586
  const name = pickString(r.name, r.full_name, r.player_name, r.title, r.nickname, r.displayName, r.tag, r.code, slug, id) ?? id;
773
587
  const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname);
774
588
  return {
@@ -789,11 +603,6 @@ export function entityRef(row, type, game) {
789
603
  },
790
604
  };
791
605
  }
792
- /**
793
- * Fuzzy score for entity resolution: exact > startsWith > includes > token overlap.
794
- * Extra args (nickname, alt names) are scored as additional candidates.
795
- * Multi-token person names ("jon jones") get a strong boost when every token hits.
796
- */
797
606
  export function rankScore(query, name, id, slug, ...extra) {
798
607
  const q = query.trim().toLowerCase().replace(/\s+/g, ' ');
799
608
  if (!q)
@@ -816,27 +625,18 @@ export function rankScore(query, name, id, slug, ...extra) {
816
625
  best = Math.max(best, 55 - Math.min(20, c.indexOf(q)));
817
626
  else {
818
627
  const ct = c.split(/[\s]+/).filter(Boolean);
819
- // all query tokens present as whole tokens (order-independent)
820
628
  const allTokens = qt.length > 0 &&
821
629
  qt.every((t) => ct.some((x) => x === t || x.startsWith(t) || t.startsWith(x)));
822
630
  if (allTokens && qt.length >= 2) {
823
- best = Math.max(best, 92); // "jon jones" vs "Jon Jones"
631
+ best = Math.max(best, 92);
824
632
  }
825
633
  else {
826
- // A hit is the candidate token containing the query token, or the query
827
- // token containing a candidate token OF REAL LENGTH. The reverse
828
- // direction used to accept any length, so the single letter "e" in
829
- // "E Skinner" counted as a hit against the query "sinner" ("sinner"
830
- // contains "e") and scored 32 — which is how eight strangers padded a
831
- // search for one player. A one- or two-character fragment carries no
832
- // identity, so it no longer counts.
833
634
  const hits = qt.filter((t) => ct.some((x) => x.includes(t) || (t.includes(x) && x.length >= 3))).length;
834
635
  if (hits === qt.length && qt.length > 0)
835
636
  best = Math.max(best, 75);
836
637
  else if (hits)
837
638
  best = Math.max(best, 20 + hits * 12);
838
639
  }
839
- // last-name only: query last token equals candidate last token
840
640
  if (qt.length >= 2 && ct.length >= 1 && ct[ct.length - 1] === qt[qt.length - 1]) {
841
641
  best = Math.max(best, 60);
842
642
  }
@@ -844,13 +644,6 @@ export function rankScore(query, name, id, slug, ...extra) {
844
644
  }
845
645
  return best;
846
646
  }
847
- /**
848
- * Edition year encoded in a tennis-style entity id ("atp_2026_580" -> 2026).
849
- *
850
- * `/tennis/competitions` returns no `year` field while the calendar does, so
851
- * the id is the only signal available on both, and the two tools that disagreed
852
- * about "Australian Open" were each reading a different one of those endpoints.
853
- */
854
647
  export function editionYear(id, explicit) {
855
648
  const direct = Number(explicit);
856
649
  if (Number.isFinite(direct) && direct > 1800)
@@ -858,22 +651,6 @@ export function editionYear(id, explicit) {
858
651
  const match = /^[a-z]+_(\d{4})_/i.exec(String(id ?? ''));
859
652
  return match ? Number(match[1]) : null;
860
653
  }
861
- /**
862
- * Deterministic winner among rows whose names match a query equally well.
863
- *
864
- * "Australian Open" is two rows in every season, one ATP and one WTA, sharing a
865
- * name exactly. resolve_entity read /tennis/competitions, which lists ATP
866
- * first; event_card read /tennis/tournaments/calendar, which lists WTA first;
867
- * and neither applied a tiebreak, so the same question got atp_2026_580 from
868
- * one tool and wta_2026_580 from the other. Neither answer was wrong on its
869
- * own. The absence of a rule was the bug.
870
- *
871
- * Order: an exact name beats a substring, a newer edition beats an older one,
872
- * and the id settles whatever is left. That last step is arbitrary, and that is
873
- * fine -- it only has to be FIXED, which is the property that was missing.
874
- * Callers that need the alternatives still surface them; this only decides
875
- * which single row is called "best".
876
- */
877
654
  export function chooseNamedEntity(rows, needle, read) {
878
655
  const want = needle.trim().toLowerCase();
879
656
  if (!want || !rows.length)