cito-mcp 0.3.20 → 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/cs2.js +421 -0
- package/dist/tools/index.js +13 -1
- package/dist/tools/leaderboard.js +427 -0
- package/dist/tools/meta.js +241 -1
- 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/team.js +183 -1
- package/dist/tools/tournaments.js +253 -0
- package/package.json +2 -2
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* leaderboard_aces (TENNIS-18 follow-up).
|
|
3
|
+
*
|
|
4
|
+
* Tennis-only: season aces leaderboard from real match_stats aggregates
|
|
5
|
+
* (GET /tennis/leaderboards/aces). Upstream shape (verified 2026-09-09):
|
|
6
|
+
* { season, tour, stat: 'aces', items: [{ player_id, full_name, aces, matches }], total }.
|
|
7
|
+
* Upstream defaults: season=2026, tour=null (both tours combined), and it
|
|
8
|
+
* 422s on a bad tour or a non-year season — both surface as VALIDATION here.
|
|
9
|
+
*/
|
|
10
|
+
import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
|
|
11
|
+
import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
|
|
12
|
+
import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
13
|
+
const DEFAULT_SEASON = 2026;
|
|
14
|
+
const MIN_SEASON = 1968;
|
|
15
|
+
const MAX_SEASON = 2100;
|
|
16
|
+
function parseOptionalTour(args) {
|
|
17
|
+
const rawTour = typeof args.tour === 'string' ? args.tour.trim() : '';
|
|
18
|
+
const rawDivision = typeof args.division === 'string' ? args.division.trim() : '';
|
|
19
|
+
if (rawTour && rawDivision && rawTour.toUpperCase() !== rawDivision.toUpperCase()) {
|
|
20
|
+
return { error: `tour ('${rawTour}') and division ('${rawDivision}') disagree; pass one of ATP|WTA` };
|
|
21
|
+
}
|
|
22
|
+
const raw = rawTour || rawDivision;
|
|
23
|
+
if (!raw)
|
|
24
|
+
return {};
|
|
25
|
+
const upper = raw.toUpperCase();
|
|
26
|
+
if (upper !== 'ATP' && upper !== 'WTA') {
|
|
27
|
+
return { error: `tour must be ATP or WTA (got '${raw}')` };
|
|
28
|
+
}
|
|
29
|
+
return { tour: upper };
|
|
30
|
+
}
|
|
31
|
+
function parseSeason(args) {
|
|
32
|
+
const raw = args.season;
|
|
33
|
+
if (raw === undefined || raw === null || (typeof raw === 'string' && raw.trim() === '')) {
|
|
34
|
+
return { season: DEFAULT_SEASON };
|
|
35
|
+
}
|
|
36
|
+
const num = typeof raw === 'number' ? raw : Number(String(raw).trim());
|
|
37
|
+
if (!Number.isInteger(num) || num < MIN_SEASON || num > MAX_SEASON) {
|
|
38
|
+
return { error: `season must be a YYYY year ${MIN_SEASON}-${MAX_SEASON} (got '${String(raw)}')` };
|
|
39
|
+
}
|
|
40
|
+
return { season: num };
|
|
41
|
+
}
|
|
42
|
+
function normalizeAceRow(row) {
|
|
43
|
+
const r = asRecord(row) ?? {};
|
|
44
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
45
|
+
return {
|
|
46
|
+
playerId: pickString(r.player_id) ?? null,
|
|
47
|
+
playerName: pickString(r.full_name, r.player_name) ?? null,
|
|
48
|
+
aces: num(r.aces),
|
|
49
|
+
matches: num(r.matches),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export const leaderboardAces = {
|
|
53
|
+
name: 'leaderboard_aces',
|
|
54
|
+
description: `Tennis season aces leaderboard: most aces served in a season, from real match-stats aggregates.
|
|
55
|
+
|
|
56
|
+
When to use:
|
|
57
|
+
- Who leads the tour in aces this season; season serving-leader tables.
|
|
58
|
+
|
|
59
|
+
Prefer over: raw leaderboard via call_api for agent-normalized rows.
|
|
60
|
+
|
|
61
|
+
Do not use when: ranking position → standings with game tennis; week-over-week movement → rankings_movers; one player's recent form → player_form.
|
|
62
|
+
|
|
63
|
+
Tennis-only. Season defaults to 2026; tour (ATP|WTA) is optional — omit it for the combined board. Rows carry aces + matches played, ordered most aces first.
|
|
64
|
+
|
|
65
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
required: ['game'],
|
|
70
|
+
properties: {
|
|
71
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
72
|
+
tour: stringSchema('ATP or WTA tour; omit for the combined board. Synonym: division.', 'WTA'),
|
|
73
|
+
division: stringSchema('Synonym for tour (ATP or WTA), matching the standings tool spelling.'),
|
|
74
|
+
season: {
|
|
75
|
+
type: 'integer',
|
|
76
|
+
minimum: MIN_SEASON,
|
|
77
|
+
maximum: MAX_SEASON,
|
|
78
|
+
default: DEFAULT_SEASON,
|
|
79
|
+
description: 'Season year (YYYY). Default 2026.',
|
|
80
|
+
},
|
|
81
|
+
limit: limitSchema({ default: 10, max: 100 }),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
handler: async (args, ctx) => {
|
|
85
|
+
const started = Date.now();
|
|
86
|
+
const requestId = newRequestId();
|
|
87
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
88
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
89
|
+
return errorEnvelope({
|
|
90
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
91
|
+
message: gameParse.error ?? 'game is required',
|
|
92
|
+
game: null,
|
|
93
|
+
source: 'leaderboard_aces',
|
|
94
|
+
requestId,
|
|
95
|
+
tookMs: Date.now() - started,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const game = gameParse.game;
|
|
99
|
+
if (game !== 'tennis') {
|
|
100
|
+
return errorEnvelope({
|
|
101
|
+
code: 'NOT_IMPLEMENTED',
|
|
102
|
+
message: `leaderboard_aces is tennis-only (game '${game}' has no aces leaderboard)`,
|
|
103
|
+
game,
|
|
104
|
+
source: 'leaderboard_aces',
|
|
105
|
+
requestId,
|
|
106
|
+
tookMs: Date.now() - started,
|
|
107
|
+
recover: [
|
|
108
|
+
'Use standings for the latest snapshot of this game',
|
|
109
|
+
'Retry leaderboard_aces with game tennis and an optional tour ATP|WTA',
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const tourParse = parseOptionalTour(args);
|
|
114
|
+
if (tourParse.error) {
|
|
115
|
+
return errorEnvelope({
|
|
116
|
+
code: 'VALIDATION',
|
|
117
|
+
message: tourParse.error,
|
|
118
|
+
game,
|
|
119
|
+
source: 'leaderboard_aces',
|
|
120
|
+
requestId,
|
|
121
|
+
tookMs: Date.now() - started,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const seasonParse = parseSeason(args);
|
|
125
|
+
if (seasonParse.error || seasonParse.season === undefined) {
|
|
126
|
+
return errorEnvelope({
|
|
127
|
+
code: 'VALIDATION',
|
|
128
|
+
message: seasonParse.error ?? 'season is invalid',
|
|
129
|
+
game,
|
|
130
|
+
source: 'leaderboard_aces',
|
|
131
|
+
requestId,
|
|
132
|
+
tookMs: Date.now() - started,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const limit = clampInt(args.limit, 10, 1, 100);
|
|
136
|
+
const res = await fetchJson(ctx, '/tennis/leaderboards/aces', {
|
|
137
|
+
query: {
|
|
138
|
+
season: seasonParse.season,
|
|
139
|
+
limit,
|
|
140
|
+
...(tourParse.tour ? { tour: tourParse.tour } : {}),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
return errorEnvelope({
|
|
145
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
146
|
+
message: `Aces leaderboard failed (HTTP ${res.status})`,
|
|
147
|
+
game,
|
|
148
|
+
source: 'leaderboard_aces',
|
|
149
|
+
requestId,
|
|
150
|
+
tookMs: Date.now() - started,
|
|
151
|
+
upstreamCalls: 1,
|
|
152
|
+
httpStatus: res.status,
|
|
153
|
+
rateLimit: res.headers,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const root = asRecord(res.data);
|
|
157
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
158
|
+
const items = (Array.isArray(body.items) ? body.items : []).map(normalizeAceRow);
|
|
159
|
+
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
160
|
+
return successEnvelope({
|
|
161
|
+
pagination: {
|
|
162
|
+
limit,
|
|
163
|
+
offset: 0,
|
|
164
|
+
total,
|
|
165
|
+
hasMore: false,
|
|
166
|
+
nextCursor: null,
|
|
167
|
+
prevCursor: null,
|
|
168
|
+
},
|
|
169
|
+
source: 'leaderboard_aces',
|
|
170
|
+
game,
|
|
171
|
+
requestId,
|
|
172
|
+
tookMs: Date.now() - started,
|
|
173
|
+
upstreamCalls: 1,
|
|
174
|
+
rateLimit: res.headers,
|
|
175
|
+
data: {
|
|
176
|
+
title: `${tourParse.tour ?? 'Combined'} aces leaderboard ${seasonParse.season}`,
|
|
177
|
+
season: typeof body.season === 'number' ? body.season : seasonParse.season,
|
|
178
|
+
tour: pickString(body.tour) ?? tourParse.tour ?? null,
|
|
179
|
+
stat: pickString(body.stat) ?? 'aces',
|
|
180
|
+
items,
|
|
181
|
+
total,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
function normalizePctRow(numKey, denKey) {
|
|
187
|
+
return (row) => {
|
|
188
|
+
const r = asRecord(row) ?? {};
|
|
189
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
190
|
+
return {
|
|
191
|
+
playerId: pickString(r.player_id) ?? null,
|
|
192
|
+
playerName: pickString(r.full_name, r.player_name) ?? null,
|
|
193
|
+
attempts: num(r[numKey]),
|
|
194
|
+
total: num(r[denKey]),
|
|
195
|
+
pct: num(r.pct),
|
|
196
|
+
matches: num(r.matches),
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function makePctLeaderboardTool(spec) {
|
|
201
|
+
const normalizeRow = normalizePctRow(spec.numKey, spec.denKey);
|
|
202
|
+
return {
|
|
203
|
+
name: spec.name,
|
|
204
|
+
description: `Tennis season ${spec.titleNoun} leaderboard: ${spec.blurb}
|
|
205
|
+
|
|
206
|
+
When to use:
|
|
207
|
+
- ${spec.blurb} season tables; clutch/serve-efficiency leader queries.
|
|
208
|
+
|
|
209
|
+
Prefer over: raw leaderboard via call_api for agent-normalized rows.
|
|
210
|
+
|
|
211
|
+
Do not use when: ranking position → standings with game tennis; week-over-week movement → rankings_movers; one player's recent form → player_form.
|
|
212
|
+
|
|
213
|
+
Tennis-only. Season defaults to 2026; tour (ATP|WTA) is optional — omit it for the combined board. ${spec.minAttemptsNote}
|
|
214
|
+
|
|
215
|
+
Parallel-safe: yes. Upstream cost: 1.`,
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: 'object',
|
|
218
|
+
additionalProperties: false,
|
|
219
|
+
required: ['game'],
|
|
220
|
+
properties: {
|
|
221
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
222
|
+
tour: stringSchema('ATP or WTA tour; omit for the combined board. Synonym: division.', 'WTA'),
|
|
223
|
+
division: stringSchema('Synonym for tour (ATP or WTA), matching the standings tool spelling.'),
|
|
224
|
+
season: {
|
|
225
|
+
type: 'integer',
|
|
226
|
+
minimum: MIN_SEASON,
|
|
227
|
+
maximum: MAX_SEASON,
|
|
228
|
+
default: DEFAULT_SEASON,
|
|
229
|
+
description: 'Season year (YYYY). Default 2026.',
|
|
230
|
+
},
|
|
231
|
+
limit: limitSchema({ default: 10, max: 100 }),
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
handler: async (args, ctx) => {
|
|
235
|
+
const started = Date.now();
|
|
236
|
+
const requestId = newRequestId();
|
|
237
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
238
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
239
|
+
return errorEnvelope({
|
|
240
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
241
|
+
message: gameParse.error ?? 'game is required',
|
|
242
|
+
game: null,
|
|
243
|
+
source: spec.name,
|
|
244
|
+
requestId,
|
|
245
|
+
tookMs: Date.now() - started,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const game = gameParse.game;
|
|
249
|
+
if (game !== 'tennis') {
|
|
250
|
+
return errorEnvelope({
|
|
251
|
+
code: 'NOT_IMPLEMENTED',
|
|
252
|
+
message: `${spec.name} is tennis-only (game '${game}' has no ${spec.titleNoun} leaderboard)`,
|
|
253
|
+
game,
|
|
254
|
+
source: spec.name,
|
|
255
|
+
requestId,
|
|
256
|
+
tookMs: Date.now() - started,
|
|
257
|
+
recover: [
|
|
258
|
+
'Use standings for the latest snapshot of this game',
|
|
259
|
+
`Retry ${spec.name} with game tennis and an optional tour ATP|WTA`,
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const tourParse = parseOptionalTour(args);
|
|
264
|
+
if (tourParse.error) {
|
|
265
|
+
return errorEnvelope({
|
|
266
|
+
code: 'VALIDATION',
|
|
267
|
+
message: tourParse.error,
|
|
268
|
+
game,
|
|
269
|
+
source: spec.name,
|
|
270
|
+
requestId,
|
|
271
|
+
tookMs: Date.now() - started,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const seasonParse = parseSeason(args);
|
|
275
|
+
if (seasonParse.error || seasonParse.season === undefined) {
|
|
276
|
+
return errorEnvelope({
|
|
277
|
+
code: 'VALIDATION',
|
|
278
|
+
message: seasonParse.error ?? 'season is invalid',
|
|
279
|
+
game,
|
|
280
|
+
source: spec.name,
|
|
281
|
+
requestId,
|
|
282
|
+
tookMs: Date.now() - started,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const limit = clampInt(args.limit, 10, 1, 100);
|
|
286
|
+
const res = await fetchJson(ctx, `/tennis${spec.path}`, {
|
|
287
|
+
query: {
|
|
288
|
+
season: seasonParse.season,
|
|
289
|
+
limit,
|
|
290
|
+
...(tourParse.tour ? { tour: tourParse.tour } : {}),
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
if (!res.ok) {
|
|
294
|
+
return errorEnvelope({
|
|
295
|
+
code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
|
|
296
|
+
message: `${spec.titleNoun} leaderboard failed (HTTP ${res.status})`,
|
|
297
|
+
game,
|
|
298
|
+
source: spec.name,
|
|
299
|
+
requestId,
|
|
300
|
+
tookMs: Date.now() - started,
|
|
301
|
+
upstreamCalls: 1,
|
|
302
|
+
httpStatus: res.status,
|
|
303
|
+
rateLimit: res.headers,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const root = asRecord(res.data);
|
|
307
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
308
|
+
const items = (Array.isArray(body.items) ? body.items : []).map(normalizeRow);
|
|
309
|
+
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
310
|
+
return successEnvelope({
|
|
311
|
+
pagination: {
|
|
312
|
+
limit,
|
|
313
|
+
offset: 0,
|
|
314
|
+
total,
|
|
315
|
+
hasMore: false,
|
|
316
|
+
nextCursor: null,
|
|
317
|
+
prevCursor: null,
|
|
318
|
+
},
|
|
319
|
+
source: spec.name,
|
|
320
|
+
game,
|
|
321
|
+
requestId,
|
|
322
|
+
tookMs: Date.now() - started,
|
|
323
|
+
upstreamCalls: 1,
|
|
324
|
+
rateLimit: res.headers,
|
|
325
|
+
data: {
|
|
326
|
+
title: `${tourParse.tour ?? 'Combined'} ${spec.titleNoun} leaderboard ${seasonParse.season}`,
|
|
327
|
+
season: typeof body.season === 'number' ? body.season : seasonParse.season,
|
|
328
|
+
tour: pickString(body.tour) ?? tourParse.tour ?? null,
|
|
329
|
+
stat: pickString(body.stat) ?? spec.stat,
|
|
330
|
+
items,
|
|
331
|
+
total,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
export const leaderboardBreakPointsSaved = makePctLeaderboardTool({
|
|
338
|
+
name: 'leaderboard_break_points_saved',
|
|
339
|
+
path: '/leaderboards/break-points-saved',
|
|
340
|
+
stat: 'break_points_saved',
|
|
341
|
+
numKey: 'break_points_saved',
|
|
342
|
+
denKey: 'break_points_faced',
|
|
343
|
+
titleNoun: 'break-points-saved',
|
|
344
|
+
blurb: 'Share of break points saved per player, from real match-stats aggregates, ordered highest pct first.',
|
|
345
|
+
minAttemptsNote: 'Only players facing at least 50 break points qualify.',
|
|
346
|
+
});
|
|
347
|
+
export const leaderboardFirstServeWon = makePctLeaderboardTool({
|
|
348
|
+
name: 'leaderboard_first_serve_won',
|
|
349
|
+
path: '/leaderboards/1st-serve-won',
|
|
350
|
+
stat: 'first_serve_won',
|
|
351
|
+
numKey: 'first_serve_points_won',
|
|
352
|
+
denKey: 'first_serves_in',
|
|
353
|
+
titleNoun: 'first-serve-won',
|
|
354
|
+
blurb: 'Share of first-serve points won per player, from real match-stats aggregates, ordered highest pct first.',
|
|
355
|
+
minAttemptsNote: 'Only players with at least 300 first serves in qualify.',
|
|
356
|
+
});
|
|
357
|
+
export const leaderboardTiebreakWinPct = makePctLeaderboardTool({
|
|
358
|
+
name: 'leaderboard_tiebreak_win_pct',
|
|
359
|
+
path: '/leaderboards/tiebreak-win-pct',
|
|
360
|
+
stat: 'tiebreak_win_pct',
|
|
361
|
+
numKey: 'tiebreaks_won',
|
|
362
|
+
denKey: 'tiebreaks_played',
|
|
363
|
+
titleNoun: 'tiebreak-win-pct',
|
|
364
|
+
blurb: 'Share of classic 7-point tiebreaks won per player, from real match-set aggregates, ordered highest pct first.',
|
|
365
|
+
minAttemptsNote: 'Only players contesting at least 10 tiebreaks qualify.',
|
|
366
|
+
});
|
|
367
|
+
export const leaderboardBreakConversion = makePctLeaderboardTool({
|
|
368
|
+
name: 'leaderboard_break_conversion',
|
|
369
|
+
path: '/leaderboards/break-conversion',
|
|
370
|
+
stat: 'break_conversion',
|
|
371
|
+
numKey: 'break_points_won',
|
|
372
|
+
denKey: 'break_opportunities',
|
|
373
|
+
titleNoun: 'break-conversion',
|
|
374
|
+
blurb: 'Share of break opportunities converted per player, from real match-stats aggregates read return-side, ordered highest pct first.',
|
|
375
|
+
minAttemptsNote: 'Only players with at least 50 break opportunities qualify.',
|
|
376
|
+
});
|
|
377
|
+
export const leaderboardReturnGamesWon = makePctLeaderboardTool({
|
|
378
|
+
name: 'leaderboard_return_games_won',
|
|
379
|
+
path: '/leaderboards/return-games-won',
|
|
380
|
+
stat: 'return_games_won',
|
|
381
|
+
numKey: 'return_games_won',
|
|
382
|
+
denKey: 'return_games_played',
|
|
383
|
+
titleNoun: 'return-games-won',
|
|
384
|
+
blurb: 'Share of return games won per player (breaks over opponent service games played), from real match-stats aggregates read return-side, ordered highest pct first.',
|
|
385
|
+
minAttemptsNote: 'Only players with at least 100 return games played qualify.',
|
|
386
|
+
});
|
|
387
|
+
export const leaderboardFinalsRecord = makePctLeaderboardTool({
|
|
388
|
+
name: 'leaderboard_finals_record',
|
|
389
|
+
path: '/leaderboards/finals-record',
|
|
390
|
+
stat: 'finals_record',
|
|
391
|
+
numKey: 'finals_won',
|
|
392
|
+
denKey: 'finals_played',
|
|
393
|
+
titleNoun: 'finals-record',
|
|
394
|
+
blurb: 'Share of finals won (titles clutch) per player, from real completed finals rows, ordered highest pct first.',
|
|
395
|
+
minAttemptsNote: 'Only players contesting at least 3 finals qualify.',
|
|
396
|
+
});
|
|
397
|
+
export const leaderboardComebackWins = makePctLeaderboardTool({
|
|
398
|
+
name: 'leaderboard_comeback_wins',
|
|
399
|
+
path: '/leaderboards/comeback-wins',
|
|
400
|
+
stat: 'comeback_wins',
|
|
401
|
+
numKey: 'comebacks_won',
|
|
402
|
+
denKey: 'matches_with_first_set',
|
|
403
|
+
titleNoun: 'comeback-wins',
|
|
404
|
+
blurb: 'Share of matches won after losing the first set per player, from real first-set rows joined to completed matches, ordered highest pct first.',
|
|
405
|
+
minAttemptsNote: 'Only players with first-set data on at least 20 matches qualify.',
|
|
406
|
+
});
|
|
407
|
+
export const leaderboardDecidingSetRecord = makePctLeaderboardTool({
|
|
408
|
+
name: 'leaderboard_deciding_set_record',
|
|
409
|
+
path: '/leaderboards/deciding-set-record',
|
|
410
|
+
stat: 'deciding_set_record',
|
|
411
|
+
numKey: 'deciders_won',
|
|
412
|
+
denKey: 'deciders_played',
|
|
413
|
+
titleNoun: 'deciding-set-record',
|
|
414
|
+
blurb: 'Share of deciding sets (full-length final sets) won per player, from real match rows, ordered highest pct first.',
|
|
415
|
+
minAttemptsNote: 'Only players contesting at least 10 deciders qualify.',
|
|
416
|
+
});
|
|
417
|
+
export const leaderboardTools = [
|
|
418
|
+
leaderboardAces,
|
|
419
|
+
leaderboardBreakPointsSaved,
|
|
420
|
+
leaderboardFirstServeWon,
|
|
421
|
+
leaderboardTiebreakWinPct,
|
|
422
|
+
leaderboardBreakConversion,
|
|
423
|
+
leaderboardReturnGamesWon,
|
|
424
|
+
leaderboardDecidingSetRecord,
|
|
425
|
+
leaderboardComebackWins,
|
|
426
|
+
leaderboardFinalsRecord,
|
|
427
|
+
];
|