diff --git a/apps/api/src/routes/admin.test.ts b/apps/api/src/routes/admin.test.ts index 9b7caaa..848428a 100644 --- a/apps/api/src/routes/admin.test.ts +++ b/apps/api/src/routes/admin.test.ts @@ -14,6 +14,12 @@ vi.mock('../db.js', () => ({ vi.mock('../services/predgg-token-service.js', () => ({ getPlatformAccessToken: vi.fn().mockResolvedValue(null), + readPlatformOAuthStatus: vi.fn().mockResolvedValue({ + requestedScopes: ['offline_access', 'profile'], grantedScopes: [], missingScopes: ['offline_access', 'profile'], + capabilities: { profile: false, offlineRefresh: false, playerIntervals: false, heroLeaderboard: false, matchupStatistics: false }, + checkedAt: null, error: 'Granted scopes have not been inspected yet', + }), + inspectPlatformOAuthStatus: vi.fn(), platformTokenState: { status: 'missing', lastCheckedAt: null, lastError: null }, })); @@ -22,6 +28,8 @@ vi.mock('../services/sync-service.js', () => ({ syncVersionsFromPredgg: vi.fn().mockResolvedValue(5), syncStalePlayers: vi.fn().mockResolvedValue({ synced: 2, skipped: 0, errors: 0 }), syncIncompleteMatches: vi.fn().mockResolvedValue({ synced: 1, errors: 0 }), + syncGameCatalog: vi.fn().mockResolvedValue({ version: '1.16.1', items: 270, perks: 276, eternalCategories: 6 }), + syncTrackedGameCatalogs: vi.fn().mockResolvedValue({ versions: 1, catalogs: [{ version: '1.16.1', items: 270, perks: 276, eternalCategories: 6 }] }), repairEventStreamPlayerIds: vi.fn().mockResolvedValue({ heroKillsUpdated: 1, objectiveKillsUpdated: 1, diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 88fc251..cccf769 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -12,13 +12,15 @@ import { syncMatchEventStream, resyncMatch, cleanupOldData, + syncGameCatalog, + syncTrackedGameCatalogs, } from '../services/sync-service.js'; import { syncHeroMeta } from '../services/hero-meta-service.js'; import { invalidateHeroMetaCache } from './hero-meta.js'; import { getAllConfig, updateConfigValue, updateConfigText, resetConfigValue } from '../services/config-service.js'; import { getPermissions, savePermissions, DEFAULT_PERMISSIONS, CONFIGURABLE_ROLES } from '../services/permissions-service.js'; import { getValidToken } from './auth.js'; -import { getPlatformAccessToken, platformTokenState } from '../services/predgg-token-service.js'; +import { getPlatformAccessToken, inspectPlatformOAuthStatus, platformTokenState, readPlatformOAuthStatus } from '../services/predgg-token-service.js'; import { requireAuth } from '../middleware/require-auth.js'; import { requirePlatformAdmin } from '../middleware/require-platform-admin.js'; @@ -676,6 +678,11 @@ adminRouter.get('/api-status', async (_req, res, next) => { } const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const platformToken = await getPlatformAccessToken(); + const oauth = platformToken + ? await inspectPlatformOAuthStatus(platformToken.accessToken) + : await readPlatformOAuthStatus(); + const [totalErrors, recentErrors, lastSuccess, recentBySource] = await Promise.all([ db.syncLog.count({ where: { status: 'error' } }), db.syncLog.count({ where: { status: 'error', syncedAt: { gte: dayAgo } } }), @@ -684,7 +691,7 @@ adminRouter.get('/api-status', async (_req, res, next) => { ]); res.json({ - predgg: { status: predggStatus, responseMs: predggMs, error: predggError, endpoint: GQL_URL }, + predgg: { status: predggStatus, responseMs: predggMs, error: predggError, endpoint: GQL_URL, oauth }, syncErrors: { total: totalErrors, last24h: recentErrors, bySource: recentBySource }, lastSuccessfulSync: lastSuccess, }); @@ -705,6 +712,19 @@ adminRouter.post('/sync-heroes', requireAuth, requirePlatformAdmin, async (_req, } }); +/** POST /admin/sync-game-catalog — current or selected patch catalog. */ +adminRouter.post('/sync-game-catalog', requireAuth, requirePlatformAdmin, async (req, res, next) => { + try { + const { versionId } = z.object({ versionId: z.string().optional() }).parse(req.body ?? {}); + const result = versionId + ? await syncGameCatalog(db, versionId) + : await syncTrackedGameCatalogs(db); + res.json({ ok: true, ...result }); + } catch (err) { + next(err); + } +}); + /** * GET /admin/config * Returns all platform config entries. diff --git a/apps/api/src/routes/auth.test.ts b/apps/api/src/routes/auth.test.ts index 85f1fba..b6be38e 100644 --- a/apps/api/src/routes/auth.test.ts +++ b/apps/api/src/routes/auth.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest'; import request from 'supertest'; import express from 'express'; import cookieParser from 'cookie-parser'; -import { authRouter } from './auth.js'; -import { savePlatformOAuthTokens } from '../services/predgg-token-service.js'; +import { authRouter, getValidToken } from './auth.js'; +import { getPlatformAccessToken, savePlatformOAuthTokens } from '../services/predgg-token-service.js'; vi.hoisted(() => { process.env.PRED_GG_CLIENT_ID = 'test-client-id'; @@ -22,9 +22,28 @@ vi.mock('../services/predgg-token-service.js', () => ({ const app = express(); app.use(cookieParser()); +app.get('/token-probe', async (req, res) => { + const token = await getValidToken(req, res); + res.json({ token: token ?? null }); +}); app.use('/auth', authRouter); describe('GET /auth/predgg', () => { + it('clears an expired browser session when silent refresh is unavailable', async () => { + vi.mocked(getPlatformAccessToken).mockResolvedValueOnce(null); + + const res = await request(app) + .get('/token-probe') + .set('Cookie', ['predgg_expires_at=1']); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ token: null }); + expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([ + expect.stringContaining('predgg_token='), + expect.stringContaining('predgg_expires_at='), + ])); + }); + it('redirects to the pred.gg OAuth SPA authorize route with PKCE', async () => { const res = await request(app).get('/auth/predgg'); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 7705ae1..d428498 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -55,7 +55,7 @@ export async function getValidToken(req: Request, res: Response): Promise ({ + matchFindUnique: vi.fn(), + getMatchDetail: vi.fn(), + getMatchEvents: vi.fn(), + getValidToken: vi.fn(), +})); + +vi.mock('../db.js', () => ({ + db: { + match: { findUnique: mocks.matchFindUnique }, + user: { findUnique: vi.fn() }, + matchPlayer: { findFirst: vi.fn() }, + }, +})); +vi.mock('../services/match-service.js', () => ({ + getMatchDetail: mocks.getMatchDetail, + getMatchEvents: mocks.getMatchEvents, +})); +vi.mock('./auth.js', () => ({ getValidToken: mocks.getValidToken })); +vi.mock('../services/sync-service.js', () => ({ resyncMatch: vi.fn(), syncMatchEventStream: vi.fn() })); +vi.mock('../services/build-coach-service.js', () => ({ getMatchBuildAnalysis: vi.fn() })); + +import { buildLiveDetail, matchesRouter } from './matches.js'; + +const app = express(); +app.use(express.json()); +app.use(cookieParser()); +app.use('/matches', matchesRouter); +app.use(errorHandler); describe('buildLiveDetail', () => { it('ignores null and empty inventory slots returned by pred.gg', () => { @@ -26,3 +60,25 @@ describe('buildLiveDetail', () => { expect(result.detail.dusk[0].inventoryItems).toEqual(['lightning-hawk']); }); }); + +describe('GET /matches/live/:predggUuid', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.matchFindUnique.mockResolvedValue({ id: 'stored-match-1' }); + mocks.getMatchDetail.mockResolvedValue({ id: 'stored-match-1', predggUuid: 'uuid-1', dusk: [], dawn: [] }); + mocks.getMatchEvents.mockResolvedValue({ heroKills: [], objectiveKills: [], structureDestructions: [], wardEvents: [], transactions: [] }); + }); + + it('serves a synced local match without depending on the browser Pred.gg token', async () => { + const cookie = await authCookie({ globalRole: 'PLAYER', memberships: [] }); + + const response = await request(app).get('/matches/live/uuid-1').set('Cookie', cookie); + + expect(response.status).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + expect(response.body.detail.id).toBe('stored-match-1'); + expect(mocks.getMatchDetail).toHaveBeenCalledWith('stored-match-1'); + expect(mocks.getMatchEvents).toHaveBeenCalledWith('stored-match-1'); + expect(mocks.getValidToken).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/routes/matches.ts b/apps/api/src/routes/matches.ts index 63378df..4a5724e 100644 --- a/apps/api/src/routes/matches.ts +++ b/apps/api/src/routes/matches.ts @@ -6,9 +6,39 @@ import { db } from '../db.js'; import { AppError } from '../middleware/error-handler.js'; import { requireAuth } from '../middleware/require-auth.js'; import { logger } from '../logger.js'; +import { getMatchBuildAnalysis } from '../services/build-coach-service.js'; export const matchesRouter = Router(); +matchesRouter.get('/live/:predggUuid/build-analysis', requireAuth, async (req, res, next) => { + try { + const account = await db.user.findUnique({ + where: { id: req.user!.userId }, + select: { linkedPlayerId: true }, + }); + if (!account?.linkedPlayerId) throw new AppError(400, 'No player linked to this account', 'NO_PLAYER_LINKED'); + const matchPlayer = await db.matchPlayer.findFirst({ + where: { + playerId: account.linkedPlayerId, + match: { predggUuid: String(req.params.predggUuid) }, + }, + select: { id: true, matchId: true }, + }); + if (!matchPlayer) throw new AppError(404, 'Linked player not found in this match', 'MATCH_PLAYER_NOT_FOUND'); + res.json(await getMatchBuildAnalysis(matchPlayer.matchId, matchPlayer.id)); + } catch (err) { + next(err); + } +}); + +matchesRouter.get('/:id/build-analysis/:matchPlayerId', requireAuth, async (req, res, next) => { + try { + res.json(await getMatchBuildAnalysis(String(req.params.id), String(req.params.matchPlayerId))); + } catch (err) { + next(err); + } +}); + const GQL_URL = process.env.PRED_GG_GQL_URL ?? 'https://pred.gg/gql'; const API_KEY = process.env.PRED_GG_CLIENT_SECRET; @@ -30,10 +60,13 @@ const LIVE_MATCH_QUERY = ` id uuid startTime + endTime duration gameMode region winningTeam + endReason + spoilerBlockedUntil version { name } matchPlayers { player { id name } @@ -52,9 +85,24 @@ const LIVE_MATCH_QUERY = ` trueDamageDealtToHeroes totalDamageTaken totalHealingDone + crestHealingDone + itemHealingDone + utilityHealingDone + totalShieldingReceived + totalDamageMitigated + physicalDamageTaken + magicalDamageTaken + trueDamageTaken + physicalDamageTakenFromHeroes + magicalDamageTakenFromHeroes + trueDamageTakenFromHeroes totalDamageDealtToStructures totalDamageDealtToObjectives laneMinionsKilled + minionsKilled + neutralMinionsKilled + neutralMinionsTeamJungle + neutralMinionsEnemyJungle wardsPlaced wardsDestroyed level @@ -67,6 +115,19 @@ const LIVE_MATCH_QUERY = ` data { displayName icon promoIcon defaultSkin { icon portrait smallPortrait } } } inventoryItemData { item { slug } } + perks { + id name + data { + displayName slot icon simpleDescription description unlockLevel + eternalCategory { id name } + } + } + abilityOrder { ability gameTime } + rating { + points newPoints isRankup + rating { id name } + rank { name tierName } + } wardPlacements { gameTime type location { x y z } } wardDestructions { gameTime type location { x y z } } transactions { gameTime transactionType itemData { name } } @@ -108,12 +169,24 @@ type LiveMatchPlayer = { totalDamageDealt?: number; physicalDamageDealtToHeroes?: number; magicalDamageDealtToHeroes?: number; trueDamageDealtToHeroes?: number; totalDamageTaken?: number; totalHealingDone?: number; + crestHealingDone?: number; itemHealingDone?: number; utilityHealingDone?: number; + totalShieldingReceived?: number; totalDamageMitigated?: number; + physicalDamageTaken?: number; magicalDamageTaken?: number; trueDamageTaken?: number; + physicalDamageTakenFromHeroes?: number; magicalDamageTakenFromHeroes?: number; trueDamageTakenFromHeroes?: number; totalDamageDealtToStructures?: number; totalDamageDealtToObjectives?: number; - laneMinionsKilled?: number; wardsPlaced?: number; wardsDestroyed?: number; + laneMinionsKilled?: number; minionsKilled?: number; neutralMinionsKilled?: number; + neutralMinionsTeamJungle?: number; neutralMinionsEnemyJungle?: number; + wardsPlaced?: number; wardsDestroyed?: number; level?: number; largestCriticalStrike?: number; largestKillingSpree?: number; multiKill?: number; goldEarnedAtInterval?: number[] | null; hero?: { slug?: string; data?: { displayName?: string; icon?: string; promoIcon?: string; defaultSkin?: { icon?: string; portrait?: string; smallPortrait?: string } | null } | null } | null; inventoryItemData?: Array<{ item?: { slug?: string } | null } | null> | null; + perks?: Array<{ id: string; name: string; data?: { + displayName?: string; slot?: string; icon?: string; simpleDescription?: string; description?: string; + unlockLevel?: number; eternalCategory?: { id: string; name: string } | null; + } | null }> | null; + abilityOrder?: Array<{ ability: string; gameTime: number }> | null; + rating?: { points?: number | null; newPoints?: number | null; isRankup?: boolean | null; rating?: { id?: string } | null; rank?: { name?: string; tierName?: string } | null } | null; wardPlacements?: Array<{ gameTime: number; type?: string | null; location?: { x?: number; y?: number } | null }> | null; wardDestructions?: Array<{ gameTime: number; type?: string | null; location?: { x?: number; y?: number } | null }> | null; transactions?: Array<{ gameTime: number; transactionType?: string | null; itemData?: { name?: string | null } | null }> | null; @@ -121,8 +194,9 @@ type LiveMatchPlayer = { type LiveMatchData = { match?: { - id?: string; uuid?: string; startTime?: string; duration?: number; gameMode?: string; + id?: string; uuid?: string; startTime?: string; endTime?: string | null; duration?: number; gameMode?: string; region?: string | null; winningTeam?: string | null; version?: { name?: string } | null; + endReason?: string | null; spoilerBlockedUntil?: string | null; matchPlayers?: LiveMatchPlayer[]; heroKills?: Array<{ gameTime: number; killerTeam?: string | null; killedTeam?: string | null; killerHero?: { slug?: string } | null; killedHero?: { slug?: string } | null; killerPlayer?: { id?: string } | null; killedPlayer?: { id?: string } | null; location?: { x?: number; y?: number } | null }>; objectiveKills?: Array<{ gameTime: number; killedEntityType?: string | null; killerTeam?: string | null; killerPlayer?: { id?: string } | null; location?: { x?: number; y?: number } | null }>; @@ -173,7 +247,18 @@ export function buildLiveDetail(raw: LiveMatchData) { level: mp.level ?? null, inventoryItems, perkSlug: null as null, - perks: null as null, + perks: mp.perks?.map((perk) => ({ + id: perk.id, + name: perk.name, + displayName: perk.data?.displayName ?? perk.name, + slot: perk.data?.slot ?? null, + icon: perk.data?.icon ?? null, + simpleDescription: perk.data?.simpleDescription ?? null, + description: perk.data?.description ?? null, + unlockLevel: perk.data?.unlockLevel ?? null, + eternalCategory: perk.data?.eternalCategory ?? null, + })) ?? null, + abilityOrder: mp.abilityOrder ?? null, rankLabel: null as null, ratingPoints: null as null, physicalDamageDealtToHeroes: mp.physicalDamageDealtToHeroes ?? null, @@ -182,16 +267,40 @@ export function buildLiveDetail(raw: LiveMatchData) { heroDamageTaken: mp.heroDamageTaken ?? null, totalDamageTaken: mp.totalDamageTaken ?? null, totalHealingDone: mp.totalHealingDone ?? null, + crestHealingDone: mp.crestHealingDone ?? null, + itemHealingDone: mp.itemHealingDone ?? null, + utilityHealingDone: mp.utilityHealingDone ?? null, + totalShieldingReceived: mp.totalShieldingReceived ?? null, + totalDamageMitigated: mp.totalDamageMitigated ?? null, + physicalDamageTaken: mp.physicalDamageTaken ?? null, + magicalDamageTaken: mp.magicalDamageTaken ?? null, + trueDamageTaken: mp.trueDamageTaken ?? null, + physicalDamageTakenFromHeroes: mp.physicalDamageTakenFromHeroes ?? null, + magicalDamageTakenFromHeroes: mp.magicalDamageTakenFromHeroes ?? null, + trueDamageTakenFromHeroes: mp.trueDamageTakenFromHeroes ?? null, totalDamageDealtToStructures: mp.totalDamageDealtToStructures ?? null, totalDamageDealtToObjectives: mp.totalDamageDealtToObjectives ?? null, largestCriticalStrike: mp.largestCriticalStrike ?? null, laneMinionsKilled: mp.laneMinionsKilled ?? null, + minionsKilled: mp.minionsKilled ?? null, + neutralMinionsKilled: mp.neutralMinionsKilled ?? null, + neutralMinionsTeamJungle: mp.neutralMinionsTeamJungle ?? null, + neutralMinionsEnemyJungle: mp.neutralMinionsEnemyJungle ?? null, goldSpent: mp.goldSpent ?? null, largestKillingSpree: mp.largestKillingSpree ?? null, multiKill: mp.multiKill ?? null, physicalDamageDealt: null as null, magicalDamageDealt: null as null, trueDamageDealt: null as null, + matchRating: mp.rating ? { + ratingId: mp.rating.rating?.id ?? null, + points: mp.rating.points ?? null, + newPoints: mp.rating.newPoints ?? null, + delta: mp.rating.points != null && mp.rating.newPoints != null ? mp.rating.newPoints - mp.rating.points : null, + rankName: mp.rating.rank?.name ?? null, + tierName: mp.rating.rank?.tierName ?? null, + isRankup: mp.rating.isRankup ?? null, + } : null, goldEarnedAtInterval: mp.goldEarnedAtInterval ?? null, // Keep ward/tx data for event stream construction _wardPlacements: mp.wardPlacements ?? [], @@ -207,10 +316,13 @@ export function buildLiveDetail(raw: LiveMatchData) { id: m.uuid ?? m.id ?? '', predggUuid: m.uuid ?? m.id ?? '', startTime: m.startTime ?? new Date().toISOString(), + endTime: m.endTime ?? null, duration: m.duration ?? 0, gameMode: m.gameMode ?? 'UNKNOWN', region: typeof m.region === 'string' ? m.region : null, winningTeam: m.winningTeam ?? null, + endReason: m.endReason ?? null, + spoilerBlockedUntil: m.spoilerBlockedUntil ?? null, version: m.version?.name ?? null, rosterSynced: true, eventStreamSynced: true, @@ -318,6 +430,24 @@ matchesRouter.get('/:id/events', async (req, res, next) => { matchesRouter.get('/live/:predggUuid', requireAuth, async (req, res, next) => { try { const { predggUuid } = req.params; + res.set('Cache-Control', 'no-store'); + + // Player match history is persisted locally. Prefer that enriched copy so + // opening a known match never depends on browser OAuth scopes or pred.gg + // availability. The live query remains as a fallback for unknown matches. + const storedMatch = await db.match.findUnique({ + where: { predggUuid }, + select: { id: true }, + }); + if (storedMatch) { + const [detail, events] = await Promise.all([ + getMatchDetail(storedMatch.id), + getMatchEvents(storedMatch.id), + ]); + res.json({ detail, events }); + return; + } + const userToken = await getValidToken(req, res); logger.info({ predggUuid }, 'live match fetch'); @@ -335,22 +465,17 @@ matchesRouter.get('/live/:predggUuid', requireAuth, async (req, res, next) => { /** * POST /matches/:id/sync - * Re-fetches this match from pred.gg using the user's Bearer token. - * Reveals player names that appear as HIDDEN without auth. + * Re-fetches this match from pred.gg. A user's Bearer token enriches private + * player data when available, but the event stream can also be refreshed via + * the public/API-key path. */ matchesRouter.post('/:id/sync', async (req, res, next) => { try { const match = await db.match.findUnique({ where: { id: req.params.id } }); if (!match) throw new AppError(404, 'Match not found', 'MATCH_NOT_FOUND'); const userToken = await getValidToken(req, res); - // Force full re-sync when user explicitly requests it + // Force both roster and event-stream refresh when the user requests it. await resyncMatch(db, match.predggUuid, userToken ?? undefined, true); - // Also force event stream re-sync to get goldEarnedAtInterval and latest events - if (userToken) { - await (await import('../services/sync-service.js')).syncMatchEventStream( - db, match.id, match.predggUuid, userToken, true - ); - } const detail = await getMatchDetail(req.params.id); res.json(detail); } catch (err) { diff --git a/apps/api/src/routes/players.ts b/apps/api/src/routes/players.ts index 74fd343..505eb5d 100644 --- a/apps/api/src/routes/players.ts +++ b/apps/api/src/routes/players.ts @@ -7,6 +7,8 @@ import { requireAuth } from '../middleware/require-auth.js'; import { db } from '../db.js'; import { getValidToken } from './auth.js'; import { getPlatformAccessToken } from '../services/predgg-token-service.js'; +import { getPlayerChampionPoolContext } from '../services/player-champion-pool-service.js'; +import { getPlayerPredggBenchmarks } from '../services/predgg-benchmark-service.js'; export const playersRouter = Router(); @@ -314,6 +316,38 @@ playersRouter.get('/:id/scout', requireAuth, async (req, res, next) => { } }); +/** + * GET /players/:id/champion-pool-context + * Local, patch-safe champion pool cohorts with enemy matchups and ally synergies. + */ +playersRouter.get('/:id/champion-pool-context', requireAuth, async (req, res, next) => { + try { + const filters = z.object({ + days: z.coerce.number().int().min(7).max(365).default(90), + role: z.enum(['CARRY', 'JUNGLE', 'MIDLANE', 'OFFLANE', 'SUPPORT']).optional(), + gameMode: z.string().trim().min(1).max(40).optional(), + heroSlug: z.string().trim().min(1).max(80).toLowerCase().optional(), + }).parse(req.query); + res.json(await getPlayerChampionPoolContext(String(req.params.id), filters)); + } catch (err) { + next(err); + } +}); + +/** GET /players/:id/benchmarks — personal metrics against pred.gg population data. */ +playersRouter.get('/:id/benchmarks', requireAuth, async (req, res, next) => { + try { + const options = z.object({ + heroSlug: z.string().trim().min(1).max(80).toLowerCase(), + role: z.enum(['CARRY', 'JUNGLE', 'MIDLANE', 'OFFLANE', 'SUPPORT']).optional(), + gameMode: z.enum(['RANKED', 'STANDARD', 'ARAM', 'RUSH', 'DAYBREAK']).optional(), + }).parse(req.query); + res.json(await getPlayerPredggBenchmarks(String(req.params.id), options)); + } catch (err) { + next(err); + } +}); + /** * POST /players/sync * Body: { name: string } diff --git a/apps/api/src/routes/reports.test.ts b/apps/api/src/routes/reports.test.ts index d332960..a2c0fed 100644 --- a/apps/api/src/routes/reports.test.ts +++ b/apps/api/src/routes/reports.test.ts @@ -16,10 +16,14 @@ vi.mock('../services/player-weekly-report-service.js', () => ({ vi.mock('../services/player-coach-chat-service.js', () => ({ answerPlayerCoachQuestion: vi.fn().mockResolvedValue({ answer: 'Respuesta [E2]', evidence: [{ id: 'E2' }] }), })); +vi.mock('../services/player-build-review-service.js', () => ({ + getPlayerBuildReview: vi.fn().mockResolvedValue({ playerId: 'player-1', matches: [] }), +})); import { db } from '../db.js'; import { generatePlayerWeeklyReport } from '../services/player-weekly-report-service.js'; import { answerPlayerCoachQuestion } from '../services/player-coach-chat-service.js'; +import { getPlayerBuildReview } from '../services/player-build-review-service.js'; import { reportsRouter } from './reports.js'; const app = express(); @@ -54,6 +58,30 @@ describe('GET /reports/player-weekly/:playerId', () => { }); }); +describe('GET /reports/player-builds/:playerId', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns recent build reviews for the linked player', async () => { + mockDb.user.findUnique.mockResolvedValue({ linkedPlayerId: 'player-1' }); + const cookie = await authCookie({ globalRole: 'PLAYER', memberships: [] }); + + const response = await request(app).get('/reports/player-builds/player-1?days=30&limit=3').set('Cookie', cookie); + + expect(response.status).toBe(200); + expect(getPlayerBuildReview).toHaveBeenCalledWith('player-1', { days: 30, limit: 3 }); + }); + + it('does not expose another player build review', async () => { + mockDb.user.findUnique.mockResolvedValue({ linkedPlayerId: 'player-1' }); + const cookie = await authCookie({ globalRole: 'PLAYER', memberships: [] }); + + const response = await request(app).get('/reports/player-builds/player-2').set('Cookie', cookie); + + expect(response.status).toBe(403); + expect(getPlayerBuildReview).not.toHaveBeenCalled(); + }); +}); + describe('POST /reports/player-coach/:playerId/chat', () => { beforeEach(() => vi.clearAllMocks()); diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index b587f91..24d5925 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { generateScrimReport } from '../services/report-service.js'; import { generatePlayerWeeklyReport } from '../services/player-weekly-report-service.js'; import { answerPlayerCoachQuestion } from '../services/player-coach-chat-service.js'; +import { getPlayerBuildReview } from '../services/player-build-review-service.js'; import { requireAuth } from '../middleware/require-auth.js'; import { requireRole } from '../middleware/require-role.js'; import { db } from '../db.js'; @@ -76,6 +77,24 @@ reportsRouter.get('/player-weekly/:playerId', requireAuth, async (req, res, next } }); +/** GET /reports/player-builds/:playerId — recent builds, Ancestors and contextual recommendations. */ +reportsRouter.get('/player-builds/:playerId', requireAuth, async (req, res, next) => { + try { + const playerId = z.string().min(1).parse(req.params.playerId); + if (!await canAccessPlayer(req, playerId)) { + res.status(403).json({ error: { message: 'You can only view your own build review', code: 'FORBIDDEN' } }); + return; + } + const options = z.object({ + days: z.coerce.number().int().min(7).max(90).default(30), + limit: z.coerce.number().int().min(1).max(10).default(5), + }).parse(req.query); + res.json(await getPlayerBuildReview(playerId, options)); + } catch (err) { + next(err); + } +}); + /** POST /reports/player-coach/:playerId/chat — evidence-grounded personal coach conversation. */ reportsRouter.post('/player-coach/:playerId/chat', requireAuth, async (req, res, next) => { try { diff --git a/apps/api/src/services/build-coach-service.test.ts b/apps/api/src/services/build-coach-service.test.ts new file mode 100644 index 0000000..832747e --- /dev/null +++ b/apps/api/src/services/build-coach-service.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + matchPlayerFindFirst: vi.fn(), + gameItemFindMany: vi.fn(), + gameItemVersionFindMany: vi.fn(), + gamePerkVersionFindMany: vi.fn(), + heroMetaFindMany: vi.fn(), + transactionFindMany: vi.fn(), +})); + +vi.mock('../db.js', () => ({ + db: { + matchPlayer: { findFirst: mocks.matchPlayerFindFirst }, + gameItem: { findMany: mocks.gameItemFindMany }, + gameItemVersion: { findMany: mocks.gameItemVersionFindMany }, + gamePerkVersion: { findMany: mocks.gamePerkVersionFindMany }, + heroMeta: { findMany: mocks.heroMetaFindMany }, + transaction: { findMany: mocks.transactionFindMany }, + }, +})); + +import { getMatchBuildAnalysis } from './build-coach-service.js'; + +describe('contextual build coach', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.matchPlayerFindFirst.mockResolvedValue({ + id: 'mp-1', matchId: 'match-1', playerId: 'player-1', heroSlug: 'dekker', role: 'SUPPORT', team: 'DAWN', + kills: 0, deaths: 7, assists: 10, inventoryItems: ['offensive-item'], perks: [], abilityOrder: [], + heroDamage: 10_000, totalHealingDone: 2_000, totalShieldingReceived: 1_000, + physicalDamageTakenFromHeroes: 18_000, physicalDamageTaken: 19_000, + magicalDamageTakenFromHeroes: 4_000, magicalDamageTaken: 4_500, + trueDamageTakenFromHeroes: 0, trueDamageTaken: 0, + match: { + versionId: 'version-1', winningTeam: 'DUSK', + matchPlayers: [ + { id: 'mp-1', playerId: 'player-1', playerName: 'Coach', team: 'DAWN', role: 'SUPPORT', heroSlug: 'dekker', totalHealingDone: 2_000, totalShieldingReceived: 1_000, totalDamageMitigated: 8_000 }, + { id: 'enemy-1', playerId: 'enemy-player-1', playerName: 'Enemy', team: 'DUSK', role: 'SUPPORT', heroSlug: 'narbash', totalHealingDone: 20_000, totalShieldingReceived: 2_000, totalDamageMitigated: 25_000 }, + ], + }, + }); + mocks.gameItemFindMany.mockResolvedValue([{ predggId: 'item-1', slug: 'offensive-item', name: 'Offense', versions: [{ + predggDataId: 'data-1', displayName: 'Offense', aggressionType: 'OFFENSE', rarity: 'EPIC', slotType: 'PASSIVE', + isEvolved: false, isHidden: false, stats: [], effects: [], blocksIds: ['data-1'], blockedByIds: ['data-1'], + }] }]); + mocks.gameItemVersionFindMany.mockResolvedValue([ + { displayName: 'Physical Guard', aggressionType: 'ARMOR', totalPrice: 3000, stats: [], effects: [], item: { slug: 'physical-guard', name: 'PhysicalGuard' } }, + { displayName: 'Tainted Guard', aggressionType: 'ANTI_HEAL', totalPrice: 3100, stats: [], effects: [], item: { slug: 'tainted-guard', name: 'TaintedGuard' } }, + ]); + mocks.heroMetaFindMany.mockResolvedValue([ + { slug: 'dekker', displayName: 'Dekker', abilities: [] }, + { slug: 'narbash', displayName: 'Narbash', abilities: [ + { display_name: 'Song of My People', game_description: 'Heal nearby allied heroes over time.' }, + { display_name: 'Thunk', game_description: 'Throw a projectile that stuns the first enemy Hero hit.' }, + ] }, + ]); + mocks.gamePerkVersionFindMany.mockResolvedValue([]); + mocks.transactionFindMany.mockResolvedValue([ + { gameTime: 720, transactionType: 'BUY', itemName: 'Offense', playerId: 'player-1', team: 'DAWN' }, + { gameTime: 850, transactionType: 'BUY', itemName: 'PhysicalGuard', playerId: 'enemy-player-1', team: 'DUSK' }, + ]); + }); + + it('turns damage composition and enemy healing into evidence-backed item signals', async () => { + const result = await getMatchBuildAnalysis('match-1', 'mp-1'); + + expect(result.signals).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: 'physical-defense', severity: 'critical' }), + expect.objectContaining({ key: 'anti-heal', severity: 'warning' }), + ])); + expect(result.signals.find((signal) => signal.key === 'physical-defense')?.suggestedItems?.[0]).toMatchObject({ slug: 'physical-guard' }); + expect(result.context.damageReceived.physical).toBe(18_000); + expect(result.verdict.grade).toBe('poor'); + expect(result.recommendedBuild.changes.find((change) => change.signalKey === 'anti-heal')?.why).toContain('narbash'); + expect(result.signals.find((signal) => signal.key === 'anti-heal')).toMatchObject({ + whyItMatters: expect.stringContaining('Heridas Graves'), + sources: [expect.objectContaining({ heroSlug: 'narbash', name: 'Song of My People' })], + }); + expect(result.globalAnalysis.playerIdentity).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'TEAM_UTILITY' })])); + expect(result.globalAnalysis.enemyThreats).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: 'SUSTAIN' }), + expect.objectContaining({ key: 'CONTROL', sources: [expect.objectContaining({ name: 'Thunk' })] }), + ])); + expect(result.inventoryAssessments[0]).toEqual(expect.objectContaining({ roleFit: expect.any(String), matchupFit: expect.any(String), tradeoff: expect.any(String) })); + expect(result.purchaseTimeline.ownPurchases[0]).toMatchObject({ minute: '12:00', itemName: 'Offense' }); + expect(result.purchaseTimeline.opponentResponses[0]).toMatchObject({ heroSlug: 'narbash', minute: '14:10' }); + expect(result.signals).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ key: 'item-conflict' }), + ])); + expect(mocks.gameItemVersionFindMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ rarity: 'EPIC', slotType: 'PASSIVE' }), + })); + }); + + it('credits an existing shred item and flags a farm-scaling Eternal for Support', async () => { + mocks.matchPlayerFindFirst.mockResolvedValueOnce({ + id: 'mp-1', matchId: 'match-1', playerId: 'player-1', heroSlug: 'dekker', role: 'SUPPORT', team: 'DAWN', + kills: 0, deaths: 5, assists: 8, heroDamage: 10_000, + totalHealingDone: 2_500, totalShieldingReceived: 1_000, + inventoryItems: ['dynamo'], abilityOrder: [], + perks: [{ + id: '699', name: 'Eternal_Xyris', displayName: 'Xyris', slot: 'ETERNAL_1', + description: 'Deal +3% Damage. Gain more per 10 Units killed.', + }], + physicalDamageTakenFromHeroes: 10_000, physicalDamageTaken: 10_000, + magicalDamageTakenFromHeroes: 10_000, magicalDamageTaken: 10_000, + trueDamageTakenFromHeroes: 0, trueDamageTaken: 0, + match: { + versionId: 'version-1', winningTeam: 'DUSK', + matchPlayers: [ + { id: 'mp-1', playerId: 'player-1', playerName: 'Coach', team: 'DAWN', role: 'SUPPORT', heroSlug: 'dekker', totalHealingDone: 2_500, totalShieldingReceived: 1_000, totalDamageMitigated: 8_000 }, + { id: 'enemy-1', playerId: 'enemy-player-1', playerName: 'Enemy', team: 'DUSK', role: 'OFFLANE', heroSlug: 'akeron', totalHealingDone: 0, totalShieldingReceived: 0, totalDamageMitigated: 100_000 }, + ], + }, + }); + mocks.gameItemFindMany.mockResolvedValueOnce([{ predggId: 'item-1', slug: 'dynamo', name: 'Dynamo', versions: [{ + predggDataId: 'data-1', displayName: 'Dynamo', aggressionType: 'MAGICAL_SHRED', rarity: 'EPIC', slotType: 'PASSIVE', + isEvolved: false, isHidden: false, stats: [], effects: [], blocksIds: [], blockedByIds: [], + }] }]); + mocks.gamePerkVersionFindMany.mockResolvedValueOnce([ + { predggDataId: 'augment-data', displayName: 'Ionic Surge', slot: 'HERO_SPECIFIC_1', icon: null, simpleDescription: null, description: 'Stunning a Hero shreds their Physical and Magical Armor.', heroSlug: 'dekker', minorBlessingPredggIds: [], perk: { predggId: 'augment-1', slug: 'ionic-surge' } }, + { predggDataId: 'xyris-data', displayName: 'Xyris', slot: 'ETERNAL_1', icon: null, simpleDescription: null, description: 'Gain damage per 10 Units killed.', heroSlug: null, minorBlessingPredggIds: [], perk: { predggId: '699', slug: 'xyris' } }, + { predggDataId: 'knell-data', displayName: 'Knell', slot: 'ETERNAL_1', icon: null, simpleDescription: null, description: 'Abilities apply Rust and shred Physical and Magical Armor.', heroSlug: null, minorBlessingPredggIds: ['frequency', 'peal'], perk: { predggId: 'knell', slug: 'knell' } }, + { predggDataId: 'frequency-data', displayName: 'Frequency', slot: 'BLESSING_MINOR_1', icon: null, simpleDescription: null, description: 'Gain Ability Haste per Rust stack.', heroSlug: null, minorBlessingPredggIds: [], perk: { predggId: 'frequency', slug: 'frequency' } }, + { predggDataId: 'peal-data', displayName: 'Peal', slot: 'BLESSING_MINOR_2', icon: null, simpleDescription: null, description: 'After your Ultimate apply maximum Rust.', heroSlug: null, minorBlessingPredggIds: [], perk: { predggId: 'peal', slug: 'peal' } }, + ]); + mocks.transactionFindMany.mockResolvedValueOnce([]); + + const result = await getMatchBuildAnalysis('match-1', 'mp-1'); + + expect(result.signals).not.toEqual(expect.arrayContaining([expect.objectContaining({ key: 'anti-tank' })])); + expect(result.inventoryAssessments[0]).toMatchObject({ slug: 'dynamo', verdict: 'correct' }); + expect(result.eternalLoadout[0]).toMatchObject({ displayName: 'Xyris', verdict: 'questionable' }); + expect(result.recommendedLoadout.augment).toMatchObject({ displayName: 'Ionic Surge' }); + expect(result.recommendedLoadout.eternal).toMatchObject({ displayName: 'Knell', replaces: { displayName: 'Xyris' } }); + expect(result.recommendedLoadout.blessings.map((perk) => perk.displayName)).toEqual(['Frequency', 'Peal']); + }); +}); diff --git a/apps/api/src/services/build-coach-service.ts b/apps/api/src/services/build-coach-service.ts new file mode 100644 index 0000000..42a4050 --- /dev/null +++ b/apps/api/src/services/build-coach-service.ts @@ -0,0 +1,949 @@ +import { db } from '../db.js'; +import { AppError } from '../middleware/error-handler.js'; + +export type BuildSignalSeverity = 'info' | 'warning' | 'critical'; + +interface BuildSignal { + key: string; + severity: BuildSignalSeverity; + title: string; + evidence: string; + recommendation: string; + whyItMatters?: string; + sources?: Array<{ heroSlug: string; sourceType: 'ability' | 'item'; name: string; description: string }>; + appliesAgainst?: string[]; + desiredTags: string[]; + suggestedItems?: Array<{ + slug: string; displayName: string; aggressionType: string | null; reason: string; totalPrice: number; + stats: Array<{ stat: string; value: number; showPercent?: boolean }>; + effects: Array<{ name: string; text: string; condition?: string | null; cooldown?: string | null }>; + }>; +} + +type LoadoutPerk = { + id: string; + name?: string; + displayName: string; + slot: string; + simpleDescription?: string | null; + description?: string | null; + icon?: string | null; +}; + +type HeroAbility = { + key?: string; + display_name?: string; + game_description?: string; + menu_description?: string; +}; + +type CatalogPerk = { + predggDataId: string; + displayName: string; + slot: string; + icon: string | null; + simpleDescription: string | null; + description: string; + heroSlug: string | null; + minorBlessingPredggIds: unknown; + perk: { predggId: string; slug: string }; +}; + +function jsonArray(value: unknown): T[] { + return Array.isArray(value) ? value as T[] : []; +} + +function share(value: number, total: number): number { + return total > 0 ? Math.round((value / total) * 1000) / 10 : 0; +} + +function semanticTags(item: { + displayName: string; + aggressionType: string | null; + stats: Array<{ stat: string }>; + effects: Array<{ name: string; text: string; condition: string | null }>; +}): Set { + const tags = new Set(); + if (item.aggressionType) tags.add(item.aggressionType); + const stats = new Set(item.stats.map((stat) => stat.stat)); + if (stats.has('PHYSICAL_ARMOR')) tags.add('ARMOR'); + if (stats.has('MAGICAL_ARMOR')) tags.add('ANTI_MAGIC'); + const text = [item.displayName, ...item.effects.flatMap((effect) => [effect.name, effect.text, effect.condition ?? ''])] + .join(' ') + .replace(/<[^>]+>/g, ' ') + .toLowerCase(); + if (/healing.*reduc|reduce.*healing|anti.?heal|tainted/.test(text)) tags.add('ANTI_HEAL'); + if (/shield.*reduc|reduce.*shield|anti.?shield/.test(text)) tags.add('ANTI_SHIELD'); + if (/spell shield|block.*ability/.test(text)) tags.add('SPELL_SHIELD'); + if (/critical.*reduc|reduce.*critical|anti.?crit/.test(text)) tags.add('ANTI_CRIT'); + if (/armor.*shred|shred.*physical.*armor|reduce.*armor|physical penetration/.test(text)) tags.add('PHYSICAL_SHRED'); + if (/magical armor.*reduc|shred.*magical.*armor|magical penetration/.test(text)) tags.add('MAGICAL_SHRED'); + if (/maximum health|bonus health|current health/.test(text)) tags.add('ANTI_TANK'); + if (/\bheal(?:s|ed|ing)?\b|health regeneration|\bomnivamp\b|\blifesteal\b/.test(text) && !tags.has('ANTI_HEAL')) tags.add('SUSTAIN'); + if (/shield/.test(text) && !tags.has('ANTI_SHIELD')) tags.add('SHIELD'); + if (/movement speed|dash|blink|leap/.test(text)) tags.add('MOBILITY'); + if (/crowd control|stun|slow|root|knock/.test(text)) tags.add('CONTROL'); + if (/basic attack|critical strike/.test(text)) tags.add('BASIC_ATTACK'); + if (/ability damage|magical power|ability haste|cooldown/.test(text)) tags.add('ABILITY_DAMAGE'); + if (/deal.*damage|damage.*target/.test(text)) tags.add('DAMAGE'); + if (/attack speed|on.?hit|damage over time|every basic attack|basic attacks.*stack/.test(text)) tags.add('DPS'); + if (/burst|execute|missing health|below.*health|next ability.*damage/.test(text)) tags.add('BURST'); + if (/projectile|range|from a distance|above.*health/.test(text)) tags.add('POKE'); + if (/\bdash(?:es|ed)?\b|\bblink\b|\bleap(?:s|ed)?\b|\bcharge(?:s|d)?\s+(?:forward|toward|to)\b|\bpull.*toward|\bteleport/.test(text)) tags.add('ENGAGE'); + if (/knockback|knock up|slow|stun|root|shield.*all|nearby allies/.test(text)) tags.add('PEEL'); + if (/area|nearby enemies|all enemies|multiple enemies|radius/.test(text)) tags.add('AOE'); + if (/tenacity|crowd control duration/.test(text)) tags.add('TENACITY'); + if (/ability haste|cooldown/.test(text)) tags.add('HASTE'); + if (/nearby allies|allied heroes|your team|teammates/.test(text)) tags.add('TEAM_UTILITY'); + if (stats.has('ATTACK_SPEED')) tags.add('DPS'); + if (stats.has('CRITICAL_STRIKE_CHANCE')) tags.add('BURST'); + if (stats.has('MAX_HEALTH') || stats.has('PHYSICAL_ARMOR') || stats.has('MAGICAL_ARMOR')) tags.add('DURABILITY'); + return tags; +} + +const CONCEPT_DETAILS: Record = { + CONTROL: { label: 'Control de masas', description: 'Interrumpe, inmoviliza o limita el movimiento para crear ventanas de daño.' }, + ENGAGE: { label: 'Iniciación', description: 'Permite empezar la pelea o alcanzar un objetivo prioritario.' }, + PEEL: { label: 'Protección y peel', description: 'Aleja amenazas o protege al aliado que debe seguir haciendo daño.' }, + DPS: { label: 'Daño sostenido', description: 'Aumenta el daño repetido en peleas largas, normalmente mediante ataques, acumulaciones o efectos por segundo.' }, + BURST: { label: 'Daño explosivo', description: 'Concentra mucho daño en una ventana corta para eliminar un objetivo antes de que responda.' }, + POKE: { label: 'Poke', description: 'Desgasta desde una distancia segura antes de comprometer la pelea.' }, + SUSTAIN: { label: 'Sustain', description: 'Recupera vida o mantiene al héroe activo durante intercambios prolongados.' }, + SHIELD: { label: 'Escudos', description: 'Añade vida temporal durante la ventana crítica de daño rival.' }, + MOBILITY: { label: 'Movilidad', description: 'Facilita entrar, reposicionarse o escapar.' }, + DURABILITY: { label: 'Resistencia', description: 'Aumenta el tiempo que el héroe puede permanecer en combate.' }, + HASTE: { label: 'Frecuencia de habilidades', description: 'Reduce el tiempo entre rotaciones y permite repetir antes el control, daño o utilidad.' }, + TEAM_UTILITY: { label: 'Utilidad de equipo', description: 'Convierte una compra individual en valor compartido con los aliados.' }, + ANTI_HEAL: { label: 'Anti-curación', description: 'Reduce la recuperación de vida rival durante la pelea.' }, + ANTI_SHIELD: { label: 'Anti-escudo', description: 'Reduce el valor de los escudos rivales.' }, + ARMOR: { label: 'Defensa física', description: 'Reduce el daño físico recibido.' }, + ANTI_MAGIC: { label: 'Defensa mágica', description: 'Reduce el daño mágico recibido.' }, + PHYSICAL_SHRED: { label: 'Shred físico', description: 'Reduce la armadura física del objetivo para aumentar el daño posterior.' }, + MAGICAL_SHRED: { label: 'Shred mágico', description: 'Reduce la resistencia mágica del objetivo para aumentar el daño posterior.' }, +}; + +const GLOBAL_CONCEPT_KEYS = new Set(Object.keys(CONCEPT_DETAILS)); + +function conceptsFor(item: Parameters[0]): string[] { + return [...semanticTags(item)].filter((tag) => GLOBAL_CONCEPT_KEYS.has(tag)); +} + +function formatHeroList(players: Array<{ heroSlug: string; value: number }>): string { + return players + .filter((player) => player.value > 0) + .sort((a, b) => b.value - a.value) + .slice(0, 3) + .map((player) => `${player.heroSlug} (${player.value.toLocaleString()})`) + .join(', '); +} + +function tagPurpose(tag: string): string { + const labels: Record = { + ANTI_HEAL: 'reduce la curación rival', ANTI_SHIELD: 'castiga los escudos', + ARMOR: 'reduce el daño físico', ANTI_CRIT: 'responde al daño crítico', + ANTI_MAGIC: 'reduce el daño mágico', SPELL_SHIELD: 'protege frente a una habilidad decisiva', + ANTI_TANK: 'escala contra mucha vida', PHYSICAL_SHRED: 'reduce la armadura física', + MAGICAL_SHRED: 'reduce la resistencia mágica', SHRED: 'reduce las defensas rivales', + SUSTAIN: 'aporta aguante sostenido', SHIELD: 'aporta protección', MOBILITY: 'mejora la movilidad', + CONTROL: 'mejora el control', BASIC_ATTACK: 'potencia los ataques básicos', ABILITY_DAMAGE: 'potencia las habilidades', + DAMAGE: 'aumenta el daño', DPS: 'mejora el daño sostenido', BURST: 'refuerza el daño explosivo', + POKE: 'mejora el desgaste a distancia', ENGAGE: 'facilita la iniciación', PEEL: 'protege a los aliados', + AOE: 'aumenta el impacto en área', TENACITY: 'reduce el control recibido', HASTE: 'permite repetir antes las habilidades', + TEAM_UTILITY: 'aporta utilidad al equipo', DURABILITY: 'aumenta la resistencia', + }; + return labels[tag] ?? tag.toLowerCase().replaceAll('_', ' '); +} + +function minuteLabel(gameTime: number): string { + const minutes = Math.floor(gameTime / 60); + const seconds = Math.max(0, gameTime % 60); + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function normalizeCatalogKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function plainText(value: string): string { + return value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function educationalExplanation(key: string): { whyItMatters: string; appliesAgainst: string[] } | null { + const explanations: Record = { + 'anti-heal': { + whyItMatters: 'La curación borra parte del daño que tu equipo ya ha invertido y permite al rival alargar o reiniciar la pelea. Aplicar Heridas Graves antes de su curación reduce la vida que recupera; comprar anti-heal después de que ya se haya curado llega tarde.', + appliesAgainst: ['Akeron', 'Countess', 'Narbash', 'Khaimera', 'Phase', 'Rampage'], + }, + 'anti-shield': { + whyItMatters: 'Un escudo añade vida temporal justo durante la ventana en la que intentas asegurar una eliminación. Una respuesta anti-escudo evita que ese valor defensivo invalide tu control y el burst coordinado del equipo.', + appliesAgainst: ['Kwang', 'Muriel', 'Phase'], + }, + 'anti-tank': { + whyItMatters: 'Contra mucha mitigación, añadir más daño plano ofrece un rendimiento decreciente. La penetración o el shred reducen primero la defensa rival y hacen que el daño posterior de todo el equipo sea más efectivo.', + appliesAgainst: ['Steel', 'Riktor', 'Sevarog', 'Rampage', 'Kwang'], + }, + 'physical-defense': { + whyItMatters: 'La armadura reduce cada instancia de daño físico. Adelantarla cuando ese tipo domina el daño recibido te da más tiempo vivo para lanzar otra rotación de habilidades.', + appliesAgainst: ['Murdock', 'Sparrow', 'Drongo', 'Kallari', 'Grux'], + }, + 'magical-defense': { + whyItMatters: 'La resistencia mágica y los escudos de hechizo amortiguan el burst de habilidades. Son especialmente valiosos si una sola rotación rival te obliga a abandonar la pelea.', + appliesAgainst: ['Countess', 'Gideon', 'Morigesh', 'Argus', 'Howitzer'], + }, + }; + return explanations[key] ?? null; +} + +function conceptResponse(key: string): string { + const responses: Record = { + CONTROL: 'Valora tenacidad, escudo de hechizo, posicionamiento y una pieza de peel; no todo el problema se resuelve comprando daño.', + ENGAGE: 'Reserva movilidad o una herramienta defensiva para la entrada rival y evita gastar el peel antes de que inicien.', + PEEL: 'No fuerces un engage largo si el rival conserva sus herramientas de protección; provoca primero esos enfriamientos.', + DPS: 'Acorta la pelea con control y burst coordinado o construye resistencia sostenida; una defensa de un solo uso puede no ser suficiente.', + BURST: 'Prioriza vida efectiva, resistencia apropiada o escudo de hechizo y evita entrar sin visión en su ventana de daño.', + POKE: 'Añade sustain o movilidad y llega a los objetivos con tiempo para no empezar la pelea ya desgastado.', + SUSTAIN: 'Aplica anti-curación antes de su ventana de recuperación y coordina el foco para que no pueda reiniciar la pelea.', + SHIELD: 'Sincroniza el burst después del escudo o incorpora anti-escudo cuando ese valor se repite en cada pelea.', + MOBILITY: 'Guarda control fiable para después de su desplazamiento y evita depender solo de habilidades lineales.', + DURABILITY: 'La penetración, el shred y el daño porcentual ganan valor; repetir daño plano ofrece cada vez menos rendimiento.', + HASTE: 'Espera rotaciones más frecuentes y evita reentrar pensando que sus habilidades clave siguen en enfriamiento.', + TEAM_UTILITY: 'Identifica qué aliado recibe el aura o la protección y decide si debes separar la pelea o eliminar primero al facilitador.', + }; + return responses[key] ?? 'Adapta la siguiente compra y la forma de ejecutar la pelea a este patrón, no solo a las estadísticas finales.'; +} + +function perkScore(perk: CatalogPerk, role: string | null, enemyMitigation: number, enablesHealingOrShielding: boolean): number { + const text = plainText(`${perk.displayName} ${perk.simpleDescription ?? ''} ${perk.description}`).toLowerCase(); + let score = 0; + if (enemyMitigation >= 80_000 && /(shred|reduce)[^.]{0,50}(armor|defen)|rust/.test(text)) score += 10; + if (/stun|immobil|crowd control|movement speed/.test(text)) score += role === 'SUPPORT' ? 4 : 2; + if (/ability haste|cooldown|ultimate haste/.test(text)) score += 3; + if (/shield allies|allied hero/.test(text)) score += role === 'SUPPORT' ? 4 : 1; + if (/\bheal(?:s|ed|ing)?\b|\bshield(?:s|ed|ing)?\b/.test(text) && enablesHealingOrShielding) score += 3; + if (role === 'SUPPORT' && /units? killed|minions? killed/.test(text)) score -= 10; + if (role === 'SUPPORT' && /basic attacks?/.test(text) && !/abilities/.test(text)) score -= 2; + return score; +} + +function perkRecommendation(perk: CatalogPerk, current: LoadoutPerk | undefined, reason: string) { + return { + id: perk.perk.predggId, + slug: perk.perk.slug, + displayName: perk.displayName, + slot: perk.slot, + icon: perk.icon, + effect: perk.simpleDescription ?? perk.description, + reason, + replaces: current && current.displayName !== perk.displayName + ? { id: current.id, displayName: current.displayName } + : null, + }; +} + +function perkAssessment(perk: LoadoutPerk, player: { + deaths: number; + role: string | null; + heroDamage: number | null; + totalHealingDone: number | null; + totalShieldingReceived: number | null; +}, needTags: Set) { + const text = `${perk.displayName} ${perk.simpleDescription ?? ''} ${perk.description ?? ''}`; + const tags = semanticTags({ displayName: text, aggressionType: null, stats: [], effects: [] }); + const matchedNeed = [...tags].find((tag) => needTags.has(tag)); + const slot = perk.slot.toUpperCase(); + let verdict: 'correct' | 'conditional' | 'questionable' = 'conditional'; + let why = `Su efecto se orienta a ${[...tags].slice(0, 2).map(tagPurpose).join(' y ') || 'una ventaja situacional'}.`; + + if (slot.includes('HERO_SPECIFIC') || slot.includes('AUGMENT')) { + verdict = 'correct'; + why = `Es un Augmento propio del héroe y su efecto (${[...tags].slice(0, 2).map(tagPurpose).join(' y ') || 'mejora de habilidad'}) se activa jugando su patrón normal. Es una elección coherente; para llamarla óptima habría que compararla con los otros Augmentos disponibles.`; + } else if (player.role === 'SUPPORT' && /units?[^.]{0,30}killed|minions?[^.]{0,30}killed/.test(text.toLowerCase())) { + verdict = 'questionable'; + why = 'Escala consiguiendo súbditos o unidades, pero como Support no deberías apropiarte del farmeo del Carry. Alcanzará su pico tarde y otra opción de utilidad o impacto inmediato suele aprovecharse mejor.'; + } else if (player.role === 'SUPPORT' && tags.has('BASIC_ATTACK') && tags.has('SUSTAIN')) { + verdict = 'conditional'; + why = 'Da sustain mediante ataques básicos. Puede ayudar en línea, pero un Support con poco daño básico obtiene menos valor que un héroe de Carry; es correcta solo si realmente necesitas aguante temprano.'; + } else if (player.role === 'SUPPORT' && tags.has('DAMAGE') && /above.*health|health.*above/.test(text.toLowerCase())) { + verdict = 'correct'; + why = 'Encaja con un Support de poke: premia golpear al rival antes de que empiece la pelea y no exige quitar farmeo a tus aliados.'; + } else if (matchedNeed) { + verdict = 'correct'; + why = `Encaja con esta partida porque ${tagPurpose(matchedNeed)}, una necesidad visible frente a la composición rival.`; + } else if (tags.has('SUSTAIN') && (player.totalHealingDone ?? 0) > 2_000) { + verdict = 'correct'; + why = `Fue coherente: el efecto de sustain sí tuvo uso y registraste ${(player.totalHealingDone ?? 0).toLocaleString()} de curación.`; + } else if (tags.has('SHIELD') && (player.totalShieldingReceived ?? 0) > 4_000) { + verdict = 'correct'; + why = `Fue coherente con peleas prolongadas: recibiste ${(player.totalShieldingReceived ?? 0).toLocaleString()} de escudos.`; + } else if ((tags.has('ABILITY_DAMAGE') || tags.has('BASIC_ATTACK')) && (player.heroDamage ?? 0) > 15_000) { + verdict = 'correct'; + why = `La orientación ofensiva produjo valor: terminaste con ${(player.heroDamage ?? 0).toLocaleString()} de daño a héroes.`; + } else if ((tags.has('SUSTAIN') || tags.has('SHIELD')) && player.deaths >= 7) { + verdict = 'questionable'; + why = `La idea defensiva era razonable, pero ${player.deaths} muertes indican que no resolvió el principal patrón de riesgo; convenía priorizar una respuesta más específica.`; + } + + return { ...perk, verdict, why, effect: perk.simpleDescription ?? perk.description ?? null }; +} + +function opponentPurchaseExplanation(heroSlug: string, itemName: string, tags: Set): string { + if (tags.has('ARMOR')) return `${heroSlug} compró ${itemName} para reducir el daño físico. Desde ese minuto, el daño físico plano pierde valor y la penetración o el shred ganan prioridad.`; + if (tags.has('ANTI_MAGIC') || tags.has('SPELL_SHIELD')) return `${heroSlug} compró ${itemName} para resistir daño mágico. Si es uno de tus objetivos principales, tu siguiente pico ofensivo debe incluir penetración mágica o shred.`; + if (tags.has('ANTI_HEAL')) return `${heroSlug} compró ${itemName} para reducir la curación. El rival estaba intentando cortar el sustain de tu equipo; no conviene depender únicamente de curas para sobrevivir.`; + if (tags.has('ANTI_TANK')) return `${heroSlug} compró ${itemName} para castigar objetivos con mucha vida. Si tu primera línea estaba acumulando salud, necesitaba combinarla con resistencias en lugar de seguir apilando solo vida.`; + if (tags.has('PHYSICAL_SHRED') || tags.has('MAGICAL_SHRED') || tags.has('SHRED')) return `${heroSlug} compró ${itemName} para reducir defensas. A partir de ese minuto, las peleas largas favorecían su daño y era más importante proteger al objetivo que estaba debilitando.`; + return `${heroSlug} completó ${itemName} en ese momento; era una señal para revisar la siguiente compra antes de volver a base.`; +} + +export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: string) { + const row = await db.matchPlayer.findFirst({ + where: { id: matchPlayerId, matchId }, + include: { + match: { + select: { + versionId: true, + winningTeam: true, + matchPlayers: { + select: { + id: true, team: true, role: true, heroSlug: true, totalHealingDone: true, + totalShieldingReceived: true, totalDamageMitigated: true, playerId: true, playerName: true, + inventoryItems: true, + }, + }, + }, + }, + }, + }); + if (!row) throw new AppError(404, 'Match player not found', 'MATCH_PLAYER_NOT_FOUND'); + + const enemies = row.match.matchPlayers.filter((player) => player.team !== row.team); + const inventorySlugs = jsonArray(row.inventoryItems).filter(Boolean); + const enemyInventorySlugs = enemies.flatMap((enemy) => jsonArray(enemy.inventoryItems)).filter(Boolean); + const catalogSlugs = [...new Set([...inventorySlugs, ...enemyInventorySlugs])]; + let catalogVersionId = row.match.versionId; + let catalogItems = catalogVersionId && catalogSlugs.length > 0 + ? await db.gameItem.findMany({ + where: { slug: { in: catalogSlugs } }, + include: { versions: { where: { versionId: catalogVersionId }, take: 1 } }, + }) + : []; + if (catalogVersionId && catalogSlugs.length > 0 && catalogItems.every((item) => item.versions.length === 0)) { + const matchVersion = await db.version.findUnique({ where: { id: catalogVersionId }, select: { releaseDate: true } }); + const fallback = matchVersion ? await db.version.findFirst({ + where: { releaseDate: { lte: matchVersion.releaseDate }, itemVersions: { some: {} } }, + orderBy: { releaseDate: 'desc' }, + select: { id: true }, + }) : null; + if (fallback) { + catalogVersionId = fallback.id; + catalogItems = await db.gameItem.findMany({ + where: { slug: { in: catalogSlugs } }, + include: { versions: { where: { versionId: catalogVersionId }, take: 1 } }, + }); + } + } + + const allCatalogItems = catalogItems.map((item) => { + const data = item.versions[0]; + return { + predggId: item.predggId, + predggDataId: data?.predggDataId ?? null, + slug: item.slug, + displayName: data?.displayName ?? item.name, + aggressionType: data?.aggressionType ?? null, + rarity: data?.rarity ?? null, + slotType: data?.slotType ?? null, + isEvolved: data?.isEvolved ?? false, + isHidden: data?.isHidden ?? false, + stats: jsonArray<{ stat: string; value: number; showPercent: boolean }>(data?.stats), + effects: jsonArray<{ name: string; text: string; active: boolean; condition: string | null; cooldown: string | null }>(data?.effects), + blocksIds: jsonArray(data?.blocksIds), + blockedByIds: jsonArray(data?.blockedByIds), + }; + }); + const catalogItemBySlug = new Map(allCatalogItems.map((item) => [item.slug, item])); + const inventory = inventorySlugs.flatMap((slug) => { + const item = catalogItemBySlug.get(slug); + return item ? [item] : []; + }); + const tags = new Set(inventory.flatMap((item) => [...semanticTags(item)])); + const statNames = new Set(inventory.flatMap((item) => item.stats.map((stat) => stat.stat))); + + const heroMetadata = await db.heroMeta.findMany({ + where: { slug: { in: [...new Set([row.heroSlug, ...enemies.map((enemy) => enemy.heroSlug)])] } }, + select: { slug: true, displayName: true, abilities: true }, + }); + const heroMetaBySlug = new Map(heroMetadata.map((hero) => [hero.slug, hero])); + + const physical = row.physicalDamageTakenFromHeroes ?? row.physicalDamageTaken ?? 0; + const magical = row.magicalDamageTakenFromHeroes ?? row.magicalDamageTaken ?? 0; + const trueDamage = row.trueDamageTakenFromHeroes ?? row.trueDamageTaken ?? 0; + const damageTotal = physical + magical + trueDamage; + const enemyHealing = enemies.reduce((sum, player) => sum + (player.totalHealingDone ?? 0), 0); + const enemyShielding = enemies.reduce((sum, player) => sum + (player.totalShieldingReceived ?? 0), 0); + const enemyMitigation = enemies.reduce((sum, player) => sum + (player.totalDamageMitigated ?? 0), 0); + const healingHeroes = formatHeroList(enemies.map((player) => ({ heroSlug: player.heroSlug, value: player.totalHealingDone ?? 0 }))); + const shieldedHeroes = formatHeroList(enemies.map((player) => ({ heroSlug: player.heroSlug, value: player.totalShieldingReceived ?? 0 }))); + const resistantHeroes = formatHeroList(enemies.map((player) => ({ heroSlug: player.heroSlug, value: player.totalDamageMitigated ?? 0 }))); + const playerAbilityConcepts = new Map(); + for (const ability of jsonArray(heroMetaBySlug.get(row.heroSlug)?.abilities)) { + const description = plainText(ability.game_description ?? ability.menu_description ?? ''); + if (!description) continue; + for (const concept of conceptsFor({ displayName: description, aggressionType: null, stats: [], effects: [] })) { + const evidence = playerAbilityConcepts.get(concept) ?? []; + evidence.push(ability.display_name ?? ability.key ?? 'Habilidad'); + playerAbilityConcepts.set(concept, evidence); + } + } + const roleConcepts: Record = { + SUPPORT: ['TEAM_UTILITY', 'PEEL', 'CONTROL'], CARRY: ['DPS', 'BURST'], + JUNGLE: ['ENGAGE', 'DPS'], MIDLANE: ['POKE', 'BURST', 'CONTROL'], + OFFLANE: ['DURABILITY', 'ENGAGE'], + }; + for (const concept of roleConcepts[row.role ?? ''] ?? []) { + if (!playerAbilityConcepts.has(concept)) playerAbilityConcepts.set(concept, [`Responsabilidad de ${row.role?.toLowerCase()}`]); + } + + type GlobalSource = { heroSlug: string; sourceType: 'ability' | 'item'; name: string; description: string }; + const enemyConceptSources = new Map(); + const addEnemyConcept = (concept: string, source: GlobalSource) => { + const values = enemyConceptSources.get(concept) ?? []; + if (!values.some((value) => value.heroSlug === source.heroSlug && value.name === source.name)) values.push(source); + enemyConceptSources.set(concept, values); + }; + for (const enemy of enemies) { + const meta = heroMetaBySlug.get(enemy.heroSlug); + for (const ability of jsonArray(meta?.abilities)) { + const description = plainText(ability.game_description ?? ability.menu_description ?? ''); + if (!description) continue; + for (const concept of conceptsFor({ displayName: description, aggressionType: null, stats: [], effects: [] })) { + addEnemyConcept(concept, { heroSlug: enemy.heroSlug, sourceType: 'ability', name: ability.display_name ?? ability.key ?? 'Habilidad', description }); + } + } + for (const slug of jsonArray(enemy.inventoryItems)) { + const item = catalogItemBySlug.get(slug); + if (!item) continue; + const description = plainText(item.effects.map((effect) => `${effect.name}: ${effect.text}`).join(' ')); + for (const concept of conceptsFor(item)) { + addEnemyConcept(concept, { heroSlug: enemy.heroSlug, sourceType: 'item', name: item.displayName, description }); + } + } + } + const threatPriority = ['SUSTAIN', 'SHIELD', 'CONTROL', 'ENGAGE', 'BURST', 'DPS', 'POKE', 'MOBILITY', 'DURABILITY', 'HASTE', 'TEAM_UTILITY']; + const enemyBySlug = new Map(enemies.map((enemy) => [enemy.heroSlug, enemy])); + const enemyThreats = threatPriority.flatMap((key) => { + const sources = [...(enemyConceptSources.get(key) ?? [])].sort((a, b) => { + const aEnemy = enemyBySlug.get(a.heroSlug); + const bEnemy = enemyBySlug.get(b.heroSlug); + const aImpact = key === 'SUSTAIN' ? aEnemy?.totalHealingDone ?? 0 : key === 'SHIELD' ? aEnemy?.totalShieldingReceived ?? 0 : 0; + const bImpact = key === 'SUSTAIN' ? bEnemy?.totalHealingDone ?? 0 : key === 'SHIELD' ? bEnemy?.totalShieldingReceived ?? 0 : 0; + return bImpact - aImpact || (a.sourceType === 'ability' ? -1 : 1); + }); + const telemetryValue = key === 'SUSTAIN' ? enemyHealing : key === 'SHIELD' ? enemyShielding : key === 'DURABILITY' ? enemyMitigation : 0; + const visible = sources.length > 0 || telemetryValue > 0; + if (!visible) return []; + const detail = CONCEPT_DETAILS[key]; + const critical = (key === 'SUSTAIN' && enemyHealing >= 25_000) || (key === 'SHIELD' && enemyShielding >= 25_000) || (key === 'DURABILITY' && enemyMitigation >= 150_000); + const evidence = key === 'SUSTAIN' + ? `${enemyHealing.toLocaleString()} de curación rival registrada.` + : key === 'SHIELD' + ? `${enemyShielding.toLocaleString()} de escudos recibidos por el rival.` + : key === 'DURABILITY' + ? `${enemyMitigation.toLocaleString()} de daño mitigado por el equipo rival.` + : `${sources.length} habilidades u objetos rivales muestran este patrón.`; + return [{ key, label: detail.label, description: detail.description, severity: critical ? 'critical' as const : 'warning' as const, evidence, response: conceptResponse(key), sources: sources.slice(0, 5) }]; + }).slice(0, 8); + const signals: BuildSignal[] = []; + + if (damageTotal > 0 && share(physical, damageTotal) >= 60 && !tags.has('ARMOR') && !tags.has('ANTI_CRIT') && !statNames.has('PHYSICAL_ARMOR')) { + signals.push({ + key: 'physical-defense', severity: row.deaths >= 6 ? 'critical' : 'warning', title: 'Faltó respuesta al daño físico', + evidence: `${share(physical, damageTotal)}% del daño de héroes recibido fue físico.`, + recommendation: 'Considera adelantar armadura o una respuesta anti-crítico si el carry rival está acelerado.', + desiredTags: ['ARMOR', 'ANTI_CRIT', 'SUSTAINED_DURABILITY'], + }); + } + if (damageTotal > 0 && share(magical, damageTotal) >= 60 && !tags.has('ANTI_MAGIC') && !tags.has('SPELL_SHIELD') && !statNames.has('MAGICAL_ARMOR')) { + signals.push({ + key: 'magical-defense', severity: row.deaths >= 6 ? 'critical' : 'warning', title: 'Faltó respuesta al daño mágico', + evidence: `${share(magical, damageTotal)}% del daño de héroes recibido fue mágico.`, + recommendation: 'Añade resistencia mágica, anti-magia o escudo de hechizo antes de completar otra pieza ofensiva.', + desiredTags: ['ANTI_MAGIC', 'SPELL_SHIELD', 'ANTI_BURST'], + }); + } + if (enemyHealing >= 12_000 && !tags.has('ANTI_HEAL')) { + signals.push({ + key: 'anti-heal', severity: enemyHealing >= 25_000 ? 'critical' : 'warning', title: 'El rival tuvo demasiada curación sin anti-heal', + evidence: `La composición rival acumuló ${enemyHealing.toLocaleString()} de curación${healingHeroes ? `; destacaron ${healingHeroes}` : ''}.`, + recommendation: 'Introduce anti-curación antes del siguiente pico de pelea del rival.', desiredTags: ['ANTI_HEAL'], + }); + } + if (enemyShielding >= 15_000 && !tags.has('ANTI_SHIELD')) { + signals.push({ + key: 'anti-shield', severity: 'warning', title: 'La composición rival generó muchos escudos', + evidence: `Los rivales recibieron ${enemyShielding.toLocaleString()} de escudos${shieldedHeroes ? `; el valor se concentró en ${shieldedHeroes}` : ''}.`, + recommendation: 'Valora una respuesta anti-escudo si el valor procede de peleas repetidas y no de daño incidental.', desiredTags: ['ANTI_SHIELD'], + }); + } + if (enemyMitigation >= 80_000 && !['ANTI_TANK', 'SHRED', 'PHYSICAL_SHRED', 'MAGICAL_SHRED'].some((tag) => tags.has(tag))) { + signals.push({ + key: 'anti-tank', severity: 'warning', title: 'Faltó penetración contra una primera línea resistente', + evidence: `El equipo rival mitigó ${enemyMitigation.toLocaleString()} de daño${resistantHeroes ? `; la primera línea más resistente fue ${resistantHeroes}` : ''}.`, + recommendation: 'Adapta una pieza a anti-tanque, penetración o shred en lugar de repetir daño plano.', + desiredTags: ['ANTI_TANK', 'PHYSICAL_SHRED', 'MAGICAL_SHRED', 'SHRED'], + }); + } + + const selectedIds = new Set(inventory.map((item) => item.predggDataId).filter((id): id is string => Boolean(id))); + const conflict = inventory.find((item) => [...item.blocksIds, ...item.blockedByIds] + .some((id) => id !== item.predggDataId && selectedIds.has(id))); + if (conflict) { + signals.push({ + key: 'item-conflict', severity: 'critical', title: 'La build contiene objetos incompatibles', + evidence: `${conflict.displayName} bloquea o es bloqueado por otra pieza del inventario.`, + recommendation: 'Revisa el orden de compra y sustituye una de las piezas para no perder valor.', desiredTags: [], + }); + } + + if (signals.length === 0) { + signals.push({ + key: 'adaptation-ok', severity: 'info', title: 'La build no muestra una carencia contextual evidente', + evidence: damageTotal > 0 ? `Reparto recibido: ${share(physical, damageTotal)}% físico, ${share(magical, damageTotal)}% mágico y ${share(trueDamage, damageTotal)}% verdadero.` : 'La telemetría de daño recibido todavía es insuficiente.', + recommendation: 'Valida ahora el momento de compra y si cada pico llegó antes de las peleas decisivas.', desiredTags: [], + }); + } + + for (const signal of signals) { + const lesson = educationalExplanation(signal.key); + if (lesson) Object.assign(signal, lesson); + if (!['anti-heal', 'anti-shield'].includes(signal.key)) continue; + const pattern = signal.key === 'anti-heal' + ? /\bheal(?:s|ed|ing)?\b|\brestore[^.]{0,30}\bhealth\b|\blifesteal\b|\bomnivamp\b/i + : /shield/i; + const sources: NonNullable = []; + const relevantEnemies = [...enemies].sort((a, b) => { + const aValue = signal.key === 'anti-heal' ? a.totalHealingDone : a.totalShieldingReceived; + const bValue = signal.key === 'anti-heal' ? b.totalHealingDone : b.totalShieldingReceived; + return (bValue ?? 0) - (aValue ?? 0); + }); + for (const enemy of relevantEnemies) { + const relevantValue = signal.key === 'anti-heal' ? enemy.totalHealingDone : enemy.totalShieldingReceived; + if ((relevantValue ?? 0) <= 0) continue; + const meta = heroMetaBySlug.get(enemy.heroSlug); + for (const ability of jsonArray(meta?.abilities)) { + const description = plainText(ability.game_description ?? ability.menu_description ?? ''); + if (!description || !pattern.test(description) || /reduc[^.]{0,30}heal|heal[^.]{0,30}reduc/i.test(description)) continue; + sources.push({ + heroSlug: enemy.heroSlug, + sourceType: 'ability', + name: ability.display_name ?? ability.key ?? 'Habilidad', + description, + }); + } + } + for (const enemy of relevantEnemies) { + const relevantValue = signal.key === 'anti-heal' ? enemy.totalHealingDone : enemy.totalShieldingReceived; + if ((relevantValue ?? 0) <= 0) continue; + for (const slug of jsonArray(enemy.inventoryItems)) { + const item = catalogItemBySlug.get(slug); + if (!item) continue; + const matchingEffects = item.effects.filter((effect) => { + const description = plainText(`${effect.name} ${effect.text}`); + return pattern.test(description) && !/reduc[^.]{0,30}heal|heal[^.]{0,30}reduc/i.test(description); + }); + for (const effect of matchingEffects) { + sources.push({ + heroSlug: enemy.heroSlug, + sourceType: 'item', + name: item.displayName, + description: plainText(`${effect.name ? `${effect.name}: ` : ''}${effect.text}`), + }); + } + } + } + signal.sources = sources.slice(0, 8); + } + + const desiredTags = [...new Set(signals.flatMap((signal) => signal.desiredTags))]; + let candidates: Array<{ + displayName: string; aggressionType: string | null; totalPrice: number; + stats: unknown; effects: unknown; item: { slug: string; name?: string }; + }> = []; + if (catalogVersionId && desiredTags.length > 0) { + candidates = await db.gameItemVersion.findMany({ + where: { + versionId: catalogVersionId, isHidden: false, rarity: 'EPIC', slotType: 'PASSIVE', + }, + include: { item: { select: { slug: true, name: true } } }, + orderBy: { totalPrice: 'asc' }, + }); + for (const signal of signals) { + signal.suggestedItems = candidates + .filter((candidate) => { + const candidateTags = semanticTags({ + displayName: candidate.displayName, + aggressionType: candidate.aggressionType, + stats: jsonArray<{ stat: string }>(candidate.stats), + effects: jsonArray<{ name: string; text: string; condition: string | null }>(candidate.effects), + }); + return signal.desiredTags.some((tag) => candidateTags.has(tag)); + }) + .slice(0, 3) + .map((candidate) => { + const candidateTags = semanticTags({ + displayName: candidate.displayName, + aggressionType: candidate.aggressionType, + stats: jsonArray<{ stat: string }>(candidate.stats), + effects: jsonArray<{ name: string; text: string; condition: string | null }>(candidate.effects), + }); + const matched = signal.desiredTags.find((tag) => candidateTags.has(tag)); + return { + slug: candidate.item.slug, + displayName: candidate.displayName, + aggressionType: candidate.aggressionType, + totalPrice: candidate.totalPrice, + stats: jsonArray<{ stat: string; value: number; showPercent?: boolean }>(candidate.stats), + effects: jsonArray<{ name: string; text: string; condition?: string | null; cooldown?: string | null }>(candidate.effects), + reason: matched ? `${candidate.displayName} ${tagPurpose(matched)}; responde directamente a esta amenaza.` : 'Aporta una respuesta situacional a esta amenaza.', + }; + }); + } + } + + const activeSignals = signals.filter((signal) => signal.key !== 'adaptation-ok'); + const needTagSet = new Set(desiredTags); + const contextualNeedTags = new Set(desiredTags); + if (enemyHealing >= 12_000) contextualNeedTags.add('ANTI_HEAL'); + if (enemyShielding >= 15_000) contextualNeedTags.add('ANTI_SHIELD'); + if (enemyMitigation >= 80_000) ['ANTI_TANK', 'SHRED', 'PHYSICAL_SHRED', 'MAGICAL_SHRED'].forEach((tag) => contextualNeedTags.add(tag)); + if (damageTotal > 0 && share(physical, damageTotal) >= 40) ['ARMOR', 'ANTI_CRIT'].forEach((tag) => contextualNeedTags.add(tag)); + if (damageTotal > 0 && share(magical, damageTotal) >= 40) ['ANTI_MAGIC', 'SPELL_SHIELD'].forEach((tag) => contextualNeedTags.add(tag)); + const finalInventory = inventory.filter((item) => item.rarity === 'EPIC' && item.slotType === 'PASSIVE'); + const identityConcepts = new Set(playerAbilityConcepts.keys()); + const buildConceptItems = new Map(); + for (const item of finalInventory) { + for (const concept of conceptsFor(item)) { + const itemNames = buildConceptItems.get(concept) ?? []; + itemNames.push(item.displayName); + buildConceptItems.set(concept, itemNames); + } + } + const aggressionCounts = new Map(); + for (const item of finalInventory) { + if (item.aggressionType) aggressionCounts.set(item.aggressionType, (aggressionCounts.get(item.aggressionType) ?? 0) + 1); + } + const inventoryAssessments = finalInventory.map((item) => { + const itemTags = semanticTags(item); + const matched = [...itemTags].filter((tag) => contextualNeedTags.has(tag)); + const functions = conceptsFor(item).map((concept) => ({ key: concept, ...CONCEPT_DETAILS[concept] })); + const identityMatches = functions.filter((concept) => identityConcepts.has(concept.key)); + const repeatsPurpose = item.aggressionType ? (aggressionCounts.get(item.aggressionType) ?? 0) > 1 : false; + return { + slug: item.slug, + displayName: item.displayName, + verdict: matched.length > 0 ? 'correct' as const : activeSignals.length > 0 ? 'neutral' as const : 'correct' as const, + purpose: [...itemTags].slice(0, 3).map(tagPurpose), + functions, + roleFit: identityMatches.length > 0 + ? `Encaja con ${identityMatches.map((concept) => concept.label.toLowerCase()).join(' y ')}, partes del plan de ${row.heroSlug} ${row.role?.toLowerCase() ?? ''}.` + : `Aporta valor genérico, pero no refuerza de forma directa los conceptos principales detectados para ${row.heroSlug} ${row.role?.toLowerCase() ?? ''}.`, + matchupFit: matched.length > 0 + ? `Responde a esta partida porque ${matched.map(tagPurpose).join(' y ')}.` + : activeSignals.length > 0 + ? 'No cubre por sí solo las principales respuestas que exigía la composición rival.' + : 'No necesitaba cubrir una carencia contextual grave con los datos disponibles.', + tradeoff: repeatsPurpose + ? 'Repite una función ya presente en la build; esa redundancia reduce el espacio disponible para adaptación.' + : functions.length > 0 + ? `Al ocupar esta ranura priorizas ${functions.slice(0, 2).map((concept) => concept.label.toLowerCase()).join(' y ')} frente a otra posible respuesta situacional.` + : 'El catálogo no describe suficientes efectos para medir con precisión el coste de oportunidad de esta ranura.', + explanation: matched.length > 0 + ? `Fue una buena adaptación porque ${matched.map(tagPurpose).join(' y ')}.` + : repeatsPurpose + ? `Repite el mismo enfoque (${item.aggressionType!.toLowerCase().replaceAll('_', ' ')}) que otra pieza. Esa redundancia era la ranura más fácil de convertir en una respuesta al rival.` + : activeSignals.length > 0 + ? 'Puede formar parte del núcleo del héroe, pero no respondía a las amenazas que decidieron esta partida.' + : 'Encajó sin dejar una carencia contextual evidente en la build final.', + }; + }); + + const buildProfile = [...buildConceptItems.entries()] + .map(([key, items]) => ({ key, label: CONCEPT_DETAILS[key].label, description: CONCEPT_DETAILS[key].description, items })) + .sort((a, b) => b.items.length - a.items.length || a.label.localeCompare(b.label)); + const responseConcepts: Record = { + SUSTAIN: ['ANTI_HEAL'], SHIELD: ['ANTI_SHIELD'], CONTROL: ['TENACITY', 'DURABILITY', 'MOBILITY', 'PEEL'], + ENGAGE: ['PEEL', 'MOBILITY', 'DURABILITY'], BURST: ['DURABILITY', 'ANTI_MAGIC', 'ARMOR', 'SPELL_SHIELD'], + DPS: ['DURABILITY', 'CONTROL', 'SUSTAIN'], POKE: ['SUSTAIN', 'MOBILITY'], MOBILITY: ['CONTROL'], + DURABILITY: ['PHYSICAL_SHRED', 'MAGICAL_SHRED', 'ANTI_TANK'], HASTE: ['DURABILITY'], TEAM_UTILITY: ['BURST', 'ENGAGE'], + }; + const buildTagSet = new Set(finalInventory.flatMap((item) => [...semanticTags(item)])); + const availableResponseSet = new Set([...buildTagSet, ...identityConcepts]); + const unresolvedConcepts = enemyThreats.filter((threat) => { + const responses = responseConcepts[threat.key] ?? []; + return responses.length > 0 && !responses.some((response) => availableResponseSet.has(response)); + }); + const alignedItems = inventoryAssessments.filter((item) => item.functions.some((concept) => identityConcepts.has(concept.key))).length; + const buildCoherence = finalInventory.length > 0 ? Math.round((alignedItems / finalInventory.length) * 100) : 0; + const playerIdentity = [...playerAbilityConcepts.entries()].map(([key, evidence]) => ({ + key, + label: CONCEPT_DETAILS[key]?.label ?? tagPurpose(key), + description: CONCEPT_DETAILS[key]?.description ?? tagPurpose(key), + evidence: [...new Set(evidence)].slice(0, 4), + })).slice(0, 7); + + const alreadyOwned = new Set(inventory.map((item) => item.slug)); + const replaceable = finalInventory + .filter((item) => ![...semanticTags(item)].some((tag) => needTagSet.has(tag))) + .sort((a, b) => { + const aDuplicate = a.aggressionType && (aggressionCounts.get(a.aggressionType) ?? 0) > 1 ? 1 : 0; + const bDuplicate = b.aggressionType && (aggressionCounts.get(b.aggressionType) ?? 0) > 1 ? 1 : 0; + return bDuplicate - aDuplicate; + }); + const usedReplacements = new Set(); + const usedSuggestions = new Set(); + const changes = activeSignals.flatMap((signal) => { + const suggestion = signal.suggestedItems?.find((item) => !alreadyOwned.has(item.slug)); + if (!suggestion || usedSuggestions.has(suggestion.slug)) return []; + usedSuggestions.add(suggestion.slug); + const insteadOf = replaceable.find((item) => !usedReplacements.has(item.slug)); + if (insteadOf) usedReplacements.add(insteadOf.slug); + const timing = signal.key === 'anti-heal' + ? 'Como segunda pieza completa, antes de que la curación domine las peleas grupales.' + : signal.key === 'anti-tank' + ? 'Como tercera pieza, antes de las peleas repetidas por Fangtooth u Orb Prime.' + : signal.key.includes('defense') + ? 'Adelántalo a segunda o tercera pieza si vuelves a morir dos veces por el mismo tipo de daño.' + : 'Compra el componente de respuesta en cuanto identifiques la amenaza y completa la pieza antes del siguiente objetivo mayor.'; + return [{ + signalKey: signal.key, + action: insteadOf ? 'replace' as const : 'add' as const, + item: suggestion, + insteadOf: insteadOf ? { slug: insteadOf.slug, displayName: insteadOf.displayName } : null, + timing, + why: `${signal.evidence} ${suggestion.reason}${activeSignals.some((other) => other.key !== signal.key && other.suggestedItems?.some((item) => item.slug === suggestion.slug)) ? ' Además, la misma pieza cubre más de una de las carencias detectadas, por lo que no necesitas sacrificar otra ranura.' : ''}`, + }]; + }); + + const transactions = await db.transaction.findMany({ + where: { matchId, transactionType: { in: ['BUY', 'SELL'] } }, + orderBy: { gameTime: 'asc' }, + }); + const catalogByKey = new Map }>(); + for (const item of inventory) { + const value = { slug: item.slug, displayName: item.displayName, tags: semanticTags(item) }; + catalogByKey.set(normalizeCatalogKey(item.slug), value); + catalogByKey.set(normalizeCatalogKey(item.displayName), value); + } + for (const candidate of candidates) { + const value = { + slug: candidate.item.slug, + displayName: candidate.displayName, + tags: semanticTags({ + displayName: candidate.displayName, aggressionType: candidate.aggressionType, + stats: jsonArray<{ stat: string }>(candidate.stats), + effects: jsonArray<{ name: string; text: string; condition: string | null }>(candidate.effects), + }), + }; + catalogByKey.set(normalizeCatalogKey(candidate.item.slug), value); + catalogByKey.set(normalizeCatalogKey(candidate.item.name ?? ''), value); + catalogByKey.set(normalizeCatalogKey(candidate.displayName), value); + } + const enemyByPlayerId = new Map(enemies.filter((enemy) => enemy.playerId).map((enemy) => [enemy.playerId!, enemy])); + const ownPurchases = transactions + .filter((transaction) => transaction.playerId === row.playerId && transaction.transactionType === 'BUY' && transaction.itemName) + .map((transaction) => { + const item = catalogByKey.get(normalizeCatalogKey(transaction.itemName!)); + return { gameTime: transaction.gameTime, minute: minuteLabel(transaction.gameTime), itemName: item?.displayName ?? transaction.itemName!, itemSlug: item?.slug ?? normalizeCatalogKey(transaction.itemName!) }; + }); + const opponentResponses = transactions.flatMap((transaction) => { + if (!transaction.itemName || transaction.transactionType !== 'BUY' || !transaction.playerId) return []; + const enemy = enemyByPlayerId.get(transaction.playerId); + const item = catalogByKey.get(normalizeCatalogKey(transaction.itemName)); + if (!enemy || !item) return []; + const relevant = [...item.tags].filter((tag) => ['ARMOR', 'ANTI_MAGIC', 'ANTI_HEAL', 'ANTI_SHIELD', 'ANTI_TANK', 'PHYSICAL_SHRED', 'MAGICAL_SHRED'].includes(tag)); + if (relevant.length === 0) return []; + return [{ + gameTime: transaction.gameTime, + minute: minuteLabel(transaction.gameTime), + heroSlug: enemy.heroSlug, + playerName: enemy.playerName, + itemName: item.displayName, + itemSlug: item.slug, + explanation: opponentPurchaseExplanation(enemy.heroSlug, item.displayName, item.tags), + }]; + }).slice(0, 8); + + const loadout = jsonArray(row.perks).map((perk) => perkAssessment(perk, row, needTagSet)); + const currentLoadout = jsonArray(row.perks); + let recommendedLoadout: { + augment: ReturnType | null; + eternal: ReturnType | null; + blessings: Array>; + explanation: string; + } = { + augment: null, + eternal: null, + blessings: [], + explanation: 'No hay catálogo de loadout disponible para comparar alternativas en este parche.', + }; + const perkCatalogVersionId = row.match.versionId ?? catalogVersionId; + if (perkCatalogVersionId) { + let perkCatalog = await db.gamePerkVersion.findMany({ + where: { + versionId: perkCatalogVersionId, + OR: [ + { slot: 'ETERNAL_1' }, + { heroSlug: row.heroSlug }, + { slot: { startsWith: 'BLESSING_MINOR_' } }, + ], + }, + include: { perk: { select: { predggId: true, slug: true } } }, + }) as CatalogPerk[]; + if (perkCatalog.length === 0 && catalogVersionId && catalogVersionId !== perkCatalogVersionId) { + perkCatalog = await db.gamePerkVersion.findMany({ + where: { + versionId: catalogVersionId, + OR: [ + { slot: 'ETERNAL_1' }, + { heroSlug: row.heroSlug }, + { slot: { startsWith: 'BLESSING_MINOR_' } }, + ], + }, + include: { perk: { select: { predggId: true, slug: true } } }, + }) as CatalogPerk[]; + } + const playerMeta = heroMetaBySlug.get(row.heroSlug); + const playerAbilityText = jsonArray(playerMeta?.abilities) + .map((ability) => `${ability.game_description ?? ''} ${ability.menu_description ?? ''}`) + .join(' '); + const enablesHealingOrShielding = /heal|shield/i.test(playerAbilityText); + const scored = (values: CatalogPerk[]) => values + .map((perk) => ({ perk, score: perkScore(perk, row.role, enemyMitigation, enablesHealingOrShielding) })) + .sort((a, b) => b.score - a.score || a.perk.displayName.localeCompare(b.perk.displayName)); + const augment = scored(perkCatalog.filter((perk) => perk.heroSlug === row.heroSlug && perk.slot.includes('HERO_SPECIFIC')))[0]?.perk ?? null; + const eternal = scored(perkCatalog.filter((perk) => perk.slot === 'ETERNAL_1'))[0]?.perk ?? null; + const blessingIds = new Set(jsonArray(eternal?.minorBlessingPredggIds).map(String)); + const blessingCandidates = perkCatalog.filter((perk) => blessingIds.has(String(perk.perk.predggId))); + const blessingOne = scored(blessingCandidates.filter((perk) => perk.slot.endsWith('_1')))[0]?.perk ?? null; + const blessingTwo = scored(blessingCandidates.filter((perk) => perk.slot.endsWith('_2')))[0]?.perk ?? null; + const currentAugment = currentLoadout.find((perk) => perk.slot.includes('HERO_SPECIFIC')); + const currentEternal = currentLoadout.find((perk) => perk.slot === 'ETERNAL_1'); + const currentBlessings = currentLoadout.filter((perk) => perk.slot.includes('BLESSING')); + recommendedLoadout = { + augment: augment ? perkRecommendation( + augment, + currentAugment, + enemyMitigation >= 80_000 && /shred|armor/i.test(`${augment.simpleDescription ?? ''} ${augment.description}`) + ? 'Prioriza utilidad para todo el equipo: su control aplica reducción de defensas y abre una ventana de daño para tus aliados.' + : 'Es el Augmento que mejor refuerza tu función y las necesidades observadas en esta partida.', + ) : null, + eternal: eternal ? perkRecommendation( + eternal, + currentEternal, + enemyMitigation >= 80_000 && /shred|rust|armor/i.test(`${eternal.simpleDescription ?? ''} ${eternal.description}`) + ? 'Supera a una opción de daño genérico porque reduce las defensas del objetivo y convierte tu poke y control en valor compartido por el equipo.' + : 'Su condición de activación encaja mejor con tu rol y no depende de apropiarte del farmeo de otra posición.', + ) : null, + blessings: [blessingOne, blessingTwo].flatMap((perk) => perk ? [perkRecommendation( + perk, + currentBlessings.find((current) => current.slot === perk.slot), + /rust|shred/i.test(`${perk.simpleDescription ?? ''} ${perk.description}`) + ? 'Acelera o maximiza la reducción de defensas del Eternal recomendado durante tu ventana de control.' + : /haste|cooldown/i.test(`${perk.simpleDescription ?? ''} ${perk.description}`) + ? 'Te permite repetir antes tus habilidades de control y mantener activa la utilidad del Eternal.' + : 'Es la bendición de su ranura que mejor complementa el patrón de utilidad recomendado.', + )] : []), + explanation: 'La comparación usa tu rol, las habilidades del héroe, la mitigación rival y las condiciones reales de activación. Una opción que exige matar unidades pierde valor en Support aunque su bonificación final parezca atractiva.', + }; + } + + const replacementsBySlug = new Map(changes.flatMap((change) => change.insteadOf ? [[change.insteadOf.slug, change.item] as const] : [])); + const addedSuggestions = changes.filter((change) => !change.insteadOf).map((change) => change.item); + const retainedCore = finalInventory.flatMap((item) => replacementsBySlug.has(item.slug) ? [] : [item]); + const recommendedItems = (retainedCore.length > 0 + ? [retainedCore[0], ...changes.map((change) => change.item), ...retainedCore.slice(1), ...addedSuggestions] + : [...changes.map((change) => change.item), ...addedSuggestions] + ).filter((item, index, values) => values.findIndex((other) => other.slug === item.slug) === index).slice(0, 5); + const recommendedSequence = recommendedItems.map((item, index) => { + const change = changes.find((entry) => entry.item.slug === item.slug); + const inventoryItem = item; + return { + position: index + 1, + slug: item.slug, + displayName: item.displayName, + phase: index === 0 ? 'Núcleo temprano' : index === 1 ? 'Adaptación temprana' : index < 4 ? 'Medio juego' : 'Cierre', + reason: change?.timing ?? (index === 0 + ? 'Conserva esta pieza como base funcional del héroe salvo que la amenaza rival exija una respuesta inmediata.' + : 'Completa esta pieza después de cubrir la amenaza prioritaria; aporta valor al núcleo sin retrasar la adaptación.'), + replaces: change?.insteadOf ?? null, + stats: inventoryItem ? jsonArray<{ stat: string; value: number; showPercent?: boolean }>(inventoryItem.stats) : [], + effects: inventoryItem ? jsonArray<{ name: string; text: string; condition?: string | null; cooldown?: string | null }>(inventoryItem.effects) : [], + }; + }); + const criticalCount = activeSignals.filter((signal) => signal.severity === 'critical').length; + const warningCount = activeSignals.filter((signal) => signal.severity === 'warning').length; + const grade = criticalCount > 0 ? 'poor' : warningCount > 1 ? 'mixed' : warningCount === 1 ? 'mostly_correct' : 'correct'; + const verdictSummary = grade === 'correct' + ? 'La build final fue coherente con las amenazas observadas y no dejó una respuesta contextual importante sin cubrir.' + : grade === 'mostly_correct' + ? 'La base de la build era razonable, pero faltó una adaptación concreta que habría aumentado su valor frente a este rival.' + : grade === 'mixed' + ? 'La build tenía un núcleo utilizable, pero dedicó demasiadas ranuras a valor genérico y pocas a responder a la composición rival.' + : 'La build no respondió a una o más amenazas decisivas; el principal aprendizaje es adaptar antes, no cambiar todo el núcleo del héroe.'; + + return { + matchId, + matchPlayerId, + heroSlug: row.heroSlug, + role: row.role, + result: row.match.winningTeam === row.team ? 'win' : 'loss', + context: { + catalogVersionId, + deaths: row.deaths, + damageReceived: { physical, magical, true: trueDamage, total: damageTotal }, + enemyHealing, + enemyShielding, + enemyMitigation, + enemyHeroes: enemies.map((enemy) => ({ + heroSlug: enemy.heroSlug, role: enemy.role, playerName: enemy.playerName, + healing: enemy.totalHealingDone ?? 0, shieldingReceived: enemy.totalShieldingReceived ?? 0, + damageMitigated: enemy.totalDamageMitigated ?? 0, + })), + }, + globalAnalysis: { + playerIdentity, + enemyThreats, + buildProfile, + coherence: { + score: buildCoherence, + summary: buildCoherence >= 70 + ? `La mayoría de las piezas refuerzan el plan natural de ${row.heroSlug}, aunque todavía deben juzgarse por el rival y el momento de compra.` + : buildCoherence >= 40 + ? `La build mezcla piezas coherentes con ${row.heroSlug} y valor genérico; conviene definir mejor si la partida exigía control, protección, daño o supervivencia.` + : `Pocas piezas refuerzan de forma directa el patrón detectado para ${row.heroSlug}; la build parece construida más por valor aislado que por un plan conjunto.`, + }, + strengths: buildProfile.slice(0, 3).map((concept) => `${concept.label}: ${concept.items.join(', ')}.`), + tradeoffs: unresolvedConcepts.slice(0, 4).map((threat) => `${threat.label}: la build final no muestra una respuesta directa. ${threat.response}`), + }, + inventory, + inventoryAssessments, + verdict: { grade, summary: verdictSummary, score: Math.max(20, 100 - criticalCount * 25 - warningCount * 12) }, + recommendedBuild: { + principle: 'Mantén el núcleo que hace funcionar al héroe y reserva al menos una ranura para responder a lo que realmente está comprando y produciendo el rival.', + changes, + sequence: recommendedSequence, + }, + purchaseTimeline: { + available: transactions.length > 0, + ownPurchases, + opponentResponses, + lesson: transactions.length > 0 + ? 'La adaptación se decide en base: revisa composición al inicio y vuelve a comprobar las compras rivales antes de completar tu segunda y tercera pieza.' + : 'No hay transacciones sincronizadas para evaluar el minuto exacto. La recomendación se basa en la composición y el resultado final.', + }, + eternalLoadout: loadout, + recommendedLoadout, + abilityOrder: jsonArray<{ ability: string; gameTime: number }>(row.abilityOrder), + signals, + }; +} diff --git a/apps/api/src/services/match-service.ts b/apps/api/src/services/match-service.ts index 975bd7d..d6b9050 100644 --- a/apps/api/src/services/match-service.ts +++ b/apps/api/src/services/match-service.ts @@ -194,8 +194,13 @@ export interface MatchPlayerDetail { inventoryItems: string[]; /** @deprecated Always null — use perks instead */ perkSlug: string | null; - /** Pre-game augments: [{id, name, displayName, slot}]. null = not yet synced. */ - perks: Array<{ id: string; name: string; displayName: string; slot: string | null }> | null; + /** Current loadout: hero augment, Eternal and two minor blessings. */ + perks: Array<{ + id: string; name: string; displayName: string; slot: string | null; + icon?: string | null; simpleDescription?: string | null; description?: string | null; + unlockLevel?: number | null; eternalCategory?: { id: string; name: string } | null; + }> | null; + abilityOrder: Array<{ ability: string; gameTime: number }> | null; /** From the most recent PlayerSnapshot — e.g. "Diamond III" */ rankLabel: string | null; ratingPoints: number | null; @@ -205,17 +210,36 @@ export interface MatchPlayerDetail { heroDamageTaken: number | null; totalDamageTaken: number | null; totalHealingDone: number | null; + crestHealingDone: number | null; + itemHealingDone: number | null; + utilityHealingDone: number | null; + totalShieldingReceived: number | null; + totalDamageMitigated: number | null; + physicalDamageTaken: number | null; + magicalDamageTaken: number | null; + trueDamageTaken: number | null; + physicalDamageTakenFromHeroes: number | null; + magicalDamageTakenFromHeroes: number | null; + trueDamageTakenFromHeroes: number | null; totalDamageDealtToStructures: number | null; totalDamageDealtToObjectives: number | null; largestCriticalStrike: number | null; /** CS — lane minions only, excludes jungle camps */ laneMinionsKilled: number | null; + minionsKilled: number | null; + neutralMinionsKilled: number | null; + neutralMinionsTeamJungle: number | null; + neutralMinionsEnemyJungle: number | null; goldSpent: number | null; largestKillingSpree: number | null; multiKill: number | null; physicalDamageDealt: number | null; magicalDamageDealt: number | null; trueDamageDealt: number | null; + matchRating: { + ratingId: string | null; points: number | null; newPoints: number | null; delta: number | null; + rankName: string | null; tierName: string | null; isRankup: boolean | null; + } | null; /** Cumulative gold per minute array — requires event stream sync */ goldEarnedAtInterval: number[] | null; } @@ -228,6 +252,7 @@ export interface MatchDetail { id: string; predggUuid: string; startTime: Date; + endTime: Date | null; /** Duration in seconds */ duration: number; /** "RANKED" | "ARAM" | "BRAWL" | "STANDARD" */ @@ -235,6 +260,8 @@ export interface MatchDetail { region: string | null; /** "DUSK" | "DAWN" | null */ winningTeam: string | null; + endReason: string | null; + spoilerBlockedUntil: Date | null; /** Patch name string e.g. "0.19.2", null if version not resolved */ version: string | null; rosterSynced: boolean; @@ -316,7 +343,8 @@ export async function getMatchDetail(matchId: string): Promise { } /** Maps a DB MatchPlayer row to the MatchPlayerDetail API shape. */ - function toDetail(mp: (typeof match.matchPlayers)[number]): MatchPlayerDetail { + type LoadedMatch = NonNullable; + function toDetail(mp: LoadedMatch['matchPlayers'][number]): MatchPlayerDetail { const snap = mp.player?.snapshots[0] ?? null; const hero = heroMeta.get(mp.heroSlug); return { @@ -342,7 +370,8 @@ export async function getMatchDetail(matchId: string): Promise { level: mp.level, inventoryItems: Array.isArray(mp.inventoryItems) ? (mp.inventoryItems as string[]) : [], perkSlug: mp.perkSlug, - perks: Array.isArray(mp.perks) ? (mp.perks as Array<{ id: string; name: string; displayName: string; slot: string | null }>) : null, + perks: Array.isArray(mp.perks) ? (mp.perks as MatchPlayerDetail['perks']) : null, + abilityOrder: Array.isArray(mp.abilityOrder) ? (mp.abilityOrder as Array<{ ability: string; gameTime: number }>) : null, rankLabel: snap?.rankLabel ?? null, ratingPoints: snap?.ratingPoints ?? null, physicalDamageDealtToHeroes: mp.physicalDamageDealtToHeroes, @@ -351,16 +380,40 @@ export async function getMatchDetail(matchId: string): Promise { heroDamageTaken: mp.heroDamageTaken, totalDamageTaken: mp.totalDamageTaken, totalHealingDone: mp.totalHealingDone, + crestHealingDone: mp.crestHealingDone, + itemHealingDone: mp.itemHealingDone, + utilityHealingDone: mp.utilityHealingDone, + totalShieldingReceived: mp.totalShieldingReceived, + totalDamageMitigated: mp.totalDamageMitigated, + physicalDamageTaken: mp.physicalDamageTaken, + magicalDamageTaken: mp.magicalDamageTaken, + trueDamageTaken: mp.trueDamageTaken, + physicalDamageTakenFromHeroes: mp.physicalDamageTakenFromHeroes, + magicalDamageTakenFromHeroes: mp.magicalDamageTakenFromHeroes, + trueDamageTakenFromHeroes: mp.trueDamageTakenFromHeroes, totalDamageDealtToStructures: mp.totalDamageDealtToStructures, totalDamageDealtToObjectives: mp.totalDamageDealtToObjectives, largestCriticalStrike: mp.largestCriticalStrike, laneMinionsKilled: mp.laneMinionsKilled, + minionsKilled: mp.minionsKilled, + neutralMinionsKilled: mp.neutralMinionsKilled, + neutralMinionsTeamJungle: mp.neutralMinionsTeamJungle, + neutralMinionsEnemyJungle: mp.neutralMinionsEnemyJungle, goldSpent: mp.goldSpent, largestKillingSpree: mp.largestKillingSpree, multiKill: mp.multiKill, physicalDamageDealt: mp.physicalDamageDealt, magicalDamageDealt: mp.magicalDamageDealt, trueDamageDealt: mp.trueDamageDealt, + matchRating: mp.ratingId || mp.ratingPoints != null || mp.ratingNewPoints != null ? { + ratingId: mp.ratingId, + points: mp.ratingPoints, + newPoints: mp.ratingNewPoints, + delta: mp.ratingDelta, + rankName: mp.ratingRankName, + tierName: mp.ratingTierName, + isRankup: mp.ratingIsRankup, + } : null, goldEarnedAtInterval: Array.isArray(mp.goldEarnedAtInterval) ? (mp.goldEarnedAtInterval as number[]) : null, }; } @@ -373,10 +426,13 @@ export async function getMatchDetail(matchId: string): Promise { id: match.id, predggUuid: match.predggUuid, startTime: match.startTime, + endTime: match.endTime, duration: match.duration, gameMode: match.gameMode, region: match.region, winningTeam: match.winningTeam, + endReason: match.endReason, + spoilerBlockedUntil: match.spoilerBlockedUntil, version: match.version?.name ?? null, rosterSynced: match.rosterSynced, eventStreamSynced: match.eventStreamSynced, diff --git a/apps/api/src/services/player-build-review-service.test.ts b/apps/api/src/services/player-build-review-service.test.ts new file mode 100644 index 0000000..3ca4627 --- /dev/null +++ b/apps/api/src/services/player-build-review-service.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + playerFindUnique: vi.fn(), + matchPlayerFindMany: vi.fn(), + getMatchBuildAnalysis: vi.fn(), +})); + +vi.mock('../db.js', () => ({ + db: { + player: { findUnique: mocks.playerFindUnique }, + matchPlayer: { findMany: mocks.matchPlayerFindMany }, + }, +})); + +vi.mock('./build-coach-service.js', () => ({ + getMatchBuildAnalysis: mocks.getMatchBuildAnalysis, +})); + +import { getPlayerBuildReview } from './player-build-review-service.js'; + +describe('player build review', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.playerFindUnique.mockResolvedValue({ id: 'player-1' }); + mocks.matchPlayerFindMany.mockResolvedValue([{ + id: 'mp-1', + matchId: 'match-1', + match: { + predggUuid: 'uuid-1', startTime: new Date('2026-08-25T18:00:00Z'), duration: 1800, + gameMode: 'RANKED', version: { name: '1.16.1' }, + }, + }]); + mocks.getMatchBuildAnalysis.mockResolvedValue({ heroSlug: 'dekker', signals: [] }); + }); + + it('combines recent match context with build and Ancestor analysis', async () => { + const result = await getPlayerBuildReview('player-1', { days: 30, limit: 3 }); + + expect(mocks.matchPlayerFindMany).toHaveBeenCalledWith(expect.objectContaining({ take: 3 })); + expect(mocks.getMatchBuildAnalysis).toHaveBeenCalledWith('match-1', 'mp-1'); + expect(result.matches[0]).toMatchObject({ + match: { id: 'match-1', predggUuid: 'uuid-1', version: '1.16.1' }, + analysis: { heroSlug: 'dekker' }, + }); + }); + + it('keeps the report usable when one match cannot be analysed', async () => { + mocks.getMatchBuildAnalysis.mockRejectedValue(new Error('catalog missing')); + + const result = await getPlayerBuildReview('player-1'); + + expect(result.matches).toEqual([]); + }); +}); diff --git a/apps/api/src/services/player-build-review-service.ts b/apps/api/src/services/player-build-review-service.ts new file mode 100644 index 0000000..effd340 --- /dev/null +++ b/apps/api/src/services/player-build-review-service.ts @@ -0,0 +1,51 @@ +import { db } from '../db.js'; +import { AppError } from '../middleware/error-handler.js'; +import { getMatchBuildAnalysis } from './build-coach-service.js'; + +export async function getPlayerBuildReview(playerId: string, options: { days?: number; limit?: number } = {}) { + const days = options.days ?? 30; + const limit = options.limit ?? 5; + const player = await db.player.findUnique({ + where: { id: playerId }, + select: { id: true }, + }); + if (!player) throw new AppError(404, 'Player not found', 'PLAYER_NOT_FOUND'); + + const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const rows = await db.matchPlayer.findMany({ + where: { playerId, match: { startTime: { gte: from } } }, + orderBy: { match: { startTime: 'desc' } }, + take: limit, + select: { + id: true, + matchId: true, + match: { + select: { + predggUuid: true, + startTime: true, + duration: true, + gameMode: true, + version: { select: { name: true } }, + }, + }, + }, + }); + + const reviewed = await Promise.allSettled(rows.map(async (row) => ({ + match: { + id: row.matchId, + predggUuid: row.match.predggUuid, + startTime: row.match.startTime, + duration: row.match.duration, + gameMode: row.match.gameMode, + version: row.match.version?.name ?? null, + }, + analysis: await getMatchBuildAnalysis(row.matchId, row.id), + }))); + + return { + playerId, + period: { days, from, to: new Date() }, + matches: reviewed.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []), + }; +} diff --git a/apps/api/src/services/player-champion-pool-service.test.ts b/apps/api/src/services/player-champion-pool-service.test.ts new file mode 100644 index 0000000..ac8d29f --- /dev/null +++ b/apps/api/src/services/player-champion-pool-service.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + playerFindUnique: vi.fn(), + matchPlayerFindMany: vi.fn(), +})); + +vi.mock('../db.js', () => ({ + db: { + player: { findUnique: mocks.playerFindUnique }, + matchPlayer: { findMany: mocks.matchPlayerFindMany }, + }, +})); + +import { getPlayerChampionPoolContext } from './player-champion-pool-service.js'; + +describe('player champion pool context', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.playerFindUnique.mockResolvedValue({ id: 'player-1', displayName: 'CoachMe', customName: null }); + mocks.matchPlayerFindMany.mockResolvedValue([ + { + id: 'own-1', role: 'SUPPORT', heroSlug: 'dekker', team: 'DAWN', kills: 1, deaths: 2, assists: 12, + match: { gameMode: 'RANKED', winningTeam: 'DAWN', matchPlayers: [ + { id: 'own-1', team: 'DAWN', heroSlug: 'dekker', role: 'SUPPORT' }, + { id: 'ally-1', team: 'DAWN', heroSlug: 'murdock', role: 'CARRY' }, + { id: 'enemy-1', team: 'DUSK', heroSlug: 'riktor', role: 'SUPPORT' }, + ] }, + }, + { + id: 'own-2', role: 'SUPPORT', heroSlug: 'dekker', team: 'DUSK', kills: 0, deaths: 4, assists: 8, + match: { gameMode: 'RANKED', winningTeam: 'DAWN', matchPlayers: [ + { id: 'own-2', team: 'DUSK', heroSlug: 'dekker', role: 'SUPPORT' }, + { id: 'ally-2', team: 'DUSK', heroSlug: 'murdock', role: 'CARRY' }, + { id: 'enemy-2', team: 'DAWN', heroSlug: 'riktor', role: 'SUPPORT' }, + ] }, + }, + { + id: 'own-3', role: 'MIDLANE', heroSlug: 'gideon', team: 'DAWN', kills: 7, deaths: 1, assists: 5, + match: { gameMode: 'STANDARD', winningTeam: 'DAWN', matchPlayers: [ + { id: 'own-3', team: 'DAWN', heroSlug: 'gideon', role: 'MIDLANE' }, + { id: 'enemy-3', team: 'DUSK', heroSlug: 'morigesh', role: 'MIDLANE' }, + ] }, + }, + ]); + }); + + it('filters the pool and aggregates personal enemy matchups and ally synergies', async () => { + const result = await getPlayerChampionPoolContext('player-1', { days: 90, role: 'SUPPORT', gameMode: 'RANKED', heroSlug: 'dekker' }); + + expect(result.sampleSize).toBe(2); + expect(result.heroes[0]).toMatchObject({ heroSlug: 'dekker', matches: 2, wins: 1, winRate: 50 }); + expect(result.matchups[0]).toMatchObject({ heroSlug: 'riktor', matches: 2, wins: 1, winRate: 50 }); + expect(result.synergies[0]).toMatchObject({ heroSlug: 'murdock', matches: 2, wins: 1, winRate: 50 }); + expect(result.filters.available.roles).toEqual(['MIDLANE', 'SUPPORT']); + expect(result.strongestMatchup?.heroSlug).toBe('riktor'); + expect(result.hardestMatchup?.heroSlug).toBe('riktor'); + }); +}); diff --git a/apps/api/src/services/player-champion-pool-service.ts b/apps/api/src/services/player-champion-pool-service.ts new file mode 100644 index 0000000..c54ab44 --- /dev/null +++ b/apps/api/src/services/player-champion-pool-service.ts @@ -0,0 +1,112 @@ +import { db } from '../db.js'; +import { AppError } from '../middleware/error-handler.js'; + +export interface ChampionPoolFilters { + days: number; + role?: string; + gameMode?: string; + heroSlug?: string; +} + +interface Aggregate { + heroSlug: string; + matches: number; + wins: number; + kills: number; + deaths: number; + assists: number; +} + +function add(map: Map, heroSlug: string, won: boolean, kills: number, deaths: number, assists: number): void { + const current = map.get(heroSlug) ?? { heroSlug, matches: 0, wins: 0, kills: 0, deaths: 0, assists: 0 }; + current.matches += 1; + current.wins += won ? 1 : 0; + current.kills += kills; + current.deaths += deaths; + current.assists += assists; + map.set(heroSlug, current); +} + +function finish(row: Aggregate) { + return { + ...row, + winRate: Math.round((row.wins / row.matches) * 1000) / 10, + kda: Math.round(((row.kills + row.assists) / Math.max(row.deaths, 1)) * 100) / 100, + }; +} + +export async function getPlayerChampionPoolContext(playerId: string, filters: ChampionPoolFilters) { + const player = await db.player.findUnique({ + where: { id: playerId }, + select: { id: true, displayName: true, customName: true }, + }); + if (!player) throw new AppError(404, 'Player not found', 'PLAYER_NOT_FOUND'); + + const from = new Date(Date.now() - filters.days * 24 * 60 * 60 * 1000); + const rows = await db.matchPlayer.findMany({ + where: { playerId, match: { startTime: { gte: from } } }, + orderBy: { match: { startTime: 'desc' } }, + include: { + match: { + select: { + gameMode: true, + winningTeam: true, + matchPlayers: { select: { id: true, team: true, heroSlug: true, role: true } }, + }, + }, + }, + }); + + const available = { + roles: [...new Set(rows.map((row) => row.role).filter((value): value is string => Boolean(value)))].sort(), + gameModes: [...new Set(rows.map((row) => row.match.gameMode))].sort(), + heroes: [...new Set(rows.map((row) => row.heroSlug))].sort(), + }; + + const selected = rows.filter((row) => ( + (!filters.role || row.role === filters.role) + && (!filters.gameMode || row.match.gameMode === filters.gameMode) + && (!filters.heroSlug || row.heroSlug === filters.heroSlug) + )); + + const heroes = new Map(); + const enemies = new Map(); + const allies = new Map(); + + for (const row of selected) { + const won = row.match.winningTeam === row.team; + add(heroes, row.heroSlug, won, row.kills, row.deaths, row.assists); + for (const participant of row.match.matchPlayers) { + if (participant.id === row.id) continue; + if (participant.team === row.team) add(allies, participant.heroSlug, won, row.kills, row.deaths, row.assists); + else add(enemies, participant.heroSlug, won, row.kills, row.deaths, row.assists); + } + } + + const sortBySample = (a: ReturnType, b: ReturnType) => b.matches - a.matches || b.winRate - a.winRate; + const heroResults = [...heroes.values()].map(finish).sort(sortBySample); + const matchupResults = [...enemies.values()].map(finish).sort(sortBySample); + const synergyResults = [...allies.values()].map(finish).sort(sortBySample); + const reliableMatchups = matchupResults.filter((row) => row.matches >= 2); + + return { + player, + period: { days: filters.days, from: from.toISOString(), to: new Date().toISOString() }, + filters: { + role: filters.role ?? null, + gameMode: filters.gameMode ?? null, + heroSlug: filters.heroSlug ?? null, + available, + }, + sampleSize: selected.length, + heroes: heroResults, + matchups: matchupResults.slice(0, 20), + synergies: synergyResults.slice(0, 20), + strongestMatchup: reliableMatchups.length > 0 + ? [...reliableMatchups].sort((a, b) => b.winRate - a.winRate || b.matches - a.matches)[0] + : null, + hardestMatchup: reliableMatchups.length > 0 + ? [...reliableMatchups].sort((a, b) => a.winRate - b.winRate || b.matches - a.matches)[0] + : null, + }; +} diff --git a/apps/api/src/services/player-coach-chat-service.ts b/apps/api/src/services/player-coach-chat-service.ts index b9d04ef..ae92eef 100644 --- a/apps/api/src/services/player-coach-chat-service.ts +++ b/apps/api/src/services/player-coach-chat-service.ts @@ -25,7 +25,7 @@ const PLAYER_COACH_SYSTEM_PROMPT = `Eres el coach personal de RiftLine para juga REGLAS OBLIGATORIAS: 1. Responde en español claro, directo y respetuoso. -2. Usa únicamente las evidencias E1-E6 entregadas en el contexto. No inventes estadísticas, eventos, builds, timings ni causas. +2. Usa únicamente las evidencias E1-E7 entregadas en el contexto. No inventes estadísticas, eventos, builds, timings ni causas. 3. Cita cada afirmación cuantitativa o diagnóstico con el identificador correspondiente, por ejemplo [E2]. 4. Distingue correlación de causa: si los datos no explican por qué ocurrió algo, dilo explícitamente. 5. Prioriza una o dos acciones practicables para las próximas partidas; evita listas genéricas. @@ -77,6 +77,18 @@ export async function answerPlayerCoachQuestion( gold: true, wardsPlaced: true, laneMinionsKilled: true, + inventoryItems: true, + perks: true, + abilityOrder: true, + crestHealingDone: true, + itemHealingDone: true, + utilityHealingDone: true, + totalShieldingReceived: true, + totalDamageMitigated: true, + physicalDamageTakenFromHeroes: true, + magicalDamageTakenFromHeroes: true, + trueDamageTakenFromHeroes: true, + ratingDelta: true, match: { select: { predggUuid: true, startTime: true, winningTeam: true, duration: true } }, }, }), @@ -111,6 +123,19 @@ export async function answerPlayerCoachQuestion( return `${row.match.predggUuid}: ${row.heroSlug} ${row.role ?? 'sin rol'}, ${result}, ${row.kills}/${row.deaths}/${row.assists}, daño ${row.heroDamage ?? 'sin dato'}, oro ${row.gold ?? 'sin dato'}, wards ${row.wardsPlaced ?? 'sin dato'}, CS ${row.laneMinionsKilled ?? 'sin dato'}`; }).join(' | ') || 'Sin partidas recientes.', }, + { + id: 'E7', label: 'Builds, Eternals y adaptación', scope: 'últimas 5', + value: recentMatches.map((row) => { + const items = Array.isArray(row.inventoryItems) ? (row.inventoryItems as string[]).join(', ') : 'sin inventario'; + const perks = Array.isArray(row.perks) + ? (row.perks as Array<{ displayName?: string; name?: string; slot?: string }>).map((perk) => `${perk.slot ?? 'mejora'}:${perk.displayName ?? perk.name ?? '?'}`).join(', ') + : 'sin loadout'; + const physical = row.physicalDamageTakenFromHeroes ?? 0; + const magical = row.magicalDamageTakenFromHeroes ?? 0; + const trueDamage = row.trueDamageTakenFromHeroes ?? 0; + return `${row.match.predggUuid}: objetos [${items}]; loadout [${perks}]; recibido P/M/T ${physical}/${magical}/${trueDamage}; mitigado ${row.totalDamageMitigated ?? 'sin dato'}; curación crest/item/utilidad ${row.crestHealingDone ?? 'sin dato'}/${row.itemHealingDone ?? 'sin dato'}/${row.utilityHealingDone ?? 'sin dato'}; escudo ${row.totalShieldingReceived ?? 'sin dato'}; rating ${row.ratingDelta == null ? 'sin dato' : row.ratingDelta >= 0 ? `+${row.ratingDelta}` : row.ratingDelta}.`; + }).join(' | ') || 'Sin partidas recientes.', + }, ]; const client = new OpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseUrl }); @@ -134,7 +159,7 @@ export async function answerPlayerCoachQuestion( const answer = completion.choices[0]?.message?.content?.trim(); if (!answer) throw new Error('Empty LLM response'); - const citedIds = [...answer.matchAll(/\[(E[1-6])\]/g)].map((match) => match[1]); + const citedIds = [...answer.matchAll(/\[(E[1-7])\]/g)].map((match) => match[1]); const citedEvidence = evidence.filter((item) => citedIds.includes(item.id)); await db.syncLog.create({ data: { entity: 'player-coach', entityId: playerId, operation: 'chat', status: 'ok', source: 'user' }, diff --git a/apps/api/src/services/player-match-enrichment-service.test.ts b/apps/api/src/services/player-match-enrichment-service.test.ts index d5bd752..04dad10 100644 --- a/apps/api/src/services/player-match-enrichment-service.test.ts +++ b/apps/api/src/services/player-match-enrichment-service.test.ts @@ -18,9 +18,9 @@ describe('player match enrichment', () => { const db = { match: { findMany: vi.fn().mockResolvedValue([ - { rosterSynced: true, eventStreamSynced: true, eventStreamFailed: false, syncedAt: new Date('2026-08-25T12:00:00Z') }, - { rosterSynced: true, eventStreamSynced: false, eventStreamFailed: true, syncedAt: new Date('2026-08-24T12:00:00Z') }, - { rosterSynced: false, eventStreamSynced: false, eventStreamFailed: false, syncedAt: new Date('2026-08-23T12:00:00Z') }, + { rosterSynced: true, eventStreamSynced: true, eventStreamFailed: false, syncedAt: new Date('2026-08-25T12:00:00Z'), matchPlayers: [{ goldEarnedAtInterval: [500, 900] }] }, + { rosterSynced: true, eventStreamSynced: false, eventStreamFailed: true, syncedAt: new Date('2026-08-24T12:00:00Z'), matchPlayers: [{ goldEarnedAtInterval: null }] }, + { rosterSynced: false, eventStreamSynced: false, eventStreamFailed: false, syncedAt: new Date('2026-08-23T12:00:00Z'), matchPlayers: [{ goldEarnedAtInterval: null }] }, ]), }, }; @@ -47,19 +47,21 @@ describe('player match enrichment', () => { const candidates = [ { id: 'match-1', predggUuid: 'uuid-1', rosterSynced: false, eventStreamSynced: false, eventStreamFailed: false }, { id: 'match-2', predggUuid: 'uuid-2', rosterSynced: true, eventStreamSynced: false, eventStreamFailed: false }, + { id: 'match-3', predggUuid: 'uuid-3', rosterSynced: true, eventStreamSynced: true, eventStreamFailed: false, matchPlayers: [{ physicalDamageTaken: null }] }, ]; const db = { match: { findMany: vi.fn().mockResolvedValue(candidates), - findUnique: vi.fn().mockResolvedValue({ rosterSynced: true, eventStreamSynced: true }), + findUnique: vi.fn().mockResolvedValue({ rosterSynced: true, eventStreamSynced: true, matchPlayers: [{ goldEarnedAtInterval: [500] }] }), }, syncLog: { create: vi.fn().mockResolvedValue({}) }, }; const result = await enrichPlayerMatches(db as never, 'player-1', 'token', { concurrency: 2 }); - expect(result).toEqual({ total: 2, processed: 2, succeeded: 2, errors: 0 }); + expect(result).toEqual({ total: 3, processed: 3, succeeded: 3, errors: 0 }); expect(resyncMatch).toHaveBeenCalledWith(db, 'uuid-1', 'token', true); + expect(resyncMatch).toHaveBeenCalledWith(db, 'uuid-3', 'token', true); expect(syncMatchEventStream).toHaveBeenCalledWith(db, 'match-2', 'uuid-2', 'token', false); expect(db.match.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ matchPlayers: { some: { playerId: 'player-1' } } }), @@ -67,6 +69,26 @@ describe('player match enrichment', () => { })); }); + it('forces event refresh for a previously synced match whose gold timeline is missing', async () => { + const db = { + match: { + findMany: vi.fn().mockResolvedValue([{ + id: 'match-1', predggUuid: 'uuid-1', rosterSynced: true, eventStreamSynced: true, + eventStreamFailed: false, matchPlayers: [{ physicalDamageTaken: 1200, goldEarnedAtInterval: null }], + }]), + findUnique: vi.fn().mockResolvedValue({ + rosterSynced: true, eventStreamSynced: true, matchPlayers: [{ goldEarnedAtInterval: [500, 900] }], + }), + }, + syncLog: { create: vi.fn().mockResolvedValue({}) }, + }; + + const result = await enrichPlayerMatches(db as never, 'player-1', 'token'); + + expect(result).toEqual({ total: 1, processed: 1, succeeded: 1, errors: 0 }); + expect(syncMatchEventStream).toHaveBeenCalledWith(db, 'match-1', 'uuid-1', 'token', true); + }); + it('continues after a partial failure and records it for diagnosis', async () => { vi.mocked(syncMatchEventStream) .mockRejectedValueOnce(new Error('pred.gg timeout')) @@ -77,7 +99,7 @@ describe('player match enrichment', () => { { id: 'match-1', predggUuid: 'uuid-1', rosterSynced: true, eventStreamSynced: false, eventStreamFailed: false }, { id: 'match-2', predggUuid: 'uuid-2', rosterSynced: true, eventStreamSynced: false, eventStreamFailed: false }, ]), - findUnique: vi.fn().mockResolvedValue({ rosterSynced: true, eventStreamSynced: true }), + findUnique: vi.fn().mockResolvedValue({ rosterSynced: true, eventStreamSynced: true, matchPlayers: [{ goldEarnedAtInterval: [500] }] }), }, syncLog: { create: vi.fn().mockResolvedValue({}) }, }; diff --git a/apps/api/src/services/player-match-enrichment-service.ts b/apps/api/src/services/player-match-enrichment-service.ts index 268962f..a507de0 100644 --- a/apps/api/src/services/player-match-enrichment-service.ts +++ b/apps/api/src/services/player-match-enrichment-service.ts @@ -1,4 +1,4 @@ -import type { PrismaClient } from '@prisma/client'; +import { Prisma, type PrismaClient } from '@prisma/client'; import { logger } from '../logger.js'; import { resyncMatch, syncMatchEventStream } from './sync-service.js'; @@ -40,6 +40,8 @@ interface EnrichmentCandidate { rosterSynced: boolean; eventStreamSynced: boolean; eventStreamFailed: boolean; + needsRosterRefresh?: boolean; + needsGoldRefresh?: boolean; } interface EnrichmentOptions { @@ -73,6 +75,11 @@ export async function getPlayerMatchEnrichmentCoverage( eventStreamSynced: true, eventStreamFailed: true, syncedAt: true, + matchPlayers: { + where: { playerId }, + take: 1, + select: { goldEarnedAtInterval: true }, + }, }, }); @@ -85,7 +92,9 @@ export async function getPlayerMatchEnrichmentCoverage( for (const match of matches) { if (match.rosterSynced) rosterSynced++; if (match.eventStreamSynced) eventStreamSynced++; - if (match.rosterSynced && match.eventStreamSynced) fullyEnriched++; + const goldTimeline = match.matchPlayers[0]?.goldEarnedAtInterval; + const hasGoldTimeline = Array.isArray(goldTimeline) && goldTimeline.length > 0; + if (match.rosterSynced && match.eventStreamSynced && hasGoldTimeline) fullyEnriched++; if (match.eventStreamFailed && !match.eventStreamSynced) failed++; if (!lastMatchSyncedAt || match.syncedAt > lastMatchSyncedAt) lastMatchSyncedAt = match.syncedAt; } @@ -119,6 +128,8 @@ async function loadCandidates( matchPlayers: { some: { playerId } }, OR: [ { rosterSynced: false }, + { matchPlayers: { some: { playerId, physicalDamageTaken: null } } }, + { matchPlayers: { some: { playerId, goldEarnedAtInterval: { equals: Prisma.DbNull } } } }, { eventStreamSynced: false, ...(retryFailed ? {} : { eventStreamFailed: false }), @@ -133,10 +144,27 @@ async function loadCandidates( rosterSynced: true, eventStreamSynced: true, eventStreamFailed: true, + matchPlayers: { + where: { playerId }, + take: 1, + select: { physicalDamageTaken: true, goldEarnedAtInterval: true }, + }, }, }); - return matches; + return matches.map((match) => { + const playerMatch = match.matchPlayers?.[0]; + const goldTimeline = playerMatch?.goldEarnedAtInterval; + return { + id: match.id, + predggUuid: match.predggUuid, + rosterSynced: match.rosterSynced, + eventStreamSynced: match.eventStreamSynced, + eventStreamFailed: match.eventStreamFailed, + needsRosterRefresh: playerMatch?.physicalDamageTaken === null, + needsGoldRefresh: Boolean(playerMatch) && (!Array.isArray(goldTimeline) || goldTimeline.length === 0), + }; + }); } export async function enrichPlayerMatches( @@ -155,17 +183,34 @@ export async function enrichPlayerMatches( const processCandidate = async (match: EnrichmentCandidate) => { let success = false; try { - if (!match.rosterSynced) { + if (!match.rosterSynced || match.needsRosterRefresh) { await resyncMatch(db, match.predggUuid, accessToken, true); - } else if (!match.eventStreamSynced) { - await syncMatchEventStream(db, match.id, match.predggUuid, accessToken, options.retryFailed ?? false); + } else if (!match.eventStreamSynced || match.needsGoldRefresh) { + await syncMatchEventStream( + db, + match.id, + match.predggUuid, + accessToken, + Boolean(options.retryFailed || match.needsGoldRefresh), + ); } const refreshed = await db.match.findUnique({ where: { id: match.id }, - select: { rosterSynced: true, eventStreamSynced: true }, + select: { + rosterSynced: true, + eventStreamSynced: true, + matchPlayers: { + where: { playerId }, + take: 1, + select: { goldEarnedAtInterval: true }, + }, + }, }); - success = Boolean(refreshed?.rosterSynced && refreshed.eventStreamSynced); + const refreshedGold = refreshed?.matchPlayers[0]?.goldEarnedAtInterval; + const hasRefreshedGold = Array.isArray(refreshedGold) && refreshedGold.length > 0; + success = Boolean(refreshed?.rosterSynced && refreshed.eventStreamSynced + && (!match.needsGoldRefresh || hasRefreshedGold)); if (!success) throw new Error('La partida no quedó completamente enriquecida.'); succeeded++; } catch (error) { diff --git a/apps/api/src/services/player-weekly-report-service.test.ts b/apps/api/src/services/player-weekly-report-service.test.ts index e5a4682..1a80036 100644 --- a/apps/api/src/services/player-weekly-report-service.test.ts +++ b/apps/api/src/services/player-weekly-report-service.test.ts @@ -89,10 +89,10 @@ describe('generatePlayerWeeklyReport', () => { it.each([ ['CARRY', ['csPerMinute', 'goldPerMinute', 'damagePerMinute', 'deathsPerMatch']], - ['SUPPORT', ['killParticipation', 'wardsPerMinute', 'deathsPerMatch', 'healingPerMinute']], + ['SUPPORT', ['killParticipation', 'wardsPerMinute', 'healingPerMinute', 'shieldingPerMinute']], ['MIDLANE', ['damagePerMinute', 'csPerMinute', 'killParticipation', 'deathsPerMatch']], ['JUNGLE', ['objectiveDamagePerMinute', 'killParticipation', 'damagePerMinute', 'deathsPerMatch']], - ['OFFLANE', ['damageTakenPerMinute', 'structureDamagePerMinute', 'csPerMinute', 'deathsPerMatch']], + ['OFFLANE', ['damageTakenPerMinute', 'mitigationPerMinute', 'structureDamagePerMinute', 'deathsPerMatch']], ])('uses role-specific evidence and actions for %s', async (role, expectedMetrics) => { mockDb.matchPlayer.findMany.mockResolvedValue([ match(1, { role }), diff --git a/apps/api/src/services/player-weekly-report-service.ts b/apps/api/src/services/player-weekly-report-service.ts index 303508a..4e3b438 100644 --- a/apps/api/src/services/player-weekly-report-service.ts +++ b/apps/api/src/services/player-weekly-report-service.ts @@ -18,6 +18,8 @@ type MatchRow = { totalDamageDealtToStructures: number | null; totalDamageTaken: number | null; totalHealingDone: number | null; + totalShieldingReceived: number | null; + totalDamageMitigated: number | null; match: { startTime: Date; winningTeam: string | null; @@ -40,7 +42,9 @@ type RoleMetricKey = | 'objectiveDamagePerMinute' | 'structureDamagePerMinute' | 'damageTakenPerMinute' - | 'healingPerMinute'; + | 'healingPerMinute' + | 'shieldingPerMinute' + | 'mitigationPerMinute'; export type PlayerRoleCoach = { role: PlayerRole; @@ -191,6 +195,8 @@ function aggregateRole(rows: MatchRow[]): RoleMetricValues { structureDamagePerMinute: metricPerMinute((row) => row.totalDamageDealtToStructures), damageTakenPerMinute: metricPerMinute((row) => row.totalDamageTaken), healingPerMinute: metricPerMinute((row) => row.totalHealingDone), + shieldingPerMinute: metricPerMinute((row) => row.totalShieldingReceived), + mitigationPerMinute: metricPerMinute((row) => row.totalDamageMitigated), }; } @@ -214,14 +220,16 @@ const METRIC_LABELS: Record = { structureDamagePerMinute: 'Daño a estructuras / min', damageTakenPerMinute: 'Daño recibido / min', healingPerMinute: 'Curación / min', + shieldingPerMinute: 'Escudos recibidos / min', + mitigationPerMinute: 'Daño mitigado / min', }; const ROLE_METRICS: Record = { CARRY: ['csPerMinute', 'goldPerMinute', 'damagePerMinute', 'deathsPerMatch'], - SUPPORT: ['killParticipation', 'wardsPerMinute', 'deathsPerMatch', 'healingPerMinute'], + SUPPORT: ['killParticipation', 'wardsPerMinute', 'healingPerMinute', 'shieldingPerMinute'], MIDLANE: ['damagePerMinute', 'csPerMinute', 'killParticipation', 'deathsPerMatch'], JUNGLE: ['objectiveDamagePerMinute', 'killParticipation', 'damagePerMinute', 'deathsPerMatch'], - OFFLANE: ['damageTakenPerMinute', 'structureDamagePerMinute', 'csPerMinute', 'deathsPerMatch'], + OFFLANE: ['damageTakenPerMinute', 'mitigationPerMinute', 'structureDamagePerMinute', 'deathsPerMatch'], }; function lowerThanBaseline(current: number | null, baseline: number | null, ratio = 0.9): boolean { @@ -669,6 +677,8 @@ export async function generatePlayerWeeklyReport(playerId: string, now = new Dat totalDamageDealtToStructures: true, totalDamageTaken: true, totalHealingDone: true, + totalShieldingReceived: true, + totalDamageMitigated: true, match: { select: { startTime: true, diff --git a/apps/api/src/services/predgg-benchmark-service.test.ts b/apps/api/src/services/predgg-benchmark-service.test.ts new file mode 100644 index 0000000..ff1c35b --- /dev/null +++ b/apps/api/src/services/predgg-benchmark-service.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../db.js', () => ({ + db: { player: { findUnique: vi.fn().mockResolvedValue({ predggId: 'predgg-player' }) } }, +})); + +vi.mock('./predgg-token-service.js', () => ({ + getPlatformAccessToken: vi.fn().mockResolvedValue({ accessToken: 'token', expiresAt: Date.now() + 60_000 }), + readPlatformOAuthStatus: vi.fn().mockResolvedValue({ + requestedScopes: [], grantedScopes: ['profile'], missingScopes: ['hero_leaderboard:read', 'matchup_statistic:read'], + capabilities: { profile: true, offlineRefresh: true, playerIntervals: false, heroLeaderboard: false, matchupStatistics: false }, + checkedAt: null, error: null, + }), +})); + +import { getPlayerPredggBenchmarks } from './predgg-benchmark-service.js'; + +describe('pred.gg player benchmarks', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => { + const query = JSON.parse(String(init?.body)).query as string; + if (query.includes('RiftlineRatingDistribution')) { + return { ok: true, status: 200, json: async () => ({ data: { ratingDistribution: null }, errors: [{ message: 'Forbidden' }] }) }; + } + return { + ok: true, status: 200, json: async () => ({ data: { + player: { + ratings: [{ points: 250, percentile: 0.42, rating: { id: '12', name: 'Split', startTime: '2026-06-30T12:00:00Z', endTime: null }, rank: { name: 'Bronze', tierName: 'I' } }], + heroStatistics: { results: [{ + hero: { slug: 'dekker' }, matchesPlayed: 10, matchesWon: 6, totalKills: 10, totalDeaths: 20, + totalAssists: 80, totalHeroDamage: 100_000, totalGold: 100_000, totalMinionsKilled: 500, + totalWardsPlaced: 100, totalWardsDestroyed: 10, totalTime: 18_000, + }] }, + }, + hero: { id: '2', slug: 'dekker', generalStatistic: { result: { + matchesPlayed: 100, matchesWon: 50, matchesPlayedMirrorless: 98, matchesWonMirrorless: 49, + totalKills: 100, totalDeaths: 250, totalAssists: 700, heroDamage: 900_000, gold: 950_000, + totalMinionsKilled: 4_500, wardsPlaced: 850, wardsDestroyed: 90, totalSecondsPlayed: 180_000, + totalGoldAt15: 700_000, totalEnemyGoldAt15: 710_000, totalFirstTowerTime: 80_000, totalEnemyFirstTowerTime: 78_000, + } } }, + } }), + }; + })); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it('compares personal metrics while degrading locked capabilities independently', async () => { + const result = await getPlayerPredggBenchmarks('player-1', { heroSlug: 'dekker', role: 'SUPPORT', gameMode: 'RANKED' }); + + expect(result.benchmark.available).toBe(true); + expect('comparison' in result.benchmark && result.benchmark.comparison.find((metric) => metric.key === 'winRate')).toMatchObject({ player: 60, population: 50, delta: 10 }); + expect(result.specialists).toMatchObject({ available: false, reason: 'Falta el permiso hero_leaderboard:read.' }); + expect(result.matchups).toMatchObject({ available: false, reason: 'Falta el permiso matchup_statistic:read.' }); + expect(result.ratingDistribution).toMatchObject({ available: false, reason: 'Pred.gg no ha concedido acceso a la distribución de rango.' }); + }); +}); diff --git a/apps/api/src/services/predgg-benchmark-service.ts b/apps/api/src/services/predgg-benchmark-service.ts new file mode 100644 index 0000000..db1c980 --- /dev/null +++ b/apps/api/src/services/predgg-benchmark-service.ts @@ -0,0 +1,194 @@ +import { db } from '../db.js'; +import { AppError } from '../middleware/error-handler.js'; +import { getPlatformAccessToken, readPlatformOAuthStatus } from './predgg-token-service.js'; + +const GQL_URL = process.env.PRED_GG_GQL_URL ?? 'https://pred.gg/gql'; + +async function gql(accessToken: string, query: string, variables: Record): Promise { + const response = await fetch(GQL_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, + body: JSON.stringify({ query, variables }), + }); + const payload = await response.json() as { data?: T; errors?: Array<{ message: string }> }; + if (!response.ok || payload.errors?.length || !payload.data) { + throw new Error(payload.errors?.map((error) => error.message).join('; ') || `pred.gg HTTP ${response.status}`); + } + return payload.data; +} + +const BENCHMARK_QUERY = ` + query RiftlinePlayerBenchmark( + $playerId: ID!, $heroSlug: String!, + $playerFilter: PlayerHeroStatisticFilterInput, + $globalFilter: HeroGeneralStatisticFilterInput + ) { + player(by: { id: $playerId }) { + ratings { points percentile rating { id name startTime endTime } rank { name tierName } } + heroStatistics(filter: $playerFilter) { + results { + hero { slug } + matchesPlayed matchesWon totalKills totalDeaths totalAssists totalHeroDamage totalGold + totalMinionsKilled totalWardsPlaced totalWardsDestroyed totalTime + } + } + } + hero(by: { slug: $heroSlug }) { + id slug + generalStatistic(filter: $globalFilter) { + result { + matchesPlayed matchesWon matchesPlayedMirrorless matchesWonMirrorless + totalKills totalDeaths totalAssists heroDamage gold totalMinionsKilled wardsPlaced wardsDestroyed totalSecondsPlayed + totalGoldAt15 totalEnemyGoldAt15 totalFirstTowerTime totalEnemyFirstTowerTime + } + } + } + } +`; + +type Aggregate = { + matchesPlayed: number; matchesWon: number; totalKills: number; totalDeaths: number; totalAssists: number; + totalHeroDamage?: number; heroDamage?: number; totalGold?: number; gold?: number; + totalMinionsKilled: number; totalWardsPlaced?: number; wardsPlaced?: number; + totalWardsDestroyed?: number; wardsDestroyed?: number; totalTime?: number; totalSecondsPlayed?: number; +}; + +function round(value: number): number { return Math.round(value * 100) / 100; } +function metrics(row: Aggregate | null | undefined, global = false) { + if (!row || row.matchesPlayed <= 0) return null; + const seconds = global ? row.totalSecondsPlayed ?? 0 : row.totalTime ?? 0; + const minutes = Math.max(seconds / 60, 1); + return { + matches: row.matchesPlayed, + winRate: round((row.matchesWon / row.matchesPlayed) * 100), + kda: round((row.totalKills + row.totalAssists) / Math.max(row.totalDeaths, 1)), + damagePerMinute: round((global ? row.heroDamage ?? 0 : row.totalHeroDamage ?? 0) / minutes), + goldPerMinute: round((global ? row.gold ?? 0 : row.totalGold ?? 0) / minutes), + csPerMinute: round(row.totalMinionsKilled / minutes), + wardsPerMatch: round((global ? row.wardsPlaced ?? 0 : row.totalWardsPlaced ?? 0) / row.matchesPlayed), + }; +} + +function unavailable(reason: string) { return { available: false as const, reason, results: [] as unknown[] }; } + +export async function getPlayerPredggBenchmarks(playerId: string, options: { heroSlug: string; role?: string; gameMode?: string }) { + const player = await db.player.findUnique({ where: { id: playerId }, select: { predggId: true } }); + if (!player) throw new AppError(404, 'Player not found', 'PLAYER_NOT_FOUND'); + const token = await getPlatformAccessToken(); + const oauth = await readPlatformOAuthStatus(); + if (!token) { + return { + heroSlug: options.heroSlug, role: options.role ?? null, gameMode: options.gameMode ?? null, + oauth, benchmark: unavailable('Conecta pred.gg para comparar tu rendimiento.'), + specialists: unavailable('Conecta pred.gg.'), matchups: unavailable('Conecta pred.gg.'), + ratingDistribution: unavailable('Conecta pred.gg.'), + }; + } + + const playerFilter = { + ...(options.role ? { roles: [options.role] } : {}), + ...(options.gameMode ? { gameModes: [options.gameMode] } : {}), + }; + const globalFilter = playerFilter; + const base = await gql<{ + player: { ratings: Array<{ points: number; percentile: number | null; rating: { id: string; name: string; startTime: string; endTime: string | null }; rank: { name: string; tierName: string } | null }>; heroStatistics: { results: Array } } | null; + hero: { id: string; slug: string; generalStatistic: { result: (Aggregate & { matchesPlayedMirrorless: number; matchesWonMirrorless: number; totalGoldAt15: number; totalEnemyGoldAt15: number; totalFirstTowerTime: number; totalEnemyFirstTowerTime: number }) | null } | null } | null; + }>(token.accessToken, BENCHMARK_QUERY, { playerId: player.predggId, heroSlug: options.heroSlug, playerFilter, globalFilter }); + + const personalRaw = base.player?.heroStatistics.results.find((row) => row.hero.slug === options.heroSlug) ?? null; + const globalRaw = base.hero?.generalStatistic?.result ?? null; + const personal = metrics(personalRaw); + const population = metrics(globalRaw, true); + const currentRating = [...(base.player?.ratings ?? [])] + .filter((rating) => rating.points != null) + .sort((a, b) => { + if (a.rating.endTime === null && b.rating.endTime !== null) return -1; + if (a.rating.endTime !== null && b.rating.endTime === null) return 1; + return new Date(b.rating.startTime).getTime() - new Date(a.rating.startTime).getTime(); + })[0] ?? null; + const comparison = personal && population ? (['winRate', 'kda', 'damagePerMinute', 'goldPerMinute', 'csPerMinute', 'wardsPerMatch'] as const).map((key) => ({ + key, + player: personal[key], + population: population[key], + delta: round(personal[key] - population[key]), + })) : []; + + let specialists: { available: boolean; reason: string | null; results: unknown[] } = oauth.capabilities.heroLeaderboard + ? { available: true, reason: null, results: [] } + : unavailable('Falta el permiso hero_leaderboard:read.'); + if (oauth.capabilities.heroLeaderboard && base.hero) { + try { + const result = await gql<{ hero: { leaderboard: { results: Array<{ player: { id: string; name: string }; matchesPlayed: number; matchesWon: number; winrate: number }> } | null } | null }>(token.accessToken, ` + query RiftlineHeroSpecialists($heroSlug: String!) { + hero(by: { slug: $heroSlug }) { + leaderboard(timeframe: LAST_30_DAYS, scope: NONE, sortBy: MATCHES_PLAYED) { + results { player { id name } matchesPlayed matchesWon winrate } + } + } + } + `, { heroSlug: options.heroSlug }); + specialists = { available: true, reason: null, results: result.hero?.leaderboard?.results.slice(0, 10) ?? [] }; + } catch (error) { specialists = unavailable(error instanceof Error ? error.message : 'No disponible'); } + } + + let matchups: { available: boolean; reason: string | null; results: unknown[] } = oauth.capabilities.matchupStatistics + ? { available: true, reason: null, results: [] } + : unavailable('Falta el permiso matchup_statistic:read.'); + if (oauth.capabilities.matchupStatistics && base.hero) { + try { + const roleFilter = options.role ? `roles: [${options.role}], matchupRoles: [${options.role}]` : ''; + const modeFilter = options.gameMode ? `gameModes: [${options.gameMode}]` : ''; + const result = await gql<{ hero: { matchupStatistic: { results: Array<{ matchupHero: { slug: string; data: { displayName: string } | null }; matchesPlayed: number; winrate: number; loserate: number; firstTowerTime: number; firstTowerTimeDiff: number }> } | null } | null }>(token.accessToken, ` + query RiftlineHeroMatchups($heroSlug: String!) { + hero(by: { slug: $heroSlug }) { + matchupStatistic(filter: { ${[modeFilter, roleFilter].filter(Boolean).join(', ')} }, metric: WINRATE, order: DESC, isAlly: false, sameRole: true) { + results { matchupHero { slug data { displayName } } matchesPlayed winrate loserate firstTowerTime firstTowerTimeDiff } + } + } + } + `, { heroSlug: options.heroSlug }); + matchups = { available: true, reason: null, results: result.hero?.matchupStatistic?.results.slice(0, 20) ?? [] }; + } catch (error) { matchups = unavailable(error instanceof Error ? error.message : 'No disponible'); } + } + + let ratingDistribution: { available: boolean; reason: string | null; results: unknown[] } = unavailable('Pred.gg no ha concedido acceso a la distribución de rating.'); + if (currentRating) { + try { + const result = await gql<{ ratingDistribution: { results: Array<{ bucket: number; count: number; rank: string | null }> } | null }>(token.accessToken, ` + query RiftlineRatingDistribution($ratingId: ID!) { + ratingDistribution(ratingId: $ratingId, bucketSize: RANK) { results { bucket count rank } } + } + `, { ratingId: currentRating.rating.id }); + ratingDistribution = { available: true, reason: null, results: result.ratingDistribution?.results ?? [] }; + } catch (error) { + const reason = error instanceof Error ? error.message : 'No disponible'; + ratingDistribution = unavailable(reason.includes('Forbidden') + ? 'Pred.gg no ha concedido acceso a la distribución de rango.' + : reason); + } + } + + return { + heroSlug: options.heroSlug, + role: options.role ?? null, + gameMode: options.gameMode ?? null, + oauth, + benchmark: { + available: Boolean(personal && population), + reason: personal && population ? null : 'No hay muestra suficiente para este héroe y contexto.', + player: personal, + population, + comparison, + earlyGame: globalRaw ? { + averageGoldAt15: round(globalRaw.totalGoldAt15 / Math.max(globalRaw.matchesPlayed, 1)), + averageEnemyGoldAt15: round(globalRaw.totalEnemyGoldAt15 / Math.max(globalRaw.matchesPlayed, 1)), + averageFirstTowerSeconds: round(globalRaw.totalFirstTowerTime / Math.max(globalRaw.matchesPlayed, 1)), + averageEnemyFirstTowerSeconds: round(globalRaw.totalEnemyFirstTowerTime / Math.max(globalRaw.matchesPlayed, 1)), + } : null, + rating: currentRating, + }, + specialists, + matchups, + ratingDistribution, + }; +} diff --git a/apps/api/src/services/predgg-oauth.ts b/apps/api/src/services/predgg-oauth.ts index 30ad891..03f7d1e 100644 --- a/apps/api/src/services/predgg-oauth.ts +++ b/apps/api/src/services/predgg-oauth.ts @@ -1,7 +1,7 @@ import { logger } from '../logger.js'; const DEFAULT_AUTHORIZE_URL = 'https://pred.gg/oauth2/authorize'; -const DEFAULT_SCOPES = 'offline_access profile player:read:interval hero_leaderboard:read matchup_statistic:read'; +export const DEFAULT_PREDGG_SCOPES = 'offline_access profile player:read:interval hero_leaderboard:read matchup_statistic:read'; function resolveAuthorizeUrl(value: string | undefined): string { if (!value) return DEFAULT_AUTHORIZE_URL; @@ -15,8 +15,8 @@ function resolveAuthorizeUrl(value: string | undefined): string { } function resolveScopes(value: string | undefined): string { - const scopes = (value ?? DEFAULT_SCOPES).trim().split(/\s+/).filter(Boolean); - return scopes.length > 0 ? scopes.join(' ') : DEFAULT_SCOPES; + const scopes = (value ?? DEFAULT_PREDGG_SCOPES).trim().split(/\s+/).filter(Boolean); + return scopes.length > 0 ? scopes.join(' ') : DEFAULT_PREDGG_SCOPES; } export type ClientAuthMethod = 'none' | 'body' | 'basic'; diff --git a/apps/api/src/services/predgg-token-service.test.ts b/apps/api/src/services/predgg-token-service.test.ts index 0040396..f48ca18 100644 --- a/apps/api/src/services/predgg-token-service.test.ts +++ b/apps/api/src/services/predgg-token-service.test.ts @@ -33,9 +33,12 @@ vi.mock('../db.js', () => ({ vi.mock('./predgg-oauth.js', () => ({ exchangeToken: mocks.exchangeToken, + predggOAuthConfig: { + scopes: 'offline_access profile player:read:interval hero_leaderboard:read matchup_statistic:read', + }, })); -import { getPlatformAccessToken } from './predgg-token-service.js'; +import { getPlatformAccessToken, inspectPlatformOAuthStatus, savePlatformOAuthTokens } from './predgg-token-service.js'; describe('pred.gg platform token manager', () => { beforeEach(() => { @@ -71,4 +74,30 @@ describe('pred.gg platform token manager', () => { expect(cached?.accessToken).toBe('new-access-token'); expect(mocks.exchangeToken).toHaveBeenCalledTimes(1); }); + + it('persists granted scopes and reports missing capabilities without exposing tokens', async () => { + await savePlatformOAuthTokens({ + access_token: 'access-token', refresh_token: 'refresh-token', expires_in: 3600, + scope: 'profile offline_access', + }); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: { currentAuth: { scope: 'profile offline_access' } } }), + })); + + const status = await inspectPlatformOAuthStatus('access-token'); + + expect(status.grantedScopes).toEqual(['offline_access', 'profile']); + expect(status.missingScopes).toEqual([ + 'hero_leaderboard:read', 'matchup_statistic:read', 'player:read:interval', + ]); + expect(status.capabilities).toMatchObject({ + profile: true, offlineRefresh: true, playerIntervals: false, + heroLeaderboard: false, matchupStatistics: false, + }); + expect(JSON.stringify(status)).not.toContain('access-token'); + expect(mocks.credentials.get('predgg_granted_scopes')).toBe('offline_access profile'); + }); }); diff --git a/apps/api/src/services/predgg-token-service.ts b/apps/api/src/services/predgg-token-service.ts index 98b323c..4d5826e 100644 --- a/apps/api/src/services/predgg-token-service.ts +++ b/apps/api/src/services/predgg-token-service.ts @@ -1,11 +1,12 @@ import type { Prisma } from '@prisma/client'; import { db } from '../db.js'; import { logger } from '../logger.js'; -import { exchangeToken, type TokenResponse } from './predgg-oauth.js'; +import { exchangeToken, predggOAuthConfig, type TokenResponse } from './predgg-oauth.js'; const REFRESH_TOKEN_KEY = 'predgg_refresh_token'; const ACCESS_TOKEN_KEY = 'predgg_access_token'; const ACCESS_EXPIRES_AT_KEY = 'predgg_access_expires_at'; +const GRANTED_SCOPES_KEY = 'predgg_granted_scopes'; const ACCESS_TOKEN_BUFFER_MS = 10 * 60 * 1000; const DEFAULT_ACCESS_TTL_SECONDS = 3600; const ADVISORY_LOCK_KEY = 1_947_420_126; @@ -23,6 +24,22 @@ export type PlatformAccessToken = { expiresAt: number; }; +export type PredggOAuthCapability = + | 'profile' + | 'offlineRefresh' + | 'playerIntervals' + | 'heroLeaderboard' + | 'matchupStatistics'; + +export interface PredggOAuthStatus { + requestedScopes: string[]; + grantedScopes: string[]; + missingScopes: string[]; + capabilities: Record; + checkedAt: string | null; + error: string | null; +} + type CredentialClient = Pick; let inFlightRefresh: Promise | null = null; @@ -73,10 +90,90 @@ async function persistTokenResponse(client: CredentialClient, data: TokenRespons })); } + if (data.scope?.trim()) { + writes.push(client.platformCredential.upsert({ + where: { key: GRANTED_SCOPES_KEY }, + update: { value: normalizeScopes(data.scope).join(' ') }, + create: { key: GRANTED_SCOPES_KEY, value: normalizeScopes(data.scope).join(' ') }, + })); + } + await Promise.all(writes); return { accessToken: data.access_token, expiresAt }; } +function normalizeScopes(value: string | null | undefined): string[] { + return [...new Set((value ?? '').trim().split(/\s+/).filter(Boolean))].sort(); +} + +function buildOAuthStatus(grantedScope: string | null | undefined, error: string | null = null): PredggOAuthStatus { + const requestedScopes = normalizeScopes(predggOAuthConfig.scopes); + const grantedScopes = normalizeScopes(grantedScope); + const granted = new Set(grantedScopes); + return { + requestedScopes, + grantedScopes, + missingScopes: requestedScopes.filter((scope) => !granted.has(scope)), + capabilities: { + profile: granted.has('profile'), + offlineRefresh: granted.has('offline_access'), + playerIntervals: granted.has('player:read:interval'), + heroLeaderboard: granted.has('hero_leaderboard:read'), + matchupStatistics: granted.has('matchup_statistic:read'), + }, + checkedAt: platformTokenState.lastCheckedAt, + error, + }; +} + +/** Returns the last known grant without exposing any credential values. */ +export async function readPlatformOAuthStatus(): Promise { + const credential = await db.platformCredential.findUnique({ where: { key: GRANTED_SCOPES_KEY } }); + return buildOAuthStatus(credential?.value, credential ? null : 'Granted scopes have not been inspected yet'); +} + +/** + * Verifies the grant carried by the current access token against pred.gg. + * This also backfills scope metadata for credentials stored before scope tracking existed. + */ +export async function inspectPlatformOAuthStatus(accessToken: string): Promise { + const endpoint = process.env.PRED_GG_GQL_URL ?? 'https://pred.gg/gql'; + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, + body: JSON.stringify({ query: 'query RiftlineOAuthCapabilities { currentAuth { scope } }' }), + signal: AbortSignal.timeout(5000), + }); + const payload = await response.json() as { + data?: { currentAuth?: { scope?: string | null } | null }; + errors?: Array<{ message?: string }>; + }; + const scope = payload.data?.currentAuth?.scope; + if (!response.ok || !scope) { + const message = payload.errors?.map((item) => item.message).filter(Boolean).join('; ') + || `pred.gg capability check failed (HTTP ${response.status})`; + const stored = await readPlatformOAuthStatus(); + return { ...stored, checkedAt: new Date().toISOString(), error: message }; + } + + const normalized = normalizeScopes(scope).join(' '); + await db.platformCredential.upsert({ + where: { key: GRANTED_SCOPES_KEY }, + update: { value: normalized }, + create: { key: GRANTED_SCOPES_KEY, value: normalized }, + }); + return { ...buildOAuthStatus(normalized), checkedAt: new Date().toISOString() }; + } catch (error) { + const stored = await readPlatformOAuthStatus(); + return { + ...stored, + checkedAt: new Date().toISOString(), + error: error instanceof Error ? error.message : String(error), + }; + } +} + /** Stores an OAuth callback response as the single platform credential source. */ export async function savePlatformOAuthTokens(data: TokenResponse): Promise { const stored = await db.$transaction((tx) => persistTokenResponse(tx, data)); diff --git a/apps/api/src/services/sync-service.test.ts b/apps/api/src/services/sync-service.test.ts index 6dd21fe..57e9649 100644 --- a/apps/api/src/services/sync-service.test.ts +++ b/apps/api/src/services/sync-service.test.ts @@ -102,6 +102,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); describe('syncMatchEventStream', () => { @@ -133,6 +134,57 @@ describe('syncMatchEventStream', () => { data: { goldEarnedAtInterval: [100, 200] }, }); }); + + it('syncs the gold timeline without requiring a user OAuth token', async () => { + const mockDb = createMockDb(); + + await syncMatchEventStream(mockDb as never, 'match-1', 'predgg-match-1'); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ Authorization: expect.any(String) }), + }), + ); + expect(mockDb.matchPlayer.updateMany).toHaveBeenCalledWith({ + where: { matchId: 'match-1', predggPlayerUuid: 'predgg-warder' }, + data: { goldEarnedAtInterval: [100, 200] }, + }); + }); + + it('falls back to the public gold-only query when protected event data is rejected', async () => { + vi.stubEnv('PRED_GG_CLIENT_SECRET', 'stale-api-key'); + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ errors: [{ message: 'Forbidden' }] }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + match: { + matchPlayers: [{ + player: { id: 'predgg-warder' }, goldEarnedAtInterval: [100, 200], + }], + }, + }, + }), + } as Response); + const mockDb = createMockDb(); + + await syncMatchEventStream(mockDb as never, 'match-1', 'predgg-match-1'); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(fetch).mock.calls[0][1]).toEqual(expect.objectContaining({ + headers: expect.objectContaining({ 'X-Api-Key': 'stale-api-key' }), + })); + expect(vi.mocked(fetch).mock.calls[1][1]).toEqual(expect.objectContaining({ + headers: { 'Content-Type': 'application/json' }, + })); + expect(mockDb.matchPlayer.updateMany).toHaveBeenCalled(); + expect(mockDb.heroKill.deleteMany).not.toHaveBeenCalled(); + }); }); describe('repairEventStreamPlayerIds', () => { diff --git a/apps/api/src/services/sync-service.ts b/apps/api/src/services/sync-service.ts index f28982c..b21c7d6 100644 --- a/apps/api/src/services/sync-service.ts +++ b/apps/api/src/services/sync-service.ts @@ -8,7 +8,6 @@ import { logger } from '../logger.js'; import { syncHeroMeta } from './hero-meta-service.js'; const GQL_URL = process.env.PRED_GG_GQL_URL ?? 'https://pred.gg/gql'; -const API_KEY = process.env.PRED_GG_CLIENT_SECRET; const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours — active players const STALE_THRESHOLD_INACTIVE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days — inactive players const ACTIVE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // player with match in last 7 days = active @@ -28,23 +27,31 @@ async function predggQuery( query: string, variables?: Record, userToken?: string, + options: { publicOnly?: boolean } = {}, ): Promise { const headers: Record = { 'Content-Type': 'application/json' }; + const apiKey = process.env.PRED_GG_CLIENT_SECRET; // User OAuth token takes priority — unlocks player data - if (userToken) headers['Authorization'] = `Bearer ${userToken}`; - else if (API_KEY) headers['X-Api-Key'] = API_KEY; + if (!options.publicOnly) { + if (userToken) headers['Authorization'] = `Bearer ${userToken}`; + else if (apiKey) headers['X-Api-Key'] = apiKey; + } - const res = await fetch(GQL_URL, { - method: 'POST', - headers, - body: JSON.stringify({ query, variables }), - }); + const execute = async (requestHeaders: Record): Promise => { + const res = await fetch(GQL_URL, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify({ query, variables }), + }); - if (!res.ok) throw new Error(`pred.gg HTTP ${res.status}: ${await res.text()}`); + if (!res.ok) throw new Error(`pred.gg HTTP ${res.status}: ${await res.text()}`); - const json = (await res.json()) as { data?: T; errors?: { message: string }[] }; - if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join(', ')); - return json.data as T; + const json = (await res.json()) as { data?: T; errors?: { message: string }[] }; + if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join(', ')); + return json.data as T; + }; + + return execute(headers); } // ── Types ──────────────────────────────────────────────────────────────────── @@ -894,43 +901,311 @@ export async function syncVersionsFromPredgg(db: PrismaClient): Promise // When new versions are detected, refresh hero meta (abilities/stats may change per patch) if (upserted > 0) { syncHeroMeta(db).catch((err) => logger.warn({ err }, 'hero-meta: background sync failed')); + syncGameCatalog(db).catch((err) => logger.warn({ err }, 'game-catalog: background sync failed')); } logger.info({ upserted, elapsed: Date.now() - start }, 'versions sync complete'); return upserted; } -const MATCH_DETAIL_QUERY = ` +interface PredggCatalogItemData { + id: string; + displayName: string; + rarity: string; + slotType: string; + class: string; + aggressionType: string; + price: number; + totalPrice: number; + isEvolved: boolean; + isHidden: boolean; + icon: string; + smallIcon: string; + stats: Array<{ id: string; stat: string; value: number; showPercent: boolean }>; + effects: Array<{ id: string; name: string; text: string; active: boolean; condition: string | null; cooldown: string | null }>; + buildsFrom: Array<{ id: string }>; + buildsInto: Array<{ id: string }>; + blockedBy: Array<{ id: string }>; + blocks: Array<{ id: string }>; + item: { id: string; name: string; slug: string }; +} + +interface PredggCatalogPerkData { + id: string; + name: string; + displayName: string; + slot: string; + icon: string; + iconCenterPosition: number[] | null; + simpleDescription: string | null; + description: string; + aggressionTypes: string[]; + displayOrder: number; + unlockLevel: number | null; + hero: { slug: string } | null; + eternalCategory: { id: string; name: string } | null; + minorBlessings: Array<{ perk: { id: string } }> | null; + perk: { id: string; name: string }; +} + +interface PredggEternalCategory { + id: string; + name: string; + data: { + id: string; + displayName: string; + displayNameSingular: string; + description: string; + color: string; + perks: Array<{ perk: { id: string } }>; + } | null; +} + +const GAME_CATALOG_QUERY = ` + query RiftlineGameCatalog($versionId: ID!) { + version(by: { id: $versionId }) { + id name + itemData { + id displayName rarity slotType class aggressionType price totalPrice isEvolved isHidden icon smallIcon + stats { id stat value showPercent } + effects { id name text active condition cooldown } + buildsFrom { id } + buildsInto { id } + blockedBy { id } + blocks { id } + item { id name slug } + } + perkData { + id name displayName slot icon iconCenterPosition simpleDescription description aggressionTypes displayOrder unlockLevel + hero { slug } + eternalCategory { id name } + minorBlessings { perk { id } } + perk { id name } + } + } + eternalCategories { + id name + data(versionId: $versionId) { + id displayName displayNameSingular description color + perks { perk { id } } + } + } + } +`; + +/** Synchronizes patch-specific items, hero augments, Eternals and blessings. */ +export async function syncGameCatalog(db: PrismaClient, versionPredggId?: string): Promise<{ + version: string; + items: number; + perks: number; + eternalCategories: number; +}> { + const localVersion = versionPredggId + ? await db.version.findUnique({ where: { predggId: versionPredggId } }) + : await db.version.findFirst({ orderBy: { releaseDate: 'desc' } }); + if (!localVersion) throw new Error('No local game version available for catalog sync'); + + const data = await predggQuery<{ + version: { id: string; name: string | null; itemData: PredggCatalogItemData[]; perkData: PredggCatalogPerkData[] } | null; + eternalCategories: PredggEternalCategory[]; + }>(GAME_CATALOG_QUERY, { versionId: localVersion.predggId }); + if (!data.version) throw new Error(`Version ${localVersion.predggId} not found on pred.gg`); + + const syncedAt = new Date(); + let itemCount = 0; + let perkCount = 0; + let categoryCount = 0; + + for (const itemData of data.version.itemData) { + const item = await db.gameItem.upsert({ + where: { predggId: itemData.item.id }, + update: { name: itemData.item.name, slug: itemData.item.slug }, + create: { predggId: itemData.item.id, name: itemData.item.name, slug: itemData.item.slug }, + }); + await db.gameItemVersion.upsert({ + where: { itemId_versionId: { itemId: item.id, versionId: localVersion.id } }, + update: { + predggDataId: itemData.id, displayName: itemData.displayName, rarity: itemData.rarity, + slotType: itemData.slotType, heroClass: itemData.class, + aggressionType: itemData.aggressionType === 'TESTING_OUR_SPACE_WITH_TEXT_123' ? null : itemData.aggressionType, + price: itemData.price, totalPrice: itemData.totalPrice, isEvolved: itemData.isEvolved, + isHidden: itemData.isHidden, icon: itemData.icon, smallIcon: itemData.smallIcon, + stats: itemData.stats, effects: itemData.effects, + buildsFromIds: itemData.buildsFrom.map((item) => item.id), + buildsIntoIds: itemData.buildsInto.map((item) => item.id), + blockedByIds: itemData.blockedBy.map((item) => item.id), + blocksIds: itemData.blocks.map((item) => item.id), syncedAt, + }, + create: { + itemId: item.id, versionId: localVersion.id, predggDataId: itemData.id, + displayName: itemData.displayName, rarity: itemData.rarity, slotType: itemData.slotType, + heroClass: itemData.class, + aggressionType: itemData.aggressionType === 'TESTING_OUR_SPACE_WITH_TEXT_123' ? null : itemData.aggressionType, + price: itemData.price, totalPrice: itemData.totalPrice, isEvolved: itemData.isEvolved, + isHidden: itemData.isHidden, icon: itemData.icon, smallIcon: itemData.smallIcon, + stats: itemData.stats, effects: itemData.effects, + buildsFromIds: itemData.buildsFrom.map((candidate) => candidate.id), + buildsIntoIds: itemData.buildsInto.map((candidate) => candidate.id), + blockedByIds: itemData.blockedBy.map((candidate) => candidate.id), + blocksIds: itemData.blocks.map((candidate) => candidate.id), syncedAt, + }, + }); + itemCount++; + } + + for (const perkData of data.version.perkData) { + const perkSlug = `${perkData.perk.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'perk'}-${perkData.perk.id}`; + const perk = await db.gamePerk.upsert({ + where: { predggId: perkData.perk.id }, + update: { name: perkData.perk.name, slug: perkSlug }, + create: { predggId: perkData.perk.id, name: perkData.perk.name, slug: perkSlug }, + }); + const validCategory = perkData.eternalCategory?.name === 'Placeholder' ? null : perkData.eternalCategory; + await db.gamePerkVersion.upsert({ + where: { perkId_versionId: { perkId: perk.id, versionId: localVersion.id } }, + update: { + predggDataId: perkData.id, displayName: perkData.displayName, slot: perkData.slot, + icon: perkData.icon, iconCenterPosition: perkData.iconCenterPosition ?? Prisma.DbNull, + simpleDescription: perkData.simpleDescription, description: perkData.description, + aggressionTypes: perkData.aggressionTypes, displayOrder: perkData.displayOrder, + unlockLevel: perkData.unlockLevel, heroSlug: perkData.hero?.slug ?? null, + eternalCategoryPredggId: validCategory?.id ?? null, + minorBlessingPredggIds: perkData.minorBlessings?.map((blessing) => blessing.perk.id) ?? [], syncedAt, + }, + create: { + perkId: perk.id, versionId: localVersion.id, predggDataId: perkData.id, + displayName: perkData.displayName, slot: perkData.slot, icon: perkData.icon, + iconCenterPosition: perkData.iconCenterPosition ?? Prisma.DbNull, + simpleDescription: perkData.simpleDescription, description: perkData.description, + aggressionTypes: perkData.aggressionTypes, displayOrder: perkData.displayOrder, + unlockLevel: perkData.unlockLevel, heroSlug: perkData.hero?.slug ?? null, + eternalCategoryPredggId: validCategory?.id ?? null, + minorBlessingPredggIds: perkData.minorBlessings?.map((blessing) => blessing.perk.id) ?? [], syncedAt, + }, + }); + perkCount++; + } + + for (const categoryData of data.eternalCategories.filter((category) => category.name !== 'Placeholder' && category.data)) { + const category = await db.eternalCategory.upsert({ + where: { predggId: categoryData.id }, + update: { name: categoryData.name }, + create: { predggId: categoryData.id, name: categoryData.name }, + }); + const detail = categoryData.data!; + await db.eternalCategoryVersion.upsert({ + where: { categoryId_versionId: { categoryId: category.id, versionId: localVersion.id } }, + update: { + predggDataId: detail.id, displayName: detail.displayName, + displayNameSingular: detail.displayNameSingular, description: detail.description, + color: detail.color, perkPredggIds: detail.perks.map((perk) => perk.perk.id), syncedAt, + }, + create: { + categoryId: category.id, versionId: localVersion.id, predggDataId: detail.id, + displayName: detail.displayName, displayNameSingular: detail.displayNameSingular, + description: detail.description, color: detail.color, + perkPredggIds: detail.perks.map((perk) => perk.perk.id), syncedAt, + }, + }); + categoryCount++; + } + + await db.syncLog.create({ + data: { entity: 'game-catalog', entityId: localVersion.predggId, operation: 'upsert', status: 'ok' }, + }); + logger.info({ version: localVersion.name, items: itemCount, perks: perkCount, eternalCategories: categoryCount }, 'game catalog sync complete'); + return { version: localVersion.name, items: itemCount, perks: perkCount, eternalCategories: categoryCount }; +} + +/** Backfills the current catalog and the most recent patches represented in match history. */ +export async function syncTrackedGameCatalogs(db: PrismaClient): Promise<{ + versions: number; + catalogs: Array<{ version: string; items: number; perks: number; eternalCategories: number }>; +}> { + const tracked = await db.match.findMany({ + where: { + versionId: { not: null }, + startTime: { gte: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) }, + }, + orderBy: { startTime: 'desc' }, + distinct: ['versionId'], + take: 16, + select: { version: { select: { predggId: true } } }, + }); + const latest = await db.version.findFirst({ orderBy: { releaseDate: 'desc' }, select: { predggId: true } }); + const versionIds = [...new Set([ + latest?.predggId, + ...tracked.map((row) => row.version?.predggId), + ].filter((id): id is string => Boolean(id)))]; + const catalogs = []; + for (const predggId of versionIds) { + const version = await db.version.findUnique({ where: { predggId }, select: { id: true } }); + if (!version) continue; + const [itemsAlreadySynced, perksAlreadySynced] = await Promise.all([ + db.gameItemVersion.count({ where: { versionId: version.id } }), + db.gamePerkVersion.count({ where: { versionId: version.id } }), + ]); + if (itemsAlreadySynced > 0 || perksAlreadySynced > 0) continue; + catalogs.push(await syncGameCatalog(db, predggId)); + } + return { versions: catalogs.length, catalogs }; +} + +function matchDetailQuery(includeProtectedFields: boolean): string { + const protectedFields = includeProtectedFields ? ` + abilityOrder { ability gameTime } + rating { + points newPoints isRankup + rating { id name } + rank { name tierName } + } + ` : ''; + return ` query GetMatch($uuid: String!) { match(by: { id: $uuid }) { - id uuid startTime duration gameMode region winningTeam + id uuid startTime endTime duration gameMode region winningTeam endReason spoilerBlockedUntil version { id } matchPlayers { name team role kills deaths assists heroDamage totalDamageDealt gold wardsPlaced wardsDestroyed level physicalDamageDealtToHeroes magicalDamageDealtToHeroes trueDamageDealtToHeroes heroDamageTaken totalDamageTaken totalHealingDone + crestHealingDone itemHealingDone utilityHealingDone totalShieldingReceived totalDamageMitigated + physicalDamageTaken magicalDamageTaken trueDamageTaken + physicalDamageTakenFromHeroes magicalDamageTakenFromHeroes trueDamageTakenFromHeroes totalDamageDealtToStructures totalDamageDealtToObjectives - largestCriticalStrike laneMinionsKilled goldSpent + largestCriticalStrike laneMinionsKilled minionsKilled + neutralMinionsKilled neutralMinionsTeamJungle neutralMinionsEnemyJungle goldSpent largestKillingSpree multiKill physicalDamageDealt magicalDamageDealt trueDamageDealt hero { slug } - inventoryItemData { name } - perks { id name data { displayName slot } } + inventoryItemData { name item { slug } } + perks { + id name + data { + displayName slot icon simpleDescription description unlockLevel + eternalCategory { id name } + } + } + ${protectedFields} player { id name isNameConsole } } } } `; +} interface PredggMatchDetail { id: string; uuid: string; startTime: string; + endTime: string | null; duration: number; gameMode: string; region: string | null; winningTeam: string | null; + endReason: string | null; + spoilerBlockedUntil: string | null; version: { id: string } | null; matchPlayers: Array<{ name: string | null; @@ -951,10 +1226,25 @@ interface PredggMatchDetail { heroDamageTaken: number | null; totalDamageTaken: number | null; totalHealingDone: number | null; + crestHealingDone: number | null; + itemHealingDone: number | null; + utilityHealingDone: number | null; + totalShieldingReceived: number | null; + totalDamageMitigated: number | null; + physicalDamageTaken: number | null; + magicalDamageTaken: number | null; + trueDamageTaken: number | null; + physicalDamageTakenFromHeroes: number | null; + magicalDamageTakenFromHeroes: number | null; + trueDamageTakenFromHeroes: number | null; totalDamageDealtToStructures: number | null; totalDamageDealtToObjectives: number | null; largestCriticalStrike: number | null; laneMinionsKilled: number | null; + minionsKilled: number | null; + neutralMinionsKilled: number | null; + neutralMinionsTeamJungle: number | null; + neutralMinionsEnemyJungle: number | null; goldSpent: number | null; largestKillingSpree: number | null; multiKill: number | null; @@ -962,8 +1252,17 @@ interface PredggMatchDetail { magicalDamageDealt: number | null; trueDamageDealt: number | null; hero: { slug: string } | null; - inventoryItemData: Array<{ name: string } | null>; - perks: Array<{ id: string; name: string; data: { displayName: string; slot: string } | null }> | null; + inventoryItemData: Array<{ name: string; item: { slug: string } | null } | null>; + perks: Array<{ id: string; name: string; data: { + displayName: string; slot: string; icon: string; simpleDescription: string; description: string; + unlockLevel: number; eternalCategory: { id: string; name: string } | null; + } | null }> | null; + abilityOrder?: Array<{ ability: string; gameTime: number }> | null; + rating?: { + points: number | null; newPoints: number | null; isRankup: boolean; + rating: { id: string; name: string }; + rank: { name: string; tierName: string } | null; + } | null; player: { id: string; name: string | null; isNameConsole: boolean } | null; }>; } @@ -976,9 +1275,11 @@ export async function resyncMatch( ): Promise { const existing = await db.match.findUnique({ where: { predggUuid }, select: { id: true, rosterSynced: true, eventStreamSynced: true } }); - // If fully synced and not forced, only run event stream sync if still pending + // If fully synced and not forced, only run event stream sync if still pending. + // pred.gg currently exposes the event stream without OAuth as well, so a + // missing/expired browser token must not leave the gold timeline empty. if (existing?.rosterSynced && !forceRoster) { - if (!existing.eventStreamSynced && userToken) { + if (!existing.eventStreamSynced) { await syncMatchEventStream(db, existing.id, predggUuid, userToken); } logger.info({ predggUuid }, 'match already roster-synced — skipping basic resync'); @@ -986,7 +1287,7 @@ export async function resyncMatch( } const data = await predggQuery<{ match: PredggMatchDetail | null }>( - MATCH_DETAIL_QUERY, + matchDetailQuery(Boolean(userToken)), { uuid: predggUuid }, userToken, ); @@ -1006,7 +1307,13 @@ export async function resyncMatch( await db.match.update({ where: { id: match.id }, - data: { syncedAt: new Date(), versionId: version?.id ?? undefined }, + data: { + syncedAt: new Date(), + versionId: version?.id ?? undefined, + endTime: data.match.endTime ? new Date(data.match.endTime) : null, + endReason: data.match.endReason ?? null, + spoilerBlockedUntil: data.match.spoilerBlockedUntil ? new Date(data.match.spoilerBlockedUntil) : null, + }, }); const now = new Date(); @@ -1057,25 +1364,54 @@ export async function resyncMatch( heroDamageTaken: mp.heroDamageTaken ?? null, totalDamageTaken: mp.totalDamageTaken ?? null, totalHealingDone: mp.totalHealingDone ?? null, + crestHealingDone: mp.crestHealingDone ?? null, + itemHealingDone: mp.itemHealingDone ?? null, + utilityHealingDone: mp.utilityHealingDone ?? null, + totalShieldingReceived: mp.totalShieldingReceived ?? null, + totalDamageMitigated: mp.totalDamageMitigated ?? null, + physicalDamageTaken: mp.physicalDamageTaken ?? null, + magicalDamageTaken: mp.magicalDamageTaken ?? null, + trueDamageTaken: mp.trueDamageTaken ?? null, + physicalDamageTakenFromHeroes: mp.physicalDamageTakenFromHeroes ?? null, + magicalDamageTakenFromHeroes: mp.magicalDamageTakenFromHeroes ?? null, + trueDamageTakenFromHeroes: mp.trueDamageTakenFromHeroes ?? null, totalDamageDealtToStructures: mp.totalDamageDealtToStructures ?? null, totalDamageDealtToObjectives: mp.totalDamageDealtToObjectives ?? null, largestCriticalStrike: mp.largestCriticalStrike ?? null, laneMinionsKilled: mp.laneMinionsKilled ?? null, + minionsKilled: mp.minionsKilled ?? null, + neutralMinionsKilled: mp.neutralMinionsKilled ?? null, + neutralMinionsTeamJungle: mp.neutralMinionsTeamJungle ?? null, + neutralMinionsEnemyJungle: mp.neutralMinionsEnemyJungle ?? null, goldSpent: mp.goldSpent ?? null, largestKillingSpree: mp.largestKillingSpree ?? null, multiKill: mp.multiKill ?? null, physicalDamageDealt: mp.physicalDamageDealt ?? null, magicalDamageDealt: mp.magicalDamageDealt ?? null, trueDamageDealt: mp.trueDamageDealt ?? null, - inventoryItems: (mp.inventoryItemData ?? []).filter(Boolean).map((i) => i!.name.toLowerCase()), + inventoryItems: (mp.inventoryItemData ?? []).filter(Boolean).map((i) => i!.item?.slug ?? i!.name.toLowerCase()), perkSlug: null, perks: (mp.perks ?? []).map((p) => ({ id: p.id, name: p.name, displayName: p.data?.displayName ?? p.name, slot: p.data?.slot ?? null, + icon: p.data?.icon ?? null, + simpleDescription: p.data?.simpleDescription ?? null, + description: p.data?.description ?? null, + unlockLevel: p.data?.unlockLevel ?? null, + eternalCategory: p.data?.eternalCategory ?? null, })), - abilityOrder: [], + abilityOrder: mp.abilityOrder ?? Prisma.DbNull, + ratingId: mp.rating?.rating.id ?? null, + ratingPoints: mp.rating?.points ?? null, + ratingNewPoints: mp.rating?.newPoints ?? null, + ratingDelta: mp.rating?.points != null && mp.rating?.newPoints != null + ? mp.rating.newPoints - mp.rating.points + : null, + ratingRankName: mp.rating?.rank?.name ?? null, + ratingTierName: mp.rating?.rank?.tierName ?? null, + ratingIsRankup: mp.rating?.isRankup ?? null, }, }); } catch (err: unknown) { @@ -1087,13 +1423,14 @@ export async function resyncMatch( // Mark roster as synced await db.match.update({ where: { id: match.id }, data: { rosterSynced: true } }); - // Sync event stream when Bearer token is available - if (userToken) { - try { - await syncMatchEventStream(db, match.id, predggUuid, userToken); - } catch (err) { - logger.warn({ matchId: match.id, err }, 'event stream sync failed — basic resync succeeded'); - } + // Event data (including goldEarnedAtInterval) is also available through the + // public/API-key request path. OAuth remains an optional enhancement. A + // forced roster refresh replaces MatchPlayer rows, so its event refresh must + // also be forced or their stored gold timelines would be lost. + try { + await syncMatchEventStream(db, match.id, predggUuid, userToken, forceRoster); + } catch (err) { + logger.warn({ matchId: match.id, err }, 'event stream sync failed — basic resync succeeded'); } } @@ -1153,6 +1490,20 @@ const MATCH_EVENT_STREAM_QUERY = ` } `; +// Gold intervals are currently public on pred.gg even when other event-stream +// fields require OAuth. Keeping this query separate prevents one protected +// field from making the whole economic timeline unavailable. +const MATCH_GOLD_TIMELINE_QUERY = ` + query GetMatchGoldTimeline($uuid: String!) { + match(by: { id: $uuid }) { + matchPlayers { + player { id } + goldEarnedAtInterval + } + } + } +`; + interface EventStreamLocation { x?: number | null; y?: number | null; @@ -1278,7 +1629,7 @@ export async function syncMatchEventStream( db: PrismaClient, matchId: string, predggUuid: string, - userToken: string, + userToken?: string, force = false, ): Promise { // Skip if already synced unless forced, to prevent double-insertion @@ -1300,7 +1651,26 @@ export async function syncMatchEventStream( } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized') || msg.includes('Forbidden')) { - throw err; // Auth error — let caller handle token refresh + const publicData = await predggQuery<{ + match: { matchPlayers: Array<{ player?: { id?: string | null } | null; goldEarnedAtInterval?: number[] | null }> } | null; + }>(MATCH_GOLD_TIMELINE_QUERY, { uuid: predggUuid }, undefined, { publicOnly: true }); + + let timelinesUpdated = 0; + for (const player of publicData.match?.matchPlayers ?? []) { + const predggPlayerId = player.player?.id; + if (!predggPlayerId || !player.goldEarnedAtInterval?.length) continue; + const result = await db.matchPlayer.updateMany({ + where: { matchId, predggPlayerUuid: predggPlayerId }, + data: { goldEarnedAtInterval: player.goldEarnedAtInterval }, + }); + timelinesUpdated += result.count; + } + + if (timelinesUpdated > 0) { + logger.warn({ matchId, predggUuid, timelinesUpdated }, 'protected event stream unavailable — public gold timeline synced'); + return; + } + throw err; // No useful public fallback — let caller handle token refresh } // API error that won't be fixed by retrying — mark as failed to skip in future runs await db.match.update({ where: { id: matchId }, data: { eventStreamFailed: true } }).catch(() => null); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 6618b50..59cb7da 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -387,7 +387,12 @@ export interface MatchPlayerDetail { level: number | null; inventoryItems: string[]; perkSlug: string | null; - perks: Array<{ id: string; name: string; displayName: string; slot: string | null }> | null; + perks: Array<{ + id: string; name: string; displayName: string; slot: string | null; + icon?: string | null; simpleDescription?: string | null; description?: string | null; + unlockLevel?: number | null; eternalCategory?: { id: string; name: string } | null; + }> | null; + abilityOrder: Array<{ ability: string; gameTime: number }> | null; rankLabel: string | null; ratingPoints: number | null; physicalDamageDealtToHeroes: number | null; @@ -396,16 +401,35 @@ export interface MatchPlayerDetail { heroDamageTaken: number | null; totalDamageTaken: number | null; totalHealingDone: number | null; + crestHealingDone: number | null; + itemHealingDone: number | null; + utilityHealingDone: number | null; + totalShieldingReceived: number | null; + totalDamageMitigated: number | null; + physicalDamageTaken: number | null; + magicalDamageTaken: number | null; + trueDamageTaken: number | null; + physicalDamageTakenFromHeroes: number | null; + magicalDamageTakenFromHeroes: number | null; + trueDamageTakenFromHeroes: number | null; totalDamageDealtToStructures: number | null; totalDamageDealtToObjectives: number | null; largestCriticalStrike: number | null; laneMinionsKilled: number | null; + minionsKilled: number | null; + neutralMinionsKilled: number | null; + neutralMinionsTeamJungle: number | null; + neutralMinionsEnemyJungle: number | null; goldSpent: number | null; largestKillingSpree: number | null; multiKill: number | null; physicalDamageDealt: number | null; magicalDamageDealt: number | null; trueDamageDealt: number | null; + matchRating: { + ratingId: string | null; points: number | null; newPoints: number | null; delta: number | null; + rankName: string | null; tierName: string | null; isRankup: boolean | null; + } | null; goldEarnedAtInterval: number[] | null; } @@ -467,10 +491,13 @@ export interface MatchDetail { id: string; predggUuid: string; startTime: string; + endTime: string | null; duration: number; gameMode: string; region: string | null; winningTeam: string | null; + endReason: string | null; + spoilerBlockedUntil: string | null; version: string | null; rosterSynced: boolean; eventStreamSynced: boolean; @@ -1164,6 +1191,213 @@ export interface LiveMatchResponse { events: MatchEvents; } +export interface ChampionPoolContextRow { + heroSlug: string; + matches: number; + wins: number; + kills: number; + deaths: number; + assists: number; + winRate: number; + kda: number; +} + +export interface ChampionPoolContext { + period: { days: number; from: string; to: string }; + filters: { + role: string | null; + gameMode: string | null; + heroSlug: string | null; + available: { roles: string[]; gameModes: string[]; heroes: string[] }; + }; + sampleSize: number; + heroes: ChampionPoolContextRow[]; + matchups: ChampionPoolContextRow[]; + synergies: ChampionPoolContextRow[]; + strongestMatchup: ChampionPoolContextRow | null; + hardestMatchup: ChampionPoolContextRow | null; +} + +export interface MatchBuildAnalysis { + matchId: string; + matchPlayerId: string; + heroSlug: string; + role: string | null; + result: 'win' | 'loss'; + context: { + catalogVersionId: string | null; + deaths: number; + damageReceived: { physical: number; magical: number; true: number; total: number }; + enemyHealing: number; + enemyShielding: number; + enemyMitigation: number; + enemyHeroes: Array<{ + heroSlug: string; + role: string | null; + playerName: string; + healing: number; + shieldingReceived: number; + damageMitigated: number; + }>; + }; + globalAnalysis: { + playerIdentity: Array<{ key: string; label: string; description: string; evidence: string[] }>; + enemyThreats: Array<{ + key: string; + label: string; + description: string; + severity: 'warning' | 'critical'; + evidence: string; + response: string; + sources: Array<{ heroSlug: string; sourceType: 'ability' | 'item'; name: string; description: string }>; + }>; + buildProfile: Array<{ key: string; label: string; description: string; items: string[] }>; + coherence: { score: number; summary: string }; + strengths: string[]; + tradeoffs: string[]; + }; + inventory: Array<{ + slug: string; + displayName: string; + aggressionType: string | null; + rarity: string | null; + slotType: string | null; + isEvolved: boolean; + isHidden: boolean; + stats: Array<{ stat: string; value: number; showPercent?: boolean }>; + effects: Array<{ name: string; text: string; condition?: string | null; cooldown?: string | null }>; + }>; + inventoryAssessments: Array<{ + slug: string; + displayName: string; + verdict: 'correct' | 'neutral'; + purpose: string[]; + functions: Array<{ key: string; label: string; description: string }>; + roleFit: string; + matchupFit: string; + tradeoff: string; + explanation: string; + }>; + verdict: { + grade: 'correct' | 'mostly_correct' | 'mixed' | 'poor'; + summary: string; + score: number; + }; + recommendedBuild: { + principle: string; + changes: Array<{ + signalKey: string; + action: 'add' | 'replace'; + item: { + slug: string; displayName: string; aggressionType: string | null; reason: string; totalPrice: number; + stats: Array<{ stat: string; value: number; showPercent?: boolean }>; + effects: Array<{ name: string; text: string; condition?: string | null; cooldown?: string | null }>; + }; + insteadOf: { slug: string; displayName: string } | null; + timing: string; + why: string; + }>; + sequence: Array<{ + position: number; + slug: string; + displayName: string; + phase: string; + reason: string; + replaces: { slug: string; displayName: string } | null; + stats: Array<{ stat: string; value: number; showPercent?: boolean }>; + effects: Array<{ name: string; text: string; condition?: string | null; cooldown?: string | null }>; + }>; + }; + purchaseTimeline: { + available: boolean; + ownPurchases: Array<{ gameTime: number; minute: string; itemName: string; itemSlug: string }>; + opponentResponses: Array<{ + gameTime: number; + minute: string; + heroSlug: string; + playerName: string; + itemName: string; + itemSlug: string; + explanation: string; + }>; + lesson: string; + }; + eternalLoadout: Array<{ + id: string; + displayName: string; + slot: string; + verdict: 'correct' | 'conditional' | 'questionable'; + why: string; + effect: string | null; + icon?: string | null; + }>; + recommendedLoadout: { + augment: { + id: string; slug: string; displayName: string; slot: string; icon: string | null; effect: string; reason: string; + replaces: { id: string; displayName: string } | null; + } | null; + eternal: { + id: string; slug: string; displayName: string; slot: string; icon: string | null; effect: string; reason: string; + replaces: { id: string; displayName: string } | null; + } | null; + blessings: Array<{ + id: string; slug: string; displayName: string; slot: string; icon: string | null; effect: string; reason: string; + replaces: { id: string; displayName: string } | null; + }>; + explanation: string; + }; + abilityOrder: Array<{ ability: string; gameTime: number }>; + signals: Array<{ + key: string; + severity: 'info' | 'warning' | 'critical'; + title: string; + evidence: string; + recommendation: string; + whyItMatters?: string; + sources?: Array<{ heroSlug: string; sourceType: 'ability' | 'item'; name: string; description: string }>; + appliesAgainst?: string[]; + suggestedItems?: Array<{ + slug: string; displayName: string; aggressionType: string | null; reason: string; totalPrice: number; + stats: Array<{ stat: string; value: number; showPercent?: boolean }>; + effects: Array<{ name: string; text: string; condition?: string | null; cooldown?: string | null }>; + }>; + }>; +} + +export interface PlayerBuildReview { + playerId: string; + period: { days: number; from: string; to: string }; + matches: Array<{ + match: { + id: string; + predggUuid: string; + startTime: string; + duration: number; + gameMode: string; + version: string | null; + }; + analysis: MatchBuildAnalysis; + }>; +} + +export interface PlayerBenchmarkResponse { + heroSlug: string; + role: string | null; + gameMode: string | null; + oauth: { grantedScopes: string[]; missingScopes: string[] }; + benchmark: { + available: boolean; + reason: string | null; + player?: Record | null; + population?: Record | null; + comparison?: Array<{ key: string; player: number; population: number; delta: number }>; + rating?: { points: number; percentile: number | null; rating: { id: string; name: string; startTime: string; endTime: string | null }; rank: { name: string; tierName: string } | null } | null; + }; + specialists: { available: boolean; reason: string | null; results: Array<{ player: { id: string; name: string }; matchesPlayed: number; matchesWon: number; winrate: number }> }; + matchups: { available: boolean; reason: string | null; results: unknown[] }; + ratingDistribution: { available: boolean; reason: string | null; results: unknown[] }; +} + // ── Fetch helper ───────────────────────────────────────────────────────────── async function fetchApi(endpoint: string, options: RequestInit = {}): Promise { @@ -1209,6 +1443,20 @@ export const apiClient = { }), getProfile: (id: string) => fetchApi(`/players/${id}`), scout: (id: string) => fetchApi(`/players/${id}/scout`), + championPoolContext: (id: string, filters: { days?: number; role?: string; gameMode?: string; heroSlug?: string }) => { + const params = new URLSearchParams(); + if (filters.days) params.set('days', String(filters.days)); + if (filters.role) params.set('role', filters.role); + if (filters.gameMode) params.set('gameMode', filters.gameMode); + if (filters.heroSlug) params.set('heroSlug', filters.heroSlug); + return fetchApi(`/players/${id}/champion-pool-context?${params}`); + }, + benchmarks: (id: string, filters: { heroSlug: string; role?: string; gameMode?: string }) => { + const params = new URLSearchParams({ heroSlug: filters.heroSlug }); + if (filters.role) params.set('role', filters.role); + if (filters.gameMode) params.set('gameMode', filters.gameMode); + return fetchApi(`/players/${id}/benchmarks?${params}`); + }, compare: (playerIdA: string, playerIdB: string) => fetchApi<{ players: [PlayerProfile, PlayerProfile]; deltas: unknown[] }>('/players/compare', { method: 'POST', @@ -1310,6 +1558,10 @@ export const apiClient = { syncPlayers: (id: string) => fetchApi(`/matches/${id}/sync`, { method: 'POST' }), getEvents: (id: string) => fetchApi(`/matches/${id}/events`), getLive: (predggUuid: string) => fetchApi(`/matches/live/${predggUuid}`), + buildAnalysis: (matchId: string, matchPlayerId: string) => + fetchApi(`/matches/${matchId}/build-analysis/${matchPlayerId}`), + liveBuildAnalysis: (predggUuid: string) => + fetchApi(`/matches/live/${predggUuid}/build-analysis`), }, reports: { @@ -1320,6 +1572,12 @@ export const apiClient = { }), playerWeekly: (playerId: string) => fetchApi(`/reports/player-weekly/${encodeURIComponent(playerId)}`), + playerBuilds: (playerId: string, options: { days?: number; limit?: number } = {}) => { + const params = new URLSearchParams(); + if (options.days) params.set('days', String(options.days)); + if (options.limit) params.set('limit', String(options.limit)); + return fetchApi(`/reports/player-builds/${encodeURIComponent(playerId)}${params.size ? `?${params}` : ''}`); + }, playerCoachChat: (playerId: string, question: string, history: Array<{ role: 'user' | 'assistant'; content: string }>) => fetchApi(`/reports/player-coach/${encodeURIComponent(playerId)}/chat`, { method: 'POST', @@ -1437,6 +1695,8 @@ export const apiClient = { admin: { syncHeroes: () => fetchApi<{ ok: boolean; synced: number; errors: number }>('/admin/sync-heroes', { method: 'POST' }), + syncGameCatalog: (versionId?: string) => + fetchApi<{ ok: boolean; version: string; items: number; perks: number; eternalCategories: number }>('/admin/sync-game-catalog', { method: 'POST', body: JSON.stringify({ versionId }) }), syncVersions: () => fetchApi('/admin/sync-versions', { method: 'POST' }), syncStaleAll: () => diff --git a/apps/web/src/pages/ApiStatusPage.tsx b/apps/web/src/pages/ApiStatusPage.tsx index dbda6f0..403c561 100644 --- a/apps/web/src/pages/ApiStatusPage.tsx +++ b/apps/web/src/pages/ApiStatusPage.tsx @@ -5,7 +5,17 @@ import { apiClient } from '../api/client'; import { useAuth } from '../hooks/useAuth'; interface ApiStatusData { - predgg: { status: 'ok' | 'error'; responseMs: number | null; error: string | null; endpoint: string }; + predgg: { + status: 'ok' | 'error'; responseMs: number | null; error: string | null; endpoint: string; + oauth: { + requestedScopes: string[]; + grantedScopes: string[]; + missingScopes: string[]; + capabilities: Record; + checkedAt: string | null; + error: string | null; + }; + }; syncErrors: { total: number; last24h: number; bySource: Array<{ source: string | null; _count: { id: number } }> }; lastSuccessfulSync: { syncedAt: string; entity: string; source: string | null } | null; } @@ -102,6 +112,37 @@ export default function ApiStatusPage() { {data.predgg.error} )} +
+
+ Permisos OAuth de pred.gg + + {data.predgg.oauth.missingScopes.length === 0 ? 'Completos' : `${data.predgg.oauth.missingScopes.length} pendientes`} + +
+
+ {data.predgg.oauth.requestedScopes.map((scope) => { + const granted = data.predgg.oauth.grantedScopes.includes(scope); + return ( + + {granted ? '✓' : '!'} {scope} + + ); + })} +
+ {data.predgg.oauth.missingScopes.length > 0 && ( +
+ Vuelve a conectar pred.gg para solicitar las funciones avanzadas. Renovar el token no añade permisos nuevos. +
+ )} + {data.predgg.oauth.error && ( +
{data.predgg.oauth.error}
+ )} +
diff --git a/apps/web/src/pages/MatchDetail.tsx b/apps/web/src/pages/MatchDetail.tsx index 7811aad..bbc43eb 100644 --- a/apps/web/src/pages/MatchDetail.tsx +++ b/apps/web/src/pages/MatchDetail.tsx @@ -5,7 +5,8 @@ import { HeroAvatarWithTooltip } from '../components/HeroAvatar'; import { useHeroMeta } from '../hooks/useHeroMeta'; import { ArrowLeft, Trophy, Skull, Clock, RefreshCw, Pencil, Check, X, Monitor, Gamepad2, Swords } from 'lucide-react'; import { toast } from 'sonner'; -import { apiClient, type MatchDetail as MatchDetailData, type MatchPlayerDetail, type MatchEvents, ApiErrorResponse } from '../api/client'; +import { apiClient, type MatchBuildAnalysis, type MatchDetail as MatchDetailData, type MatchPlayerDetail, type MatchEvents, ApiErrorResponse } from '../api/client'; +import { useAuth } from '../hooks/useAuth'; export default function MatchDetail({ liveMode = false }: { liveMode?: boolean }) { const { id, predggUuid } = useParams<{ id?: string; predggUuid?: string }>(); @@ -13,11 +14,15 @@ export default function MatchDetail({ liveMode = false }: { liveMode?: boolean } const location = useLocation(); const fromState = location.state as { fromPlayerId?: string; fromPlayerName?: string } | null; const heroMeta = useHeroMeta(); + const { user } = useAuth(); const [match, setMatch] = useState(null); const [preloadedEvents, setPreloadedEvents] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); - const [tab, setTab] = useState<'scoreboard' | 'statistics' | 'timeline' | 'analysis'>('scoreboard'); + const requestedTab = new URLSearchParams(location.search).get('tab'); + const [tab, setTab] = useState<'scoreboard' | 'statistics' | 'timeline' | 'analysis'>( + requestedTab === 'statistics' || requestedTab === 'timeline' || requestedTab === 'analysis' ? requestedTab : 'scoreboard', + ); const [syncing, setSyncing] = useState(false); const [editingPlayerId, setEditingPlayerId] = useState(null); const [editingValue, setEditingValue] = useState(''); @@ -94,7 +99,16 @@ export default function MatchDetail({ liveMode = false }: { liveMode?: boolean } try { const updated = await apiClient.matches.syncPlayers(id); setMatch(updated); - toast.success('Match data updated', { id: toastId }); + const updatedPlayers = [...updated.dusk, ...updated.dawn]; + const hasGoldTimeline = updatedPlayers.some((player) => player.goldEarnedAtInterval?.length); + if (hasGoldTimeline) { + toast.success('Partida actualizada, incluida la curva de oro', { id: toastId }); + } else { + toast.info('Partida actualizada, pero pred.gg no devolvió la curva de oro', { + id: toastId, + description: 'Repetir la sincronización no generará esos intervalos con los permisos actuales.', + }); + } } catch (err) { toast.error(err instanceof ApiErrorResponse ? err.error.message : 'Failed to sync match.', { id: toastId }); } finally { @@ -124,6 +138,7 @@ export default function MatchDetail({ liveMode = false }: { liveMode?: boolean } const hasHidden = [...match.dusk, ...match.dawn].some((p) => p.playerName === 'HIDDEN'); const fullySynced = match.rosterSynced && match.eventStreamSynced; const showSyncButton = !liveMode && (!fullySynced || hasHidden); + const personalMatchPlayer = [...match.dusk, ...match.dawn].find((player) => player.playerId === user?.linkedPlayerId) ?? null; const tabs: { key: typeof tab; label: string; disabled?: boolean }[] = [ { key: 'scoreboard', label: 'Scoreboard' }, @@ -215,13 +230,20 @@ export default function MatchDetail({ liveMode = false }: { liveMode?: boolean } /> )} {tab === 'statistics' && ( - + )} {tab === 'timeline' && ( )} {tab === 'analysis' && ( - + )}
); @@ -360,7 +382,7 @@ function ScoreboardTab({ match, duskWon, dawnWon, isAram, heroMeta, liveMode, ed {!isAram && } - + {/* Player rows */} @@ -388,6 +410,18 @@ function ScoreboardTab({ match, duskWon, dawnWon, isAram, heroMeta, liveMode, ed ); } +function perkSlotLabel(slot: string | null): string { + if (slot === 'HERO_SPECIFIC_1') return 'Augmento'; + if (slot === 'ETERNAL_1') return 'Eternal'; + if (slot === 'BLESSING_MINOR_1') return 'Bendición I'; + if (slot === 'BLESSING_MINOR_2') return 'Bendición II'; + return 'Mejora'; +} + +function stripPredggMarkup(value: string): string { + return value.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); +} + function PlayerRow({ player, isAram, teamColor, maxDamage, teamKills, matchDuration, heroMeta, liveMode, isEditing, editingValue, onStartEdit, onSaveEdit, onCancelEdit, onEditValueChange }: { player: MatchPlayerDetail; isAram: boolean; teamColor: string; maxDamage: number; teamKills: number; matchDuration: number; heroMeta: Map; @@ -556,22 +590,26 @@ function PlayerRow({ player, isAram, teamColor, maxDamage, teamKills, matchDurat ))} - {/* Augments */} -
+ {/* Hero augment + Eternal loadout */} +
{player.perks && player.perks.length > 0 ? player.perks.map((perk) => ( -
- {perk.displayName} + {perk.icon && } + + {perkSlotLabel(perk.slot)} + {perk.displayName} +
)) : ( @@ -586,14 +624,15 @@ function PlayerRow({ player, isAram, teamColor, maxDamage, teamKills, matchDurat const OBJ_GROUPS = [ { key: 'fangtooth', label: 'Fangtooth', types: ['FANGTOOTH', 'PRIMAL_FANGTOOTH'], color: '#ef4444' }, { key: 'prime', label: 'Prime', types: ['ORB_PRIME', 'MINI_PRIME'], color: '#a78bfa' }, - { key: 'shaper', label: 'Shaper', types: ['SHAPER'], color: '#c084fc' }, + { key: 'shaper', label: 'Shaper', types: ['SHAPER', 'GENESIS_CORE'], color: '#c084fc' }, { key: 'buffs', label: 'Buffs', types: ['RED_BUFF','BLUE_BUFF','CYAN_BUFF','GOLD_BUFF'], color: '#f0b429' }, - { key: 'river', label: 'River', types: ['RIVER','SEEDLING'], color: '#38d4c8' }, + { key: 'river', label: 'River', types: ['RIVER','SEEDLING','LANE_SEEDLING'], color: '#38d4c8' }, ] as const; -function AnalysisTab({ match, duskWon, dawnWon: _dawnWon, onResync, syncing, preloadedEvents }: { +function AnalysisTab({ match, duskWon, dawnWon: _dawnWon, onResync, syncing, preloadedEvents, buildCoachPlayerId, liveMode }: { match: MatchDetailData; duskWon: boolean; dawnWon: boolean; onResync: () => void; syncing: boolean; preloadedEvents?: MatchEvents; + buildCoachPlayerId: string | null; liveMode: boolean; }) { const [events, setEvents] = useState(preloadedEvents ?? null); const [loadingEvents, setLoadingEvents] = useState(false); @@ -610,15 +649,18 @@ function AnalysisTab({ match, duskWon, dawnWon: _dawnWon, onResync, syncing, pre if (!match.eventStreamSynced) { return ( -
-
Analysis not available
-
- Sync this match to load objective control, gold timeline and deaths before objectives. +
+ {(liveMode || buildCoachPlayerId) && } +
+
Objective analysis not available
+
+ Sync this match to load objective control, gold timeline and deaths before objectives. El análisis de build superior no depende de estos eventos. +
+
-
); } @@ -675,6 +717,7 @@ function AnalysisTab({ match, duskWon, dawnWon: _dawnWon, onResync, syncing, pre return (
+ {(liveMode || buildCoachPlayerId) && } {/* ── Objective Control ── */}
@@ -720,9 +763,13 @@ function AnalysisTab({ match, duskWon, dawnWon: _dawnWon, onResync, syncing, pre
) : match.eventStreamSynced && !hasGold ? ( -
- Gold timeline not available — re-sync to fetch per-minute gold data. - +
+
+
Curva de oro no disponible
+
+ La partida y sus eventos están actualizados, pero pred.gg no devolvió los intervalos de oro por minuto con los permisos actuales. Repetir la sincronización no puede reconstruirlos. +
+
) : null} @@ -1668,7 +1715,335 @@ function TlTipItemRow({ itemName }: { itemName: string | null }) { ); } -// ── Statistics Tab ──────────────────────────────────────────────────────────── +// ── Player build coaching ───────────────────────────────────────────────────── + +function CoachHover({ children, content, label }: { children: React.ReactNode; content: React.ReactNode; label: string }) { + const [position, setPosition] = useState<{ left: number; top: number } | null>(null); + const show = (element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + setPosition({ + left: Math.min(Math.max(rect.left + rect.width / 2, 170), window.innerWidth - 170), + top: rect.bottom + 8, + }); + }; + return ( +
show(event.currentTarget)} + onMouseLeave={() => setPosition(null)} + onFocus={(event) => show(event.currentTarget)} + onBlur={() => setPosition(null)} + style={{ display: 'inline-flex', cursor: 'help', outline: 'none' }} + > + {children} + {position && createPortal( +
+ {content} +
, + document.body, + )} +
+ ); +} + +function ItemCoachTooltip({ item }: { + item: { displayName: string; totalPrice?: number; stats?: Array<{ stat: string; value: number; showPercent?: boolean }>; effects?: Array<{ name: string; text: string; condition?: string | null; cooldown?: string | null }> }; +}) { + return ( +
+ {item.displayName}{item.totalPrice ? ` · ${item.totalPrice.toLocaleString()} oro` : ''} + {item.stats && item.stats.length > 0 &&
+ {item.stats.map((stat) => +{stat.value}{stat.showPercent ? '%' : ''} {stat.stat.toLowerCase().replaceAll('_', ' ')})} +
} + {item.effects && item.effects.length > 0 + ? item.effects.map((effect, index) =>

{effect.name}{effect.name ? ': ' : ''}{stripPredggMarkup(effect.text)}{effect.condition ? ` · ${stripPredggMarkup(effect.condition)}` : ''}

) + : No hay descripción adicional del objeto para este parche.} +
+ ); +} + +function PerkCoachTooltip({ perk }: { perk: { displayName: string; slot: string; effect: string | null } }) { + return
{perkSlotLabel(perk.slot)} · {perk.displayName}{perk.effect ? stripPredggMarkup(perk.effect) : 'Sin descripción disponible para este parche.'}
; +} + +function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string; matchPlayerId: string | null; liveMode: boolean }) { + const [analysis, setAnalysis] = useState(null); + useEffect(() => { + if (!liveMode && !matchPlayerId) return; + let cancelled = false; + const request = liveMode + ? apiClient.matches.liveBuildAnalysis(matchId) + : apiClient.matches.buildAnalysis(matchId, matchPlayerId!); + void request + .then((result) => { if (!cancelled) setAnalysis(result); }) + .catch(() => { if (!cancelled) setAnalysis(null); }); + return () => { cancelled = true; }; + }, [liveMode, matchId, matchPlayerId]); + + if (!analysis) return null; + const gradeLabel = { + correct: 'Build correcta', mostly_correct: 'Correcta con una adaptación pendiente', + mixed: 'Build mejorable', poor: 'Build mal adaptada al rival', + }[analysis.verdict.grade]; + const gradeColor = analysis.verdict.grade === 'correct' + ? 'var(--accent-win)' + : analysis.verdict.grade === 'poor' + ? 'var(--accent-loss)' + : 'var(--accent-prime)'; + return ( +
+
+
+

Coach de build

+ {analysis.heroSlug} · {analysis.role?.toLowerCase() ?? 'sin rol'} +
+
+ Daño recibido: {analysis.context.damageReceived.physical.toLocaleString()} físico · {analysis.context.damageReceived.magical.toLocaleString()} mágico +
+
+ +
+
+ {gradeLabel} + {analysis.verdict.score}/100 adaptación +
+

{analysis.verdict.summary}

+
+ +
+

Qué tenía el equipo rival

+
+ {analysis.context.enemyHeroes.map((enemy) => ( + + { (event.currentTarget as HTMLImageElement).style.display = 'none'; }} /> + {enemy.heroSlug} · {enemy.role?.toLowerCase() ?? 'sin rol'} + + ))} +
+
+ +
+

Lectura global de la partida

+
+
+ Qué quiere hacer {analysis.heroSlug} +
+ {analysis.globalAnalysis.playerIdentity.map((concept) => {concept.label}{concept.description}Evidencia: {concept.evidence.join(', ')}
}> + {concept.label} + )} +
+ +
+
Qué priorizó tu build{analysis.globalAnalysis.coherence.score}% coherencia
+

{analysis.globalAnalysis.coherence.summary}

+
+ {analysis.globalAnalysis.buildProfile.slice(0, 6).map((concept) => {concept.label}{concept.description}Objetos: {concept.items.join(', ')}
}> + {concept.label} · {concept.items.length} + )} +
+ +
+ + {analysis.globalAnalysis.enemyThreats.length > 0 &&
+ Cómo quería jugar el rival y cómo responder +
+ {analysis.globalAnalysis.enemyThreats.map((threat) =>
+ {threat.label} +

{threat.description} {threat.evidence}

+ {threat.sources.length > 0 &&
+ {threat.sources.map((source, index) => {source.heroSlug} · {source.name}{source.description}
}> + {source.heroSlug} · {source.name} + )} +
} +

Respuesta: {threat.response}

+ )} +
+
} + + {analysis.globalAnalysis.tradeoffs.length > 0 &&
+ Huecos que dejó la build + {analysis.globalAnalysis.tradeoffs.map((tradeoff, index) =>

• {tradeoff}

)} +
} +
+ +
+

Evaluación de tu build final

+
+ {analysis.inventoryAssessments.map((item) => { + const catalogItem = analysis.inventory.find((entry) => entry.slug === item.slug); + return
+ }> + { (event.currentTarget as HTMLImageElement).style.display = 'none'; }} /> + +
+ {item.displayName} + {item.functions.length > 0 &&
{item.functions.slice(0, 4).map((concept) => {concept.label})}
} +

{item.explanation}

+

Con tu héroe: {item.roleFit}

+

En esta partida: {item.matchupFit}

+

Coste de oportunidad: {item.tradeoff}

+
+
; + })} +
+
+ +
+ {analysis.signals.map((signal) => { + const color = signal.severity === 'critical' ? 'var(--accent-loss)' : signal.severity === 'warning' ? 'var(--accent-prime)' : 'var(--accent-win)'; + return ( +
+ {signal.title} +

{signal.evidence}

+ {signal.whyItMatters &&
+ Por qué importa + {signal.whyItMatters} +
} + {signal.sources && signal.sources.length > 0 &&
+ De dónde salió en esta partida + {signal.sources.map((source, index) =>
+ + {source.heroSlug} · {source.name} ({source.sourceType === 'ability' ? 'habilidad' : 'objeto'}): {source.description} +
)} +
} +

{signal.recommendation}

+ {signal.appliesAgainst && signal.appliesAgainst.length > 0 &&

Recuerda el concepto contra: {signal.appliesAgainst.join(', ')}.

} + {signal.suggestedItems && signal.suggestedItems.length > 0 && ( +
+ {signal.suggestedItems.map((item) => ( + }> + + {item.displayName} + + + ))} +
+ )} +
+ ); + })} +
+ +
+

Build recomendada contra este equipo

+

{analysis.recommendedBuild.principle}

+ {analysis.recommendedBuild.changes.length > 0 ? ( +
+ {analysis.recommendedBuild.changes.map((change) => ( +
+
+ {change.insteadOf && <> + item.slug === change.insteadOf?.slug), displayName: change.insteadOf.displayName }} />}> + + + {change.insteadOf.displayName} + + } + }> + + + {change.item.displayName} +
+

Por qué: {change.why}

+

Cuándo: {change.timing}

+
+ ))} +
+ ) :

No hace falta sustituir una pieza por una respuesta contextual con los datos disponibles.

} + {analysis.recommendedBuild.sequence.length > 0 &&
+ Orden que habría usado en esta partida +
+ {analysis.recommendedBuild.sequence.map((step) =>
+
+ {step.position} + }> + + + {step.displayName}{step.phase} +
+

{step.reason}

+
)} +
+
} +
+ + {analysis.eternalLoadout.length > 0 && ( +
+

Augmento, Eternal y bendiciones

+
+ {analysis.eternalLoadout.map((perk, index) => { + const color = perk.verdict === 'correct' ? 'var(--accent-win)' : perk.verdict === 'questionable' ? 'var(--accent-loss)' : 'var(--accent-prime)'; + const verdict = perk.verdict === 'correct' ? 'Correcto' : perk.verdict === 'questionable' ? 'Cuestionable' : 'Situacional'; + return ( + }> +
+
+
+ {perkSlotLabel(perk.slot)} + {perk.displayName} +
+ {verdict} +
+

{perk.why}

+ {perk.effect &&

{stripPredggMarkup(perk.effect)}

} +
+
+ ); + })} +
+
+ )} + + {(analysis.recommendedLoadout.augment || analysis.recommendedLoadout.eternal || analysis.recommendedLoadout.blessings.length > 0) && ( +
+

Loadout que habría elegido el coach

+

{analysis.recommendedLoadout.explanation}

+
+ {[analysis.recommendedLoadout.augment, analysis.recommendedLoadout.eternal, ...analysis.recommendedLoadout.blessings].flatMap((perk) => perk ? [perk] : []).map((perk) => ( + }> +
+ {perkSlotLabel(perk.slot)} + {perk.displayName} + {perk.replaces && En lugar de {perk.replaces.displayName}} +

{perk.reason}

+
+
+ ))} +
+
+ )} + +
+

Cómo evolucionó la compra

+

{analysis.purchaseTimeline.lesson}

+ {analysis.purchaseTimeline.ownPurchases.length > 0 && ( +
+ {analysis.purchaseTimeline.ownPurchases.slice(0, 16).map((purchase, index) => ( + + { (event.currentTarget as HTMLImageElement).style.display = 'none'; }} /> + {purchase.minute} · {purchase.itemName} + + ))} +
+ )} + {analysis.purchaseTimeline.opponentResponses.length > 0 && ( +
+ {analysis.purchaseTimeline.opponentResponses.map((response, index) => ( +
+ {response.minute} · {response.heroSlug}: {response.explanation} +
+ ))} +
+ )} +
+ + ); +} function StatisticsTab({ match, duskWon, dawnWon, onResync, syncing }: { match: MatchDetailData; duskWon: boolean; dawnWon: boolean; diff --git a/apps/web/src/pages/PlayerMatchesPage.tsx b/apps/web/src/pages/PlayerMatchesPage.tsx index 86ce63a..c58d26c 100644 --- a/apps/web/src/pages/PlayerMatchesPage.tsx +++ b/apps/web/src/pages/PlayerMatchesPage.tsx @@ -147,7 +147,7 @@ export default function PlayerMatchesPage() { cursor: 'pointer', transition: 'background 0.15s', }} - onClick={() => navigate(`/matches/live/${match.matchUuid}`)} + onClick={() => navigate(`/matches/${match.matchId}`)} onMouseEnter={(e) => (e.currentTarget.style.background = isWin ? 'rgba(52,211,153,0.07)' : 'rgba(248,113,113,0.07)')} onMouseLeave={(e) => (e.currentTarget.style.background = isWin ? 'rgba(52,211,153,0.03)' : 'rgba(248,113,113,0.03)')} > diff --git a/apps/web/src/pages/PlayerWeeklyReportPage.tsx b/apps/web/src/pages/PlayerWeeklyReportPage.tsx index 1515bcb..473f1d4 100644 --- a/apps/web/src/pages/PlayerWeeklyReportPage.tsx +++ b/apps/web/src/pages/PlayerWeeklyReportPage.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; -import { Activity, ArrowDownRight, ArrowUpRight, Crosshair, Flag, Link as LinkIcon, Minus, RefreshCw, Sparkles, Target } from 'lucide-react'; +import { Activity, ArrowDownRight, ArrowUpRight, Crosshair, Flag, Link as LinkIcon, Minus, Package, RefreshCw, Shield, Sparkles, Target } from 'lucide-react'; import { toast } from 'sonner'; -import { ApiErrorResponse, apiClient, type PlayerMetricTrend, type PlayerWeeklyReport, type WeeklyGoalEvaluation } from '../api/client'; +import { ApiErrorResponse, apiClient, type ChampionPoolContext, type PlayerBenchmarkResponse, type PlayerBuildReview, type PlayerMetricTrend, type PlayerWeeklyReport, type WeeklyGoalEvaluation } from '../api/client'; import { HeroAvatarWithTooltip } from '../components/HeroAvatar'; import { MatchEnrichmentCard } from '../components/MatchEnrichmentCard'; import { PlayerCoachChat } from '../components/PlayerCoachChat'; @@ -57,6 +57,54 @@ function MetricCard({ trend }: { trend: PlayerMetricTrend }) { ); } +const poolSelectStyle = { + padding: '0.35rem 0.5rem', borderRadius: 6, border: '1px solid var(--border-color)', + background: 'var(--bg-dark)', color: 'var(--text-secondary)', fontSize: '0.68rem', +} as const; + +function MatchupSummary({ title, row, accent, empty }: { + title: string; + row: ChampionPoolContext['hardestMatchup']; + accent: string; + empty: string; +}) { + return ( +
+

{title}

+ {row ? ( +
+ +
+ {row.heroSlug} +

{row.matches} partidas · {row.winRate}% WR · {row.kda.toFixed(2)} KDA

+
+
+ ) :

{empty}

} +
+ ); +} + +function benchmarkMetricLabel(key: string): string { + const labels: Record = { + winRate: 'Win rate', kda: 'KDA', damagePerMinute: 'Daño/min', goldPerMinute: 'Oro/min', + csPerMinute: 'CS/min', wardsPerMatch: 'Wards/partida', + }; + return labels[key] ?? key; +} + +function CapabilityBadge({ label, available, reason }: { label: string; available: boolean; reason: string | null }) { + return ( + + {available ? '✓' : '🔒'} {label} + + ); +} + function roleMetricValue(value: number | null, unit: 'ratio' | 'per_match' | 'per_minute' | 'percent'): string { if (value === null || !Number.isFinite(value)) return '—'; if (unit === 'percent') return `${value.toFixed(1)}%`; @@ -65,6 +113,185 @@ function roleMetricValue(value: number | null, unit: 'ratio' | 'per_match' | 'pe return value < 10 ? value.toFixed(2) : Math.round(value).toLocaleString(); } +function loadoutSlotLabel(slot: string, index: number): string { + const normalized = slot.toUpperCase(); + if (normalized.includes('AUGMENT') || normalized.includes('HERO_SPECIFIC')) return 'Augmento'; + if (normalized.includes('ETERNAL')) return 'Eternal'; + if (normalized.includes('BLESSING')) return normalized.endsWith('_2') ? 'Bendición II' : 'Bendición I'; + return slot || `Ranura ${index + 1}`; +} + +function WeeklyBuildLessons({ review }: { review: PlayerBuildReview }) { + const adaptationNeeded = review.matches.filter(({ analysis }) => analysis.verdict.grade !== 'correct').length; + const signalCounts = new Map(); + const loadoutCounts = new Map(); + const changeCounts = new Map(); + + for (const { analysis } of review.matches) { + for (const signal of analysis.signals.filter((entry) => entry.key !== 'adaptation-ok')) { + const current = signalCounts.get(signal.key); + signalCounts.set(signal.key, { title: signal.title, count: (current?.count ?? 0) + 1 }); + } + for (const perk of analysis.eternalLoadout.filter((entry) => entry.verdict !== 'correct')) { + const key = `${perk.slot}:${perk.displayName}`; + const current = loadoutCounts.get(key); + loadoutCounts.set(key, { + label: `${loadoutSlotLabel(perk.slot, 0)} · ${perk.displayName}`, + count: (current?.count ?? 0) + 1, + why: perk.why, + verdict: perk.verdict, + }); + } + for (const change of analysis.recommendedBuild.changes) { + const current = changeCounts.get(change.item.slug); + changeCounts.set(change.item.slug, { item: change.item.displayName, slug: change.item.slug, count: (current?.count ?? 0) + 1, why: change.item.reason }); + } + } + + const repeatedSignal = [...signalCounts.values()].sort((a, b) => b.count - a.count)[0]; + const loadoutLesson = [...loadoutCounts.values()].sort((a, b) => { + if (a.verdict !== b.verdict) return a.verdict === 'questionable' ? -1 : 1; + return b.count - a.count; + })[0]; + const repeatedChange = [...changeCounts.values()].sort((a, b) => b.count - a.count)[0]; + + return ( +
+
+

Patrón de adaptación

+ {adaptationNeeded} de {review.matches.length} builds necesitaban ajustes +

+ {repeatedSignal ? `${repeatedSignal.title} apareció en ${repeatedSignal.count} ${repeatedSignal.count === 1 ? 'partida' : 'partidas'}.` : 'No se repitió ninguna carencia contextual importante.'} +

+
+ +
+

Aprendizaje de loadout

+ {loadoutLesson?.label ?? 'Loadouts coherentes'} +

+ {loadoutLesson ? `${loadoutLesson.why}${loadoutLesson.count > 1 ? ` Se repitió ${loadoutLesson.count} veces.` : ''}` : 'Los Augmentos, Eternals y Bendiciones analizados encajaron con su rol y patrón de juego.'} +

+
+ +
+

Regla para la próxima partida

+ {repeatedChange ? ( +
+ +
+ Considera {repeatedChange.item} +

{repeatedChange.why}{repeatedChange.count > 1 ? ` Fue recomendable en ${repeatedChange.count} partidas.` : ''}

+
+
+ ) :

Mantén el núcleo actual, pero vuelve a revisar la composición rival antes de completar la segunda pieza.

} +
+
+ ); +} + +function BuildReviewCard({ entry, onOpen }: { entry: PlayerBuildReview['matches'][number]; onOpen: () => void }) { + const { analysis, match } = entry; + const finalItems = analysis.inventory.filter((item) => item.slotType === 'PASSIVE' && item.rarity === 'EPIC'); + const visibleItems = finalItems.length > 0 ? finalItems : analysis.inventory.filter((item) => item.slotType === 'PASSIVE').slice(0, 6); + const primarySignal = analysis.signals.find((signal) => signal.severity === 'critical') + ?? analysis.signals.find((signal) => signal.severity === 'warning') + ?? analysis.signals[0]; + const signalColor = primarySignal?.severity === 'critical' + ? 'var(--accent-loss)' + : primarySignal?.severity === 'warning' + ? 'var(--accent-prime)' + : 'var(--accent-win)'; + const assessmentBySlug = new Map(analysis.inventoryAssessments.map((item) => [item.slug, item])); + const firstChange = analysis.recommendedBuild.changes[0]; + const buildVerdictLabel = analysis.verdict.grade === 'correct' + ? 'Correcta' + : analysis.verdict.grade === 'mostly_correct' + ? 'Casi correcta' + : analysis.verdict.grade === 'mixed' + ? 'Mejorable' + : 'Mal adaptada'; + const buildVerdictColor = analysis.verdict.grade === 'correct' + ? 'var(--accent-win)' + : analysis.verdict.grade === 'poor' + ? 'var(--accent-loss)' + : 'var(--accent-prime)'; + + return ( +
+
+
+ +
+ {analysis.heroSlug} + {analysis.role ?? 'Sin rol'} · {new Date(match.startTime).toLocaleDateString()} +
+
+
+ {analysis.result === 'win' ? 'Victoria' : 'Derrota'} + {buildVerdictLabel} · {analysis.verdict.score}/100 +
+
+ +
+

Build final

+
+ {visibleItems.length > 0 ? visibleItems.map((item) => { + const assessment = assessmentBySlug.get(item.slug); + return ( + + {item.displayName} { (event.currentTarget as HTMLImageElement).style.display = 'none'; }} /> + + );}) : Build todavía incompleta} +
+
+ +
+

Ancestro y bendiciones

+
+ {analysis.eternalLoadout.length > 0 ? analysis.eternalLoadout.map((perk, index) => { + const color = perk.verdict === 'correct' ? 'var(--accent-win)' : perk.verdict === 'questionable' ? 'var(--accent-loss)' : 'var(--accent-prime)'; + return ( + + {loadoutSlotLabel(perk.slot, index)}: {perk.displayName} + + + );}) : Sin loadout sincronizado} +
+ {analysis.eternalLoadout.some((perk) => perk.verdict !== 'correct') && ( +
+ {analysis.eternalLoadout.filter((perk) => perk.verdict !== 'correct').slice(0, 2).map((perk) => ( +

{perk.displayName}: {perk.why}

+ ))} +
+ )} +
+ + {primarySignal ? ( +
+ {primarySignal.title} + {primarySignal.recommendation} +
+ ) : null} + + {firstChange && ( +
+ Cambio recomendado +
+ {firstChange.insteadOf && <>{firstChange.insteadOf.displayName}} + + {firstChange.item.displayName} +
+

{firstChange.timing}

+
+ )} + + +
+ ); +} + export default function PlayerWeeklyReportPage() { const { user } = useAuth(); const navigate = useNavigate(); @@ -74,6 +301,12 @@ export default function PlayerWeeklyReportPage() { const [coverageRefresh, setCoverageRefresh] = useState(0); const [goalEvaluations, setGoalEvaluations] = useState([]); const [savingGoal, setSavingGoal] = useState(false); + const [poolContext, setPoolContext] = useState(null); + const [benchmark, setBenchmark] = useState(null); + const [buildReview, setBuildReview] = useState(null); + const [buildReviewLoading, setBuildReviewLoading] = useState(true); + const [poolFilters, setPoolFilters] = useState({ days: 90, role: '', gameMode: 'RANKED', heroSlug: '' }); + const [poolFiltersReady, setPoolFiltersReady] = useState(false); const linkedPlayerId = user?.linkedPlayerId; const loadReport = useCallback(async () => { @@ -101,16 +334,58 @@ export default function PlayerWeeklyReportPage() { } }, [linkedPlayerId]); + const loadBuildReview = useCallback(async () => { + if (!linkedPlayerId) return; + try { + setBuildReviewLoading(true); + setBuildReview(await apiClient.reports.playerBuilds(linkedPlayerId, { days: 30, limit: 5 })); + } catch { + setBuildReview(null); + } finally { + setBuildReviewLoading(false); + } + }, [linkedPlayerId]); + useEffect(() => { - const timer = window.setTimeout(() => void Promise.all([loadReport(), loadGoalProgress()]), 0); + const timer = window.setTimeout(() => void Promise.all([loadReport(), loadGoalProgress(), loadBuildReview()]), 0); return () => window.clearTimeout(timer); - }, [loadGoalProgress, loadReport]); + }, [loadBuildReview, loadGoalProgress, loadReport]); + + useEffect(() => { + if (!report || poolFiltersReady) return; + setPoolFilters((current) => ({ + ...current, + role: report.roleCoach?.role ?? '', + heroSlug: report.championPool.mainHero ?? '', + })); + setPoolFiltersReady(true); + }, [poolFiltersReady, report]); + + useEffect(() => { + if (!linkedPlayerId || !poolFiltersReady) return; + let cancelled = false; + const contextRequest = apiClient.players.championPoolContext(linkedPlayerId, { + days: poolFilters.days, + role: poolFilters.role || undefined, + gameMode: poolFilters.gameMode || undefined, + heroSlug: poolFilters.heroSlug || undefined, + }).then((data) => { if (!cancelled) setPoolContext(data); }).catch(() => { if (!cancelled) setPoolContext(null); }); + const benchmarkRequest = poolFilters.heroSlug + ? apiClient.players.benchmarks(linkedPlayerId, { + heroSlug: poolFilters.heroSlug, + role: poolFilters.role || undefined, + gameMode: poolFilters.gameMode || undefined, + }).then((data) => { if (!cancelled) setBenchmark(data); }).catch(() => { if (!cancelled) setBenchmark(null); }) + : Promise.resolve(setBenchmark(null)); + void Promise.all([contextRequest, benchmarkRequest]); + return () => { cancelled = true; }; + }, [linkedPlayerId, poolFilters, poolFiltersReady]); async function syncMatches() { try { setSyncing(true); const result = await apiClient.sync.myMatches(); - await Promise.all([loadReport(), loadGoalProgress()]); + await Promise.all([loadReport(), loadGoalProgress(), loadBuildReview()]); setCoverageRefresh((current) => current + 1); toast.success(result.newMatches > 0 ? `${result.newMatches} partidas nuevas · ${result.syncedMatches} revisadas para el informe.` @@ -256,6 +531,40 @@ export default function PlayerWeeklyReportPage() { ) : null} +
+
+
+
+ Decisiones de partida +
+

Builds, Augmentos, Eternals y Bendiciones

+

Qué decisiones repites, cuáles funcionan para tu rol y qué debes adaptar en la próxima partida.

+
+ + Últimos 30 días + +
+ + {buildReviewLoading ? ( +
+ Analizando tus builds y Ancestros recientes… +
+ ) : buildReview && buildReview.matches.length > 0 ? ( + <> + +
+ {buildReview.matches.map((entry) => ( + navigate(`/matches/${entry.match.id}?tab=analysis`)} /> + ))} +
+ + ) : ( +
+ Todavía no hay builds analizables. Pulsa «Actualizar historial» para completar las partidas recientes. +
+ )} +
+
@@ -304,6 +613,69 @@ export default function PlayerWeeklyReportPage() {

{report.championPool.recommendation.rationale}

Plan: {report.championPool.recommendation.action}

+ +
+
+
+ Enfrentamientos personales +

+ Tu rendimiento real contra héroes enemigos y junto a aliados, filtrado por contexto. +

+
+
+ + + + +
+
+ + {poolContext && poolContext.sampleSize > 0 ? ( +
+ + + row.matches >= 2).sort((a, b) => b.winRate - a.winRate)[0] ?? null} accent="var(--accent-cyan)" empty="Aún no hay una pareja con muestra suficiente." /> +
+ ) : ( +

No hay partidas que coincidan con estos filtros.

+ )} + + {benchmark && ( +
+
+ Comparación con jugadores de Pred.gg + {benchmark.benchmark.rating?.percentile != null && Top {(benchmark.benchmark.rating.percentile * 100).toFixed(1)}% en Pred.gg} +
+ {benchmark.benchmark.available && benchmark.benchmark.comparison ? ( +
+ {benchmark.benchmark.comparison.map((metric) => ( +
+ {benchmarkMetricLabel(metric.key)} + = 0 ? 'var(--accent-win)' : 'var(--accent-loss)' }}>{metric.player.toFixed(2)} + · base {metric.population.toFixed(2)} +
+ ))} +
+ ) :

{benchmark.benchmark.reason}

} +
+ + + +
+
+ )} +
{report.roleCoach ? ( diff --git a/docs/predecessor_api_technical_doc.md b/docs/predecessor_api_technical_doc.md index 92c5e9a..413adb9 100644 --- a/docs/predecessor_api_technical_doc.md +++ b/docs/predecessor_api_technical_doc.md @@ -10,10 +10,10 @@ Este documento cubre **la capa de integración de API** (GraphQL/OAuth2, queries No reemplaza la especificación de producto. La visión, alcance y roadmap funcional viven en `docs/project_predecessor.md`. Prioridad actual del producto: -1. Seguimiento de jugadores. -2. Scouting de jugadores rivales. -3. Análisis de equipos para scrims y partidos. -4. Build/stat calculator como fase posterior. +1. Coach personal basado en partidas propias. +2. Champion pool, matchups, progreso y builds contextuales. +3. Mantener scouting y análisis ya existentes. +4. Gestión y mejora de equipos como siguiente etapa del producto. --- @@ -85,7 +85,7 @@ LeaderboardRatingType: (valores del sistema de ranking por temporada) pred.gg usa **OAuth2**. Su cliente web guarda tokens en `localStorage`, pero RiftLine implementa OAuth2 PKCE con una única credencial de plataforma controlada por el servidor. -En RiftLine, el refresh token rotatorio se guarda exclusivamente en `PlatformCredential`. El navegador solo recibe un access token HTTP-only de corta duración y una marca de expiración. Todas las sincronizaciones solicitan acceso a `predgg-token-service`, que serializa la renovación y persiste atómicamente cualquier refresh token rotado. +En RiftLine, el refresh token rotatorio se guarda exclusivamente en `PlatformCredential`. El navegador solo recibe un access token HTTP-only de corta duración y una marca de expiración. Todas las sincronizaciones solicitan acceso a `predgg-token-service`, que serializa la renovación y persiste atómicamente cualquier refresh token rotado. También se persisten los scopes concedidos y se comparan con los solicitados; una función avanzada sin permiso se desactiva de forma aislada. ### Variables en localStorage (solo referencia del cliente web de pred.gg) @@ -243,7 +243,9 @@ PREDGG_CLIENT_SECRET= ## 3. Schema GraphQL Completo -### 3.1 Queries disponibles (35 total) +### 3.1 Queries disponibles + +La tabla original se levantó cuando el schema exponía 35 queries. La introspección de agosto de 2026 expone 39; entre las incorporaciones relevantes para el coach están `eternalCategories` y `ratingDistribution`. Véase `docs/predgg_api_inventory.md` para la fecha y el estado de acceso. | Query | Parámetros | Retorna | Descripción | |-------|-----------|---------|-------------| @@ -277,6 +279,8 @@ PREDGG_CLIENT_SECRET= | `backend` | — | `Backend` | Info del servidor | | `connectionInfo` | — | `ConnectionInfo` | IP y país del cliente | | `currentAuth` | — | `Authorization` | Auth actual | +| `eternalCategories` | — | `[EternalCategory]` | Categorías y relaciones de Eternals | +| `ratingDistribution` | `ratingId, bucketSize` | `RatingDistribution` | Distribución de rating; puede estar restringida | | `comment` | `id: ID` | `Comment` | Comentario | | `communityChallenge` | `id: Int` | `CommunityChallenge` | Reto comunidad | | `matchSpoilerBlocks` | `includeRejected` | `[MatchSpoilerBlock]` | Bloques spoiler | @@ -1258,24 +1262,30 @@ query ItemDiff($itemId: ID!, $versionId: ID!) { > **Nota:** La API no tiene un endpoint directo "dame el ítem en versión X vs versión Y". La estrategia es cachear todos los ítems con cada versión y comparar localmente. -### 9.4 Tipo `PerkData` (Crests) +### 9.4 Tipo `PerkData` (loadout actual) ``` id, name, displayName -slot: String # Posición del crest +slot: String # Posición reportada por pred.gg dentro del loadout aggressionTypes: [String] simpleDescription: String description: String icon: String (URL) +iconCenterPosition: [Float] displayOrder: Int +unlockLevel: Int -hero → Hero # Si es crest específico de héroe +hero → Hero # Si es augmento específico de héroe heroData → HeroData # Datos del héroe asociado +eternalCategory → EternalCategory +minorBlessings → [{ perk → Perk }] version → Version perk → Perk ``` -### 9.5 Todos los crests +En una partida moderna se esperan cuatro elecciones ordenadas: augmento, Eternal y dos bendiciones menores. Deben representarse como loadout; “tres augments” es un modelo heredado incorrecto. + +### 9.5 Catálogo de perks, Eternals y bendiciones ```graphql query AllPerks { diff --git a/docs/predgg_api_inventory.md b/docs/predgg_api_inventory.md index 23099ab..41d1724 100644 --- a/docs/predgg_api_inventory.md +++ b/docs/predgg_api_inventory.md @@ -1,6 +1,7 @@ # pred.gg GraphQL API — Full Inventory **Generated:** 2026-05-02 23:22:06 +**Coach integration review:** 2026-08-26 **Endpoint:** `https://pred.gg/gql` **Token:** ✅ provided @@ -22,6 +23,14 @@ These are the queries and mutations the app actively calls. For auth details see | `heroes` | Hero catalog | No | | `items` | Item catalog | No | | `ratings` | Ranked seasons | No | +| `currentAuth` | Verificación de scopes realmente concedidos | **Yes** | +| `match(by: {id})` | Telemetría completa, loadout, habilidad y rating de una partida | Parcial: algunos campos | +| `version.itemData` / `version.perkData` | Catálogo versionado de items, Eternals y bendiciones | No | +| `eternalCategories` | Categorías y relaciones de Eternals | No | +| `hero.generalStatistic` | Benchmark global por héroe, rol, modo y parche | No | +| `hero.leaderboard` | Especialistas del héroe | Scope específico | +| `hero.matchupStatistic` | Matchups globales | Scope específico | +| `ratingDistribution` | Distribución poblacional de rating | Acceso restringido observado | ### Mutations in use @@ -36,8 +45,10 @@ These are the queries and mutations the app actively calls. For auth details see --- ## Schema overview -- **Queries:** 35 -- **Mutations:** 63 +- **Inventory baseline (2026-05-02):** 35 queries / 63 mutations. +- **Latest introspection reviewed (2026-08-26):** 39 queries / 65 mutations. + +The schema is not static. Code must degrade per capability rather than assuming that a valid OAuth token grants every root field. The implementation notes and the seven coach-facing integrations are documented in `docs/predgg_coach_data_integration.md`. ## Queries diff --git a/docs/predgg_coach_data_integration.md b/docs/predgg_coach_data_integration.md new file mode 100644 index 0000000..88877bc --- /dev/null +++ b/docs/predgg_coach_data_integration.md @@ -0,0 +1,74 @@ +# Integración pred.gg para el coach personal + +**Actualizado:** 2026-08-26 + +Este documento describe la base de datos y las capacidades de pred.gg que RiftLine usa para convertir el historial de un jugador en recomendaciones de mejora. El diseño mantiene los módulos de equipo existentes, pero prioriza el uso personal y evita que una función opcional bloquee el informe completo. + +## Capacidades implementadas + +### 1. OAuth y permisos + +RiftLine solicita `offline_access`, `profile`, `player:read:interval`, `hero_leaderboard:read` y `matchup_statistic:read`. El servidor consulta `currentAuth.scope`, conserva los permisos realmente concedidos y muestra por separado los que faltan. + +La ausencia de un permiso avanzado no invalida la conexión: las comparativas personales siguen disponibles y solo se bloquea el módulo dependiente. Una cuenta que se conectó antes de ampliar los permisos debe volver a autorizar pred.gg una vez. + +### 2. Telemetría de partidas + +Además de KDA, oro, daño y visión, se sincronizan: + +- daño recibido físico, mágico y verdadero, total y procedente de héroes; +- curación por habilidades, objetos y crest; +- escudos recibidos y daño mitigado; +- súbditos de línea y monstruos de jungla propia/enemiga; +- orden de habilidades cuando el permiso lo permite; +- cambio de rating y rango de la partida; +- causa y hora de finalización y bloqueo temporal de spoilers. + +Los campos protegidos se solicitan únicamente con token. Si pred.gg no los devuelve, se almacenan como desconocidos, no como cero. + +### 3. Loadout: augmento, Eternal y bendiciones + +El loadout actual tiene cuatro posiciones: augmento de héroe, Eternal y dos bendiciones menores. Se conserva el orden, nombre, icono, descripción, categoría y relación entre Eternal y bendiciones. La interfaz ya no presenta el conjunto como tres “augments”. + +### 4. Champion pool y contexto personal + +El informe puede filtrarse por ventana temporal, modo, rol y héroe. Sobre las partidas sincronizadas calcula rendimiento por héroe, rivales más difíciles, rivales dominados y mejores sinergias. Las conclusiones con menos de dos partidas se mantienen fuera de los destacados para reducir falsos patrones. + +### 5. Catálogos versionados + +Items, perks, Eternals, bendiciones y categorías se guardan por versión del juego. Para cada item se conservan estadísticas, efectos estructurados, precios, árbol de construcción e incompatibilidades. Para cada perk se conservan posición, texto, iconos, héroe, categoría y relaciones con bendiciones menores. + +El catálogo se sincroniza al actualizar versiones y también mediante `POST /admin/sync-game-catalog`. Sin indicar versión, esta ruta rellena el parche actual y hasta 16 parches recientes que aparecen en el último año del historial local, evitando mezclar datos actuales con partidas antiguas sin intentar reconstruir innecesariamente todo el archivo histórico. + +### 6. Análisis contextual de build + +El análisis de una partida cruza la build usada con la composición real de daño recibida, curación, escudos y mitigación del rival. Puede detectar falta de defensa física o mágica, anti-curación, anti-escudo, penetración contra tanques e items incompatibles. Las sugerencias se limitan a items legendarios visibles del parche de la partida. + +Este módulo explica evidencia y recomendación; no afirma que exista una única build correcta ni sustituye el contexto de ejecución de la partida. Si una partida referencia una compilación interna sin items, se usa el catálogo de contenido más cercano y anterior, manteniendo la coherencia del parche. + +### 7. Benchmarks y especialistas + +Para un héroe, rol y modo se comparan win rate, KDA, daño/minuto, oro/minuto, CS/minuto y visión con la población de pred.gg. Cuando están autorizados se añaden especialistas del héroe y matchups globales. La distribución de rating se intenta por separado porque pred.gg puede denegarla incluso con un token válido. + +## Degradación y fiabilidad + +- Cada capacidad informa su estado y motivo de indisponibilidad. +- La falta de OAuth no impide usar el análisis local de partidas ya sincronizadas. +- Los filtros de benchmark personal y población son idénticos para evitar comparaciones engañosas. +- Los catálogos se vinculan al parche de la partida para no recomendar datos de otra versión. +- Los datos ausentes permanecen como `null`; no se convierten en rendimiento cero. + +## Rutas de aplicación + +| Ruta | Resultado | +|---|---| +| `GET /admin/api-status` | Estado OAuth, permisos solicitados, concedidos y ausentes | +| `POST /admin/sync-game-catalog` | Sincronización del catálogo del parche actual | +| `GET /players/:id/champion-pool-context` | Pool, matchups y sinergias personales con filtros | +| `GET /players/:id/benchmarks` | Comparación poblacional y capacidades avanzadas | +| `GET /matches/:id/build-analysis/:matchPlayerId` | Diagnóstico contextual de la build de una partida | +| `GET /matches/live/:predggUuid/build-analysis` | El mismo diagnóstico para la partida abierta desde “Mis partidas” | + +## Siguiente evolución recomendada + +La siguiente iteración debe medir si las recomendaciones producen cambios: guardar un foco semanal, comprobarlo en las siguientes partidas y mostrar progreso. Antes de ampliar a gestión de equipos, conviene validar con suficientes partidas personales que el coach distingue señal estable de una muestra pequeña. diff --git a/workers/data-sync/prisma/migrations/20260826180000_expand_predgg_coach_match_data/migration.sql b/workers/data-sync/prisma/migrations/20260826180000_expand_predgg_coach_match_data/migration.sql new file mode 100644 index 0000000..50b54de --- /dev/null +++ b/workers/data-sync/prisma/migrations/20260826180000_expand_predgg_coach_match_data/migration.sql @@ -0,0 +1,28 @@ +ALTER TABLE "Match" + ADD COLUMN "endTime" TIMESTAMP(3), + ADD COLUMN "endReason" TEXT, + ADD COLUMN "spoilerBlockedUntil" TIMESTAMP(3); + +ALTER TABLE "MatchPlayer" + ADD COLUMN "crestHealingDone" INTEGER, + ADD COLUMN "itemHealingDone" INTEGER, + ADD COLUMN "utilityHealingDone" INTEGER, + ADD COLUMN "totalShieldingReceived" INTEGER, + ADD COLUMN "totalDamageMitigated" INTEGER, + ADD COLUMN "physicalDamageTaken" INTEGER, + ADD COLUMN "magicalDamageTaken" INTEGER, + ADD COLUMN "trueDamageTaken" INTEGER, + ADD COLUMN "physicalDamageTakenFromHeroes" INTEGER, + ADD COLUMN "magicalDamageTakenFromHeroes" INTEGER, + ADD COLUMN "trueDamageTakenFromHeroes" INTEGER, + ADD COLUMN "minionsKilled" INTEGER, + ADD COLUMN "neutralMinionsKilled" INTEGER, + ADD COLUMN "neutralMinionsTeamJungle" INTEGER, + ADD COLUMN "neutralMinionsEnemyJungle" INTEGER, + ADD COLUMN "ratingId" TEXT, + ADD COLUMN "ratingPoints" DOUBLE PRECISION, + ADD COLUMN "ratingNewPoints" DOUBLE PRECISION, + ADD COLUMN "ratingDelta" DOUBLE PRECISION, + ADD COLUMN "ratingRankName" TEXT, + ADD COLUMN "ratingTierName" TEXT, + ADD COLUMN "ratingIsRankup" BOOLEAN; diff --git a/workers/data-sync/prisma/migrations/20260826183000_add_versioned_game_catalog/migration.sql b/workers/data-sync/prisma/migrations/20260826183000_add_versioned_game_catalog/migration.sql new file mode 100644 index 0000000..3d464b3 --- /dev/null +++ b/workers/data-sync/prisma/migrations/20260826183000_add_versioned_game_catalog/migration.sql @@ -0,0 +1,106 @@ +CREATE TABLE "GameItem" ( + "id" TEXT NOT NULL, + "predggId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + CONSTRAINT "GameItem_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "GameItemVersion" ( + "id" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "predggDataId" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "rarity" TEXT NOT NULL, + "slotType" TEXT NOT NULL, + "heroClass" TEXT NOT NULL, + "aggressionType" TEXT, + "price" INTEGER NOT NULL, + "totalPrice" INTEGER NOT NULL, + "isEvolved" BOOLEAN NOT NULL, + "isHidden" BOOLEAN NOT NULL, + "icon" TEXT, + "smallIcon" TEXT, + "stats" JSONB NOT NULL, + "effects" JSONB NOT NULL, + "buildsFromIds" JSONB NOT NULL, + "buildsIntoIds" JSONB NOT NULL, + "blockedByIds" JSONB NOT NULL, + "blocksIds" JSONB NOT NULL, + "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "GameItemVersion_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "GamePerk" ( + "id" TEXT NOT NULL, + "predggId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + CONSTRAINT "GamePerk_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "GamePerkVersion" ( + "id" TEXT NOT NULL, + "perkId" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "predggDataId" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "slot" TEXT NOT NULL, + "icon" TEXT, + "iconCenterPosition" JSONB, + "simpleDescription" TEXT, + "description" TEXT NOT NULL, + "aggressionTypes" JSONB NOT NULL, + "displayOrder" INTEGER NOT NULL, + "unlockLevel" INTEGER, + "heroSlug" TEXT, + "eternalCategoryPredggId" TEXT, + "minorBlessingPredggIds" JSONB NOT NULL, + "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "GamePerkVersion_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "EternalCategory" ( + "id" TEXT NOT NULL, + "predggId" TEXT NOT NULL, + "name" TEXT NOT NULL, + CONSTRAINT "EternalCategory_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "EternalCategoryVersion" ( + "id" TEXT NOT NULL, + "categoryId" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "predggDataId" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "displayNameSingular" TEXT NOT NULL, + "description" TEXT NOT NULL, + "color" TEXT NOT NULL, + "perkPredggIds" JSONB NOT NULL, + "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "EternalCategoryVersion_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "GameItem_predggId_key" ON "GameItem"("predggId"); +CREATE UNIQUE INDEX "GameItem_slug_key" ON "GameItem"("slug"); +CREATE UNIQUE INDEX "GameItemVersion_itemId_versionId_key" ON "GameItemVersion"("itemId", "versionId"); +CREATE INDEX "GameItemVersion_versionId_isHidden_rarity_idx" ON "GameItemVersion"("versionId", "isHidden", "rarity"); +CREATE INDEX "GameItemVersion_aggressionType_idx" ON "GameItemVersion"("aggressionType"); +CREATE UNIQUE INDEX "GamePerk_predggId_key" ON "GamePerk"("predggId"); +CREATE UNIQUE INDEX "GamePerk_slug_key" ON "GamePerk"("slug"); +CREATE UNIQUE INDEX "GamePerkVersion_perkId_versionId_key" ON "GamePerkVersion"("perkId", "versionId"); +CREATE INDEX "GamePerkVersion_versionId_slot_idx" ON "GamePerkVersion"("versionId", "slot"); +CREATE INDEX "GamePerkVersion_heroSlug_idx" ON "GamePerkVersion"("heroSlug"); +CREATE INDEX "GamePerkVersion_eternalCategoryPredggId_idx" ON "GamePerkVersion"("eternalCategoryPredggId"); +CREATE UNIQUE INDEX "EternalCategory_predggId_key" ON "EternalCategory"("predggId"); +CREATE UNIQUE INDEX "EternalCategory_name_key" ON "EternalCategory"("name"); +CREATE UNIQUE INDEX "EternalCategoryVersion_categoryId_versionId_key" ON "EternalCategoryVersion"("categoryId", "versionId"); +CREATE INDEX "EternalCategoryVersion_versionId_idx" ON "EternalCategoryVersion"("versionId"); + +ALTER TABLE "GameItemVersion" ADD CONSTRAINT "GameItemVersion_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "GameItem"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "GameItemVersion" ADD CONSTRAINT "GameItemVersion_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "Version"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "GamePerkVersion" ADD CONSTRAINT "GamePerkVersion_perkId_fkey" FOREIGN KEY ("perkId") REFERENCES "GamePerk"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "GamePerkVersion" ADD CONSTRAINT "GamePerkVersion_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "Version"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "EternalCategoryVersion" ADD CONSTRAINT "EternalCategoryVersion_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "EternalCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "EternalCategoryVersion" ADD CONSTRAINT "EternalCategoryVersion_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "Version"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/workers/data-sync/prisma/schema.prisma b/workers/data-sync/prisma/schema.prisma index 31fc83d..a4d32bb 100644 --- a/workers/data-sync/prisma/schema.prisma +++ b/workers/data-sync/prisma/schema.prisma @@ -16,17 +16,121 @@ datasource db { /// patch-aware analytics (winrates, stats per patch). model Version { /// Internal cuid — used in joins - id String @id @default(cuid()) + id String @id @default(cuid()) /// pred.gg version ID (numeric string) - predggId String @unique + predggId String @unique /// Human-readable patch name shown in UI, e.g. "0.19.2" - name String - releaseDate DateTime + name String + releaseDate DateTime /// "MAJOR" | "MINOR" | "HOTFIX" — from pred.gg - patchType String - syncedAt DateTime - matches Match[] - snapshots PlayerSnapshot[] + patchType String + syncedAt DateTime + matches Match[] + snapshots PlayerSnapshot[] + itemVersions GameItemVersion[] + perkVersions GamePerkVersion[] + eternalCategoryVersions EternalCategoryVersion[] +} + +/// Stable pred.gg item identity. Patch-specific values live in GameItemVersion. +model GameItem { + id String @id @default(cuid()) + predggId String @unique + name String + slug String @unique + versions GameItemVersion[] +} + +model GameItemVersion { + id String @id @default(cuid()) + itemId String + versionId String + predggDataId String + displayName String + rarity String + slotType String + heroClass String + aggressionType String? + price Int + totalPrice Int + isEvolved Boolean + isHidden Boolean + icon String? + smallIcon String? + stats Json + effects Json + buildsFromIds Json + buildsIntoIds Json + blockedByIds Json + blocksIds Json + syncedAt DateTime @default(now()) + item GameItem @relation(fields: [itemId], references: [id], onDelete: Cascade) + version Version @relation(fields: [versionId], references: [id], onDelete: Cascade) + + @@unique([itemId, versionId]) + @@index([versionId, isHidden, rarity]) + @@index([aggressionType]) +} + +/// Stable pred.gg perk identity: hero augment, Eternal or minor blessing. +model GamePerk { + id String @id @default(cuid()) + predggId String @unique + name String + slug String @unique + versions GamePerkVersion[] +} + +model GamePerkVersion { + id String @id @default(cuid()) + perkId String + versionId String + predggDataId String + displayName String + slot String + icon String? + iconCenterPosition Json? + simpleDescription String? + description String + aggressionTypes Json + displayOrder Int + unlockLevel Int? + heroSlug String? + eternalCategoryPredggId String? + minorBlessingPredggIds Json + syncedAt DateTime @default(now()) + perk GamePerk @relation(fields: [perkId], references: [id], onDelete: Cascade) + version Version @relation(fields: [versionId], references: [id], onDelete: Cascade) + + @@unique([perkId, versionId]) + @@index([versionId, slot]) + @@index([heroSlug]) + @@index([eternalCategoryPredggId]) +} + +model EternalCategory { + id String @id @default(cuid()) + predggId String @unique + name String @unique + versions EternalCategoryVersion[] +} + +model EternalCategoryVersion { + id String @id @default(cuid()) + categoryId String + versionId String + predggDataId String + displayName String + displayNameSingular String + description String + color String + perkPredggIds Json + syncedAt DateTime @default(now()) + category EternalCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) + version Version @relation(fields: [versionId], references: [id], onDelete: Cascade) + + @@unique([categoryId, versionId]) + @@index([versionId]) } /// A Predecessor player known to the system. @@ -35,31 +139,31 @@ model Version { /// no linked account (~60% of players). model Player { /// Internal cuid — used in all internal joins - id String @id @default(cuid()) + id String @id @default(cuid()) /// pred.gg player ID (UUID format) — used for API lookups - predggId String @unique + predggId String @unique /// pred.gg UUID — same value as predggId in practice; kept for legacy reasons - predggUuid String @unique + predggUuid String @unique /// Display name from pred.gg. Console players show a generated "user-XXXXXXXX" name. - displayName String + displayName String /// Whether the player set their profile to private on pred.gg - isPrivate Boolean @default(false) + isPrivate Boolean @default(false) /// Region inferred from match location data (not provided by pred.gg directly) - inferredRegion String? - firstSeen DateTime @default(now()) + inferredRegion String? + firstSeen DateTime @default(now()) /// Last time this player's data was synced from pred.gg - lastSynced DateTime + lastSynced DateTime /// Optional override name set by staff in RiftLine (takes priority over displayName in UI) - customName String? + customName String? /// True for players who play on console. Console players cannot be synced /// via the pred.gg player endpoint — their matches appear with isNameConsole=true. - isConsole Boolean @default(false) - matchPlayers MatchPlayer[] - snapshots PlayerSnapshot[] - rosterEntries TeamRoster[] - rivalRosterEntries RivalRosterEntry[] + isConsole Boolean @default(false) + matchPlayers MatchPlayer[] + snapshots PlayerSnapshot[] + rosterEntries TeamRoster[] + rivalRosterEntries RivalRosterEntry[] /// RiftLine user account linked to this player (one-to-one, optional) - userAccount User? + userAccount User? } /// Point-in-time snapshot of a player's aggregate stats fetched from pred.gg. @@ -94,50 +198,50 @@ model PlayerSnapshot { /// Teams are the primary organizational unit — matches, analysis and /// goals are scoped to a team. model Team { - id String @id @default(cuid()) - name String - abbreviation String? + id String @id @default(cuid()) + name String + abbreviation String? /// OWN = team belonging to the org; RIVAL = opponent being scouted - type TeamType - region String? + type TeamType + region String? /// Internal notes visible only to staff - notes String? - createdAt DateTime @default(now()) - logoUrl String? + notes String? + createdAt DateTime @default(now()) + logoUrl String? /// Subscription tier: "FREE" | "PRO" | "TEAM" | "ENTERPRISE" - teamTier String @default("FREE") + teamTier String @default("FREE") teamTierExpiresAt DateTime? /// If set, this team is the academy/feeder team of the referenced OWN team academyOf String? llmAnalyses LlmAnalysis[] memberships TeamMembership[] roster TeamRoster[] - scrimSchedules ScrimSchedule[] @relation("HomeTeam") - rivalSchedules ScrimSchedule[] @relation("RivalTeam") + scrimSchedules ScrimSchedule[] @relation("HomeTeam") + rivalSchedules ScrimSchedule[] @relation("RivalTeam") playbookEntries PlaybookEntry[] reviewSessions ReviewSession[] comms TeamComm[] rivalRoster RivalRosterEntry[] - parentTeam Team? @relation("AcademyTeams", fields: [academyOf], references: [id]) - academyTeams Team[] @relation("AcademyTeams") + parentTeam Team? @relation("AcademyTeams", fields: [academyOf], references: [id]) + academyTeams Team[] @relation("AcademyTeams") } /// Links a Player to a Team for a specific time period. /// activeTo = null means the player is currently active on the roster. /// Historical entries (activeTo set) are kept for match attribution. model TeamRoster { - id String @id @default(cuid()) - teamId String - playerId String + id String @id @default(cuid()) + teamId String + playerId String /// In-game role: "CARRY" | "JUNGLE" | "MIDLANE" | "OFFLANE" | "SUPPORT" - role String? + role String? /// "STARTER" | "BENCH" — whether the player is in the main 5 or on the bench - rosterStatus String @default("STARTER") - activeFrom DateTime @default(now()) + rosterStatus String @default("STARTER") + activeFrom DateTime @default(now()) /// null = currently active; set when the player leaves the roster - activeTo DateTime? - player Player @relation(fields: [playerId], references: [id]) - team Team @relation(fields: [teamId], references: [id]) + activeTo DateTime? + player Player @relation(fields: [playerId], references: [id]) + team Team @relation(fields: [teamId], references: [id]) } // ───────────────────────────────────────────────────────────────────────────── @@ -154,6 +258,7 @@ model Match { /// pred.gg match UUID — used for all pred.gg API calls predggUuid String @unique startTime DateTime + endTime DateTime? /// Match duration in seconds duration Int /// "RANKED" | "ARAM" | "BRAWL" | "STANDARD" @@ -161,6 +266,9 @@ model Match { region String? /// "DUSK" | "DAWN" | null (null = match in progress or unknown) winningTeam String? + /// "CORE" | "SURRENDER" | null + endReason String? + spoilerBlockedUntil DateTime? versionId String? syncedAt DateTime /// True if the event stream sync attempt failed (prevents retry loops) @@ -186,63 +294,84 @@ model Match { /// NOTE: inventoryItems are stored as lowercase slugs (e.g. "taintedblade") /// matching the filename convention in apps/web/public/items/. /// -/// NOTE: perks stores the 3 pre-game augments the player selected: -/// [{ id, name, displayName, slot }] where slot is -/// "HERO_SPECIFIC_1" | "COMMON_1" | "COMMON_2" +/// NOTE: perks stores the current loadout selected before the match: +/// hero augment + Eternal + two minor blessings, including display metadata. model MatchPlayer { - id String @id @default(cuid()) - matchId String + id String @id @default(cuid()) + matchId String /// null when the player has no pred.gg account (console players) - playerId String? + playerId String? /// Name as it appears in pred.gg. "HIDDEN" for private/unlinked accounts. - playerName String + playerName String /// "DUSK" | "DAWN" - team String + team String /// "CARRY" | "JUNGLE" | "MIDLANE" | "OFFLANE" | "SUPPORT" | null - role String? - heroSlug String - kills Int - deaths Int - assists Int - heroDamage Int? - totalDamage Int? - gold Int? - wardsPlaced Int? + role String? + heroSlug String + kills Int + deaths Int + assists Int + heroDamage Int? + totalDamage Int? + gold Int? + wardsPlaced Int? /// Array of item slugs in lowercase, e.g. ["taintedblade", "stormbreaker"] - inventoryItems Json + inventoryItems Json /// Legacy field — always null. Kept to avoid migration on hypertable-adjacent tables. - perkSlug String? + perkSlug String? /// Pre-game augments: [{ id, name, displayName, slot }]. null = synced before augments feature. - perks Json? + perks Json? /// Sequence of ability upgrades per level: [1,3,1,2,...]. Rarely populated. - abilityOrder Json? + abilityOrder Json? /// pred.gg player UUID — same as Player.predggId when playerId is set - predggPlayerUuid String? - level Int? - wardsDestroyed Int? + predggPlayerUuid String? + level Int? + wardsDestroyed Int? /// Cumulative gold earned per minute as an array, e.g. [500, 1200, 2100, ...] /// Requires Bearer token to sync. null = event stream not yet synced. - goldEarnedAtInterval Json? - goldSpent Int? - heroDamageTaken Int? + goldEarnedAtInterval Json? + goldSpent Int? + heroDamageTaken Int? + crestHealingDone Int? + itemHealingDone Int? + utilityHealingDone Int? + totalShieldingReceived Int? + totalDamageMitigated Int? + physicalDamageTaken Int? + magicalDamageTaken Int? + trueDamageTaken Int? + physicalDamageTakenFromHeroes Int? + magicalDamageTakenFromHeroes Int? + trueDamageTakenFromHeroes Int? /// CS — lane minions killed (does not include jungle camps) - laneMinionsKilled Int? - largestCriticalStrike Int? - largestKillingSpree Int? - magicalDamageDealt Int? - magicalDamageDealtToHeroes Int? + laneMinionsKilled Int? + minionsKilled Int? + neutralMinionsKilled Int? + neutralMinionsTeamJungle Int? + neutralMinionsEnemyJungle Int? + largestCriticalStrike Int? + largestKillingSpree Int? + magicalDamageDealt Int? + magicalDamageDealtToHeroes Int? /// Highest multi-kill achieved (2=double, 3=triple, etc.) - multiKill Int? - physicalDamageDealt Int? - physicalDamageDealtToHeroes Int? - totalDamageDealtToObjectives Int? - totalDamageDealtToStructures Int? - totalDamageTaken Int? - totalHealingDone Int? - trueDamageDealt Int? - trueDamageDealtToHeroes Int? - match Match @relation(fields: [matchId], references: [id]) - player Player? @relation(fields: [playerId], references: [id]) + multiKill Int? + physicalDamageDealt Int? + physicalDamageDealtToHeroes Int? + totalDamageDealtToObjectives Int? + totalDamageDealtToStructures Int? + totalDamageTaken Int? + totalHealingDone Int? + trueDamageDealt Int? + trueDamageDealtToHeroes Int? + ratingId String? + ratingPoints Float? + ratingNewPoints Float? + ratingDelta Float? + ratingRankName String? + ratingTierName String? + ratingIsRankup Boolean? + match Match @relation(fields: [matchId], references: [id]) + player Player? @relation(fields: [playerId], references: [id]) /// A player can only appear once per match @@unique([matchId, playerId]) @@ -422,41 +551,41 @@ model SyncLog { /// - "PLATFORM_ADMIN": can configure platform settings, permissions and users /// - "VIEWER": default role; access controlled by team membership role model User { - id String @id @default(cuid()) - email String @unique + id String @id @default(cuid()) + email String @unique name String /// bcrypt hash (cost factor 12) passwordHash String /// "SUPER_ADMIN" | "PLATFORM_ADMIN" | "VIEWER" - globalRole String @default("VIEWER") - isActive Boolean @default(true) - createdAt DateTime @default(now()) + globalRole String @default("VIEWER") + isActive Boolean @default(true) + createdAt DateTime @default(now()) lastLoginAt DateTime? avatarUrl String? bio String? timezone String? /// Links to a Player record — enables personal match feed and player goals. /// Set via profile page or automatically during pred.gg OAuth login. - linkedPlayerId String? @unique + linkedPlayerId String? @unique /// "en" | "es" — UI language preference - language String @default("en") + language String @default("en") /// "FREE" | "PRO" | "PREMIUM" - playerTier String @default("FREE") + playerTier String @default("FREE") playerTierExpiresAt DateTime? /// Social / OAuth provider links (for display only; not used for auth) - discordId String? @unique + discordId String? @unique discordUsername String? - epicGamesId String? @unique + epicGamesId String? @unique epicGamesUsername String? - steamId String? @unique + steamId String? @unique steamUsername String? /// Whether the user has seen the onboarding welcome modal - onboardingModalSeen Boolean @default(false) - sentInvitations Invitation[] @relation("InvitedBy") + onboardingModalSeen Boolean @default(false) + sentInvitations Invitation[] @relation("InvitedBy") memberships TeamMembership[] - linkedPlayer Player? @relation(fields: [linkedPlayerId], references: [id]) - sentComms TeamComm[] @relation("SentComms") - receivedComms TeamComm[] @relation("ReceivedComms") + linkedPlayer Player? @relation(fields: [linkedPlayerId], references: [id]) + sentComms TeamComm[] @relation("SentComms") + receivedComms TeamComm[] @relation("ReceivedComms") scrimSchedules ScrimSchedule[] playbookEntries PlaybookEntry[] weeklyGoals WeeklyGoal[] @@ -522,9 +651,9 @@ model RivalRosterEntry { addedAt DateTime @default(now()) addedById String - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - player Player @relation(fields: [playerId], references: [id], onDelete: Cascade) - addedBy User @relation(fields: [addedById], references: [id]) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + player Player @relation(fields: [playerId], references: [id], onDelete: Cascade) + addedBy User @relation(fields: [addedById], references: [id]) @@unique([teamId, playerId]) } @@ -799,29 +928,29 @@ model FeedbackReport { /// type: "SCRIM" | "OFFICIAL" | "PRACTICE" /// result: null = not played yet; "WIN" | "LOSS" | "DRAW" after the fact model ScrimSchedule { - id String @id @default(cuid()) - teamId String + id String @id @default(cuid()) + teamId String /// RIVAL team from the DB (optional) - rivalTeamId String? + rivalTeamId String? /// Free-text rival name when not in DB - rivalName String? - scheduledAt DateTime + rivalName String? + scheduledAt DateTime /// "SCRIM" | "OFFICIAL" | "PRACTICE" - type String @default("SCRIM") + type String @default("SCRIM") /// "PENDIENTE" | "CONFIRMADO" | "CANCELADO" - status String @default("PENDIENTE") - notes String? + status String @default("PENDIENTE") + notes String? /// Filled after the match: "WIN" | "LOSS" | "DRAW" - result String? + result String? /// Set when analyst marks analysis complete (post-match task) - analysedAt DateTime? + analysedAt DateTime? /// Set when coach marks post-match review complete - reviewedAt DateTime? + reviewedAt DateTime? /// pred.gg match UUID detected automatically by the scrim-results worker - predggMatchId String? - createdById String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + predggMatchId String? + createdById String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt team Team @relation("HomeTeam", fields: [teamId], references: [id]) rivalTeam Team? @relation("RivalTeam", fields: [rivalTeamId], references: [id]) createdBy User @relation(fields: [createdById], references: [id]) @@ -831,25 +960,25 @@ model ScrimSchedule { /// Structured review session organized after a scrim or match. /// status: "PENDIENTE" | "EN_CURSO" | "COMPLETADA" model ReviewSession { - id String @id @default(cuid()) - teamId String + id String @id @default(cuid()) + teamId String /// Optional link to a ScrimSchedule entry - scrimId String? - title String + scrimId String? + title String /// General session notes (markdown / free text) - notes String? + notes String? /// "PENDIENTE" | "EN_CURSO" | "COMPLETADA" - status String @default("PENDIENTE") - scheduledAt DateTime? - completedAt DateTime? - createdById String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - scrim ScrimSchedule? @relation(fields: [scrimId], references: [id], onDelete: SetNull) - createdBy User @relation(fields: [createdById], references: [id]) - agendaItems AgendaItem[] - actionItems ActionItem[] + status String @default("PENDIENTE") + scheduledAt DateTime? + completedAt DateTime? + createdById String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + scrim ScrimSchedule? @relation(fields: [scrimId], references: [id], onDelete: SetNull) + createdBy User @relation(fields: [createdById], references: [id]) + agendaItems AgendaItem[] + actionItems ActionItem[] } /// A point on the review session agenda — what to watch/discuss. @@ -940,28 +1069,28 @@ model WeeklyGoal { /// status: "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" /// toRole: target role ("ANALISTA" | "COACH" | "MANAGER" | "JUGADOR" | null = all) model TeamComm { - id String @id @default(cuid()) - teamId String - fromUserId String + id String @id @default(cuid()) + teamId String + fromUserId String /// Role-broadcast target (null = addressed to specific user only) - toRole String? + toRole String? /// Specific user target (null = broadcast to role) - toUserId String? + toUserId String? /// "REQUEST" | "ANNOUNCEMENT" | "NOTE" - type String @default("REQUEST") - subject String - body String + type String @default("REQUEST") + subject String + body String /// "normal" | "urgent" - priority String @default("normal") + priority String @default("normal") /// "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" - status String @default("OPEN") - resolvedAt DateTime? - resolvedById String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - team Team @relation(fields: [teamId], references: [id]) - fromUser User @relation("SentComms", fields: [fromUserId], references: [id]) - toUser User? @relation("ReceivedComms", fields: [toUserId], references: [id]) + status String @default("OPEN") + resolvedAt DateTime? + resolvedById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team @relation(fields: [teamId], references: [id]) + fromUser User @relation("SentComms", fields: [fromUserId], references: [id]) + toUser User? @relation("ReceivedComms", fields: [toUserId], references: [id]) } // ─────────────────────────────────────────────────────────────────────────────