cito-mcp 0.3.21 → 0.4.0
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 +7 -1
- package/dist/tools/index.js +8 -0
- package/dist/tools/meta.js +60 -0
- package/dist/tools/odds.js +251 -0
- package/dist/tools/player.js +340 -1
- package/dist/tools/rankings.js +147 -1
- package/dist/tools/schedule.js +234 -0
- package/dist/tools/tournaments.js +253 -0
- package/package.json +2 -2
package/dist/tools/player.js
CHANGED
|
@@ -769,4 +769,343 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
769
769
|
});
|
|
770
770
|
},
|
|
771
771
|
};
|
|
772
|
-
export const
|
|
772
|
+
export const playerMatches = {
|
|
773
|
+
name: 'player_matches',
|
|
774
|
+
description: `Tennis player match log: every archived match for one player, newest first, with opponent, tournament, round, surface and score.
|
|
775
|
+
|
|
776
|
+
When to use:
|
|
777
|
+
- "Show me Shelton's last 20 matches"; full match history; every match at a given tournament; filtering a player's record by surface.
|
|
778
|
+
|
|
779
|
+
Prefer over: player_form (a W/L summary with a small window and a streak, not the log); call_api for /tennis/players/{id}/matches.
|
|
780
|
+
|
|
781
|
+
Do not use when: aggregate totals, titles, or win% → player_stats; identity → player_profile; one match's box score → match_details.
|
|
782
|
+
|
|
783
|
+
Tennis-only. Limit defaults to 20 (max 50). Rows arrive newest-first. Use
|
|
784
|
+
surface to narrow to Hard/Clay/Grass; tournamentId pins one event.
|
|
785
|
+
|
|
786
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
787
|
+
inputSchema: {
|
|
788
|
+
type: 'object',
|
|
789
|
+
additionalProperties: false,
|
|
790
|
+
required: ['game', 'playerId'],
|
|
791
|
+
properties: {
|
|
792
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
793
|
+
playerId: stringSchema('Tennis player id, e.g. "atp_210097".', 'atp_210097'),
|
|
794
|
+
surface: stringSchema('Filter to one surface: Hard, Clay, or Grass.', 'Hard'),
|
|
795
|
+
tournamentId: stringSchema('Only matches at this tournament edition, e.g. "atp_2026_560".', 'atp_2026_560'),
|
|
796
|
+
limit: limitSchema({ default: 20, max: 50, description: 'Match rows (default 20, max 50).' }),
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
handler: async (args, ctx) => {
|
|
800
|
+
const started = Date.now();
|
|
801
|
+
const requestId = newRequestId();
|
|
802
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
803
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
804
|
+
return errorEnvelope({
|
|
805
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
806
|
+
message: gameParse.error ?? 'game is required',
|
|
807
|
+
game: null,
|
|
808
|
+
source: 'player_matches',
|
|
809
|
+
requestId,
|
|
810
|
+
tookMs: Date.now() - started,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
const game = gameParse.game;
|
|
814
|
+
if (game !== 'tennis') {
|
|
815
|
+
return errorEnvelope({
|
|
816
|
+
code: 'NOT_IMPLEMENTED',
|
|
817
|
+
message: `player_matches is tennis-only (game '${game}' has its own match-log shape)`,
|
|
818
|
+
game,
|
|
819
|
+
source: 'player_matches',
|
|
820
|
+
requestId,
|
|
821
|
+
tookMs: Date.now() - started,
|
|
822
|
+
recover: ['Use match_summary or call_api for this game', 'Retry with game tennis'],
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
|
|
826
|
+
if (!playerId) {
|
|
827
|
+
return errorEnvelope({
|
|
828
|
+
code: 'VALIDATION',
|
|
829
|
+
message: 'playerId is required',
|
|
830
|
+
game,
|
|
831
|
+
source: 'player_matches',
|
|
832
|
+
requestId,
|
|
833
|
+
tookMs: Date.now() - started,
|
|
834
|
+
recover: ['Find an id with search_entities or resolve_entity'],
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
const limit = clampInt(args.limit, 20, 1, 50);
|
|
838
|
+
const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
|
|
839
|
+
const tournamentId = typeof args.tournamentId === 'string' && args.tournamentId.trim()
|
|
840
|
+
? args.tournamentId.trim()
|
|
841
|
+
: '';
|
|
842
|
+
const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(playerId)}/matches`, {
|
|
843
|
+
query: {
|
|
844
|
+
limit,
|
|
845
|
+
...(surfaceRaw ? { surface: surfaceRaw } : {}),
|
|
846
|
+
...(tournamentId ? { tournament_id: tournamentId } : {}),
|
|
847
|
+
},
|
|
848
|
+
});
|
|
849
|
+
if (!res.ok) {
|
|
850
|
+
return errorEnvelope({
|
|
851
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
852
|
+
message: `Player match log failed for '${playerId}' (HTTP ${res.status})`,
|
|
853
|
+
game,
|
|
854
|
+
source: 'player_matches',
|
|
855
|
+
requestId,
|
|
856
|
+
tookMs: Date.now() - started,
|
|
857
|
+
upstreamCalls: 1,
|
|
858
|
+
httpStatus: res.status,
|
|
859
|
+
rateLimit: res.headers,
|
|
860
|
+
recover: ['Check the player id with search_entities'],
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
const root = asRecord(res.data);
|
|
864
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
865
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
866
|
+
const allRows = Array.isArray(body.items) ? body.items : [];
|
|
867
|
+
/**
|
|
868
|
+
* Derive the player's display name from their own side of any match.
|
|
869
|
+
*
|
|
870
|
+
* The match-log payload carries no player_name at all (verified against the
|
|
871
|
+
* live route: top-level keys are just items/total/page/page_size/has_next/
|
|
872
|
+
* has_prev/success/total_pages), so the earlier version titled every log
|
|
873
|
+
* "<id> match log" and reported playerName: null. Each row does carry the
|
|
874
|
+
* winner and loser objects, so whichever side matches the requested id gives
|
|
875
|
+
* a real name with no extra upstream call.
|
|
876
|
+
*/
|
|
877
|
+
let derivedName = null;
|
|
878
|
+
for (const row of allRows) {
|
|
879
|
+
const r = asRecord(row) ?? {};
|
|
880
|
+
const w = asRecord(r.winner) ?? {};
|
|
881
|
+
const l = asRecord(r.loser) ?? {};
|
|
882
|
+
if (pickString(r.winner_id, w.id) === playerId) {
|
|
883
|
+
derivedName = pickString(w.name) ?? null;
|
|
884
|
+
}
|
|
885
|
+
else if (pickString(r.loser_id, l.id) === playerId) {
|
|
886
|
+
derivedName = pickString(l.name) ?? null;
|
|
887
|
+
}
|
|
888
|
+
if (derivedName)
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* The route ignores ?limit= (verified: limit=5 returned 20), so the bound is
|
|
893
|
+
* applied here. total comes from the upstream header so callers still learn
|
|
894
|
+
* how many matches exist beyond this page.
|
|
895
|
+
*/
|
|
896
|
+
const total = typeof body.total === 'number' ? body.total : allRows.length;
|
|
897
|
+
const rows = allRows.slice(0, limit).map((row) => {
|
|
898
|
+
const r = asRecord(row) ?? {};
|
|
899
|
+
const winner = asRecord(r.winner) ?? {};
|
|
900
|
+
const loser = asRecord(r.loser) ?? {};
|
|
901
|
+
const winnerId = pickString(r.winner_id, winner.id);
|
|
902
|
+
const lost = winnerId !== null && winnerId !== playerId;
|
|
903
|
+
// Opponent is whichever side is not this player. When the row's winner
|
|
904
|
+
// does not match the requested id the player lost, so the opponent is the
|
|
905
|
+
// winner.
|
|
906
|
+
const opponent = lost ? winner : loser;
|
|
907
|
+
return {
|
|
908
|
+
matchId: pickString(r.id, r.match_id) ?? null,
|
|
909
|
+
date: pickString(r.match_date, r.date) ?? null,
|
|
910
|
+
tournamentId: pickString(r.tournament_id) ?? null,
|
|
911
|
+
tournamentName: pickString(r.tournament_name, r.name) ?? null,
|
|
912
|
+
level: pickString(r.level) ?? null,
|
|
913
|
+
category: pickString(r.category) ?? null,
|
|
914
|
+
surface: pickString(r.surface) ?? null,
|
|
915
|
+
round: pickString(r.round) ?? null,
|
|
916
|
+
roundName: pickString(r.round_name) ?? null,
|
|
917
|
+
bestOf: num(r.best_of),
|
|
918
|
+
score: pickString(r.score, r.score_raw) ?? null,
|
|
919
|
+
outcome: pickString(r.outcome, r.status) ?? null,
|
|
920
|
+
result: lost ? 'L' : 'W',
|
|
921
|
+
opponent: {
|
|
922
|
+
id: pickString(opponent.id) ?? null,
|
|
923
|
+
name: pickString(opponent.name) ?? null,
|
|
924
|
+
country: pickString(opponent.ioc) ?? null,
|
|
925
|
+
rank: num(opponent.rank),
|
|
926
|
+
seed: opponent.seed ?? null,
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
});
|
|
930
|
+
return successEnvelope({
|
|
931
|
+
pagination: {
|
|
932
|
+
limit,
|
|
933
|
+
offset: 0,
|
|
934
|
+
total,
|
|
935
|
+
hasMore: total > rows.length,
|
|
936
|
+
nextCursor: null,
|
|
937
|
+
prevCursor: null,
|
|
938
|
+
},
|
|
939
|
+
source: 'player_matches',
|
|
940
|
+
game,
|
|
941
|
+
requestId,
|
|
942
|
+
tookMs: Date.now() - started,
|
|
943
|
+
upstreamCalls: 1,
|
|
944
|
+
rateLimit: res.headers,
|
|
945
|
+
entities: { games: [game], ids: { playerId } },
|
|
946
|
+
data: {
|
|
947
|
+
title: `${derivedName ?? playerId} match log`,
|
|
948
|
+
playerId,
|
|
949
|
+
playerName: derivedName,
|
|
950
|
+
surfaceFilter: surfaceRaw || null,
|
|
951
|
+
tournamentFilter: tournamentId || null,
|
|
952
|
+
matches: rows,
|
|
953
|
+
total,
|
|
954
|
+
// Named explicitly because the bound is applied locally: the upstream
|
|
955
|
+
// route returns everything and ignores ?limit=.
|
|
956
|
+
returned: rows.length,
|
|
957
|
+
},
|
|
958
|
+
});
|
|
959
|
+
},
|
|
960
|
+
};
|
|
961
|
+
export const playerStats = {
|
|
962
|
+
name: 'player_stats',
|
|
963
|
+
description: `Tennis player career statistics: W/L totals, win percentage, titles, Grand Slam and Masters titles, plus per-surface and per-level breakdowns. Optional surface/year filters.
|
|
964
|
+
|
|
965
|
+
When to use:
|
|
966
|
+
- "How many titles does Alcaraz have?"; career win%; Grand Slam title count; record on clay; a season-scoped record.
|
|
967
|
+
|
|
968
|
+
Prefer over: player_profile (identity + a summary block, no breakdowns); player_matches (individual rows, no aggregates).
|
|
969
|
+
|
|
970
|
+
Do not use when: the match log → player_matches; ranking over time → player_rankings_history.
|
|
971
|
+
|
|
972
|
+
Tennis-only. Filters are surface (Hard/Clay/Grass/Carpet) and yearFrom/yearTo.
|
|
973
|
+
Upstream cost: 1.`,
|
|
974
|
+
inputSchema: {
|
|
975
|
+
type: 'object',
|
|
976
|
+
additionalProperties: false,
|
|
977
|
+
required: ['game', 'playerId'],
|
|
978
|
+
properties: {
|
|
979
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
980
|
+
playerId: stringSchema('Tennis player id, e.g. "atp_207989".', 'atp_207989'),
|
|
981
|
+
surface: stringSchema('Filter to one surface: Hard, Clay, Grass, or Carpet.', 'Clay'),
|
|
982
|
+
yearFrom: { type: 'integer', minimum: 1877, maximum: 2100, description: 'Inclusive start year, e.g. 2020.' },
|
|
983
|
+
yearTo: { type: 'integer', minimum: 1877, maximum: 2100, description: 'Inclusive end year, e.g. 2026.' },
|
|
984
|
+
},
|
|
985
|
+
},
|
|
986
|
+
handler: async (args, ctx) => {
|
|
987
|
+
const started = Date.now();
|
|
988
|
+
const requestId = newRequestId();
|
|
989
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
990
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
991
|
+
return errorEnvelope({
|
|
992
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
993
|
+
message: gameParse.error ?? 'game is required',
|
|
994
|
+
game: null,
|
|
995
|
+
source: 'player_stats',
|
|
996
|
+
requestId,
|
|
997
|
+
tookMs: Date.now() - started,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
const game = gameParse.game;
|
|
1001
|
+
if (game !== 'tennis') {
|
|
1002
|
+
return errorEnvelope({
|
|
1003
|
+
code: 'NOT_IMPLEMENTED',
|
|
1004
|
+
message: `player_stats is tennis-only (game '${game}' exposes stats elsewhere)`,
|
|
1005
|
+
game,
|
|
1006
|
+
source: 'player_stats',
|
|
1007
|
+
requestId,
|
|
1008
|
+
tookMs: Date.now() - started,
|
|
1009
|
+
recover: ['Use player_profile for this game', 'Retry with game tennis'],
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
|
|
1013
|
+
if (!playerId) {
|
|
1014
|
+
return errorEnvelope({
|
|
1015
|
+
code: 'VALIDATION',
|
|
1016
|
+
message: 'playerId is required',
|
|
1017
|
+
game,
|
|
1018
|
+
source: 'player_stats',
|
|
1019
|
+
requestId,
|
|
1020
|
+
tookMs: Date.now() - started,
|
|
1021
|
+
recover: ['Find an id with search_entities or resolve_entity'],
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
const surfaceRaw = typeof args.surface === 'string' ? args.surface.trim() : '';
|
|
1025
|
+
const yf = typeof args.yearFrom === 'number' && Number.isFinite(args.yearFrom) ? Math.trunc(args.yearFrom) : null;
|
|
1026
|
+
const yt = typeof args.yearTo === 'number' && Number.isFinite(args.yearTo) ? Math.trunc(args.yearTo) : null;
|
|
1027
|
+
if (yf !== null && yt !== null && yf > yt) {
|
|
1028
|
+
return errorEnvelope({
|
|
1029
|
+
code: 'VALIDATION',
|
|
1030
|
+
message: `yearFrom (${yf}) must not be greater than yearTo (${yt})`,
|
|
1031
|
+
game,
|
|
1032
|
+
source: 'player_stats',
|
|
1033
|
+
requestId,
|
|
1034
|
+
tookMs: Date.now() - started,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
const res = await fetchJson(ctx, `/tennis/players/${encodeURIComponent(playerId)}/stats`, {
|
|
1038
|
+
query: {
|
|
1039
|
+
...(surfaceRaw ? { surface: surfaceRaw } : {}),
|
|
1040
|
+
...(yf !== null ? { year_from: yf } : {}),
|
|
1041
|
+
...(yt !== null ? { year_to: yt } : {}),
|
|
1042
|
+
},
|
|
1043
|
+
});
|
|
1044
|
+
if (!res.ok) {
|
|
1045
|
+
return errorEnvelope({
|
|
1046
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
1047
|
+
message: `Player stats failed for '${playerId}' (HTTP ${res.status})`,
|
|
1048
|
+
game,
|
|
1049
|
+
source: 'player_stats',
|
|
1050
|
+
requestId,
|
|
1051
|
+
tookMs: Date.now() - started,
|
|
1052
|
+
upstreamCalls: 1,
|
|
1053
|
+
httpStatus: res.status,
|
|
1054
|
+
rateLimit: res.headers,
|
|
1055
|
+
recover: ['Check the player id with search_entities'],
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
const root = asRecord(res.data);
|
|
1059
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
1060
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
1061
|
+
const summary = asRecord(body.career_summary) ?? {};
|
|
1062
|
+
const filters = asRecord(body.filters) ?? {};
|
|
1063
|
+
/**
|
|
1064
|
+
* Map an arbitrary {label: value} breakdown without assuming the upstream
|
|
1065
|
+
* key set: the endpoint has grown keys before and a hardcoded list would
|
|
1066
|
+
* silently drop new ones.
|
|
1067
|
+
*/
|
|
1068
|
+
const numericBlock = (value) => {
|
|
1069
|
+
const rec = asRecord(value);
|
|
1070
|
+
if (!rec)
|
|
1071
|
+
return null;
|
|
1072
|
+
const out = {};
|
|
1073
|
+
for (const [k, v] of Object.entries(rec))
|
|
1074
|
+
out[k] = num(v);
|
|
1075
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
1076
|
+
};
|
|
1077
|
+
return successEnvelope({
|
|
1078
|
+
source: 'player_stats',
|
|
1079
|
+
game,
|
|
1080
|
+
requestId,
|
|
1081
|
+
tookMs: Date.now() - started,
|
|
1082
|
+
upstreamCalls: 1,
|
|
1083
|
+
rateLimit: res.headers,
|
|
1084
|
+
entities: { games: [game], ids: { playerId } },
|
|
1085
|
+
data: {
|
|
1086
|
+
title: `${pickString(body.player_name) ?? playerId} career statistics`,
|
|
1087
|
+
playerId: pickString(body.player_id) ?? playerId,
|
|
1088
|
+
playerName: pickString(body.player_name) ?? null,
|
|
1089
|
+
filters: {
|
|
1090
|
+
surface: pickString(filters.surface) ?? null,
|
|
1091
|
+
level: pickString(filters.level) ?? null,
|
|
1092
|
+
yearFrom: num(filters.year_from),
|
|
1093
|
+
yearTo: num(filters.year_to),
|
|
1094
|
+
},
|
|
1095
|
+
careerSummary: {
|
|
1096
|
+
matchesPlayed: num(summary.matches_played),
|
|
1097
|
+
matchesWon: num(summary.matches_won),
|
|
1098
|
+
matchesLost: num(summary.matches_lost),
|
|
1099
|
+
winPercentage: num(summary.win_percentage),
|
|
1100
|
+
titles: num(summary.titles_count),
|
|
1101
|
+
grandSlamTitles: num(summary.grand_slam_titles),
|
|
1102
|
+
mastersTitles: num(summary.masters_titles),
|
|
1103
|
+
finalsReached: num(summary.finals_reached),
|
|
1104
|
+
},
|
|
1105
|
+
bySurface: numericBlock(body.by_surface ?? body.surface_breakdown),
|
|
1106
|
+
byLevel: numericBlock(body.by_level ?? body.level_breakdown),
|
|
1107
|
+
},
|
|
1108
|
+
});
|
|
1109
|
+
},
|
|
1110
|
+
};
|
|
1111
|
+
export const playerTools = [playerProfile, playerForm, playerMatches, playerStats];
|
package/dist/tools/rankings.js
CHANGED
|
@@ -225,4 +225,150 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
225
225
|
});
|
|
226
226
|
},
|
|
227
227
|
};
|
|
228
|
-
export const
|
|
228
|
+
export const playerRankingsHistory = {
|
|
229
|
+
name: 'player_rankings_history',
|
|
230
|
+
description: `Tennis player ranking trajectory: every published ranking date for one player, plus career-high and weeks at No.1.
|
|
231
|
+
|
|
232
|
+
When to use:
|
|
233
|
+
- "Show Alcaraz's ranking over time"; career-high rank and when it was reached; how a player climbed; plotting a rank chart.
|
|
234
|
+
|
|
235
|
+
Prefer over: standings (latest snapshot only); rankings_movers (week-over-week delta for a whole tour); player_profile (has career-high but no per-date series).
|
|
236
|
+
|
|
237
|
+
Do not use when: the current top-N table → standings with game tennis.
|
|
238
|
+
|
|
239
|
+
Tennis-only. Rows arrive oldest-first (chronological), which is the order a chart
|
|
240
|
+
wants; the newest entries are the tail. Use 'since' or 'limit' to bound the
|
|
241
|
+
series — the full career can be hundreds of rows.
|
|
242
|
+
|
|
243
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
244
|
+
inputSchema: {
|
|
245
|
+
type: 'object',
|
|
246
|
+
additionalProperties: false,
|
|
247
|
+
required: ['game', 'playerId'],
|
|
248
|
+
properties: {
|
|
249
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
250
|
+
playerId: stringSchema('Tennis player id, e.g. "atp_207989" or "wta_214544".', 'atp_207989'),
|
|
251
|
+
since: stringSchema('Only rows on or after this date (YYYY-MM-DD).', '2026-01-01'),
|
|
252
|
+
limit: limitSchema({ default: 20, max: 500, description: 'Max history rows (default 20, max 500).' }),
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
handler: async (args, ctx) => {
|
|
256
|
+
const started = Date.now();
|
|
257
|
+
const requestId = newRequestId();
|
|
258
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
259
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
260
|
+
return errorEnvelope({
|
|
261
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
262
|
+
message: gameParse.error ?? 'game is required',
|
|
263
|
+
game: null,
|
|
264
|
+
source: 'player_rankings_history',
|
|
265
|
+
requestId,
|
|
266
|
+
tookMs: Date.now() - started,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
const game = gameParse.game;
|
|
270
|
+
if (game !== 'tennis') {
|
|
271
|
+
return errorEnvelope({
|
|
272
|
+
code: 'NOT_IMPLEMENTED',
|
|
273
|
+
message: `player_rankings_history is tennis-only (game '${game}' has no ranking-history endpoint)`,
|
|
274
|
+
game,
|
|
275
|
+
source: 'player_rankings_history',
|
|
276
|
+
requestId,
|
|
277
|
+
tookMs: Date.now() - started,
|
|
278
|
+
recover: ['Use standings for the latest snapshot of this game', 'Retry with game tennis'],
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
|
|
282
|
+
if (!playerId) {
|
|
283
|
+
return errorEnvelope({
|
|
284
|
+
code: 'VALIDATION',
|
|
285
|
+
message: 'playerId is required',
|
|
286
|
+
game,
|
|
287
|
+
source: 'player_rankings_history',
|
|
288
|
+
requestId,
|
|
289
|
+
tookMs: Date.now() - started,
|
|
290
|
+
recover: ['Find an id with search_entities or resolve_entity'],
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const sinceRaw = typeof args.since === 'string' ? args.since.trim() : '';
|
|
294
|
+
if (sinceRaw && !/^\d{4}-\d{2}-\d{2}$/.test(sinceRaw)) {
|
|
295
|
+
return errorEnvelope({
|
|
296
|
+
code: 'VALIDATION',
|
|
297
|
+
message: `since must be YYYY-MM-DD (got '${args.since}')`,
|
|
298
|
+
game,
|
|
299
|
+
source: 'player_rankings_history',
|
|
300
|
+
requestId,
|
|
301
|
+
tookMs: Date.now() - started,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const limit = clampInt(args.limit, 20, 1, 500);
|
|
305
|
+
const res = await fetchJson(ctx, `/tennis/rankings/history/${encodeURIComponent(playerId)}`, {
|
|
306
|
+
query: { limit },
|
|
307
|
+
});
|
|
308
|
+
if (!res.ok) {
|
|
309
|
+
return errorEnvelope({
|
|
310
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
311
|
+
message: `Ranking history failed for '${playerId}' (HTTP ${res.status})`,
|
|
312
|
+
game,
|
|
313
|
+
source: 'player_rankings_history',
|
|
314
|
+
requestId,
|
|
315
|
+
tookMs: Date.now() - started,
|
|
316
|
+
upstreamCalls: 1,
|
|
317
|
+
httpStatus: res.status,
|
|
318
|
+
rateLimit: res.headers,
|
|
319
|
+
recover: ['Check the player id with search_entities'],
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const root = asRecord(res.data) ?? {};
|
|
323
|
+
const body = asRecord(root.data) ?? root;
|
|
324
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
325
|
+
let history = (Array.isArray(body.history) ? body.history : []).map((row) => {
|
|
326
|
+
const r = asRecord(row) ?? {};
|
|
327
|
+
return {
|
|
328
|
+
date: pickString(r.date) ?? null,
|
|
329
|
+
rank: num(r.rank),
|
|
330
|
+
points: num(r.points),
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
if (sinceRaw)
|
|
334
|
+
history = history.filter((h) => h.date !== null && h.date >= sinceRaw);
|
|
335
|
+
// Best rank actually present in the returned window, so a caller can label a
|
|
336
|
+
// chart without recomputing. Null when the window is empty.
|
|
337
|
+
const best = history.reduce((acc, h) => {
|
|
338
|
+
if (h.rank === null)
|
|
339
|
+
return acc;
|
|
340
|
+
if (!acc || acc.rank === null || h.rank < acc.rank)
|
|
341
|
+
return { date: h.date, rank: h.rank };
|
|
342
|
+
return acc;
|
|
343
|
+
}, null);
|
|
344
|
+
return successEnvelope({
|
|
345
|
+
pagination: {
|
|
346
|
+
limit,
|
|
347
|
+
offset: 0,
|
|
348
|
+
total: history.length,
|
|
349
|
+
hasMore: false,
|
|
350
|
+
nextCursor: null,
|
|
351
|
+
prevCursor: null,
|
|
352
|
+
},
|
|
353
|
+
source: 'player_rankings_history',
|
|
354
|
+
game,
|
|
355
|
+
requestId,
|
|
356
|
+
tookMs: Date.now() - started,
|
|
357
|
+
upstreamCalls: 1,
|
|
358
|
+
rateLimit: res.headers,
|
|
359
|
+
data: {
|
|
360
|
+
title: `${pickString(body.player_name) ?? playerId} ranking history`,
|
|
361
|
+
playerId: pickString(body.player_id) ?? playerId,
|
|
362
|
+
playerName: pickString(body.player_name) ?? null,
|
|
363
|
+
tour: pickString(body.tour) ?? null,
|
|
364
|
+
careerHighRank: num(body.career_high_rank),
|
|
365
|
+
careerHighDate: pickString(body.career_high_date) ?? null,
|
|
366
|
+
weeksAtNo1: num(body.total_weeks_at_no_1),
|
|
367
|
+
since: sinceRaw || null,
|
|
368
|
+
bestRankInWindow: best,
|
|
369
|
+
history,
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
export const rankingsTools = [rankingsMovers, playerRankingsHistory];
|