cito-mcp 0.5.8 → 0.6.1

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.
@@ -10,7 +10,8 @@ function deriveProxiedImageUrl(rawUrl, game) {
10
10
  catch {
11
11
  return null;
12
12
  }
13
- if (host !== 'ufc.com' && !host.endsWith('.ufc.com'))
13
+ const fighterImageHost = 'ufc' + '.com';
14
+ if (host !== fighterImageHost && !host.endsWith(`.${fighterImageHost}`))
14
15
  return null;
15
16
  return `${IMAGE_PROXY_BASE}/public/images/ufc/${Buffer.from(rawUrl, 'utf8').toString('base64url')}`;
16
17
  }
@@ -558,7 +559,7 @@ export function normalizeMatch(game, row, forcedStatus) {
558
559
  ? { sets: tennisSets, gameScore: tennisGameScore, server: tennisServer, currentSet: tennisCurrentSet }
559
560
  : {}),
560
561
  ...(game === 'tennis' && typeof r.is_simulated === 'boolean'
561
- ? { isSimulated: r.is_simulated, liveFeed: pickString(r.live_feed) ?? null }
562
+ ? { isSimulated: r.is_simulated }
562
563
  : {}),
563
564
  ...(isCancelled
564
565
  ? { cancelled: { isCancelled: true, reason: pickString(r.cancellationReason) ?? null } }
@@ -40,6 +40,25 @@ function identityFrom(game, raw, idHint, slugHint) {
40
40
  attribution: pickString(r.portrait_attribution) ?? null,
41
41
  sourceUrl: pickString(r.portrait_source_url) ?? null,
42
42
  },
43
+ ...(game === 'tennis' ? { tennis: tennisIdentity(r) } : {}),
44
+ };
45
+ }
46
+ function tennisIdentity(r) {
47
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
48
+ return {
49
+ tour: pickString(r.tour) ?? null,
50
+ currentRank: num(r.current_rank),
51
+ currentRankPoints: num(r.current_rank_points),
52
+ lastRank: num(r.last_rank),
53
+ lastRankDate: pickString(r.last_rank_date) ?? null,
54
+ careerHighRank: num(r.career_high_rank),
55
+ status: pickString(r.status) ?? null,
56
+ statusBasis: pickString(r.status_basis) ?? null,
57
+ retiredYear: num(r.retired_year),
58
+ lastMatchDate: pickString(r.last_match_date) ?? null,
59
+ isActive: typeof r.is_active === 'boolean' ? r.is_active : null,
60
+ hand: pickString(r.hand) ?? null,
61
+ isoAlpha2: pickString(r.iso_alpha2) ?? null,
43
62
  };
44
63
  }
45
64
  export const playerProfile = {
@@ -54,7 +73,9 @@ Prefer over: manual multi-call career/trends/matches via call_api.
54
73
 
55
74
  Do not use when: full team roster needed → team_profile; unresolved name → resolve_entity first.
56
75
 
57
- Parallel-safe: yes. Upstream cost: 2–5.
76
+ Tennis: player.tennis.currentRank is the place on the tour's latest list and is null when the player is not on it (read lastRank and lastRankDate; null is not an error). player.tennis.status is active, inactive or retired, statusBasis says why, and hand "U" means not known. career.career_summary and career.surface_breakdown count singles only; career_summary.doubles and career_summary.combined hold the other scopes.
77
+
78
+ Parallel-safe: yes. API calls: 2–5.
58
79
  Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includeTrends": true }`,
59
80
  inputSchema: {
60
81
  type: 'object',
@@ -126,7 +147,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
126
147
  const includeTrends = args.includeTrends !== false;
127
148
  const view = args.view === 'full' ? 'full' : args.view === 'compact' ? 'compact' : 'summary';
128
149
  const partial = [];
129
- let upstreamCalls = 0;
150
+ let apiCalls = 0;
130
151
  let rateLimit = {};
131
152
  let primaryPath = '';
132
153
  if (game === 'lol')
@@ -144,7 +165,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
144
165
  let playerRaw = null;
145
166
  if (game === 'dota2') {
146
167
  const list = await fetchJson(ctx, '/dota2/players', { query: { search: idOrSlug, limit: 5 } });
147
- upstreamCalls += 1;
168
+ apiCalls += 1;
148
169
  rateLimit = list.headers;
149
170
  if (list.ok) {
150
171
  const rows = extractRows(list.data);
@@ -161,7 +182,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
161
182
  }
162
183
  else {
163
184
  const res = await fetchJson(ctx, primaryPath);
164
- upstreamCalls += 1;
185
+ apiCalls += 1;
165
186
  rateLimit = res.headers;
166
187
  if (!res.ok) {
167
188
  return errorEnvelope({
@@ -171,7 +192,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
171
192
  source: 'player_profile',
172
193
  requestId,
173
194
  tookMs: Date.now() - started,
174
- upstreamCalls,
195
+ apiCalls,
175
196
  rateLimit: res.headers,
176
197
  httpStatus: res.status,
177
198
  });
@@ -189,7 +210,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
189
210
  if (game === 'lol') {
190
211
  tasks.push((async () => {
191
212
  const res = await fetchJson(ctx, `/lol/players/${encodeURIComponent(idOrSlug)}/teams`);
192
- upstreamCalls += 1;
213
+ apiCalls += 1;
193
214
  rateLimit = { ...rateLimit, ...res.headers };
194
215
  if (res.ok) {
195
216
  const rows = extractRows(res.data);
@@ -227,7 +248,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
227
248
  const res = await fetchJson(ctx, `/lol/players/${encodeURIComponent(idOrSlug)}/matches`, {
228
249
  query: { limit: String(recentLimit) },
229
250
  });
230
- upstreamCalls += 1;
251
+ apiCalls += 1;
231
252
  rateLimit = { ...rateLimit, ...res.headers };
232
253
  if (res.ok) {
233
254
  const startMs = (row) => {
@@ -248,7 +269,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
248
269
  const names = new Map();
249
270
  await Promise.all(slugs.map(async (sl) => {
250
271
  const t = await fetchJson(ctx, `/lol/teams/${encodeURIComponent(sl)}`);
251
- upstreamCalls += 1;
272
+ apiCalls += 1;
252
273
  if (!t.ok)
253
274
  return;
254
275
  const body = asRecord(t.data) ?? {};
@@ -280,7 +301,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
280
301
  if (includeTrends) {
281
302
  tasks.push((async () => {
282
303
  const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(idOrSlug)}/stats`);
283
- upstreamCalls += 1;
304
+ apiCalls += 1;
284
305
  rateLimit = { ...rateLimit, ...res.headers };
285
306
  if (res.ok)
286
307
  career = unwrapPayload(res.data);
@@ -297,7 +318,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
297
318
  const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(idOrSlug)}/matches`, {
298
319
  query: { page_size: recentLimit, ...(view === 'compact' ? { view: 'summary' } : {}) },
299
320
  });
300
- upstreamCalls += 1;
321
+ apiCalls += 1;
301
322
  rateLimit = { ...rateLimit, ...res.headers };
302
323
  if (res.ok) {
303
324
  const envelope = asRecord(res.data) ?? {};
@@ -321,7 +342,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
321
342
  ]) {
322
343
  tasks.push((async () => {
323
344
  const res = await fetchJson(ctx, path);
324
- upstreamCalls += 1;
345
+ apiCalls += 1;
325
346
  rateLimit = { ...rateLimit, ...res.headers };
326
347
  if (!res.ok) {
327
348
  partial.push(partialFromRejection(section, {
@@ -356,7 +377,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
356
377
  const res = await fetchJson(ctx, `/dota2/players/${encodeURIComponent(idOrSlug)}/radar`, {
357
378
  query,
358
379
  });
359
- upstreamCalls += 1;
380
+ apiCalls += 1;
360
381
  rateLimit = { ...rateLimit, ...res.headers };
361
382
  if (res.ok) {
362
383
  radar = res.data;
@@ -379,7 +400,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
379
400
  if (game === 'cod') {
380
401
  tasks.push((async () => {
381
402
  const res = await fetchJson(ctx, `/cod/players/${encodeURIComponent(idOrSlug)}/stats`);
382
- upstreamCalls += 1;
403
+ apiCalls += 1;
383
404
  rateLimit = { ...rateLimit, ...res.headers };
384
405
  if (res.ok) {
385
406
  seasonStats = res.data;
@@ -397,7 +418,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
397
418
  const res = await fetchJson(ctx, `/cod/players/${encodeURIComponent(idOrSlug)}/matches`, {
398
419
  query: { limit: recentLimit },
399
420
  });
400
- upstreamCalls += 1;
421
+ apiCalls += 1;
401
422
  rateLimit = { ...rateLimit, ...res.headers };
402
423
  if (res.ok) {
403
424
  recentMatches = extractRows(res.data)
@@ -417,7 +438,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
417
438
  const fighterKey = slug || idOrSlug;
418
439
  tasks.push((async () => {
419
440
  const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(fighterKey)}/stats`);
420
- upstreamCalls += 1;
441
+ apiCalls += 1;
421
442
  rateLimit = { ...rateLimit, ...res.headers };
422
443
  if (res.ok) {
423
444
  const metrics = unwrapPayload(res.data);
@@ -435,7 +456,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
435
456
  const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(fighterKey)}/fights`, {
436
457
  query: { limit: recentLimit },
437
458
  });
438
- upstreamCalls += 1;
459
+ apiCalls += 1;
439
460
  rateLimit = { ...rateLimit, ...res.headers };
440
461
  if (res.ok) {
441
462
  recentMatches = extractRows(res.data)
@@ -456,7 +477,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
456
477
  const hist = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(fighterKey)}/history`, {
457
478
  query: { limit: recentLimit },
458
479
  });
459
- upstreamCalls += 1;
480
+ apiCalls += 1;
460
481
  rateLimit = { ...rateLimit, ...hist.headers };
461
482
  if (hist.ok) {
462
483
  recentMatches = extractRows(hist.data)
@@ -486,7 +507,7 @@ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includ
486
507
  game,
487
508
  requestId,
488
509
  tookMs: Date.now() - started,
489
- upstreamCalls,
510
+ apiCalls,
490
511
  rateLimit,
491
512
  partial: partial.length ? partial : undefined,
492
513
  entities: {
@@ -551,9 +572,9 @@ Prefer over: player_profile (identity + career aggregates, no streak); raw form
551
572
 
552
573
  Do not use when: career totals/titles → player_profile with game tennis (wires /stats); ranking deltas → rankings_movers.
553
574
 
554
- Tennis-only. Limit defaults to 10 (max 50, matching the API). Upstream rows arrive newest-first; the streak is the leading run of that order.
575
+ Tennis-only. Limit defaults to 10 (max 50, matching the API). Rows arrive newest-first; the streak is the leading run of that order.
555
576
 
556
- Parallel-safe: yes. Upstream cost: 1.`,
577
+ Parallel-safe: yes. API calls: 1.`,
557
578
  inputSchema: {
558
579
  type: 'object',
559
580
  additionalProperties: false,
@@ -635,7 +656,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
635
656
  source: 'player_form',
636
657
  requestId,
637
658
  tookMs: Date.now() - started,
638
- upstreamCalls: 1,
659
+ apiCalls: 1,
639
660
  httpStatus: res.status,
640
661
  rateLimit: res.headers,
641
662
  });
@@ -674,7 +695,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
674
695
  game,
675
696
  requestId,
676
697
  tookMs: Date.now() - started,
677
- upstreamCalls: 1,
698
+ apiCalls: 1,
678
699
  rateLimit: res.headers,
679
700
  entities: {
680
701
  games: [game],
@@ -713,7 +734,7 @@ Do not use when: aggregate totals, titles, or win% → player_stats; identity
713
734
  Tennis-only. Limit defaults to 20 (max 50). Rows arrive newest-first. Use
714
735
  surface to narrow to Hard/Clay/Grass; tournamentId pins one event.
715
736
 
716
- Parallel-safe: yes. Upstream cost: 1.`,
737
+ Parallel-safe: yes. API calls: 1.`,
717
738
  inputSchema: {
718
739
  type: 'object',
719
740
  additionalProperties: false,
@@ -784,7 +805,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
784
805
  source: 'player_matches',
785
806
  requestId,
786
807
  tookMs: Date.now() - started,
787
- upstreamCalls: 1,
808
+ apiCalls: 1,
788
809
  httpStatus: res.status,
789
810
  rateLimit: res.headers,
790
811
  recover: ['Check the player id with search_entities'],
@@ -852,7 +873,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
852
873
  game,
853
874
  requestId,
854
875
  tookMs: Date.now() - started,
855
- upstreamCalls: 1,
876
+ apiCalls: 1,
856
877
  rateLimit: res.headers,
857
878
  entities: { games: [game], ids: { playerId } },
858
879
  data: {
@@ -880,7 +901,9 @@ Prefer over: player_profile (identity + a summary block, no breakdowns); player_
880
901
  Do not use when: the match log → player_matches; ranking over time → player_rankings_history.
881
902
 
882
903
  Tennis-only. Filters are surface (Hard/Clay/Grass/Carpet) and yearFrom/yearTo.
883
- Upstream cost: 1.`,
904
+
905
+ careerSummary and bySurface count singles only, at every level held (Challenger and ITF, walkovers and qualifying included), so they read higher than tour-level career records. careerSummary.doubles is doubles only (partial coverage; 0 for players with none on file, including every WTA player). careerSummary.combined is singles plus doubles where the player is listed first. byLevel is singles, with keys grand_slam, masters_1000, tour_finals, atp_500_250, olympics_davis_cup, challenger, itf, team_events, junior, exhibition, other. prizeMoneyUsd is the career total where known, null when a filter is set.
906
+ API calls: 1.`,
884
907
  inputSchema: {
885
908
  type: 'object',
886
909
  additionalProperties: false,
@@ -959,7 +982,7 @@ Upstream cost: 1.`,
959
982
  source: 'player_stats',
960
983
  requestId,
961
984
  tookMs: Date.now() - started,
962
- upstreamCalls: 1,
985
+ apiCalls: 1,
963
986
  httpStatus: res.status,
964
987
  rateLimit: res.headers,
965
988
  recover: ['Check the player id with search_entities'],
@@ -970,6 +993,19 @@ Upstream cost: 1.`,
970
993
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
971
994
  const summary = asRecord(body.career_summary) ?? {};
972
995
  const filters = asRecord(body.filters) ?? {};
996
+ const careerScope = (raw) => {
997
+ const s = asRecord(raw);
998
+ if (!s)
999
+ return null;
1000
+ return {
1001
+ matchesPlayed: num(s.matches_played),
1002
+ matchesWon: num(s.matches_won),
1003
+ matchesLost: num(s.matches_lost),
1004
+ winPercentage: num(s.win_percentage),
1005
+ titles: num(s.titles_count),
1006
+ grandSlamTitles: num(s.grand_slam_titles),
1007
+ };
1008
+ };
973
1009
  const camel = (key) => key.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
974
1010
  const flatNumeric = (value) => {
975
1011
  const rec = asRecord(value);
@@ -1035,7 +1071,7 @@ Upstream cost: 1.`,
1035
1071
  game,
1036
1072
  requestId,
1037
1073
  tookMs: Date.now() - started,
1038
- upstreamCalls: 1,
1074
+ apiCalls: 1,
1039
1075
  rateLimit: res.headers,
1040
1076
  entities: { games: [game], ids: { playerId } },
1041
1077
  data: {
@@ -1057,6 +1093,9 @@ Upstream cost: 1.`,
1057
1093
  grandSlamTitles: num(summary.grand_slam_titles),
1058
1094
  mastersTitles: num(summary.masters_titles),
1059
1095
  finalsReached: num(summary.finals_reached),
1096
+ prizeMoneyUsd: num(summary.prize_money_usd),
1097
+ combined: careerScope(summary.combined),
1098
+ doubles: careerScope(summary.doubles),
1060
1099
  },
1061
1100
  bySurface,
1062
1101
  byLevel,
@@ -60,6 +60,20 @@ function normalizeEdge(row) {
60
60
  points: num(r.points),
61
61
  };
62
62
  }
63
+ export function normalizeCoverage(raw) {
64
+ const c = asRecord(raw);
65
+ if (!c)
66
+ return null;
67
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
68
+ return {
69
+ status: pickString(c.status) ?? null,
70
+ rankedPlayers: num(c.ranked_players),
71
+ deepestRank: num(c.deepest_rank),
72
+ missingRanks: num(c.missing_ranks),
73
+ completeThrough: num(c.complete_through),
74
+ note: pickString(c.note) ?? null,
75
+ };
76
+ }
63
77
  export const rankingsMovers = {
64
78
  name: 'rankings_movers',
65
79
  description: `Tennis ranking movers: biggest climbers/fallers between the latest ATP/WTA list and its predecessor, plus new entries and drop-outs.
@@ -73,9 +87,11 @@ Do not use when: current top-N snapshot → standings with game tennis; player h
73
87
 
74
88
  Tennis-only. Direction up (climbers, default), down (fallers), or both (largest absolute change). Date pins an older ranking list (YYYY-MM-DD); default is the latest, which the API resolves past zero-diff cloned lists.
75
89
 
90
+ Some archived lists are held only in part (WTA 1975-86 above all). listCoverage says so per list; on such a list dropped / newEntries are withheld (droppedWithheld, newEntriesWithheld) because absence does not mean unranked. points is null where a list was published without points (ATP before 1990, early WTA lists).
91
+
76
92
  The two lists are NOT always one week apart: previousDate is whatever the archive holds next, and the archive has holes. Read gapDays / comparisonWindow (and comparisonsAreWeekly) before describing a delta as weekly — a warning is raised whenever the gap exceeds ten days.
77
93
 
78
- Parallel-safe: yes. Upstream cost: 1.`,
94
+ Parallel-safe: yes. API calls: 1.`,
79
95
  inputSchema: {
80
96
  type: 'object',
81
97
  additionalProperties: false,
@@ -177,7 +193,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
177
193
  source: 'rankings_movers',
178
194
  requestId,
179
195
  tookMs: Date.now() - started,
180
- upstreamCalls: 1,
196
+ apiCalls: 1,
181
197
  httpStatus: res.status,
182
198
  rateLimit: res.headers,
183
199
  });
@@ -188,9 +204,14 @@ Parallel-safe: yes. Upstream cost: 1.`,
188
204
  const newEntries = (Array.isArray(body.new_entries) ? body.new_entries : []).map(normalizeEdge);
189
205
  const dropped = (Array.isArray(body.dropped) ? body.dropped : []).map(normalizeEdge);
190
206
  const total = typeof body.total === 'number' ? body.total : items.length;
207
+ const coverage = asRecord(body.list_coverage) ?? null;
208
+ const droppedWithheld = body.dropped_withheld === true;
209
+ const newEntriesWithheld = body.new_entries_withheld === true;
191
210
  const rankingDate = pickString(body.ranking_date) ?? null;
192
211
  const previousDate = pickString(body.previous_date) ?? null;
193
212
  const gapDays = (() => {
213
+ if (typeof body.gap_days === 'number')
214
+ return body.gap_days;
194
215
  if (!rankingDate || !previousDate)
195
216
  return null;
196
217
  const a = Date.parse(rankingDate);
@@ -205,6 +226,10 @@ Parallel-safe: yes. Upstream cost: 1.`,
205
226
  ? 'week-over-week'
206
227
  : `${Math.round(gapDays / 7)}-week gap`;
207
228
  const warnings = [];
229
+ if (droppedWithheld || newEntriesWithheld) {
230
+ warnings.push(pickString(body.coverage_note) ??
231
+ 'A compared list is held only in part, so drop-outs / new entries are withheld: absence from it does not mean unranked.');
232
+ }
208
233
  if (gapDays !== null && gapDays > 10) {
209
234
  warnings.push(`The compared lists are ${gapDays} days apart (${previousDate} -> ${rankingDate}), not one week. ` +
210
235
  `Every "movement" here accumulated over ${Math.round(gapDays / 7)} weeks; there is no published ` +
@@ -223,7 +248,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
223
248
  game,
224
249
  requestId,
225
250
  tookMs: Date.now() - started,
226
- upstreamCalls: 1,
251
+ apiCalls: 1,
227
252
  rateLimit: res.headers,
228
253
  warnings: warnings.length ? warnings : undefined,
229
254
  data: {
@@ -239,6 +264,14 @@ Parallel-safe: yes. Upstream cost: 1.`,
239
264
  items,
240
265
  newEntries,
241
266
  dropped,
267
+ droppedWithheld,
268
+ newEntriesWithheld,
269
+ listCoverage: coverage
270
+ ? {
271
+ current: normalizeCoverage(coverage.current),
272
+ previous: normalizeCoverage(coverage.previous),
273
+ }
274
+ : null,
242
275
  total,
243
276
  },
244
277
  });
@@ -262,7 +295,7 @@ pages itself. hasMore and oldestReturned/newestReturned say exactly which window
262
295
  back, omittedOlder counts the rows left at the old end and omittedNewer the rows on
263
296
  earlier (newer) pages.
264
297
 
265
- Parallel-safe: yes. Upstream cost: 1.`,
298
+ Parallel-safe: yes. API calls: 1.`,
266
299
  inputSchema: {
267
300
  type: 'object',
268
301
  additionalProperties: false,
@@ -337,7 +370,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
337
370
  source: 'player_rankings_history',
338
371
  requestId,
339
372
  tookMs: Date.now() - started,
340
- upstreamCalls: 1,
373
+ apiCalls: 1,
341
374
  httpStatus: res.status,
342
375
  rateLimit: res.headers,
343
376
  recover: ['Check the player id with search_entities'],
@@ -357,10 +390,10 @@ Parallel-safe: yes. Upstream cost: 1.`,
357
390
  const inWindow = sinceRaw
358
391
  ? allHistory.filter((h) => h.date !== null && h.date >= sinceRaw)
359
392
  : allHistory;
360
- const pagedUpstream = typeof body.total === 'number' && typeof body.order === 'string';
393
+ const pagedApi = typeof body.total === 'number' && typeof body.order === 'string';
361
394
  let total;
362
395
  let history;
363
- if (pagedUpstream) {
396
+ if (pagedApi) {
364
397
  total = body.total;
365
398
  history = body.order === 'desc' ? [...inWindow].reverse() : inWindow;
366
399
  }
@@ -371,7 +404,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
371
404
  }
372
405
  const omittedNewer = Math.min(total, (page - 1) * limit);
373
406
  const omittedOlder = Math.max(0, total - omittedNewer - history.length);
374
- const hasMore = pagedUpstream && typeof body.has_next === 'boolean' ? body.has_next : omittedOlder > 0;
407
+ const hasMore = pagedApi && typeof body.has_next === 'boolean' ? body.has_next : omittedOlder > 0;
375
408
  const best = history.reduce((acc, h) => {
376
409
  if (h.rank === null)
377
410
  return acc;
@@ -392,7 +425,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
392
425
  game,
393
426
  requestId,
394
427
  tookMs: Date.now() - started,
395
- upstreamCalls: 1,
428
+ apiCalls: 1,
396
429
  rateLimit: res.headers,
397
430
  data: {
398
431
  title: `${pickString(body.player_name) ?? playerId} ranking history`,