cito-mcp 0.1.0 → 0.2.2

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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Cross-game row normalization for live/schedule boards and entity cards.
3
+ */
4
+ import { asRecord, pickString } from '../client.js';
5
+ function sideFrom(name, id, slug, score) {
6
+ if (!name && !id && !slug)
7
+ return null;
8
+ return {
9
+ name: name ?? slug ?? id ?? '?',
10
+ ...(id ? { id } : {}),
11
+ ...(slug ? { slug } : {}),
12
+ ...(score !== undefined ? { score } : {}),
13
+ };
14
+ }
15
+ function nestedSide(raw) {
16
+ const o = asRecord(raw);
17
+ if (!o) {
18
+ if (typeof raw === 'string')
19
+ return { name: raw };
20
+ return null;
21
+ }
22
+ const profile = asRecord(o.profile);
23
+ const name = pickString(o.name, o.fighterName, o.code, o.shortName, o.nickname, o.displayName, profile?.name, profile?.nickname);
24
+ const id = pickString(o.id, o.teamId, o.orgId, o.fighterId, profile?.id);
25
+ const slug = pickString(o.slug, o.orgSlug, o.fighterSlug, profile?.slug);
26
+ const scoreRaw = o.score ?? o.mapsWon ?? o.gamesWon;
27
+ const score = typeof scoreRaw === 'number'
28
+ ? scoreRaw
29
+ : typeof scoreRaw === 'string' && scoreRaw !== ''
30
+ ? Number(scoreRaw)
31
+ : null;
32
+ return sideFrom(name, id, slug, Number.isFinite(score) ? score : null);
33
+ }
34
+ function scoreNum(v) {
35
+ if (typeof v === 'number' && Number.isFinite(v))
36
+ return v;
37
+ if (typeof v === 'string' && v !== '' && Number.isFinite(Number(v)))
38
+ return Number(v);
39
+ return null;
40
+ }
41
+ export function normalizeMatch(game, row, forcedStatus) {
42
+ const r = asRecord(row) ?? {};
43
+ const matchId = pickString(r.matchId, r.boutId, r.id, r.gameId, r.match_id, r.dataId, r.fightMetricId) ?? 'unknown';
44
+ let team1 = nestedSide(r.team1) ??
45
+ 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));
46
+ let team2 = nestedSide(r.team2) ??
47
+ 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));
48
+ // UFC / live corners: red/blue objects, corner arrays, or fighters[] with corner field.
49
+ // Public API serializeBout uses fighters:[{ corner, fighterName, fighterSlug, profile:{name,slug} }].
50
+ // Live tracking uses red/blue + fighters[] (not team1/team2).
51
+ if (game === 'ufc' || (!team1 && !team2 && (r.red || r.blue || Array.isArray(r.fighters)))) {
52
+ const fromCornerObj = (raw) => {
53
+ const o = asRecord(raw);
54
+ if (!o) {
55
+ if (typeof raw === 'string' && raw.trim())
56
+ return { name: raw.trim() };
57
+ return null;
58
+ }
59
+ // Nested profile from serializeBoutFighter
60
+ const profile = asRecord(o.profile);
61
+ const fighter = asRecord(o.fighter);
62
+ const name = pickString(o.name, o.fighterName, o.displayName, o.nickname, profile?.name, profile?.nickname, fighter?.name, fighter?.nickname);
63
+ const id = pickString(o.id, o.fighterId, profile?.id, fighter?.id);
64
+ const slug = pickString(o.slug, o.fighterSlug, profile?.slug, fighter?.slug);
65
+ const score = scoreNum(o.score ?? o.points);
66
+ return sideFrom(name, id, slug, score);
67
+ };
68
+ team1 =
69
+ team1 ??
70
+ fromCornerObj(r.red) ??
71
+ fromCornerObj(r.redCorner) ??
72
+ fromCornerObj(r.fighter1) ??
73
+ fromCornerObj(r.fighterA);
74
+ team2 =
75
+ team2 ??
76
+ fromCornerObj(r.blue) ??
77
+ fromCornerObj(r.blueCorner) ??
78
+ fromCornerObj(r.fighter2) ??
79
+ fromCornerObj(r.fighterB);
80
+ const fightersList = Array.isArray(r.fighters) ? r.fighters : [];
81
+ if (fightersList.length) {
82
+ const byCorner = (want) => fightersList.find((f) => {
83
+ const c = pickString(asRecord(f)?.corner, asRecord(f)?.side)?.toLowerCase();
84
+ return c === want || c === want[0]; // "red" / "r"
85
+ });
86
+ const redF = byCorner('red') ?? byCorner('r') ?? fightersList[0];
87
+ const blueF = byCorner('blue') ??
88
+ byCorner('b') ??
89
+ (fightersList.length > 1 ? fightersList[1] : undefined);
90
+ team1 = team1 ?? fromCornerObj(redF);
91
+ team2 = team2 ?? fromCornerObj(blueF);
92
+ }
93
+ }
94
+ // COD sometimes uses teams[]
95
+ if ((!team1 || !team2) && Array.isArray(r.teams)) {
96
+ team1 = team1 ?? nestedSide(r.teams[0]);
97
+ team2 = team2 ?? nestedSide(r.teams[1]);
98
+ }
99
+ const startTime = pickString(r.startTime, r.scheduledAt, r.startsAt, r.date, r.startDate, r.beginAt) ?? null;
100
+ const statusRaw = pickString(r.status, r.state, r.matchStatus)?.toLowerCase() ?? '';
101
+ let status = forcedStatus ?? 'unknown';
102
+ if (!forcedStatus) {
103
+ if (/live|running|in_?progress|ongoing|started/.test(statusRaw))
104
+ status = 'live';
105
+ else if (/upcoming|scheduled|not_started|pending|soon/.test(statusRaw))
106
+ status = 'upcoming';
107
+ else if (/complete|finished|ended|final|closed|done/.test(statusRaw))
108
+ status = 'completed';
109
+ else if (forcedStatus)
110
+ status = forcedStatus;
111
+ }
112
+ const eventName = pickString(asRecord(r.event)?.name, r.eventName, r.event, asRecord(r.tournament)?.name, r.tournamentName);
113
+ const eventId = pickString(asRecord(r.event)?.id, r.eventId, asRecord(r.tournament)?.id, r.tournamentId);
114
+ const eventSlug = pickString(asRecord(r.event)?.slug, r.eventSlug, asRecord(r.tournament)?.slug);
115
+ const leagueName = pickString(asRecord(r.league)?.name, r.leagueName, r.league, r.leagueSlug);
116
+ const leagueId = pickString(asRecord(r.league)?.id, r.leagueId);
117
+ const leagueSlug = pickString(asRecord(r.league)?.slug, r.leagueSlug);
118
+ const label = pickString(r.label, r.title, r.name) ??
119
+ `${team1?.name ?? '?'} vs ${team2?.name ?? '?'}`;
120
+ return {
121
+ game,
122
+ matchId,
123
+ status,
124
+ startTime,
125
+ label: label.replace(/\s+/g, ' ').trim(),
126
+ team1,
127
+ team2,
128
+ event: eventName || eventId || eventSlug ? { id: eventId, slug: eventSlug, name: eventName } : null,
129
+ league: leagueName || leagueId || leagueSlug ? { id: leagueId, slug: leagueSlug, name: leagueName } : null,
130
+ };
131
+ }
132
+ export function entityRef(row, type, game) {
133
+ const r = asRecord(row) ?? {};
134
+ const id = pickString(r.id, r.teamId, r.playerId, r.lolPlayerId, r.codPlayerId, r.matchId, r.boutId, r.eventId, r.tournamentId, r.leagueId) ??
135
+ pickString(r.slug) ??
136
+ 'unknown';
137
+ const slug = pickString(r.slug, r.orgSlug, r.teamSlug);
138
+ const name = pickString(r.name, r.nickname, r.displayName, r.tag, r.code, r.title, slug, id) ?? id;
139
+ return {
140
+ game,
141
+ type,
142
+ id,
143
+ ...(slug ? { slug } : {}),
144
+ name,
145
+ meta: {
146
+ ...(r.region ? { region: r.region } : {}),
147
+ ...(r.role ? { role: r.role } : {}),
148
+ ...(r.nationality ? { nationality: r.nationality } : {}),
149
+ ...(r.entity_type ? { entity_type: r.entity_type } : {}),
150
+ },
151
+ };
152
+ }
153
+ /** Simple fuzzy score: exact > startsWith > includes (case-insensitive). */
154
+ export function rankScore(query, name, id, slug) {
155
+ const q = query.trim().toLowerCase();
156
+ if (!q)
157
+ return 0;
158
+ const candidates = [name, id, slug].filter(Boolean).map((s) => String(s).toLowerCase());
159
+ let best = 0;
160
+ for (const c of candidates) {
161
+ if (c === q)
162
+ best = Math.max(best, 100);
163
+ else if (c.startsWith(q))
164
+ best = Math.max(best, 80 - Math.min(20, c.length - q.length));
165
+ else if (c.includes(q))
166
+ best = Math.max(best, 50 - Math.min(20, c.indexOf(q)));
167
+ else {
168
+ // token overlap
169
+ const qt = q.split(/\s+/);
170
+ const ct = c.split(/[\s_-]+/);
171
+ const hits = qt.filter((t) => ct.some((x) => x.includes(t) || t.includes(x))).length;
172
+ if (hits)
173
+ best = Math.max(best, 20 + hits * 10);
174
+ }
175
+ }
176
+ return best;
177
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * player_profile
3
+ */
4
+ import { clampInt, extractRows, fetchJson, gameNotIncludedHint, asRecord, pickString, } from '../client.js';
5
+ import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
6
+ import { normalizeMatch } from './normalize.js';
7
+ import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
8
+ function identityFrom(game, raw, idHint, slugHint) {
9
+ const r = asRecord(raw) ?? {};
10
+ const id = pickString(r.id, r.playerId, r.lolPlayerId, r.codPlayerId, idHint, slugHint) ?? 'unknown';
11
+ const slug = pickString(r.slug, slugHint);
12
+ const name = pickString(r.name, r.nickname, r.displayName, r.tag, slug, id) ?? id;
13
+ const team = asRecord(r.team) ??
14
+ asRecord(r.currentTeam) ??
15
+ (r.teamName || r.orgSlug
16
+ ? { name: r.teamName, slug: r.orgSlug, id: r.teamId }
17
+ : null);
18
+ return {
19
+ id,
20
+ slug,
21
+ name,
22
+ game,
23
+ team: team
24
+ ? {
25
+ id: pickString(asRecord(team)?.id, asRecord(team)?.teamId),
26
+ slug: pickString(asRecord(team)?.slug, asRecord(team)?.orgSlug),
27
+ name: pickString(asRecord(team)?.name) ?? '?',
28
+ }
29
+ : null,
30
+ role: pickString(r.role, r.position) ?? null,
31
+ nationality: pickString(r.nationality, r.country) ?? null,
32
+ };
33
+ }
34
+ export const playerProfile = {
35
+ name: 'player_profile',
36
+ description: `Player or UFC fighter profile: identity, current team, recent matches, and form/trends/radar when available.
37
+
38
+ When to use:
39
+ - "How is X playing lately?"
40
+ - Player page scaffold; form inputs for previews
41
+
42
+ Prefer over: manual multi-call career/trends/matches via call_api.
43
+
44
+ Do not use when: full team roster needed → team_profile; unresolved name → resolve_entity first.
45
+
46
+ Parallel-safe: yes. Upstream cost: 2–5.
47
+ Example: { "game": "cs2", "playerId": "cs2-player-1", "recentLimit": 10, "includeTrends": true }`,
48
+ inputSchema: {
49
+ type: 'object',
50
+ additionalProperties: false,
51
+ required: ['game'],
52
+ properties: {
53
+ game: gameSchema({ allowAll: false, required: true }),
54
+ playerId: stringSchema('Pass exactly one of playerId or slug. Example: "cs2-player-1".', 'cs2-player-1'),
55
+ slug: stringSchema('UFC fighter slug; also accepted where game allows idOrSlug.', 'islam-makhachev'),
56
+ recentLimit: limitSchema({ default: 10, max: 25, description: 'Recent matches window (default 10, max 25).' }),
57
+ window: stringSchema('Form/radar window (e.g. Dota radar).'),
58
+ role: stringSchema('Dota radar role filter.'),
59
+ includeTrends: boolSchema('Include trends/radar/career enrichment when available.', true),
60
+ view: {
61
+ type: 'string',
62
+ enum: ['summary', 'full'],
63
+ default: 'summary',
64
+ description: 'Payload weight. Default summary.',
65
+ },
66
+ },
67
+ },
68
+ handler: async (args, ctx) => {
69
+ const started = Date.now();
70
+ const requestId = newRequestId();
71
+ const gameParse = parseGame(args.game, { allowAll: false, required: true });
72
+ if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
73
+ return errorEnvelope({
74
+ code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
75
+ message: gameParse.error ?? 'game is required',
76
+ game: null,
77
+ source: 'player_profile',
78
+ requestId,
79
+ tookMs: Date.now() - started,
80
+ });
81
+ }
82
+ const game = gameParse.game;
83
+ const playerId = typeof args.playerId === 'string' ? args.playerId.trim() : '';
84
+ const slug = typeof args.slug === 'string' ? args.slug.trim() : '';
85
+ if (!playerId && !slug) {
86
+ return errorEnvelope({
87
+ code: 'VALIDATION',
88
+ message: 'Pass exactly one of playerId or slug',
89
+ game,
90
+ source: 'player_profile',
91
+ requestId,
92
+ tookMs: Date.now() - started,
93
+ recover: [
94
+ 'Call resolve_entity with the player/fighter name',
95
+ 'Retry player_profile with the returned id or slug',
96
+ ],
97
+ });
98
+ }
99
+ if (playerId && slug && playerId !== slug) {
100
+ return errorEnvelope({
101
+ code: 'VALIDATION',
102
+ message: 'Pass exactly one of playerId or slug (not two different values)',
103
+ game,
104
+ source: 'player_profile',
105
+ requestId,
106
+ tookMs: Date.now() - started,
107
+ recover: [
108
+ 'Retry with only playerId, or only slug',
109
+ 'Use the id/slug from resolve_entity.best',
110
+ ],
111
+ });
112
+ }
113
+ const idOrSlug = playerId || slug;
114
+ const recentLimit = clampInt(args.recentLimit, 10, 1, 25);
115
+ const includeTrends = args.includeTrends !== false;
116
+ const view = args.view === 'full' ? 'full' : 'summary';
117
+ const partial = [];
118
+ let upstreamCalls = 0;
119
+ let rateLimit = {};
120
+ let primaryPath = '';
121
+ if (game === 'lol')
122
+ primaryPath = `/lol/players/${encodeURIComponent(idOrSlug)}`;
123
+ else if (game === 'cs2')
124
+ primaryPath = `/cs2/players/${encodeURIComponent(idOrSlug)}`;
125
+ else if (game === 'dota2')
126
+ primaryPath = `/dota2/players`; // list/search; detail via radar
127
+ else if (game === 'cod')
128
+ primaryPath = `/cod/players/${encodeURIComponent(idOrSlug)}`;
129
+ else if (game === 'ufc')
130
+ primaryPath = `/ufc/fighters/${encodeURIComponent(slug || idOrSlug)}`;
131
+ let playerRaw = null;
132
+ if (game === 'dota2') {
133
+ // Prefer radar as primary signal; try list filter
134
+ const list = await fetchJson(ctx, '/dota2/players', { query: { search: idOrSlug, limit: 5 } });
135
+ upstreamCalls += 1;
136
+ rateLimit = list.headers;
137
+ if (list.ok) {
138
+ const rows = extractRows(list.data);
139
+ playerRaw =
140
+ rows.find((row) => {
141
+ const r = asRecord(row) ?? {};
142
+ const hay = [r.id, r.slug, r.name, r.nickname].map((x) => String(x ?? '').toLowerCase());
143
+ return hay.includes(idOrSlug.toLowerCase());
144
+ }) ?? rows[0] ?? null;
145
+ }
146
+ if (!playerRaw) {
147
+ // still try radar with idOrSlug
148
+ playerRaw = { id: idOrSlug, slug: idOrSlug, name: idOrSlug };
149
+ }
150
+ }
151
+ else {
152
+ const res = await fetchJson(ctx, primaryPath);
153
+ upstreamCalls += 1;
154
+ rateLimit = res.headers;
155
+ if (!res.ok) {
156
+ return errorEnvelope({
157
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
158
+ message: `Player/fighter not found: ${idOrSlug}`,
159
+ game,
160
+ source: 'player_profile',
161
+ requestId,
162
+ tookMs: Date.now() - started,
163
+ upstreamCalls,
164
+ rateLimit: res.headers,
165
+ httpStatus: res.status,
166
+ });
167
+ }
168
+ playerRaw = res.data;
169
+ // some APIs wrap in data
170
+ const obj = asRecord(res.data);
171
+ if (obj?.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) {
172
+ playerRaw = obj.data;
173
+ }
174
+ }
175
+ const player = identityFrom(game, playerRaw, playerId || undefined, slug || undefined);
176
+ let currentTeam = player.team;
177
+ let recentMatches = [];
178
+ let form = null;
179
+ let career = null;
180
+ let radar = null;
181
+ let seasonStats = null;
182
+ const tasks = [];
183
+ if (game === 'lol') {
184
+ tasks.push((async () => {
185
+ const res = await fetchJson(ctx, `/lol/players/${encodeURIComponent(idOrSlug)}/teams`);
186
+ upstreamCalls += 1;
187
+ rateLimit = { ...rateLimit, ...res.headers };
188
+ if (res.ok) {
189
+ const rows = extractRows(res.data);
190
+ const first = asRecord(rows[0]);
191
+ if (first) {
192
+ currentTeam = {
193
+ id: pickString(first.id, first.teamId),
194
+ slug: pickString(first.slug),
195
+ name: pickString(first.name) ?? '?',
196
+ };
197
+ }
198
+ }
199
+ else {
200
+ partial.push(partialFromRejection('currentTeam', {
201
+ code: mapHttpToCode(res.status),
202
+ message: `team history HTTP ${res.status}`,
203
+ httpStatus: res.status,
204
+ }));
205
+ }
206
+ })());
207
+ }
208
+ if (game === 'cs2' && includeTrends) {
209
+ for (const [section, path] of [
210
+ ['career', `/cs2/players/${encodeURIComponent(idOrSlug)}/career`],
211
+ ['seasonStats', `/cs2/players/${encodeURIComponent(idOrSlug)}/seasons`],
212
+ ['form', `/cs2/players/${encodeURIComponent(idOrSlug)}/trends`],
213
+ ]) {
214
+ tasks.push((async () => {
215
+ const res = await fetchJson(ctx, path);
216
+ upstreamCalls += 1;
217
+ rateLimit = { ...rateLimit, ...res.headers };
218
+ if (!res.ok) {
219
+ partial.push(partialFromRejection(section, {
220
+ code: mapHttpToCode(res.status),
221
+ message: `${section} HTTP ${res.status}`,
222
+ httpStatus: res.status,
223
+ }));
224
+ return;
225
+ }
226
+ if (section === 'career')
227
+ career = res.data;
228
+ if (section === 'seasonStats')
229
+ seasonStats = res.data;
230
+ if (section === 'form') {
231
+ form = {
232
+ summary: 'CS2 trends',
233
+ trend: res.data,
234
+ window: null,
235
+ metrics: asRecord(res.data) ?? res.data,
236
+ };
237
+ }
238
+ })());
239
+ }
240
+ }
241
+ if (game === 'dota2' && includeTrends) {
242
+ tasks.push((async () => {
243
+ const query = {};
244
+ if (typeof args.window === 'string')
245
+ query.window = args.window;
246
+ if (typeof args.role === 'string')
247
+ query.role = args.role;
248
+ const res = await fetchJson(ctx, `/dota2/players/${encodeURIComponent(idOrSlug)}/radar`, {
249
+ query,
250
+ });
251
+ upstreamCalls += 1;
252
+ rateLimit = { ...rateLimit, ...res.headers };
253
+ if (res.ok) {
254
+ radar = res.data;
255
+ form = {
256
+ summary: 'Dota radar percentiles',
257
+ trend: null,
258
+ window: args.window ?? null,
259
+ metrics: res.data,
260
+ };
261
+ }
262
+ else {
263
+ partial.push(partialFromRejection('radar', {
264
+ code: mapHttpToCode(res.status),
265
+ message: `radar HTTP ${res.status}`,
266
+ httpStatus: res.status,
267
+ }));
268
+ }
269
+ })());
270
+ }
271
+ if (game === 'cod') {
272
+ tasks.push((async () => {
273
+ const res = await fetchJson(ctx, `/cod/players/${encodeURIComponent(idOrSlug)}/stats`);
274
+ upstreamCalls += 1;
275
+ rateLimit = { ...rateLimit, ...res.headers };
276
+ if (res.ok) {
277
+ seasonStats = res.data;
278
+ form = { summary: 'COD season stats', trend: null, window: null, metrics: res.data };
279
+ }
280
+ else {
281
+ partial.push(partialFromRejection('seasonStats', {
282
+ code: mapHttpToCode(res.status),
283
+ message: `stats HTTP ${res.status}`,
284
+ httpStatus: res.status,
285
+ }));
286
+ }
287
+ })());
288
+ tasks.push((async () => {
289
+ const res = await fetchJson(ctx, `/cod/players/${encodeURIComponent(idOrSlug)}/matches`, {
290
+ query: { limit: recentLimit },
291
+ });
292
+ upstreamCalls += 1;
293
+ rateLimit = { ...rateLimit, ...res.headers };
294
+ if (res.ok) {
295
+ recentMatches = extractRows(res.data)
296
+ .slice(0, recentLimit)
297
+ .map((row) => normalizeMatch('cod', row));
298
+ }
299
+ else {
300
+ partial.push(partialFromRejection('recentMatches', {
301
+ code: mapHttpToCode(res.status),
302
+ message: `matches HTTP ${res.status}`,
303
+ httpStatus: res.status,
304
+ }));
305
+ }
306
+ })());
307
+ }
308
+ if (game === 'ufc') {
309
+ tasks.push((async () => {
310
+ const res = await fetchJson(ctx, `/ufc/fighters/${encodeURIComponent(slug || idOrSlug)}/stats`);
311
+ upstreamCalls += 1;
312
+ rateLimit = { ...rateLimit, ...res.headers };
313
+ if (res.ok) {
314
+ form = { summary: 'UFC fighter stats', trend: null, window: null, metrics: res.data };
315
+ seasonStats = res.data;
316
+ }
317
+ else {
318
+ partial.push(partialFromRejection('form', {
319
+ code: mapHttpToCode(res.status),
320
+ message: `fighter stats HTTP ${res.status}`,
321
+ httpStatus: res.status,
322
+ }));
323
+ }
324
+ })());
325
+ }
326
+ await Promise.all(tasks);
327
+ if (view === 'summary' && recentMatches.length > recentLimit) {
328
+ recentMatches = recentMatches.slice(0, recentLimit);
329
+ }
330
+ return successEnvelope({
331
+ source: 'player_profile',
332
+ game,
333
+ requestId,
334
+ tookMs: Date.now() - started,
335
+ upstreamCalls,
336
+ rateLimit,
337
+ partial: partial.length ? partial : undefined,
338
+ entities: {
339
+ games: [game],
340
+ ids: {
341
+ playerId: player.id,
342
+ ...(player.slug ? { slug: player.slug } : {}),
343
+ },
344
+ },
345
+ data: {
346
+ player,
347
+ currentTeam,
348
+ recentMatches,
349
+ form,
350
+ career,
351
+ radar,
352
+ seasonStats,
353
+ },
354
+ });
355
+ },
356
+ };
357
+ export const playerTools = [playerProfile];