From 79ffe7f61c6741d2f4e2817ebd87eb05da514aa9 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 18:12:59 +0200 Subject: [PATCH 01/24] feat: add predgg coach data foundation --- apps/api/src/routes/admin.test.ts | 8 + apps/api/src/routes/admin.ts | 24 +- apps/api/src/routes/matches.ts | 97 +++- apps/api/src/routes/players.ts | 34 ++ .../src/services/build-coach-service.test.ts | 55 +++ apps/api/src/services/build-coach-service.ts | 204 ++++++++ apps/api/src/services/match-service.ts | 64 ++- .../player-champion-pool-service.test.ts | 59 +++ .../services/player-champion-pool-service.ts | 112 +++++ .../src/services/player-coach-chat-service.ts | 29 +- .../player-weekly-report-service.test.ts | 4 +- .../services/player-weekly-report-service.ts | 16 +- .../services/predgg-benchmark-service.test.ts | 57 +++ .../src/services/predgg-benchmark-service.ts | 191 ++++++++ apps/api/src/services/predgg-oauth.ts | 6 +- .../src/services/predgg-token-service.test.ts | 31 +- apps/api/src/services/predgg-token-service.ts | 99 +++- apps/api/src/services/sync-service.ts | 341 +++++++++++++- apps/web/src/api/client.ts | 118 ++++- apps/web/src/pages/ApiStatusPage.tsx | 43 +- apps/web/src/pages/MatchDetail.tsx | 103 +++- apps/web/src/pages/PlayerWeeklyReportPage.tsx | 147 +++++- docs/predecessor_api_technical_doc.md | 30 +- docs/predgg_api_inventory.md | 15 +- docs/predgg_coach_data_integration.md | 73 +++ .../migration.sql | 28 ++ .../migration.sql | 106 +++++ workers/data-sync/prisma/schema.prisma | 439 +++++++++++------- 28 files changed, 2315 insertions(+), 218 deletions(-) create mode 100644 apps/api/src/services/build-coach-service.test.ts create mode 100644 apps/api/src/services/build-coach-service.ts create mode 100644 apps/api/src/services/player-champion-pool-service.test.ts create mode 100644 apps/api/src/services/player-champion-pool-service.ts create mode 100644 apps/api/src/services/predgg-benchmark-service.test.ts create mode 100644 apps/api/src/services/predgg-benchmark-service.ts create mode 100644 docs/predgg_coach_data_integration.md create mode 100644 workers/data-sync/prisma/migrations/20260826180000_expand_predgg_coach_match_data/migration.sql create mode 100644 workers/data-sync/prisma/migrations/20260826183000_add_versioned_game_catalog/migration.sql diff --git a/apps/api/src/routes/admin.test.ts b/apps/api/src/routes/admin.test.ts index 9b7caaa3..848428aa 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 88fc251c..cccf7693 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/matches.ts b/apps/api/src/routes/matches.ts index 63378df3..69daee77 100644 --- a/apps/api/src/routes/matches.ts +++ b/apps/api/src/routes/matches.ts @@ -6,9 +6,18 @@ 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('/: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 +39,13 @@ const LIVE_MATCH_QUERY = ` id uuid startTime + endTime duration gameMode region winningTeam + endReason + spoilerBlockedUntil version { name } matchPlayers { player { id name } @@ -52,9 +64,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 +94,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 +148,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 +173,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 +226,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 +246,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 +295,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, diff --git a/apps/api/src/routes/players.ts b/apps/api/src/routes/players.ts index 74fd3439..505eb5d6 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/services/build-coach-service.test.ts b/apps/api/src/services/build-coach-service.test.ts new file mode 100644 index 00000000..7a9a119d --- /dev/null +++ b/apps/api/src/services/build-coach-service.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + matchPlayerFindFirst: vi.fn(), + gameItemFindMany: vi.fn(), + gameItemVersionFindMany: vi.fn(), +})); + +vi.mock('../db.js', () => ({ + db: { + matchPlayer: { findFirst: mocks.matchPlayerFindFirst }, + gameItem: { findMany: mocks.gameItemFindMany }, + gameItemVersion: { findMany: mocks.gameItemVersionFindMany }, + }, +})); + +import { getMatchBuildAnalysis } from './build-coach-service.js'; + +describe('contextual build coach', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.matchPlayerFindFirst.mockResolvedValue({ + id: 'mp-1', matchId: 'match-1', heroSlug: 'dekker', role: 'SUPPORT', team: 'DAWN', + kills: 0, deaths: 7, assists: 10, inventoryItems: ['offensive-item'], perks: [], abilityOrder: [], + 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', team: 'DAWN', role: 'SUPPORT', heroSlug: 'dekker', totalHealingDone: 2_000, totalShieldingReceived: 1_000, totalDamageMitigated: 8_000 }, + { id: 'enemy-1', 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: [{ + displayName: 'Offense', aggressionType: 'OFFENSE', stats: [], effects: [], blocksIds: [], blockedByIds: [], + }] }]); + mocks.gameItemVersionFindMany.mockResolvedValue([ + { displayName: 'Physical Guard', aggressionType: 'ARMOR', totalPrice: 3000, item: { slug: 'physical-guard' } }, + { displayName: 'Tainted Guard', aggressionType: 'ANTI_HEAL', totalPrice: 3100, item: { slug: 'tainted-guard' } }, + ]); + }); + + 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); + }); +}); 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 00000000..ffc48e91 --- /dev/null +++ b/apps/api/src/services/build-coach-service.ts @@ -0,0 +1,204 @@ +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; + desiredTags: string[]; + suggestedItems?: Array<{ slug: string; displayName: string; aggressionType: string | null }>; +} + +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|reduce.*armor|physical penetration/.test(text)) tags.add('PHYSICAL_SHRED'); + if (/magical armor.*reduc|magical penetration/.test(text)) tags.add('MAGICAL_SHRED'); + if (/maximum health|bonus health|current health/.test(text)) tags.add('ANTI_TANK'); + return tags; +} + +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, + }, + }, + }, + }, + }, + }); + if (!row) throw new AppError(404, 'Match player not found', 'MATCH_PLAYER_NOT_FOUND'); + + const inventorySlugs = jsonArray(row.inventoryItems).filter(Boolean); + const catalogItems = row.match.versionId && inventorySlugs.length > 0 + ? await db.gameItem.findMany({ + where: { slug: { in: inventorySlugs } }, + include: { versions: { where: { versionId: row.match.versionId }, take: 1 } }, + }) + : []; + + const inventory = 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, + 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 tags = new Set(inventory.flatMap((item) => [...semanticTags(item)])); + const statNames = new Set(inventory.flatMap((item) => item.stats.map((stat) => stat.stat))); + const enemies = row.match.matchPlayers.filter((player) => player.team !== row.team); + + 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 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.`, + 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.`, + 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 && !tags.has('ANTI_TANK') && !tags.has('SHRED')) { + 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.`, + 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) => 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: [], + }); + } + + const desiredTags = [...new Set(signals.flatMap((signal) => signal.desiredTags))]; + if (row.match.versionId && desiredTags.length > 0) { + const candidates = await db.gameItemVersion.findMany({ + where: { + versionId: row.match.versionId, isHidden: false, rarity: 'LEGENDARY', + }, + include: { item: { select: { slug: 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) => ({ slug: candidate.item.slug, displayName: candidate.displayName, aggressionType: candidate.aggressionType })); + } + } + + return { + matchId, + matchPlayerId, + heroSlug: row.heroSlug, + role: row.role, + result: row.match.winningTeam === row.team ? 'win' : 'loss', + context: { + deaths: row.deaths, + damageReceived: { physical, magical, true: trueDamage, total: damageTotal }, + enemyHealing, + enemyShielding, + enemyMitigation, + }, + inventory, + eternalLoadout: jsonArray<{ id: string; displayName: string; slot: string }>(row.perks), + 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 975bd7d8..d6b90503 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-champion-pool-service.test.ts b/apps/api/src/services/player-champion-pool-service.test.ts new file mode 100644 index 00000000..ac8d29f7 --- /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 00000000..c54ab443 --- /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 b9d04efe..ae92eefc 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-weekly-report-service.test.ts b/apps/api/src/services/player-weekly-report-service.test.ts index e5a4682e..1a800368 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 303508a0..4e3b4386 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 00000000..2fe86dfe --- /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: 'Forbidden' }); + }); +}); 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 00000000..88f15e88 --- /dev/null +++ b/apps/api/src/services/predgg-benchmark-service.ts @@ -0,0 +1,191 @@ +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: { name: string; tierName: string } | null }> } | null }>(token.accessToken, ` + query RiftlineRatingDistribution($ratingId: ID!) { + ratingDistribution(ratingId: $ratingId, bucketSize: RANK) { results { bucket count rank { name tierName } } } + } + `, { ratingId: currentRating.rating.id }); + ratingDistribution = { available: true, reason: null, results: result.ratingDistribution?.results ?? [] }; + } catch (error) { + ratingDistribution = unavailable(error instanceof Error ? error.message : 'No disponible'); + } + } + + 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 30ad891a..03f7d1e4 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 0040396b..f48ca188 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 98b323c3..4d5826ef 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.ts b/apps/api/src/services/sync-service.ts index f28982c0..95996543 100644 --- a/apps/api/src/services/sync-service.ts +++ b/apps/api/src/services/sync-service.ts @@ -894,43 +894,303 @@ 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 catalogs only for patches represented in the local 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 } }, + distinct: ['versionId'], + 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 alreadySynced = await db.gameItemVersion.count({ where: { versionId: version.id } }); + if (alreadySynced > 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 +1211,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 +1237,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; }>; } @@ -986,7 +1270,7 @@ export async function resyncMatch( } const data = await predggQuery<{ match: PredggMatchDetail | null }>( - MATCH_DETAIL_QUERY, + matchDetailQuery(Boolean(userToken)), { uuid: predggUuid }, userToken, ); @@ -1006,7 +1290,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 +1347,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) { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 6618b509..b3e47be8 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,77 @@ 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: { + deaths: number; + damageReceived: { physical: number; magical: number; true: number; total: number }; + enemyHealing: number; + enemyShielding: number; + enemyMitigation: number; + }; + inventory: Array<{ slug: string; displayName: string; aggressionType: string | null }>; + eternalLoadout: Array<{ id: string; displayName: string; slot: string }>; + abilityOrder: Array<{ ability: string; gameTime: number }>; + signals: Array<{ + key: string; + severity: 'info' | 'warning' | 'critical'; + title: string; + evidence: string; + recommendation: string; + suggestedItems?: Array<{ slug: string; displayName: string; aggressionType: string | null }>; + }>; +} + +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 +1307,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 +1422,8 @@ 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}`), }, reports: { @@ -1437,6 +1551,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 dbda6f00..403c5612 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 7811aada..3fa9d0e9 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,6 +14,7 @@ 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); @@ -124,6 +126,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,7 +218,10 @@ export default function MatchDetail({ liveMode = false }: { liveMode?: boolean } /> )} {tab === 'statistics' && ( - + )} {tab === 'timeline' && ( @@ -360,7 +366,7 @@ function ScoreboardTab({ match, duskWon, dawnWon, isAram, heroMeta, liveMode, ed {!isAram && } - +
{/* Player rows */} @@ -388,6 +394,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 +574,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,9 +608,9 @@ 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 }: { @@ -1670,9 +1692,57 @@ function TlTipItemRow({ itemName }: { itemName: string | null }) { // ── Statistics Tab ──────────────────────────────────────────────────────────── -function StatisticsTab({ match, duskWon, dawnWon, onResync, syncing }: { +function BuildCoachCard({ matchId, matchPlayerId }: { matchId: string; matchPlayerId: string }) { + const [analysis, setAnalysis] = useState(null); + useEffect(() => { + let cancelled = false; + void apiClient.matches.buildAnalysis(matchId, matchPlayerId) + .then((result) => { if (!cancelled) setAnalysis(result); }) + .catch(() => { if (!cancelled) setAnalysis(null); }); + return () => { cancelled = true; }; + }, [matchId, matchPlayerId]); + + if (!analysis) return null; + 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 +
+
+
+ {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.recommendation}

+ {signal.suggestedItems && signal.suggestedItems.length > 0 && ( +
+ {signal.suggestedItems.map((item) => ( + + {item.displayName} + + ))} +
+ )} +
+ ); + })} +
+
+ ); +} + +function StatisticsTab({ match, duskWon, dawnWon, onResync, syncing, buildCoachPlayerId }: { match: MatchDetailData; duskWon: boolean; dawnWon: boolean; onResync: () => void; syncing: boolean; + buildCoachPlayerId: string | null; }) { const allPlayers = [...match.dusk, ...match.dawn]; const hasExtendedStats = allPlayers.some((p) => p.physicalDamageDealtToHeroes !== null); @@ -1711,6 +1781,7 @@ function StatisticsTab({ match, duskWon, dawnWon, onResync, syncing }: { return (
+ {buildCoachPlayerId && } {/* Section 1 — Damage Output */} {teams.map(({ key, label, players, won }) => ( diff --git a/apps/web/src/pages/PlayerWeeklyReportPage.tsx b/apps/web/src/pages/PlayerWeeklyReportPage.tsx index 1515bcb0..f19f751c 100644 --- a/apps/web/src/pages/PlayerWeeklyReportPage.tsx +++ b/apps/web/src/pages/PlayerWeeklyReportPage.tsx @@ -2,7 +2,7 @@ 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 { toast } from 'sonner'; -import { ApiErrorResponse, apiClient, type PlayerMetricTrend, type PlayerWeeklyReport, type WeeklyGoalEvaluation } from '../api/client'; +import { ApiErrorResponse, apiClient, type ChampionPoolContext, type PlayerBenchmarkResponse, 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)}%`; @@ -74,6 +122,10 @@ 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 [poolFilters, setPoolFilters] = useState({ days: 90, role: '', gameMode: 'RANKED', heroSlug: '' }); + const [poolFiltersReady, setPoolFiltersReady] = useState(false); const linkedPlayerId = user?.linkedPlayerId; const loadReport = useCallback(async () => { @@ -106,6 +158,36 @@ export default function PlayerWeeklyReportPage() { return () => window.clearTimeout(timer); }, [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); @@ -304,6 +386,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 92c5e9a5..413adb93 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 23099abb..41d17244 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 00000000..d72d9bae --- /dev/null +++ b/docs/predgg_coach_data_integration.md @@ -0,0 +1,73 @@ +# 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 todos los parches que aparecen en el historial local, evitando mezclar datos actuales con partidas antiguas. + +### 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. + +### 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 | + +## 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 00000000..50b54de2 --- /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 00000000..3d464b30 --- /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 31fc83dd..a4d32bb6 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]) } // ───────────────────────────────────────────────────────────────────────────── From 916e171d853a10747cb6a972266aa458029013f4 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 18:15:18 +0200 Subject: [PATCH 02/24] fix: bound catalog backfill to recent patches --- apps/api/src/services/sync-service.ts | 9 +++++++-- docs/predgg_coach_data_integration.md | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/sync-service.ts b/apps/api/src/services/sync-service.ts index 95996543..457ebb9f 100644 --- a/apps/api/src/services/sync-service.ts +++ b/apps/api/src/services/sync-service.ts @@ -1110,14 +1110,19 @@ export async function syncGameCatalog(db: PrismaClient, versionPredggId?: string return { version: localVersion.name, items: itemCount, perks: perkCount, eternalCategories: categoryCount }; } -/** Backfills catalogs only for patches represented in the local match history. */ +/** 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 } }, + 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 } }); diff --git a/docs/predgg_coach_data_integration.md b/docs/predgg_coach_data_integration.md index d72d9bae..5858852b 100644 --- a/docs/predgg_coach_data_integration.md +++ b/docs/predgg_coach_data_integration.md @@ -38,7 +38,7 @@ El informe puede filtrarse por ventana temporal, modo, rol y héroe. Sobre las p 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 todos los parches que aparecen en el historial local, evitando mezclar datos actuales con partidas antiguas. +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 From a0e5173db6c533adb821fe94e524a127b41366a3 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 18:16:47 +0200 Subject: [PATCH 03/24] fix: resolve build catalogs for internal game versions --- apps/api/src/services/build-coach-service.ts | 25 ++++++++++++++++---- apps/api/src/services/sync-service.ts | 7 ++++-- apps/web/src/api/client.ts | 1 + docs/predgg_coach_data_integration.md | 2 +- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/api/src/services/build-coach-service.ts b/apps/api/src/services/build-coach-service.ts index ffc48e91..529ae1d6 100644 --- a/apps/api/src/services/build-coach-service.ts +++ b/apps/api/src/services/build-coach-service.ts @@ -67,12 +67,28 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri if (!row) throw new AppError(404, 'Match player not found', 'MATCH_PLAYER_NOT_FOUND'); const inventorySlugs = jsonArray(row.inventoryItems).filter(Boolean); - const catalogItems = row.match.versionId && inventorySlugs.length > 0 + let catalogVersionId = row.match.versionId; + let catalogItems = catalogVersionId && inventorySlugs.length > 0 ? await db.gameItem.findMany({ where: { slug: { in: inventorySlugs } }, - include: { versions: { where: { versionId: row.match.versionId }, take: 1 } }, + include: { versions: { where: { versionId: catalogVersionId }, take: 1 } }, }) : []; + if (catalogVersionId && inventorySlugs.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: inventorySlugs } }, + include: { versions: { where: { versionId: catalogVersionId }, take: 1 } }, + }); + } + } const inventory = catalogItems.map((item) => { const data = item.versions[0]; @@ -159,10 +175,10 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri } const desiredTags = [...new Set(signals.flatMap((signal) => signal.desiredTags))]; - if (row.match.versionId && desiredTags.length > 0) { + if (catalogVersionId && desiredTags.length > 0) { const candidates = await db.gameItemVersion.findMany({ where: { - versionId: row.match.versionId, isHidden: false, rarity: 'LEGENDARY', + versionId: catalogVersionId, isHidden: false, rarity: 'LEGENDARY', }, include: { item: { select: { slug: true } } }, orderBy: { totalPrice: 'asc' }, @@ -190,6 +206,7 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri role: row.role, result: row.match.winningTeam === row.team ? 'win' : 'loss', context: { + catalogVersionId, deaths: row.deaths, damageReceived: { physical, magical, true: trueDamage, total: damageTotal }, enemyHealing, diff --git a/apps/api/src/services/sync-service.ts b/apps/api/src/services/sync-service.ts index 457ebb9f..8bab5120 100644 --- a/apps/api/src/services/sync-service.ts +++ b/apps/api/src/services/sync-service.ts @@ -1134,8 +1134,11 @@ export async function syncTrackedGameCatalogs(db: PrismaClient): Promise<{ for (const predggId of versionIds) { const version = await db.version.findUnique({ where: { predggId }, select: { id: true } }); if (!version) continue; - const alreadySynced = await db.gameItemVersion.count({ where: { versionId: version.id } }); - if (alreadySynced > 0) 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 }; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index b3e47be8..1ae3872d 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1225,6 +1225,7 @@ export interface MatchBuildAnalysis { role: string | null; result: 'win' | 'loss'; context: { + catalogVersionId: string | null; deaths: number; damageReceived: { physical: number; magical: number; true: number; total: number }; enemyHealing: number; diff --git a/docs/predgg_coach_data_integration.md b/docs/predgg_coach_data_integration.md index 5858852b..98895835 100644 --- a/docs/predgg_coach_data_integration.md +++ b/docs/predgg_coach_data_integration.md @@ -44,7 +44,7 @@ El catálogo se sincroniza al actualizar versiones y también mediante `POST /ad 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. +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 From 630ec7410641b3b14d654b1cf43b8450d540c8a0 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 18:18:10 +0200 Subject: [PATCH 04/24] fix: backfill new telemetry during player enrichment --- .../player-match-enrichment-service.test.ts | 4 +++- .../player-match-enrichment-service.ts | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) 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 d5bd752f..c6dedb17 100644 --- a/apps/api/src/services/player-match-enrichment-service.test.ts +++ b/apps/api/src/services/player-match-enrichment-service.test.ts @@ -47,6 +47,7 @@ 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: { @@ -58,8 +59,9 @@ describe('player match enrichment', () => { 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' } } }), diff --git a/apps/api/src/services/player-match-enrichment-service.ts b/apps/api/src/services/player-match-enrichment-service.ts index 268962f2..9cd440cb 100644 --- a/apps/api/src/services/player-match-enrichment-service.ts +++ b/apps/api/src/services/player-match-enrichment-service.ts @@ -40,6 +40,7 @@ interface EnrichmentCandidate { rosterSynced: boolean; eventStreamSynced: boolean; eventStreamFailed: boolean; + needsRosterRefresh?: boolean; } interface EnrichmentOptions { @@ -119,6 +120,7 @@ async function loadCandidates( matchPlayers: { some: { playerId } }, OR: [ { rosterSynced: false }, + { matchPlayers: { some: { playerId, physicalDamageTaken: null } } }, { eventStreamSynced: false, ...(retryFailed ? {} : { eventStreamFailed: false }), @@ -133,10 +135,22 @@ async function loadCandidates( rosterSynced: true, eventStreamSynced: true, eventStreamFailed: true, + matchPlayers: { + where: { playerId }, + take: 1, + select: { physicalDamageTaken: true }, + }, }, }); - return matches; + return matches.map((match) => ({ + id: match.id, + predggUuid: match.predggUuid, + rosterSynced: match.rosterSynced, + eventStreamSynced: match.eventStreamSynced, + eventStreamFailed: match.eventStreamFailed, + needsRosterRefresh: match.matchPlayers?.[0]?.physicalDamageTaken === null, + })); } export async function enrichPlayerMatches( @@ -155,7 +169,7 @@ 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); From 499c4ffbc5ca1601a75ce122d7911490341eaa7f Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 18:19:41 +0200 Subject: [PATCH 05/24] fix: align rating distribution with live schema --- apps/api/src/services/predgg-benchmark-service.test.ts | 2 +- apps/api/src/services/predgg-benchmark-service.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/api/src/services/predgg-benchmark-service.test.ts b/apps/api/src/services/predgg-benchmark-service.test.ts index 2fe86dfe..ff1c35bd 100644 --- a/apps/api/src/services/predgg-benchmark-service.test.ts +++ b/apps/api/src/services/predgg-benchmark-service.test.ts @@ -52,6 +52,6 @@ describe('pred.gg player benchmarks', () => { 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: 'Forbidden' }); + 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 index 88f15e88..db1c9808 100644 --- a/apps/api/src/services/predgg-benchmark-service.ts +++ b/apps/api/src/services/predgg-benchmark-service.ts @@ -154,14 +154,17 @@ export async function getPlayerPredggBenchmarks(playerId: string, options: { her 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: { name: string; tierName: string } | null }> } | null }>(token.accessToken, ` + 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 { name tierName } } } + ratingDistribution(ratingId: $ratingId, bucketSize: RANK) { results { bucket count rank } } } `, { ratingId: currentRating.rating.id }); ratingDistribution = { available: true, reason: null, results: result.ratingDistribution?.results ?? [] }; } catch (error) { - ratingDistribution = unavailable(error instanceof Error ? error.message : 'No disponible'); + 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); } } From 277a57e60ddec9cdd0438cbbe5ecd16440e88f04 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 18:22:06 +0200 Subject: [PATCH 06/24] feat: expose build coach from personal match detail --- apps/api/src/routes/matches.ts | 21 +++++++++++++++++++++ apps/web/src/api/client.ts | 2 ++ apps/web/src/pages/MatchDetail.tsx | 16 +++++++++++----- docs/predgg_coach_data_integration.md | 1 + 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/matches.ts b/apps/api/src/routes/matches.ts index 69daee77..8fec2d7d 100644 --- a/apps/api/src/routes/matches.ts +++ b/apps/api/src/routes/matches.ts @@ -10,6 +10,27 @@ 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))); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 1ae3872d..031d885d 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1425,6 +1425,8 @@ export const apiClient = { 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: { diff --git a/apps/web/src/pages/MatchDetail.tsx b/apps/web/src/pages/MatchDetail.tsx index 3fa9d0e9..809facde 100644 --- a/apps/web/src/pages/MatchDetail.tsx +++ b/apps/web/src/pages/MatchDetail.tsx @@ -221,6 +221,7 @@ export default function MatchDetail({ liveMode = false }: { liveMode?: boolean } )} {tab === 'timeline' && ( @@ -1692,15 +1693,19 @@ function TlTipItemRow({ itemName }: { itemName: string | null }) { // ── Statistics Tab ──────────────────────────────────────────────────────────── -function BuildCoachCard({ matchId, matchPlayerId }: { matchId: string; matchPlayerId: string }) { +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; - void apiClient.matches.buildAnalysis(matchId, matchPlayerId) + 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; }; - }, [matchId, matchPlayerId]); + }, [liveMode, matchId, matchPlayerId]); if (!analysis) return null; return ( @@ -1739,10 +1744,11 @@ function BuildCoachCard({ matchId, matchPlayerId }: { matchId: string; matchPlay ); } -function StatisticsTab({ match, duskWon, dawnWon, onResync, syncing, buildCoachPlayerId }: { +function StatisticsTab({ match, duskWon, dawnWon, onResync, syncing, buildCoachPlayerId, liveMode }: { match: MatchDetailData; duskWon: boolean; dawnWon: boolean; onResync: () => void; syncing: boolean; buildCoachPlayerId: string | null; + liveMode: boolean; }) { const allPlayers = [...match.dusk, ...match.dawn]; const hasExtendedStats = allPlayers.some((p) => p.physicalDamageDealtToHeroes !== null); @@ -1781,7 +1787,7 @@ function StatisticsTab({ match, duskWon, dawnWon, onResync, syncing, buildCoachP return (
- {buildCoachPlayerId && } + {(liveMode || buildCoachPlayerId) && } {/* Section 1 — Damage Output */} {teams.map(({ key, label, players, won }) => ( diff --git a/docs/predgg_coach_data_integration.md b/docs/predgg_coach_data_integration.md index 98895835..88877bc2 100644 --- a/docs/predgg_coach_data_integration.md +++ b/docs/predgg_coach_data_integration.md @@ -67,6 +67,7 @@ Para un héroe, rol y modo se comparan win rate, KDA, daño/minuto, oro/minuto, | `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 From 20e37aeac11e780ebea0076e6c658a5b0edf961d Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 18:27:44 +0200 Subject: [PATCH 07/24] fix: distinguish final items from ancestors --- apps/api/src/services/build-coach-service.test.ts | 9 ++++++++- apps/api/src/services/build-coach-service.ts | 9 +++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/build-coach-service.test.ts b/apps/api/src/services/build-coach-service.test.ts index 7a9a119d..13410827 100644 --- a/apps/api/src/services/build-coach-service.test.ts +++ b/apps/api/src/services/build-coach-service.test.ts @@ -34,7 +34,8 @@ describe('contextual build coach', () => { }, }); mocks.gameItemFindMany.mockResolvedValue([{ predggId: 'item-1', slug: 'offensive-item', name: 'Offense', versions: [{ - displayName: 'Offense', aggressionType: 'OFFENSE', stats: [], effects: [], blocksIds: [], blockedByIds: [], + 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, item: { slug: 'physical-guard' } }, @@ -51,5 +52,11 @@ describe('contextual build coach', () => { ])); 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.signals).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ key: 'item-conflict' }), + ])); + expect(mocks.gameItemVersionFindMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ rarity: 'EPIC', slotType: 'PASSIVE' }), + })); }); }); diff --git a/apps/api/src/services/build-coach-service.ts b/apps/api/src/services/build-coach-service.ts index 529ae1d6..47d0cc64 100644 --- a/apps/api/src/services/build-coach-service.ts +++ b/apps/api/src/services/build-coach-service.ts @@ -98,6 +98,10 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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), @@ -157,7 +161,8 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri } 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) => selectedIds.has(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', @@ -178,7 +183,7 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri if (catalogVersionId && desiredTags.length > 0) { const candidates = await db.gameItemVersion.findMany({ where: { - versionId: catalogVersionId, isHidden: false, rarity: 'LEGENDARY', + versionId: catalogVersionId, isHidden: false, rarity: 'EPIC', slotType: 'PASSIVE', }, include: { item: { select: { slug: true } } }, orderBy: { totalPrice: 'asc' }, From 685218ea2e5f811afb03afaf87f432375e8300d0 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 19:03:23 +0200 Subject: [PATCH 08/24] feat: surface builds and ancestors in player coach --- apps/api/src/routes/reports.test.ts | 28 ++++ apps/api/src/routes/reports.ts | 19 +++ .../player-build-review-service.test.ts | 55 ++++++++ .../services/player-build-review-service.ts | 51 +++++++ apps/web/src/api/client.ts | 32 ++++- apps/web/src/pages/MatchDetail.tsx | 5 +- apps/web/src/pages/PlayerWeeklyReportPage.tsx | 127 +++++++++++++++++- 7 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/services/player-build-review-service.test.ts create mode 100644 apps/api/src/services/player-build-review-service.ts diff --git a/apps/api/src/routes/reports.test.ts b/apps/api/src/routes/reports.test.ts index d3329606..a2c0fedd 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 b587f917..24d59254 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/player-build-review-service.test.ts b/apps/api/src/services/player-build-review-service.test.ts new file mode 100644 index 00000000..3ca46270 --- /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 00000000..effd3406 --- /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/web/src/api/client.ts b/apps/web/src/api/client.ts index 031d885d..9d8fc420 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1232,7 +1232,15 @@ export interface MatchBuildAnalysis { enemyShielding: number; enemyMitigation: number; }; - inventory: Array<{ slug: string; displayName: string; aggressionType: string | null }>; + inventory: Array<{ + slug: string; + displayName: string; + aggressionType: string | null; + rarity: string | null; + slotType: string | null; + isEvolved: boolean; + isHidden: boolean; + }>; eternalLoadout: Array<{ id: string; displayName: string; slot: string }>; abilityOrder: Array<{ ability: string; gameTime: number }>; signals: Array<{ @@ -1245,6 +1253,22 @@ export interface MatchBuildAnalysis { }>; } +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; @@ -1437,6 +1461,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', diff --git a/apps/web/src/pages/MatchDetail.tsx b/apps/web/src/pages/MatchDetail.tsx index 809facde..834b89a1 100644 --- a/apps/web/src/pages/MatchDetail.tsx +++ b/apps/web/src/pages/MatchDetail.tsx @@ -19,7 +19,10 @@ export default function MatchDetail({ liveMode = false }: { liveMode?: boolean } 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(''); diff --git a/apps/web/src/pages/PlayerWeeklyReportPage.tsx b/apps/web/src/pages/PlayerWeeklyReportPage.tsx index f19f751c..316e9480 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 ChampionPoolContext, type PlayerBenchmarkResponse, 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'; @@ -113,6 +113,78 @@ 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')) return 'Augmento'; + if (normalized.includes('ETERNAL')) return 'Eternal'; + if (normalized.includes('BLESSING')) return `Bendición ${index > 1 ? index - 1 : index + 1}`; + return slot || `Ranura ${index + 1}`; +} + +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)'; + + return ( +
+
+
+ +
+ {analysis.heroSlug} + {analysis.role ?? 'Sin rol'} · {new Date(match.startTime).toLocaleDateString()} +
+
+ + {analysis.result === 'win' ? 'Victoria' : 'Derrota'} + +
+ +
+

Build final

+
+ {visibleItems.length > 0 ? visibleItems.map((item) => ( + + {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) => ( + + {loadoutSlotLabel(perk.slot, index)}: {perk.displayName} + + )) : Sin loadout sincronizado} +
+
+ + {primarySignal ? ( +
+ {primarySignal.title} + {primarySignal.recommendation} +
+ ) : null} + + +
+ ); +} + export default function PlayerWeeklyReportPage() { const { user } = useAuth(); const navigate = useNavigate(); @@ -124,6 +196,8 @@ export default function PlayerWeeklyReportPage() { 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; @@ -153,10 +227,22 @@ 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; @@ -192,7 +278,7 @@ export default function PlayerWeeklyReportPage() { 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.` @@ -338,6 +424,37 @@ export default function PlayerWeeklyReportPage() { ) : null} +
+
+
+
+ Decisiones de partida +
+

Builds, Ancestros y adaptación

+

Qué compraste, qué loadout llevaste y cómo podrías adaptarlo al rival.

+
+ + Ú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=statistics`)} /> + ))} +
+ ) : ( +
+ Todavía no hay builds analizables. Pulsa «Actualizar historial» para completar las partidas recientes. +
+ )} +
+
From 3510e933ccf6c5f756493781e1c6bf974e506fb4 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 19:04:55 +0200 Subject: [PATCH 09/24] fix: label Pred.gg ancestor slots --- apps/web/src/pages/PlayerWeeklyReportPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/PlayerWeeklyReportPage.tsx b/apps/web/src/pages/PlayerWeeklyReportPage.tsx index 316e9480..11e84499 100644 --- a/apps/web/src/pages/PlayerWeeklyReportPage.tsx +++ b/apps/web/src/pages/PlayerWeeklyReportPage.tsx @@ -115,9 +115,9 @@ function roleMetricValue(value: number | null, unit: 'ratio' | 'per_match' | 'pe function loadoutSlotLabel(slot: string, index: number): string { const normalized = slot.toUpperCase(); - if (normalized.includes('AUGMENT')) return 'Augmento'; + if (normalized.includes('AUGMENT') || normalized.includes('HERO_SPECIFIC')) return 'Augmento'; if (normalized.includes('ETERNAL')) return 'Eternal'; - if (normalized.includes('BLESSING')) return `Bendición ${index > 1 ? index - 1 : index + 1}`; + if (normalized.includes('BLESSING')) return normalized.endsWith('_2') ? 'Bendición II' : 'Bendición I'; return slot || `Ranura ${index + 1}`; } From aaf5b350f6f946cefc4944a110f7b852720ef7e7 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 19:08:14 +0200 Subject: [PATCH 10/24] fix: open synced matches without Pred.gg live access --- apps/api/src/routes/matches.test.ts | 60 +++++++++++++++++++++++- apps/api/src/routes/matches.ts | 18 +++++++ apps/web/src/pages/PlayerMatchesPage.tsx | 2 +- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/apps/api/src/routes/matches.test.ts b/apps/api/src/routes/matches.test.ts index 154c8b7a..6a9dfe0a 100644 --- a/apps/api/src/routes/matches.test.ts +++ b/apps/api/src/routes/matches.test.ts @@ -1,5 +1,39 @@ -import { describe, expect, it } from 'vitest'; -import { buildLiveDetail } from './matches.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import cookieParser from 'cookie-parser'; +import express from 'express'; +import request from 'supertest'; +import { authCookie } from '../test/auth-cookie.js'; +import { errorHandler } from '../middleware/error-handler.js'; + +const mocks = vi.hoisted(() => ({ + 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 8fec2d7d..5772fb00 100644 --- a/apps/api/src/routes/matches.ts +++ b/apps/api/src/routes/matches.ts @@ -430,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'); diff --git a/apps/web/src/pages/PlayerMatchesPage.tsx b/apps/web/src/pages/PlayerMatchesPage.tsx index 86ce63a1..c58d26c4 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)')} > From b7b7afd16cec4b43be577ecf815e5dfb71d48026 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 19:56:14 +0200 Subject: [PATCH 11/24] feat: explain contextual build coaching --- .../src/services/build-coach-service.test.ts | 21 +- apps/api/src/services/build-coach-service.ts | 247 +++++++++++++++++- apps/web/src/api/client.ts | 56 +++- apps/web/src/pages/MatchDetail.tsx | 118 +++++++++ 4 files changed, 426 insertions(+), 16 deletions(-) diff --git a/apps/api/src/services/build-coach-service.test.ts b/apps/api/src/services/build-coach-service.test.ts index 13410827..5cec9a92 100644 --- a/apps/api/src/services/build-coach-service.test.ts +++ b/apps/api/src/services/build-coach-service.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ matchPlayerFindFirst: vi.fn(), gameItemFindMany: vi.fn(), gameItemVersionFindMany: vi.fn(), + transactionFindMany: vi.fn(), })); vi.mock('../db.js', () => ({ @@ -11,6 +12,7 @@ vi.mock('../db.js', () => ({ matchPlayer: { findFirst: mocks.matchPlayerFindFirst }, gameItem: { findMany: mocks.gameItemFindMany }, gameItemVersion: { findMany: mocks.gameItemVersionFindMany }, + transaction: { findMany: mocks.transactionFindMany }, }, })); @@ -20,16 +22,17 @@ describe('contextual build coach', () => { beforeEach(() => { vi.clearAllMocks(); mocks.matchPlayerFindFirst.mockResolvedValue({ - id: 'mp-1', matchId: 'match-1', heroSlug: 'dekker', role: 'SUPPORT', team: 'DAWN', + 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', team: 'DAWN', role: 'SUPPORT', heroSlug: 'dekker', totalHealingDone: 2_000, totalShieldingReceived: 1_000, totalDamageMitigated: 8_000 }, - { id: 'enemy-1', team: 'DUSK', role: 'SUPPORT', heroSlug: 'narbash', totalHealingDone: 20_000, totalShieldingReceived: 2_000, totalDamageMitigated: 25_000 }, + { 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 }, ], }, }); @@ -38,8 +41,12 @@ describe('contextual build coach', () => { isEvolved: false, isHidden: false, stats: [], effects: [], blocksIds: ['data-1'], blockedByIds: ['data-1'], }] }]); mocks.gameItemVersionFindMany.mockResolvedValue([ - { displayName: 'Physical Guard', aggressionType: 'ARMOR', totalPrice: 3000, item: { slug: 'physical-guard' } }, - { displayName: 'Tainted Guard', aggressionType: 'ANTI_HEAL', totalPrice: 3100, item: { slug: 'tainted-guard' } }, + { 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.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' }, ]); }); @@ -52,6 +59,10 @@ describe('contextual build coach', () => { ])); 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.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' }), ])); diff --git a/apps/api/src/services/build-coach-service.ts b/apps/api/src/services/build-coach-service.ts index 47d0cc64..08726333 100644 --- a/apps/api/src/services/build-coach-service.ts +++ b/apps/api/src/services/build-coach-service.ts @@ -10,9 +10,18 @@ interface BuildSignal { evidence: string; recommendation: string; desiredTags: string[]; - suggestedItems?: Array<{ slug: string; displayName: string; aggressionType: string | null }>; + suggestedItems?: Array<{ slug: string; displayName: string; aggressionType: string | null; reason: string }>; } +type LoadoutPerk = { + id: string; + name?: string; + displayName: string; + slot: string; + simpleDescription?: string | null; + description?: string | null; +}; + function jsonArray(value: unknown): T[] { return Array.isArray(value) ? value as T[] : []; } @@ -43,9 +52,83 @@ function semanticTags(item: { if (/armor.*shred|reduce.*armor|physical penetration/.test(text)) tags.add('PHYSICAL_SHRED'); if (/magical armor.*reduc|magical penetration/.test(text)) tags.add('MAGICAL_SHRED'); if (/maximum health|bonus health|current health/.test(text)) tags.add('ANTI_TANK'); + if (/heal|healing|health regeneration|omnivamp|lifesteal/.test(text)) tags.add('SUSTAIN'); + if (/shield/.test(text)) 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/.test(text)) tags.add('ABILITY_DAMAGE'); return tags; } +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', + }; + 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 perkAssessment(perk: LoadoutPerk, player: { + deaths: number; + 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 (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.`; + } + + if (slot.includes('HERO_SPECIFIC') || slot.includes('AUGMENT')) { + why = `Es un Augmento diseñado para ${perk.name ?? 'tu héroe'}. ${why}`; + } + return { ...perk, verdict, why, effect: perk.simpleDescription ?? perk.description ?? null }; +} + export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: string) { const row = await db.matchPlayer.findFirst({ where: { id: matchPlayerId, matchId }, @@ -57,7 +140,7 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri matchPlayers: { select: { id: true, team: true, role: true, heroSlug: true, totalHealingDone: true, - totalShieldingReceived: true, totalDamageMitigated: true, + totalShieldingReceived: true, totalDamageMitigated: true, playerId: true, playerName: true, }, }, }, @@ -119,6 +202,9 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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 signals: BuildSignal[] = []; if (damageTotal > 0 && share(physical, damageTotal) >= 60 && !tags.has('ARMOR') && !tags.has('ANTI_CRIT') && !statNames.has('PHYSICAL_ARMOR')) { @@ -140,21 +226,21 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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.`, + 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.`, + 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 && !tags.has('ANTI_TANK') && !tags.has('SHRED')) { 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.`, + 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'], }); @@ -180,12 +266,16 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri } 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) { - const candidates = await db.gameItemVersion.findMany({ + candidates = await db.gameItemVersion.findMany({ where: { versionId: catalogVersionId, isHidden: false, rarity: 'EPIC', slotType: 'PASSIVE', }, - include: { item: { select: { slug: true } } }, + include: { item: { select: { slug: true, name: true } } }, orderBy: { totalPrice: 'asc' }, }); for (const signal of signals) { @@ -200,10 +290,130 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri return signal.desiredTags.some((tag) => candidateTags.has(tag)); }) .slice(0, 3) - .map((candidate) => ({ slug: candidate.item.slug, displayName: candidate.displayName, aggressionType: candidate.aggressionType })); + .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, + 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 inventoryAssessments = inventory.map((item) => { + const itemTags = semanticTags(item); + const matched = [...itemTags].filter((tag) => needTagSet.has(tag)); + 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), + explanation: matched.length > 0 + ? `Fue una buena adaptación porque ${matched.map(tagPurpose).join(' y ')}.` + : 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 alreadyOwned = new Set(inventory.map((item) => item.slug)); + const replaceable = inventory + .filter((item) => ![...semanticTags(item)].some((tag) => needTagSet.has(tag))) + .filter((item) => item.rarity === 'EPIC' && item.slotType === 'PASSIVE'); + const usedReplacements = new Set(); + const changes = activeSignals.flatMap((signal) => { + const suggestion = signal.suggestedItems?.find((item) => !alreadyOwned.has(item.slug)); + if (!suggestion) return []; + const insteadOf = [...replaceable].reverse().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}`, + }]; + }); + + 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: `${enemy.heroSlug} compró ${item.displayName} para ${relevant.slice(0, 2).map(tagPurpose).join(' y ')}. A partir de ese minuto, repetir daño plano pierde valor y conviene adaptar la siguiente pieza.`, + }]; + }).slice(0, 8); + + const loadout = jsonArray(row.perks).map((perk) => perkAssessment(perk, row, needTagSet)); + 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, @@ -217,9 +427,28 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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, + })), }, inventory, - eternalLoadout: jsonArray<{ id: string; displayName: string; slot: string }>(row.perks), + 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, + }, + 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, abilityOrder: jsonArray<{ ability: string; gameTime: number }>(row.abilityOrder), signals, }; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 9d8fc420..cbf41232 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1231,6 +1231,14 @@ export interface MatchBuildAnalysis { enemyHealing: number; enemyShielding: number; enemyMitigation: number; + enemyHeroes: Array<{ + heroSlug: string; + role: string | null; + playerName: string; + healing: number; + shieldingReceived: number; + damageMitigated: number; + }>; }; inventory: Array<{ slug: string; @@ -1241,7 +1249,51 @@ export interface MatchBuildAnalysis { isEvolved: boolean; isHidden: boolean; }>; - eternalLoadout: Array<{ id: string; displayName: string; slot: string }>; + inventoryAssessments: Array<{ + slug: string; + displayName: string; + verdict: 'correct' | 'neutral'; + purpose: 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 }; + insteadOf: { slug: string; displayName: string } | null; + timing: string; + why: string; + }>; + }; + 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; + }>; abilityOrder: Array<{ ability: string; gameTime: number }>; signals: Array<{ key: string; @@ -1249,7 +1301,7 @@ export interface MatchBuildAnalysis { title: string; evidence: string; recommendation: string; - suggestedItems?: Array<{ slug: string; displayName: string; aggressionType: string | null }>; + suggestedItems?: Array<{ slug: string; displayName: string; aggressionType: string | null; reason: string }>; }>; } diff --git a/apps/web/src/pages/MatchDetail.tsx b/apps/web/src/pages/MatchDetail.tsx index 834b89a1..072a8b85 100644 --- a/apps/web/src/pages/MatchDetail.tsx +++ b/apps/web/src/pages/MatchDetail.tsx @@ -1711,6 +1711,15 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string; }, [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 (
@@ -1722,6 +1731,42 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string; 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'} + + ))} +
+
+ +
+

Evaluación de tu build final

+
+ {analysis.inventoryAssessments.map((item) => ( +
+ { (event.currentTarget as HTMLImageElement).style.display = 'none'; }} /> +
+ {item.displayName} +

{item.explanation}

+
+
+ ))} +
+
+
{analysis.signals.map((signal) => { const color = signal.severity === 'critical' ? 'var(--accent-loss)' : signal.severity === 'warning' ? 'var(--accent-prime)' : 'var(--accent-win)'; @@ -1743,6 +1788,79 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string; ); })}
+ +
+

Build recomendada contra este equipo

+

{analysis.recommendedBuild.principle}

+ {analysis.recommendedBuild.changes.length > 0 ? ( +
+ {analysis.recommendedBuild.changes.map((change) => ( +
+
+ {change.insteadOf && <> + + {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.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)}

} +
+ ); + })} +
+
+ )} + +
+

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} +
+ ))} +
+ )} +
); } From de3e3ea3cfedccbbb1cdba2ff9da3d0925ab76f2 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 20:00:55 +0200 Subject: [PATCH 12/24] fix: keep build advice role-aware and non-duplicative --- apps/api/src/services/build-coach-service.ts | 71 +++++++++++++++----- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/apps/api/src/services/build-coach-service.ts b/apps/api/src/services/build-coach-service.ts index 08726333..6e938373 100644 --- a/apps/api/src/services/build-coach-service.ts +++ b/apps/api/src/services/build-coach-service.ts @@ -57,7 +57,8 @@ function semanticTags(item: { 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/.test(text)) tags.add('ABILITY_DAMAGE'); + if (/ability damage|magical power|ability haste|cooldown/.test(text)) tags.add('ABILITY_DAMAGE'); + if (/deal.*damage|damage.*target/.test(text)) tags.add('DAMAGE'); return tags; } @@ -79,6 +80,7 @@ function tagPurpose(tag: string): string { 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', }; return labels[tag] ?? tag.toLowerCase().replaceAll('_', ' '); } @@ -95,6 +97,7 @@ function normalizeCatalogKey(value: string): string { function perkAssessment(perk: LoadoutPerk, player: { deaths: number; + role: string | null; heroDamage: number | null; totalHealingDone: number | null; totalShieldingReceived: number | null; @@ -106,7 +109,19 @@ function perkAssessment(perk: LoadoutPerk, player: { 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 (matchedNeed) { + 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) { @@ -123,12 +138,18 @@ function perkAssessment(perk: LoadoutPerk, player: { 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.`; } - if (slot.includes('HERO_SPECIFIC') || slot.includes('AUGMENT')) { - why = `Es un Augmento diseñado para ${perk.name ?? 'tu héroe'}. ${why}`; - } 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 }, @@ -190,7 +211,7 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri blocksIds: jsonArray(data?.blocksIds), blockedByIds: jsonArray(data?.blockedByIds), }; - }); + }).sort((a, b) => inventorySlugs.indexOf(a.slug) - inventorySlugs.indexOf(b.slug)); const tags = new Set(inventory.flatMap((item) => [...semanticTags(item)])); const statNames = new Set(inventory.flatMap((item) => item.stats.map((stat) => stat.stat))); const enemies = row.match.matchPlayers.filter((player) => player.team !== row.team); @@ -237,7 +258,7 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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 && !tags.has('ANTI_TANK') && !tags.has('SHRED')) { + 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}` : ''}.`, @@ -310,9 +331,21 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri const activeSignals = signals.filter((signal) => signal.key !== 'adaptation-ok'); const needTagSet = new Set(desiredTags); - const inventoryAssessments = inventory.map((item) => { + 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 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) => needTagSet.has(tag)); + const matched = [...itemTags].filter((tag) => contextualNeedTags.has(tag)); + const repeatsPurpose = item.aggressionType ? (aggressionCounts.get(item.aggressionType) ?? 0) > 1 : false; return { slug: item.slug, displayName: item.displayName, @@ -320,6 +353,8 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri purpose: [...itemTags].slice(0, 3).map(tagPurpose), 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.', @@ -327,14 +362,20 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri }); const alreadyOwned = new Set(inventory.map((item) => item.slug)); - const replaceable = inventory + const replaceable = finalInventory .filter((item) => ![...semanticTags(item)].some((tag) => needTagSet.has(tag))) - .filter((item) => item.rarity === 'EPIC' && item.slotType === 'PASSIVE'); + .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) return []; - const insteadOf = [...replaceable].reverse().find((item) => !usedReplacements.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.' @@ -349,7 +390,7 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri item: suggestion, insteadOf: insteadOf ? { slug: insteadOf.slug, displayName: insteadOf.displayName } : null, timing, - why: `${signal.evidence} ${suggestion.reason}`, + 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.' : ''}`, }]; }); @@ -398,7 +439,7 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri playerName: enemy.playerName, itemName: item.displayName, itemSlug: item.slug, - explanation: `${enemy.heroSlug} compró ${item.displayName} para ${relevant.slice(0, 2).map(tagPurpose).join(' y ')}. A partir de ese minuto, repetir daño plano pierde valor y conviene adaptar la siguiente pieza.`, + explanation: opponentPurchaseExplanation(enemy.heroSlug, item.displayName, item.tags), }]; }).slice(0, 8); From e2539eae59af57329f702a69262f97ecd65a4915 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 20:03:12 +0200 Subject: [PATCH 13/24] test: cover role-aware build advice --- .../src/services/build-coach-service.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/api/src/services/build-coach-service.test.ts b/apps/api/src/services/build-coach-service.test.ts index 5cec9a92..836df71b 100644 --- a/apps/api/src/services/build-coach-service.test.ts +++ b/apps/api/src/services/build-coach-service.test.ts @@ -70,4 +70,38 @@ describe('contextual build coach', () => { 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.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' }); + }); }); From 18224a8c5db7ceb6a32f99b3f9f78129831f5af9 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 20:50:30 +0200 Subject: [PATCH 14/24] feat: teach build patterns in weekly coach report --- apps/web/src/pages/PlayerWeeklyReportPage.tsx | 142 ++++++++++++++++-- 1 file changed, 126 insertions(+), 16 deletions(-) diff --git a/apps/web/src/pages/PlayerWeeklyReportPage.tsx b/apps/web/src/pages/PlayerWeeklyReportPage.tsx index 11e84499..8ae5bc0b 100644 --- a/apps/web/src/pages/PlayerWeeklyReportPage.tsx +++ b/apps/web/src/pages/PlayerWeeklyReportPage.tsx @@ -121,6 +121,74 @@ function loadoutSlotLabel(slot: string, index: number): string { 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'); @@ -133,6 +201,20 @@ function BuildReviewCard({ entry, onOpen }: { entry: PlayerBuildReview['matches' : 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 (
@@ -144,31 +226,44 @@ function BuildReviewCard({ entry, onOpen }: { entry: PlayerBuildReview['matches' {analysis.role ?? 'Sin rol'} · {new Date(match.startTime).toLocaleDateString()}
- - {analysis.result === 'win' ? 'Victoria' : 'Derrota'} - +
+ {analysis.result === 'win' ? 'Victoria' : 'Derrota'} + {buildVerdictLabel} · {analysis.verdict.score}/100 +

Build final

- {visibleItems.length > 0 ? visibleItems.map((item) => ( - + {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} + );}) : Build todavía incompleta}

Ancestro y bendiciones

- {analysis.eternalLoadout.length > 0 ? analysis.eternalLoadout.map((perk, index) => ( - + {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} + );}) : 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 ? ( @@ -178,6 +273,18 @@ function BuildReviewCard({ entry, onOpen }: { entry: PlayerBuildReview['matches' ) : null} + {firstChange && ( +
+ Cambio recomendado +
+ {firstChange.insteadOf && <>{firstChange.insteadOf.displayName}} + + {firstChange.item.displayName} +
+

{firstChange.timing}

+
+ )} + @@ -430,8 +537,8 @@ export default function PlayerWeeklyReportPage() {
Decisiones de partida
-

Builds, Ancestros y adaptación

-

Qué compraste, qué loadout llevaste y cómo podrías adaptarlo al rival.

+

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 @@ -443,11 +550,14 @@ export default function PlayerWeeklyReportPage() { Analizando tus builds y Ancestros recientes… ) : buildReview && buildReview.matches.length > 0 ? ( -
- {buildReview.matches.map((entry) => ( - navigate(`/matches/${entry.match.id}?tab=statistics`)} /> - ))} -
+ <> + +
+ {buildReview.matches.map((entry) => ( + navigate(`/matches/${entry.match.id}?tab=statistics`)} /> + ))} +
+ ) : (
Todavía no hay builds analizables. Pulsa «Actualizar historial» para completar las partidas recientes. From 2789a9abaf46012c47dd8b664f5bdaf7652bd993 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 21:08:50 +0200 Subject: [PATCH 15/24] feat: make match build coaching educational --- .../src/services/build-coach-service.test.ts | 23 ++ apps/api/src/services/build-coach-service.ts | 255 +++++++++++++++++- apps/web/src/api/client.ts | 43 ++- apps/web/src/pages/MatchDetail.tsx | 172 ++++++++++-- apps/web/src/pages/PlayerWeeklyReportPage.tsx | 2 +- 5 files changed, 454 insertions(+), 41 deletions(-) diff --git a/apps/api/src/services/build-coach-service.test.ts b/apps/api/src/services/build-coach-service.test.ts index 836df71b..70c80446 100644 --- a/apps/api/src/services/build-coach-service.test.ts +++ b/apps/api/src/services/build-coach-service.test.ts @@ -4,6 +4,8 @@ const mocks = vi.hoisted(() => ({ matchPlayerFindFirst: vi.fn(), gameItemFindMany: vi.fn(), gameItemVersionFindMany: vi.fn(), + gamePerkVersionFindMany: vi.fn(), + heroMetaFindMany: vi.fn(), transactionFindMany: vi.fn(), })); @@ -12,6 +14,8 @@ vi.mock('../db.js', () => ({ matchPlayer: { findFirst: mocks.matchPlayerFindFirst }, gameItem: { findMany: mocks.gameItemFindMany }, gameItemVersion: { findMany: mocks.gameItemVersionFindMany }, + gamePerkVersion: { findMany: mocks.gamePerkVersionFindMany }, + heroMeta: { findMany: mocks.heroMetaFindMany }, transaction: { findMany: mocks.transactionFindMany }, }, })); @@ -44,6 +48,11 @@ describe('contextual build coach', () => { { 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.' }] }, + ]); + 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' }, @@ -61,6 +70,10 @@ describe('contextual build coach', () => { 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.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([ @@ -96,6 +109,13 @@ describe('contextual build coach', () => { 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'); @@ -103,5 +123,8 @@ describe('contextual build coach', () => { 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 index 6e938373..47ba57bf 100644 --- a/apps/api/src/services/build-coach-service.ts +++ b/apps/api/src/services/build-coach-service.ts @@ -9,8 +9,15 @@ interface BuildSignal { 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 }>; + 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 = { @@ -20,6 +27,26 @@ type LoadoutPerk = { 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[] { @@ -49,8 +76,8 @@ function semanticTags(item: { 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|reduce.*armor|physical penetration/.test(text)) tags.add('PHYSICAL_SHRED'); - if (/magical armor.*reduc|magical penetration/.test(text)) tags.add('MAGICAL_SHRED'); + 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 (/heal|healing|health regeneration|omnivamp|lifesteal/.test(text)) tags.add('SUSTAIN'); if (/shield/.test(text)) tags.add('SHIELD'); @@ -95,6 +122,64 @@ 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 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 (/heal|shield/.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; @@ -162,6 +247,7 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri select: { id: true, team: true, role: true, heroSlug: true, totalHealingDone: true, totalShieldingReceived: true, totalDamageMitigated: true, playerId: true, playerName: true, + inventoryItems: true, }, }, }, @@ -170,15 +256,18 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri }); 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 && inventorySlugs.length > 0 + let catalogItems = catalogVersionId && catalogSlugs.length > 0 ? await db.gameItem.findMany({ - where: { slug: { in: inventorySlugs } }, + where: { slug: { in: catalogSlugs } }, include: { versions: { where: { versionId: catalogVersionId }, take: 1 } }, }) : []; - if (catalogVersionId && inventorySlugs.length > 0 && catalogItems.every((item) => item.versions.length === 0)) { + 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: {} } }, @@ -188,13 +277,13 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri if (fallback) { catalogVersionId = fallback.id; catalogItems = await db.gameItem.findMany({ - where: { slug: { in: inventorySlugs } }, + where: { slug: { in: catalogSlugs } }, include: { versions: { where: { versionId: catalogVersionId }, take: 1 } }, }); } } - const inventory = catalogItems.map((item) => { + const allCatalogItems = catalogItems.map((item) => { const data = item.versions[0]; return { predggId: item.predggId, @@ -211,10 +300,20 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri blocksIds: jsonArray(data?.blocksIds), blockedByIds: jsonArray(data?.blockedByIds), }; - }).sort((a, b) => inventorySlugs.indexOf(a.slug) - inventorySlugs.indexOf(b.slug)); + }); + 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 enemies = row.match.matchPlayers.filter((player) => player.team !== row.team); + + 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; @@ -286,6 +385,45 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri }); } + 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' + ? /heal|healing|restore[^.]{0,30}health|lifesteal|omnivamp/i + : /shield/i; + const sources: NonNullable = []; + for (const enemy of enemies) { + 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)) continue; + sources.push({ + 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 matchingEffects = item.effects.filter((effect) => pattern.test(plainText(`${effect.name} ${effect.text}`))); + 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; @@ -323,6 +461,9 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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.', }; }); @@ -444,6 +585,98 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri }).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.', + }; + if (catalogVersionId) { + const 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'; @@ -480,6 +713,7 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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, @@ -490,6 +724,7 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri : '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/web/src/api/client.ts b/apps/web/src/api/client.ts index cbf41232..22338185 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1248,6 +1248,8 @@ export interface MatchBuildAnalysis { 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; @@ -1266,11 +1268,25 @@ export interface MatchBuildAnalysis { changes: Array<{ signalKey: string; action: 'add' | 'replace'; - item: { slug: string; displayName: string; aggressionType: string | null; reason: string }; + 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; @@ -1293,7 +1309,23 @@ export interface MatchBuildAnalysis { 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; @@ -1301,7 +1333,14 @@ export interface MatchBuildAnalysis { title: string; evidence: string; recommendation: string; - suggestedItems?: Array<{ slug: string; displayName: string; aggressionType: string | null; reason: 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 }>; + }>; }>; } diff --git a/apps/web/src/pages/MatchDetail.tsx b/apps/web/src/pages/MatchDetail.tsx index 072a8b85..9e61f710 100644 --- a/apps/web/src/pages/MatchDetail.tsx +++ b/apps/web/src/pages/MatchDetail.tsx @@ -223,15 +223,18 @@ export default function MatchDetail({ liveMode = false }: { liveMode?: boolean } {tab === 'statistics' && ( )} {tab === 'timeline' && ( )} {tab === 'analysis' && ( - + )}
); @@ -617,9 +620,10 @@ const OBJ_GROUPS = [ { 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); @@ -636,15 +640,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. +
+
-
); } @@ -701,6 +708,7 @@ function AnalysisTab({ match, duskWon, dawnWon: _dawnWon, onResync, syncing, pre return (
+ {(liveMode || buildCoachPlayerId) && } {/* ── Objective Control ── */}
@@ -1694,7 +1702,61 @@ 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); @@ -1755,15 +1817,18 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string;

Evaluación de tu build final

- {analysis.inventoryAssessments.map((item) => ( -
- { (event.currentTarget as HTMLImageElement).style.display = 'none'; }} /> + {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.explanation}

-
- ))} +
; + })}
@@ -1774,13 +1839,27 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string;
{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} - + }> + + {item.displayName} + + ))}
)} @@ -1798,11 +1877,15 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string;
{change.insteadOf && <> - + item.slug === change.insteadOf?.slug), displayName: change.insteadOf.displayName }} />}> + + {change.insteadOf.displayName} } - + }> + + {change.item.displayName}

Por qué: {change.why}

@@ -1811,6 +1894,21 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string; ))}
) :

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 && ( @@ -1821,7 +1919,8 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string; 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)} @@ -1832,12 +1931,32 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string;

{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}

@@ -1865,11 +1984,9 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string; ); } -function StatisticsTab({ match, duskWon, dawnWon, onResync, syncing, buildCoachPlayerId, liveMode }: { +function StatisticsTab({ match, duskWon, dawnWon, onResync, syncing }: { match: MatchDetailData; duskWon: boolean; dawnWon: boolean; onResync: () => void; syncing: boolean; - buildCoachPlayerId: string | null; - liveMode: boolean; }) { const allPlayers = [...match.dusk, ...match.dawn]; const hasExtendedStats = allPlayers.some((p) => p.physicalDamageDealtToHeroes !== null); @@ -1908,7 +2025,6 @@ function StatisticsTab({ match, duskWon, dawnWon, onResync, syncing, buildCoachP return (
- {(liveMode || buildCoachPlayerId) && } {/* Section 1 — Damage Output */} {teams.map(({ key, label, players, won }) => ( diff --git a/apps/web/src/pages/PlayerWeeklyReportPage.tsx b/apps/web/src/pages/PlayerWeeklyReportPage.tsx index 8ae5bc0b..473f1d4e 100644 --- a/apps/web/src/pages/PlayerWeeklyReportPage.tsx +++ b/apps/web/src/pages/PlayerWeeklyReportPage.tsx @@ -554,7 +554,7 @@ export default function PlayerWeeklyReportPage() {
{buildReview.matches.map((entry) => ( - navigate(`/matches/${entry.match.id}?tab=statistics`)} /> + navigate(`/matches/${entry.match.id}?tab=analysis`)} /> ))}
From 2dfb162c1a748cf3e706b38d4434049d34d46022 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 21:11:52 +0200 Subject: [PATCH 16/24] fix: rank build evidence by match impact --- apps/api/src/services/build-coach-service.ts | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/api/src/services/build-coach-service.ts b/apps/api/src/services/build-coach-service.ts index 47ba57bf..db35406a 100644 --- a/apps/api/src/services/build-coach-service.ts +++ b/apps/api/src/services/build-coach-service.ts @@ -159,7 +159,7 @@ function perkScore(perk: CatalogPerk, role: string | null, enemyMitigation: numb 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 (/heal|shield/.test(text) && enablesHealingOrShielding) score += 3; + 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; @@ -390,16 +390,21 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri if (lesson) Object.assign(signal, lesson); if (!['anti-heal', 'anti-shield'].includes(signal.key)) continue; const pattern = signal.key === 'anti-heal' - ? /heal|healing|restore[^.]{0,30}health|lifesteal|omnivamp/i + ? /\bheal(?:s|ed|ing)?\b|\brestore[^.]{0,30}\bhealth\b|\blifesteal\b|\bomnivamp\b/i : /shield/i; const sources: NonNullable = []; - for (const enemy of enemies) { + 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)) continue; + if (!description || !pattern.test(description) || /reduc[^.]{0,30}heal|heal[^.]{0,30}reduc/i.test(description)) continue; sources.push({ heroSlug: enemy.heroSlug, sourceType: 'ability', @@ -407,10 +412,17 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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) => pattern.test(plainText(`${effect.name} ${effect.text}`))); + 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, From 744c4a66dfe28a6f54a3f10e355ea7ff6f117509 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 21:14:01 +0200 Subject: [PATCH 17/24] fix: compare loadouts against match perk catalog --- apps/api/src/services/build-coach-service.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/build-coach-service.ts b/apps/api/src/services/build-coach-service.ts index db35406a..4faabe88 100644 --- a/apps/api/src/services/build-coach-service.ts +++ b/apps/api/src/services/build-coach-service.ts @@ -609,10 +609,11 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri blessings: [], explanation: 'No hay catálogo de loadout disponible para comparar alternativas en este parche.', }; - if (catalogVersionId) { - const perkCatalog = await db.gamePerkVersion.findMany({ + const perkCatalogVersionId = row.match.versionId ?? catalogVersionId; + if (perkCatalogVersionId) { + let perkCatalog = await db.gamePerkVersion.findMany({ where: { - versionId: catalogVersionId, + versionId: perkCatalogVersionId, OR: [ { slot: 'ETERNAL_1' }, { heroSlug: row.heroSlug }, @@ -621,6 +622,19 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri }, 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 ?? ''}`) From fcfee90d4fc55863243fe70c5ce7ddb6eb749045 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 23:38:14 +0200 Subject: [PATCH 18/24] feat: analyze full build playstyle and tradeoffs --- .../src/services/build-coach-service.test.ts | 11 +- apps/api/src/services/build-coach-service.ts | 190 +++++++++++++++++- apps/web/src/api/client.ts | 20 ++ apps/web/src/pages/MatchDetail.tsx | 52 ++++- 4 files changed, 267 insertions(+), 6 deletions(-) diff --git a/apps/api/src/services/build-coach-service.test.ts b/apps/api/src/services/build-coach-service.test.ts index 70c80446..832747e4 100644 --- a/apps/api/src/services/build-coach-service.test.ts +++ b/apps/api/src/services/build-coach-service.test.ts @@ -50,7 +50,10 @@ describe('contextual build coach', () => { ]); 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.' }] }, + { 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([ @@ -74,6 +77,12 @@ describe('contextual build coach', () => { 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([ diff --git a/apps/api/src/services/build-coach-service.ts b/apps/api/src/services/build-coach-service.ts index 4faabe88..26c581f2 100644 --- a/apps/api/src/services/build-coach-service.ts +++ b/apps/api/src/services/build-coach-service.ts @@ -79,16 +79,55 @@ function semanticTags(item: { 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 (/heal|healing|health regeneration|omnivamp|lifesteal/.test(text)) tags.add('SUSTAIN'); - if (/shield/.test(text)) tags.add('SHIELD'); + if (/heal|healing|health regeneration|omnivamp|lifesteal/.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|per second/.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 (/dash|blink|leap|charge|pull.*toward|teleport/.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) @@ -107,7 +146,10 @@ function tagPurpose(tag: string): string { 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', + 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('_', ' '); } @@ -152,6 +194,24 @@ function educationalExplanation(key: string): { whyItMatters: string; appliesAga 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; @@ -325,6 +385,67 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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 enemyThreats = threatPriority.flatMap((key) => { + const sources = enemyConceptSources.get(key) ?? []; + 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')) { @@ -491,6 +612,15 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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); @@ -498,12 +628,28 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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 @@ -514,6 +660,29 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri }; }); + 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 unresolvedConcepts = enemyThreats.filter((threat) => { + const responses = responseConcepts[threat.key] ?? []; + return responses.length > 0 && !responses.some((response) => buildTagSet.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))) @@ -733,6 +902,21 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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) }, diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 22338185..59cb7da7 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1240,6 +1240,22 @@ export interface MatchBuildAnalysis { 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; @@ -1256,6 +1272,10 @@ export interface MatchBuildAnalysis { displayName: string; verdict: 'correct' | 'neutral'; purpose: string[]; + functions: Array<{ key: string; label: string; description: string }>; + roleFit: string; + matchupFit: string; + tradeoff: string; explanation: string; }>; verdict: { diff --git a/apps/web/src/pages/MatchDetail.tsx b/apps/web/src/pages/MatchDetail.tsx index 9e61f710..aa6f5ec6 100644 --- a/apps/web/src/pages/MatchDetail.tsx +++ b/apps/web/src/pages/MatchDetail.tsx @@ -1714,7 +1714,7 @@ function CoachHover({ children, content, label }: { children: React.ReactNode; c }); }; return ( - show(event.currentTarget)} @@ -1734,7 +1734,7 @@ function CoachHover({ children, content, label }: { children: React.ReactNode; c
, document.body, )} - +
); } @@ -1814,6 +1814,50 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string; +
+

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

@@ -1825,7 +1869,11 @@ function BuildCoachCard({ matchId, matchPlayerId, liveMode }: { matchId: string;
{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}

; })} From f5c1e056b1cdbd3a2dee978dc26aec1171589b0c Mon Sep 17 00:00:00 2001 From: Saggacce Date: Wed, 26 Aug 2026 23:39:32 +0200 Subject: [PATCH 19/24] fix: remove noisy global build classifications --- apps/api/src/services/build-coach-service.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/api/src/services/build-coach-service.ts b/apps/api/src/services/build-coach-service.ts index 26c581f2..42a40505 100644 --- a/apps/api/src/services/build-coach-service.ts +++ b/apps/api/src/services/build-coach-service.ts @@ -79,17 +79,17 @@ function semanticTags(item: { 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 (/heal|healing|health regeneration|omnivamp|lifesteal/.test(text) && !tags.has('ANTI_HEAL')) tags.add('SUSTAIN'); + 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|per second/.test(text)) tags.add('DPS'); + 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 (/dash|blink|leap|charge|pull.*toward|teleport/.test(text)) tags.add('ENGAGE'); + 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'); @@ -430,8 +430,15 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri } } 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) ?? []; + 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 []; @@ -670,9 +677,10 @@ export async function getMatchBuildAnalysis(matchId: string, matchPlayerId: stri 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) => buildTagSet.has(response)); + 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; From a0d06d35eddf15c90f8874bb41448e7a00eb325b Mon Sep 17 00:00:00 2001 From: Saggacce Date: Thu, 27 Aug 2026 00:21:35 +0200 Subject: [PATCH 20/24] fix: explain unavailable gold timeline --- apps/api/src/routes/auth.test.ts | 23 +++++++++++++++++++++-- apps/api/src/routes/auth.ts | 2 +- apps/web/src/pages/MatchDetail.tsx | 21 +++++++++++++++++---- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/api/src/routes/auth.test.ts b/apps/api/src/routes/auth.test.ts index 85f1fba2..b6be38e8 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 7705ae11..d428498b 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 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 { @@ -754,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} From 1eea0aa8b04758d30ac394cca9f02315059723d9 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Thu, 27 Aug 2026 00:39:24 +0200 Subject: [PATCH 21/24] fix: preserve gold timeline on match resync --- apps/api/src/routes/matches.ts | 13 ++++-------- apps/api/src/services/sync-service.test.ts | 17 ++++++++++++++++ apps/api/src/services/sync-service.ts | 23 ++++++++++++---------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/apps/api/src/routes/matches.ts b/apps/api/src/routes/matches.ts index 5772fb00..4a5724ef 100644 --- a/apps/api/src/routes/matches.ts +++ b/apps/api/src/routes/matches.ts @@ -465,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/services/sync-service.test.ts b/apps/api/src/services/sync-service.test.ts index 6dd21fed..393ffdf2 100644 --- a/apps/api/src/services/sync-service.test.ts +++ b/apps/api/src/services/sync-service.test.ts @@ -133,6 +133,23 @@ 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] }, + }); + }); }); describe('repairEventStreamPlayerIds', () => { diff --git a/apps/api/src/services/sync-service.ts b/apps/api/src/services/sync-service.ts index 8bab5120..7d40177d 100644 --- a/apps/api/src/services/sync-service.ts +++ b/apps/api/src/services/sync-service.ts @@ -1268,9 +1268,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'); @@ -1414,13 +1416,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'); } } @@ -1605,7 +1608,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 From e678ff0248800b152b70208b2f15a7fb8b5771fe Mon Sep 17 00:00:00 2001 From: Saggacce Date: Thu, 27 Aug 2026 00:41:43 +0200 Subject: [PATCH 22/24] fix: retry public event stream after api key rejection --- apps/api/src/services/sync-service.test.ts | 36 ++++++++++++++++++++ apps/api/src/services/sync-service.ts | 38 +++++++++++++++------- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/apps/api/src/services/sync-service.test.ts b/apps/api/src/services/sync-service.test.ts index 393ffdf2..f52528de 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', () => { @@ -150,6 +151,41 @@ describe('syncMatchEventStream', () => { data: { goldEarnedAtInterval: [100, 200] }, }); }); + + it('retries public event data without the API key when pred.gg rejects it', 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: { + heroKills: [], objectiveKills: [], structureDestructions: [], heroBans: [], + matchPlayers: [{ + player: { id: 'predgg-warder' }, team: 'BLUE', goldEarnedAtInterval: [100, 200], + wardPlacements: [], wardDestructions: [], transactions: [], + }], + }, + }, + }), + } 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(); + }); }); describe('repairEventStreamPlayerIds', () => { diff --git a/apps/api/src/services/sync-service.ts b/apps/api/src/services/sync-service.ts index 7d40177d..86b22d6a 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,39 @@ async function predggQuery( query: string, variables?: Record, userToken?: string, + options: { publicFallback?: 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; + 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; + try { + return await execute(headers); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const apiKeyRejected = !userToken && Boolean(headers['X-Api-Key']) + && /401|403|unauthorized|forbidden/i.test(message); + if (!options.publicFallback || !apiKeyRejected) throw error; + + logger.warn('pred.gg API key rejected for public event data — retrying without credentials'); + return execute({ 'Content-Type': 'application/json' }); + } } // ── Types ──────────────────────────────────────────────────────────────────── @@ -1626,6 +1641,7 @@ export async function syncMatchEventStream( MATCH_EVENT_STREAM_QUERY, { uuid: predggUuid }, userToken, + { publicFallback: true }, ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); From 0f8e5b7d703bf3f88edc9635106711f944df3347 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Thu, 27 Aug 2026 00:43:26 +0200 Subject: [PATCH 23/24] fix: sync public gold timeline separately --- apps/api/src/services/sync-service.test.ts | 7 ++- apps/api/src/services/sync-service.ts | 56 +++++++++++++++------- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/apps/api/src/services/sync-service.test.ts b/apps/api/src/services/sync-service.test.ts index f52528de..57e9649f 100644 --- a/apps/api/src/services/sync-service.test.ts +++ b/apps/api/src/services/sync-service.test.ts @@ -152,7 +152,7 @@ describe('syncMatchEventStream', () => { }); }); - it('retries public event data without the API key when pred.gg rejects it', async () => { + 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({ @@ -164,10 +164,8 @@ describe('syncMatchEventStream', () => { json: async () => ({ data: { match: { - heroKills: [], objectiveKills: [], structureDestructions: [], heroBans: [], matchPlayers: [{ - player: { id: 'predgg-warder' }, team: 'BLUE', goldEarnedAtInterval: [100, 200], - wardPlacements: [], wardDestructions: [], transactions: [], + player: { id: 'predgg-warder' }, goldEarnedAtInterval: [100, 200], }], }, }, @@ -185,6 +183,7 @@ describe('syncMatchEventStream', () => { headers: { 'Content-Type': 'application/json' }, })); expect(mockDb.matchPlayer.updateMany).toHaveBeenCalled(); + expect(mockDb.heroKill.deleteMany).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/services/sync-service.ts b/apps/api/src/services/sync-service.ts index 86b22d6a..b21c7d65 100644 --- a/apps/api/src/services/sync-service.ts +++ b/apps/api/src/services/sync-service.ts @@ -27,13 +27,15 @@ async function predggQuery( query: string, variables?: Record, userToken?: string, - options: { publicFallback?: boolean } = {}, + 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 (apiKey) headers['X-Api-Key'] = apiKey; + if (!options.publicOnly) { + if (userToken) headers['Authorization'] = `Bearer ${userToken}`; + else if (apiKey) headers['X-Api-Key'] = apiKey; + } const execute = async (requestHeaders: Record): Promise => { const res = await fetch(GQL_URL, { @@ -49,17 +51,7 @@ async function predggQuery( return json.data as T; }; - try { - return await execute(headers); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const apiKeyRejected = !userToken && Boolean(headers['X-Api-Key']) - && /401|403|unauthorized|forbidden/i.test(message); - if (!options.publicFallback || !apiKeyRejected) throw error; - - logger.warn('pred.gg API key rejected for public event data — retrying without credentials'); - return execute({ 'Content-Type': 'application/json' }); - } + return execute(headers); } // ── Types ──────────────────────────────────────────────────────────────────── @@ -1498,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; @@ -1641,12 +1647,30 @@ export async function syncMatchEventStream( MATCH_EVENT_STREAM_QUERY, { uuid: predggUuid }, userToken, - { publicFallback: true }, ); } 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); From 4d3d7fe63742c95edff5c4bae61651fc1ada5558 Mon Sep 17 00:00:00 2001 From: Saggacce Date: Thu, 27 Aug 2026 00:45:40 +0200 Subject: [PATCH 24/24] fix: backfill missing gold timelines in player sync --- .../player-match-enrichment-service.test.ts | 30 +++++++-- .../player-match-enrichment-service.ts | 61 ++++++++++++++----- 2 files changed, 71 insertions(+), 20 deletions(-) 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 c6dedb17..04dad10b 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 }] }, ]), }, }; @@ -52,7 +52,7 @@ describe('player match enrichment', () => { 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({}) }, }; @@ -69,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')) @@ -79,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 9cd440cb..a507de03 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'; @@ -41,6 +41,7 @@ interface EnrichmentCandidate { eventStreamSynced: boolean; eventStreamFailed: boolean; needsRosterRefresh?: boolean; + needsGoldRefresh?: boolean; } interface EnrichmentOptions { @@ -74,6 +75,11 @@ export async function getPlayerMatchEnrichmentCoverage( eventStreamSynced: true, eventStreamFailed: true, syncedAt: true, + matchPlayers: { + where: { playerId }, + take: 1, + select: { goldEarnedAtInterval: true }, + }, }, }); @@ -86,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; } @@ -121,6 +129,7 @@ async function loadCandidates( OR: [ { rosterSynced: false }, { matchPlayers: { some: { playerId, physicalDamageTaken: null } } }, + { matchPlayers: { some: { playerId, goldEarnedAtInterval: { equals: Prisma.DbNull } } } }, { eventStreamSynced: false, ...(retryFailed ? {} : { eventStreamFailed: false }), @@ -138,19 +147,24 @@ async function loadCandidates( matchPlayers: { where: { playerId }, take: 1, - select: { physicalDamageTaken: true }, + select: { physicalDamageTaken: true, goldEarnedAtInterval: true }, }, }, }); - return matches.map((match) => ({ - id: match.id, - predggUuid: match.predggUuid, - rosterSynced: match.rosterSynced, - eventStreamSynced: match.eventStreamSynced, - eventStreamFailed: match.eventStreamFailed, - needsRosterRefresh: match.matchPlayers?.[0]?.physicalDamageTaken === null, - })); + 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( @@ -171,15 +185,32 @@ export async function enrichPlayerMatches( try { 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) {