From 1f2a5ef7d0d8c8585180dad201bd95b0379a8e57 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 31 May 2026 19:00:43 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20analyst=20insights=20batch=202=20?= =?UTF-8?q?=E2=80=94=2020=20individual=20player=20rules=20(GROUP=20I)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 20 new analyst insight rules for individual players (I1–I20): vision share, ward clears, objective/structure damage, CS, hero pool depth, first-death/early-death/pre-objective death rates, gold efficiency, and comfort overreliance. Includes EN/ES strings for all rules. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/services/analyst-service.ts | 642 +++++++++++++++++++++++ apps/api/src/services/insight-strings.ts | 504 ++++++++++++++++++ 2 files changed, 1146 insertions(+) diff --git a/apps/api/src/services/analyst-service.ts b/apps/api/src/services/analyst-service.ts index 161ce2c..a2de92e 100644 --- a/apps/api/src/services/analyst-service.ts +++ b/apps/api/src/services/analyst-service.ts @@ -171,6 +171,18 @@ export async function getTeamInsights(teamId: string, lang: InsightLang = 'es'): teamDmgPerMatch.set(key, (teamDmgPerMatch.get(key) ?? 0) + (mp.heroDamage ?? 0)); } + const teamWardsPlacedPerMatch = new Map(); + const teamWardsDestroyedPerMatch = new Map(); + const teamObjDmgPerMatch = new Map(); + const teamStrDmgPerMatch = new Map(); + for (const mp of recentMPs) { + const key = `${mp.matchId}:${mp.team}`; + teamWardsPlacedPerMatch.set(key, (teamWardsPlacedPerMatch.get(key) ?? 0) + (mp.wardsPlaced ?? 0)); + teamWardsDestroyedPerMatch.set(key, (teamWardsDestroyedPerMatch.get(key) ?? 0) + (mp.wardsDestroyed ?? 0)); + teamObjDmgPerMatch.set(key, (teamObjDmgPerMatch.get(key) ?? 0) + (mp.totalDamageDealtToObjectives ?? 0)); + teamStrDmgPerMatch.set(key, (teamStrDmgPerMatch.get(key) ?? 0) + (mp.totalDamageDealtToStructures ?? 0)); + } + // ── Role lookup from roster ─────────────────────────────────────────────── const playerRole = new Map(team.roster.map((r) => [r.player.id, r.role?.toLowerCase() ?? ''])); @@ -1226,6 +1238,636 @@ export async function getTeamInsights(teamId: string, lang: InsightLang = 'es'): } } + // ───────────────────────────────────────────────────────────────────────── + // GROUP I — Individual player rules (non-rival only) + // ───────────────────────────────────────────────────────────────────────── + if (!isRival) { + const ROLE_EXPECTED_WARD_SHARE: Record = { + support: 0.35, jungle: 0.25, midlane: 0.15, offlane: 0.15, carry: 0.15, + }; + const ROLE_EXPECTED_WARD_CLEAR_SHARE: Record = { + support: 0.25, jungle: 0.30, midlane: 0.15, offlane: 0.15, carry: 0.10, + }; + const ROLE_EXPECTED_OBJ_DMG_SHARE: Record = { + carry: 0.25, jungle: 0.25, midlane: 0.20, offlane: 0.20, support: 0.10, + }; + const ROLE_EXPECTED_STR_DMG_SHARE: Record = { + carry: 0.30, jungle: 0.15, midlane: 0.20, offlane: 0.20, support: 0.05, + }; + const ROLE_EXPECTED_CS: Record = { + carry: 180, midlane: 140, offlane: 120, jungle: 80, support: 20, + }; + + // I1 — First death rate per player + if (eventMatchIds.length >= MIN_EVENT_MATCHES) { + for (const playerId of rosterPlayerIds) { + const playerEventMatches = eventMatchIds.filter((mid) => + heroKills.some((k) => k.matchId === mid && (k.killedPlayerId === playerId || k.killerPlayerId === playerId)), + ); + if (playerEventMatches.length < 5) continue; + let firstDeathCount = 0; + for (const matchId of playerEventMatches) { + const matchKills = heroKills.filter((k) => k.matchId === matchId); + if (matchKills.length === 0) continue; + const firstDeath = matchKills.reduce((a, b) => a.gameTime < b.gameTime ? a : b); + if (firstDeath.killedPlayerId === playerId) firstDeathCount++; + } + const fdPct = pct(firstDeathCount, playerEventMatches.length); + if (fdPct >= 40) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-first-death-rate'](lang, { + name, fdPct, firstDeathCount, totalMatches: playerEventMatches.length, + }); + insights.push({ + id: `rule-first-death-rate-${playerId}`, + severity: 'medium', + category: 'performance', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + } + + // I2 — Early death rate per player + if (eventMatchIds.length >= MIN_EVENT_MATCHES) { + for (const playerId of rosterPlayerIds) { + const playerDeaths = rosterDeaths.filter((k) => k.killedPlayerId === playerId); + const playerMatches = [...new Set(playerDeaths.map((k) => k.matchId))]; + const playerEventMatches = eventMatchIds.filter((mid) => playerMatches.includes(mid) || + heroKills.some((k) => k.matchId === mid && (k.killedPlayerId === playerId || k.killerPlayerId === playerId))); + if (playerEventMatches.length < 5) continue; + const earlyDeaths = playerDeaths.filter((k) => k.gameTime < 600); + const matchesWithEarlyDeath = new Set(earlyDeaths.map((k) => k.matchId)); + const earlyPct = pct(matchesWithEarlyDeath.size, playerEventMatches.length); + if (earlyPct >= 35) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-early-death-rate'](lang, { + name, earlyPct, earlyDeathMatches: matchesWithEarlyDeath.size, totalMatches: playerEventMatches.length, + }); + insights.push({ + id: `rule-early-death-rate-${playerId}`, + severity: 'medium', + category: 'performance', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + } + + // I3 — Death before objective (per player) + if (eventMatchIds.length >= MIN_EVENT_MATCHES) { + const majorObjs = objKills.filter((o) => MAJOR_OBJECTIVES.includes(o.entityType)); + for (const playerId of rosterPlayerIds) { + const playerDeaths = rosterDeaths.filter((k) => k.killedPlayerId === playerId); + const playerObjCount = majorObjs.filter((o) => teamSideMap.has(o.matchId)).length; + if (playerObjCount < 5) continue; + let deathBeforeObj = 0; + for (const obj of majorObjs) { + if (!teamSideMap.has(obj.matchId)) continue; + const died = playerDeaths.some( + (k) => k.matchId === obj.matchId && + k.gameTime >= obj.gameTime - 90 && k.gameTime < obj.gameTime, + ); + if (died) deathBeforeObj++; + } + const dbo_pct = pct(deathBeforeObj, playerObjCount); + if (dbo_pct >= 30) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-death-before-obj-player'](lang, { + name, dbo_pct, deathBeforeObj, totalObjs: playerObjCount, + }); + insights.push({ + id: `rule-death-before-obj-player-${playerId}`, + severity: 'high', + category: 'macro', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + } + + // I4 — Low vision share per player + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + const expected = ROLE_EXPECTED_WARD_SHARE[role]; + if (!expected) continue; + const withWards = mps.filter((m) => m.wardsPlaced !== null && (teamWardsPlacedPerMatch.get(`${m.matchId}:${m.team}`) ?? 0) > 0); + if (withWards.length < 10) continue; + const avgShare = withWards.reduce((s, m) => { + const tw = teamWardsPlacedPerMatch.get(`${m.matchId}:${m.team}`) ?? 1; + return s + (m.wardsPlaced! / tw); + }, 0) / withWards.length; + if (avgShare < expected * 0.6) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-low-vision-share'](lang, { + name, role, avgSharePct: Math.round(avgShare * 100), expectedPct: Math.round(expected * 100), + }); + insights.push({ + id: `rule-low-vision-share-${playerId}`, + severity: 'medium', + category: 'vision', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + + // I5 — Low ward clear share per player + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + const expected = ROLE_EXPECTED_WARD_CLEAR_SHARE[role]; + if (!expected) continue; + const withClears = mps.filter((m) => m.wardsDestroyed !== null && (teamWardsDestroyedPerMatch.get(`${m.matchId}:${m.team}`) ?? 0) > 0); + if (withClears.length < 10) continue; + const avgShare = withClears.reduce((s, m) => { + const tw = teamWardsDestroyedPerMatch.get(`${m.matchId}:${m.team}`) ?? 1; + return s + (m.wardsDestroyed! / tw); + }, 0) / withClears.length; + if (avgShare < expected * 0.55) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-low-ward-clear-share'](lang, { + name, role, avgSharePct: Math.round(avgShare * 100), expectedPct: Math.round(expected * 100), + }); + insights.push({ + id: `rule-low-ward-clear-share-${playerId}`, + severity: 'medium', + category: 'vision', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + + // I6 — Vision drop (wards/min) + for (const [playerId, mps] of mpByPlayer) { + const withWards = mps.filter((m) => m.wardsPlaced !== null && m.match.duration > 0); + const recent5 = withWards.slice(0, 5); + const prev10 = withWards.slice(5, 15); + if (recent5.length < 4 || prev10.length < 8) continue; + const recentWpm = recent5.reduce((s, m) => s + m.wardsPlaced! / (m.match.duration / 60), 0) / recent5.length; + const prevWpm = prev10.reduce((s, m) => s + m.wardsPlaced! / (m.match.duration / 60), 0) / prev10.length; + if (prevWpm > 0 && recentWpm < prevWpm * 0.70) { + const name = playerName.get(playerId) ?? 'Unknown'; + const dropPct = Math.round((1 - recentWpm / prevWpm) * 100); + const txt = insightStrings['rule-vision-drop'](lang, { + name, dropPct, recentWpm: +recentWpm.toFixed(2), prevWpm: +prevWpm.toFixed(2), + }); + insights.push({ + id: `rule-vision-drop-${playerId}`, + severity: 'medium', + category: 'vision', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + + // I7 — Positive vision improvement (best improver only) + { + const improvementCandidates: Array<{ playerId: string; name: string; improvePct: number; recentWpm: number }> = []; + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + const baseline = WARD_BASELINE[role] ?? 0; + const withWards = mps.filter((m) => m.wardsPlaced !== null && m.match.duration > 0); + const recent5 = withWards.slice(0, 5); + const prev10 = withWards.slice(5, 15); + if (recent5.length < 4 || prev10.length < 8) continue; + const recentWpm = recent5.reduce((s, m) => s + m.wardsPlaced! / (m.match.duration / 60), 0) / recent5.length; + const prevWpm = prev10.reduce((s, m) => s + m.wardsPlaced! / (m.match.duration / 60), 0) / prev10.length; + if (prevWpm > 0 && recentWpm >= prevWpm * 1.25 && recentWpm >= baseline) { + const improvePct = Math.round((recentWpm / prevWpm - 1) * 100); + improvementCandidates.push({ playerId, name: playerName.get(playerId) ?? 'Unknown', improvePct, recentWpm: +recentWpm.toFixed(2) }); + } + } + if (improvementCandidates.length > 0) { + const best = improvementCandidates.sort((a, b) => b.improvePct - a.improvePct)[0]; + const txt = insightStrings['rule-positive-vision-improvement'](lang, { + name: best.name, improvePct: best.improvePct, recentWpm: best.recentWpm, + }); + insights.push({ + id: `rule-positive-vision-improvement-${best.playerId}`, + severity: 'positive', + category: 'vision', + ...txt, + reviewRequired: false, + affectedPlayers: [best.name], + }); + } + } + + // I8 — Low objective damage share per player + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + if (role === 'support') continue; + const expected = ROLE_EXPECTED_OBJ_DMG_SHARE[role]; + if (!expected) continue; + const withData = mps.filter((m) => m.totalDamageDealtToObjectives !== null && (teamObjDmgPerMatch.get(`${m.matchId}:${m.team}`) ?? 0) > 0); + if (withData.length < 10) continue; + const avgShare = withData.reduce((s, m) => { + const tw = teamObjDmgPerMatch.get(`${m.matchId}:${m.team}`) ?? 1; + return s + (m.totalDamageDealtToObjectives! / tw); + }, 0) / withData.length; + if (avgShare < expected * 0.50) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-low-objective-dmg-share'](lang, { + name, role, avgSharePct: Math.round(avgShare * 100), expectedPct: Math.round(expected * 100), + }); + insights.push({ + id: `rule-low-objective-dmg-share-${playerId}`, + severity: 'medium', + category: 'macro', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + + // I9 — Low structure damage share per player + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + if (role === 'support') continue; + const expected = ROLE_EXPECTED_STR_DMG_SHARE[role]; + if (!expected) continue; + const withData = mps.filter((m) => m.totalDamageDealtToStructures !== null && (teamStrDmgPerMatch.get(`${m.matchId}:${m.team}`) ?? 0) > 0); + if (withData.length < 10) continue; + const avgShare = withData.reduce((s, m) => { + const tw = teamStrDmgPerMatch.get(`${m.matchId}:${m.team}`) ?? 1; + return s + (m.totalDamageDealtToStructures! / tw); + }, 0) / withData.length; + if (avgShare < expected * 0.50) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-low-structure-dmg-share'](lang, { + name, role, avgSharePct: Math.round(avgShare * 100), expectedPct: Math.round(expected * 100), + }); + insights.push({ + id: `rule-low-structure-dmg-share-${playerId}`, + severity: 'medium', + category: 'macro', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + + // I10 — No objective impact after lead + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + if (role === 'support') continue; + const withData = mps.filter((m) => { + const tKey = `${m.matchId}:${m.team}`; + return m.gold !== null && m.totalDamageDealtToObjectives !== null && + (teamGoldPerMatch.get(tKey) ?? 0) > 0 && (teamObjDmgPerMatch.get(tKey) ?? 0) >= 0; + }); + if (withData.length < 10) continue; + const leadNoImpact = withData.filter((m) => { + const tKey = `${m.matchId}:${m.team}`; + const goldShare = m.gold! / (teamGoldPerMatch.get(tKey) ?? 1); + const objShare = m.totalDamageDealtToObjectives! / Math.max(teamObjDmgPerMatch.get(tKey) ?? 1, 1); + return goldShare > 0.22 && objShare < 0.08; + }); + const niPct = pct(leadNoImpact.length, withData.length); + if (niPct >= 40) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-no-objective-impact-after-lead'](lang, { + name, niPct, count: leadNoImpact.length, total: withData.length, + }); + insights.push({ + id: `rule-no-objective-impact-after-lead-${playerId}`, + severity: 'medium', + category: 'macro', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + + // I11 — High objective impact (best player only) + { + const objImpactCandidates: Array<{ playerId: string; name: string; avgShare: number }> = []; + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + if (role === 'support') continue; + const withData = mps.filter((m) => m.totalDamageDealtToObjectives !== null && (teamObjDmgPerMatch.get(`${m.matchId}:${m.team}`) ?? 0) > 0); + if (withData.length < 10) continue; + const avgShare = withData.reduce((s, m) => { + const tw = teamObjDmgPerMatch.get(`${m.matchId}:${m.team}`) ?? 1; + return s + (m.totalDamageDealtToObjectives! / tw); + }, 0) / withData.length; + if (avgShare > 0.30) objImpactCandidates.push({ playerId, name: playerName.get(playerId) ?? 'Unknown', avgShare }); + } + if (objImpactCandidates.length > 0) { + const best = objImpactCandidates.sort((a, b) => b.avgShare - a.avgShare)[0]; + const txt = insightStrings['rule-high-objective-impact'](lang, { + name: best.name, avgSharePct: Math.round(best.avgShare * 100), + }); + insights.push({ + id: `rule-high-objective-impact-${best.playerId}`, + severity: 'positive', + category: 'macro', + ...txt, + reviewRequired: false, + affectedPlayers: [best.name], + }); + } + } + + // I12 — High structure pressure (best player only) + { + const strPressCandidates: Array<{ playerId: string; name: string; avgShare: number }> = []; + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + if (role === 'support') continue; + const withData = mps.filter((m) => m.totalDamageDealtToStructures !== null && (teamStrDmgPerMatch.get(`${m.matchId}:${m.team}`) ?? 0) > 0); + if (withData.length < 10) continue; + const avgShare = withData.reduce((s, m) => { + const tw = teamStrDmgPerMatch.get(`${m.matchId}:${m.team}`) ?? 1; + return s + (m.totalDamageDealtToStructures! / tw); + }, 0) / withData.length; + if (avgShare > 0.35) strPressCandidates.push({ playerId, name: playerName.get(playerId) ?? 'Unknown', avgShare }); + } + if (strPressCandidates.length > 0) { + const best = strPressCandidates.sort((a, b) => b.avgShare - a.avgShare)[0]; + const txt = insightStrings['rule-high-structure-pressure'](lang, { + name: best.name, avgSharePct: Math.round(best.avgShare * 100), + }); + insights.push({ + id: `rule-high-structure-pressure-${best.playerId}`, + severity: 'positive', + category: 'macro', + ...txt, + reviewRequired: false, + affectedPlayers: [best.name], + }); + } + } + + // I13 — High gold low KP + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + if (role === 'support') continue; + const withData = mps.filter((m) => { + const tKey = `${m.matchId}:${m.team}`; + return m.gold !== null && (teamGoldPerMatch.get(tKey) ?? 0) > 0 && (teamKillsPerMatch.get(tKey) ?? 0) > 0; + }); + if (withData.length < 10) continue; + const avgGoldShare = withData.reduce((s, m) => s + m.gold! / (teamGoldPerMatch.get(`${m.matchId}:${m.team}`) ?? 1), 0) / withData.length; + const avgKp = withData.reduce((s, m) => { + const tk = teamKillsPerMatch.get(`${m.matchId}:${m.team}`) ?? 1; + return s + (m.kills + m.assists) / tk; + }, 0) / withData.length; + if (avgGoldShare > 0.22 && avgKp < 0.35) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-high-gold-low-kp'](lang, { + name, avgGoldSharePct: Math.round(avgGoldShare * 100), avgKpPct: Math.round(avgKp * 100), + }); + insights.push({ + id: `rule-high-gold-low-kp-${playerId}`, + severity: 'medium', + category: 'performance', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + + // I14 — High gold high death share + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + if (role === 'support') continue; + const withData = mps.filter((m) => { + const tKey = `${m.matchId}:${m.team}`; + return m.gold !== null && (teamGoldPerMatch.get(tKey) ?? 0) > 0 && (teamDeathsPerMatch.get(tKey) ?? 0) > 0; + }); + if (withData.length < 10) continue; + const avgGoldShare = withData.reduce((s, m) => s + m.gold! / (teamGoldPerMatch.get(`${m.matchId}:${m.team}`) ?? 1), 0) / withData.length; + const avgDeathShare = withData.reduce((s, m) => s + m.deaths / (teamDeathsPerMatch.get(`${m.matchId}:${m.team}`) ?? 1), 0) / withData.length; + if (avgGoldShare > 0.22 && avgDeathShare > 0.28) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-high-gold-high-death'](lang, { + name, avgGoldSharePct: Math.round(avgGoldShare * 100), avgDeathSharePct: Math.round(avgDeathShare * 100), + }); + insights.push({ + id: `rule-high-gold-high-death-${playerId}`, + severity: 'high', + category: 'performance', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + + // I15 — Positive efficiency (best player only) + { + const efficiencyCandidates: Array<{ playerId: string; name: string; gap: number }> = []; + for (const [playerId, mps] of mpByPlayer) { + const withData = mps.filter((m) => { + const tKey = `${m.matchId}:${m.team}`; + return m.gold !== null && m.heroDamage !== null && + (teamGoldPerMatch.get(tKey) ?? 0) > 0 && (teamDmgPerMatch.get(tKey) ?? 0) > 0; + }); + if (withData.length < 10) continue; + const avgGoldShare = withData.reduce((s, m) => s + m.gold! / (teamGoldPerMatch.get(`${m.matchId}:${m.team}`) ?? 1), 0) / withData.length; + const avgDmgShare = withData.reduce((s, m) => s + m.heroDamage! / (teamDmgPerMatch.get(`${m.matchId}:${m.team}`) ?? 1), 0) / withData.length; + const gap = (avgDmgShare - avgGoldShare) * 100; + if (gap > 5 && avgGoldShare > 0.15) { + efficiencyCandidates.push({ playerId, name: playerName.get(playerId) ?? 'Unknown', gap }); + } + } + if (efficiencyCandidates.length > 0) { + const best = efficiencyCandidates.sort((a, b) => b.gap - a.gap)[0]; + const mps = mpByPlayer.get(best.playerId) ?? []; + const withData = mps.filter((m) => { + const tKey = `${m.matchId}:${m.team}`; + return m.gold !== null && m.heroDamage !== null && + (teamGoldPerMatch.get(tKey) ?? 0) > 0 && (teamDmgPerMatch.get(tKey) ?? 0) > 0; + }); + const avgGoldShare = withData.reduce((s, m) => s + m.gold! / (teamGoldPerMatch.get(`${m.matchId}:${m.team}`) ?? 1), 0) / withData.length; + const avgDmgShare = withData.reduce((s, m) => s + m.heroDamage! / (teamDmgPerMatch.get(`${m.matchId}:${m.team}`) ?? 1), 0) / withData.length; + const txt = insightStrings['rule-positive-efficiency'](lang, { + name: best.name, + avgGoldSharePct: Math.round(avgGoldShare * 100), + avgDmgSharePct: Math.round(avgDmgShare * 100), + gapPct: Math.round(best.gap), + }); + insights.push({ + id: `rule-positive-efficiency-${best.playerId}`, + severity: 'positive', + category: 'performance', + ...txt, + reviewRequired: false, + affectedPlayers: [best.name], + }); + } + } + + // I16 — Low CS for role + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + if (role === 'support') continue; + const expected = ROLE_EXPECTED_CS[role]; + if (!expected) continue; + const withCS = mps.filter((m) => m.laneMinionsKilled !== null && m.laneMinionsKilled > 0); + if (withCS.length < 10) continue; + const avgCS = withCS.reduce((s, m) => s + m.laneMinionsKilled!, 0) / withCS.length; + if (avgCS < expected * 0.65) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-low-cs-role'](lang, { + name, role, avgCS: Math.round(avgCS), expectedCS: expected, + }); + insights.push({ + id: `rule-low-cs-role-${playerId}`, + severity: 'medium', + category: 'performance', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + + // I17 — CS drop + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + if (role === 'support') continue; + const withCS = mps.filter((m) => m.laneMinionsKilled !== null && m.laneMinionsKilled > 0); + const recent5 = withCS.slice(0, 5); + const prev10 = withCS.slice(5, 15); + if (recent5.length < 5 || prev10.length < 5) continue; + const recentCS = recent5.reduce((s, m) => s + m.laneMinionsKilled!, 0) / recent5.length; + const prevCS = prev10.reduce((s, m) => s + m.laneMinionsKilled!, 0) / prev10.length; + if (prevCS > 0 && recentCS < prevCS * 0.75) { + const name = playerName.get(playerId) ?? 'Unknown'; + const dropPct = Math.round((1 - recentCS / prevCS) * 100); + const txt = insightStrings['rule-cs-drop'](lang, { + name, dropPct, recentCS: Math.round(recentCS), prevCS: Math.round(prevCS), + }); + insights.push({ + id: `rule-cs-drop-${playerId}`, + severity: 'medium', + category: 'performance', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + + // I18 — Positive farm consistency (best player only) + { + const farmConsistCandidates: Array<{ playerId: string; name: string; avgCS: number }> = []; + for (const [playerId, mps] of mpByPlayer) { + const role = playerRole.get(playerId) ?? ''; + if (role === 'support') continue; + const expected = ROLE_EXPECTED_CS[role]; + if (!expected) continue; + const withCS = mps.filter((m) => m.laneMinionsKilled !== null && m.laneMinionsKilled > 0).slice(0, 10); + if (withCS.length < 10) continue; + const avgCS = withCS.reduce((s, m) => s + m.laneMinionsKilled!, 0) / withCS.length; + if (avgCS < expected * 0.85) continue; + const allWithin = withCS.every((m) => Math.abs(m.laneMinionsKilled! - avgCS) / avgCS <= 0.20); + if (allWithin) farmConsistCandidates.push({ playerId, name: playerName.get(playerId) ?? 'Unknown', avgCS }); + } + if (farmConsistCandidates.length > 0) { + const best = farmConsistCandidates.sort((a, b) => b.avgCS - a.avgCS)[0]; + const role = playerRole.get(best.playerId) ?? ''; + const expected = ROLE_EXPECTED_CS[role] ?? 100; + const txt = insightStrings['rule-positive-farm-consistency'](lang, { + name: best.name, avgCS: Math.round(best.avgCS), expectedCS: expected, + }); + insights.push({ + id: `rule-positive-farm-consistency-${best.playerId}`, + severity: 'positive', + category: 'performance', + ...txt, + reviewRequired: false, + affectedPlayers: [best.name], + }); + } + } + + // I19 — Low hero pool depth (from snapshots) + for (const [playerId] of mpByPlayer) { + const snap = snapshots.get(playerId); + if (!snap) continue; + const heroStats = (snap.heroStats as Record | null) ?? {}; + const totalSnapshotMatches = Object.values(heroStats).reduce((s, v) => { + const matches = (v as Record)?.matches as number | undefined; + return s + (matches ?? 0); + }, 0); + if (totalSnapshotMatches < 15) continue; + const heroesWithMin3 = Object.values(heroStats).filter((v) => { + const matches = (v as Record)?.matches as number | undefined; + return (matches ?? 0) >= 3; + }).length; + if (heroesWithMin3 < 3) { + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-low-hero-pool-depth'](lang, { + name, heroesWithMin3, totalSnapshotMatches, + }); + insights.push({ + id: `rule-low-hero-pool-depth-${playerId}`, + severity: 'medium', + category: 'draft', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + + // I20 — Comfort overreliance + for (const [playerId, mps] of mpByPlayer) { + const snap = snapshots.get(playerId); + const last20 = mps.slice(0, 20); + if (last20.length < 10) continue; + const heroCount = new Map(); + for (const mp of last20) heroCount.set(mp.heroSlug, (heroCount.get(mp.heroSlug) ?? 0) + 1); + const sorted = [...heroCount.entries()].sort((a, b) => b[1] - a[1]); + const topHero = sorted[0]; + if (!topHero) continue; + const topRate = topHero[1] / last20.length; + if (topRate < 0.60) continue; + const heroStats = snap ? ((snap.heroStats as Record | null) ?? {}) : {}; + const winRates = Object.entries(heroStats) + .map(([slug, v]) => { + const s = v as Record; + const wins = (s?.wins as number) ?? 0; + const matches = (s?.matches as number) ?? 0; + return { slug, wr: matches > 0 ? wins / matches : 0 }; + }) + .sort((a, b) => b.wr - a.wr) + .slice(0, 3) + .map((x) => x.slug); + if (winRates.includes(topHero[0])) continue; + const name = playerName.get(playerId) ?? 'Unknown'; + const txt = insightStrings['rule-comfort-overreliance'](lang, { + name, topHero: topHero[0], topRatePct: Math.round(topRate * 100), totalMatches: last20.length, + }); + insights.push({ + id: `rule-comfort-overreliance-${playerId}`, + severity: 'medium', + category: 'draft', + ...txt, + reviewRequired: false, + affectedPlayers: [name], + }); + } + } + // ── Data status insight — always shown ─────────────────────────────────── const totalMPs = [...mpByPlayer.values()].reduce((s, arr) => s + arr.length, 0); const playersWithEnoughData = [...mpByPlayer.values()].filter((arr) => arr.length >= MIN_PLAYER_MATCHES).length; diff --git a/apps/api/src/services/insight-strings.ts b/apps/api/src/services/insight-strings.ts index 8804289..793cd68 100644 --- a/apps/api/src/services/insight-strings.ts +++ b/apps/api/src/services/insight-strings.ts @@ -1270,6 +1270,510 @@ export const insightStrings = { }; }, + // ── GROUP I — Individual player rules ──────────────────────────────────────── + + 'rule-first-death-rate': ( + lang: InsightLang, + vars: { name: string; fdPct: number; firstDeathCount: number; totalMatches: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} dies first ${vars.fdPct}% of games`, + body: `${vars.name} is the first player to die in ${vars.fdPct}% of analyzed games. First-blood gifts the opponent a meaningful early advantage.`, + evidence: [ + `First death: ${vars.firstDeathCount} of ${vars.totalMatches} games`, + ], + recommendation: 'Review laning aggression and early pathing. Prioritize survival in the first 3 minutes over forcing plays.', + }; + } + return { + title: `${vars.name} muere primero en el ${vars.fdPct}% de partidas`, + body: `${vars.name} es el primer jugador en morir en el ${vars.fdPct}% de las partidas analizadas. El first-blood regala al rival una ventaja temprana significativa.`, + evidence: [ + `Primera muerte: ${vars.firstDeathCount} de ${vars.totalMatches} partidas`, + ], + recommendation: 'Revisar la agresividad en laning y el pathing temprano. Priorizar la supervivencia en los primeros 3 minutos sobre forzar jugadas.', + }; + }, + + 'rule-early-death-rate': ( + lang: InsightLang, + vars: { name: string; earlyPct: number; earlyDeathMatches: number; totalMatches: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} dies before minute 10 in ${vars.earlyPct}% of games`, + body: `${vars.name} dies in the first 10 minutes in ${vars.earlyPct}% of games. Early deaths create gold deficits that are hard to recover.`, + evidence: [ + `Early death (<10 min): ${vars.earlyDeathMatches} of ${vars.totalMatches} games`, + ], + recommendation: 'Work on early-game positioning and trading. Avoid overextending before vision is established.', + }; + } + return { + title: `${vars.name} muere antes del minuto 10 en el ${vars.earlyPct}% de partidas`, + body: `${vars.name} muere en los primeros 10 minutos en el ${vars.earlyPct}% de las partidas. Las muertes tempranas generan déficits de oro difíciles de recuperar.`, + evidence: [ + `Muerte temprana (<10 min): ${vars.earlyDeathMatches} de ${vars.totalMatches} partidas`, + ], + recommendation: 'Trabajar el posicionamiento y los trades en early game. Evitar sobreextenderse antes de establecer visión.', + }; + }, + + 'rule-death-before-obj-player': ( + lang: InsightLang, + vars: { name: string; dbo_pct: number; deathBeforeObj: number; totalObjs: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} dies before major objectives (${vars.dbo_pct}%)`, + body: `${vars.name} dies in the 90s before a major objective in ${vars.dbo_pct}% of the team's opportunities. Their absence forces the team into underdog fights.`, + evidence: [ + `Pre-objective death: ${vars.deathBeforeObj} of ${vars.totalObjs} objectives`, + ], + recommendation: `${vars.name} must reset and back to base if health is below 60% before any major objective timer. Avoid forced engages near spawn time.`, + }; + } + return { + title: `${vars.name} muere antes de objetivos mayores (${vars.dbo_pct}%)`, + body: `${vars.name} muere en los 90s previos a un objetivo mayor en el ${vars.dbo_pct}% de las oportunidades del equipo. Su ausencia obliga al equipo a pelear en desventaja.`, + evidence: [ + `Muerte pre-objetivo: ${vars.deathBeforeObj} de ${vars.totalObjs} objetivos`, + ], + recommendation: `${vars.name} debe resetear y volver a base si tiene menos del 60% de vida antes de cualquier timer de objetivo mayor. Evitar engages forzados cerca del spawn.`, + }; + }, + + 'rule-low-vision-share': ( + lang: InsightLang, + vars: { name: string; role: string; avgSharePct: number; expectedPct: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} contributes too little vision (${vars.avgSharePct}% vs ${vars.expectedPct}% expected)`, + body: `${vars.name} places only ${vars.avgSharePct}% of the team's wards on average — well below the ${vars.expectedPct}% expected for a ${vars.role}.`, + evidence: [ + `Avg ward share: ${vars.avgSharePct}%`, + `Expected for ${vars.role}: ${vars.expectedPct}%`, + ], + recommendation: `${vars.name} should increase ward purchase and placement habits, especially before objectives and when entering the rival half of the map.`, + }; + } + return { + title: `${vars.name} contribuye poca visión (${vars.avgSharePct}% vs ${vars.expectedPct}% esperado)`, + body: `${vars.name} coloca solo el ${vars.avgSharePct}% de las wards del equipo de media — muy por debajo del ${vars.expectedPct}% esperado para un ${vars.role}.`, + evidence: [ + `Share de wards promedio: ${vars.avgSharePct}%`, + `Esperado para ${vars.role}: ${vars.expectedPct}%`, + ], + recommendation: `${vars.name} debe aumentar la compra y colocación de wards, especialmente antes de objetivos y al entrar en la mitad rival del mapa.`, + }; + }, + + 'rule-low-ward-clear-share': ( + lang: InsightLang, + vars: { name: string; role: string; avgSharePct: number; expectedPct: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} clears too few enemy wards (${vars.avgSharePct}% vs ${vars.expectedPct}% expected)`, + body: `${vars.name} accounts for only ${vars.avgSharePct}% of the team's ward clears — below the ${vars.expectedPct}% expected for a ${vars.role}.`, + evidence: [ + `Avg ward clear share: ${vars.avgSharePct}%`, + `Expected for ${vars.role}: ${vars.expectedPct}%`, + ], + recommendation: `${vars.name} should carry sweepers more consistently and actively deny rival vision, particularly near key objective zones.`, + }; + } + return { + title: `${vars.name} limpia pocas wards rivales (${vars.avgSharePct}% vs ${vars.expectedPct}% esperado)`, + body: `${vars.name} aporta solo el ${vars.avgSharePct}% de las limpiezas de wards del equipo — por debajo del ${vars.expectedPct}% esperado para un ${vars.role}.`, + evidence: [ + `Share de limpiezas promedio: ${vars.avgSharePct}%`, + `Esperado para ${vars.role}: ${vars.expectedPct}%`, + ], + recommendation: `${vars.name} debe llevar sweepers de forma más consistente y denegar activamente la visión rival, especialmente cerca de las zonas de objetivos clave.`, + }; + }, + + 'rule-vision-drop': ( + lang: InsightLang, + vars: { name: string; dropPct: number; recentWpm: number; prevWpm: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — vision output dropped ${vars.dropPct}%`, + body: `${vars.name}'s wards-per-minute fell from ${vars.prevWpm} to ${vars.recentWpm} in the last 5 games — a ${vars.dropPct}% drop.`, + evidence: [ + `Recent avg: ${vars.recentWpm} wards/min`, + `Previous avg: ${vars.prevWpm} wards/min`, + ], + recommendation: 'Check if hero changes, role shift, or reduced item investment are behind the drop. Reinforce warding habits in the next training block.', + }; + } + return { + title: `${vars.name} — visión caída un ${vars.dropPct}%`, + body: `Las wards por minuto de ${vars.name} cayeron de ${vars.prevWpm} a ${vars.recentWpm} en las últimas 5 partidas — una caída del ${vars.dropPct}%.`, + evidence: [ + `Promedio reciente: ${vars.recentWpm} wards/min`, + `Promedio anterior: ${vars.prevWpm} wards/min`, + ], + recommendation: 'Verificar si el cambio de héroe, el rol o la inversión en ítems son la causa. Reforzar los hábitos de ward en el próximo bloque de entrenamiento.', + }; + }, + + 'rule-positive-vision-improvement': ( + lang: InsightLang, + vars: { name: string; improvePct: number; recentWpm: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — vision output improved ${vars.improvePct}%`, + body: `${vars.name} increased their wards-per-minute by ${vars.improvePct}% in their last 5 games, now averaging ${vars.recentWpm} wards/min.`, + evidence: [ + `Recent avg: ${vars.recentWpm} wards/min`, + `Improvement: +${vars.improvePct}%`, + ], + recommendation: 'Acknowledge this improvement. Maintain the warding habits and keep investing in vision items.', + }; + } + return { + title: `${vars.name} — visión mejorada un ${vars.improvePct}%`, + body: `${vars.name} aumentó sus wards por minuto un ${vars.improvePct}% en sus últimas 5 partidas, alcanzando ${vars.recentWpm} wards/min de media.`, + evidence: [ + `Promedio reciente: ${vars.recentWpm} wards/min`, + `Mejora: +${vars.improvePct}%`, + ], + recommendation: 'Reconocer esta mejora. Mantener los hábitos de ward y seguir invirtiendo en ítems de visión.', + }; + }, + + 'rule-low-objective-dmg-share': ( + lang: InsightLang, + vars: { name: string; role: string; avgSharePct: number; expectedPct: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — low objective damage (${vars.avgSharePct}% vs ${vars.expectedPct}% expected)`, + body: `${vars.name} contributes only ${vars.avgSharePct}% of the team's objective damage on average — well below the ${vars.expectedPct}% expected for a ${vars.role}.`, + evidence: [ + `Avg objective dmg share: ${vars.avgSharePct}%`, + `Expected for ${vars.role}: ${vars.expectedPct}%`, + ], + recommendation: `${vars.name} should prioritize attacking objectives during teamfights and after winning picks. Build items that enable fast objective taking.`, + }; + } + return { + title: `${vars.name} — bajo daño a objetivos (${vars.avgSharePct}% vs ${vars.expectedPct}% esperado)`, + body: `${vars.name} aporta solo el ${vars.avgSharePct}% del daño a objetivos del equipo de media — muy por debajo del ${vars.expectedPct}% esperado para un ${vars.role}.`, + evidence: [ + `Share de daño a objetivos: ${vars.avgSharePct}%`, + `Esperado para ${vars.role}: ${vars.expectedPct}%`, + ], + recommendation: `${vars.name} debe priorizar atacar objetivos durante los teamfights y tras ganar picks. Construir ítems que permitan hacer secure rápido.`, + }; + }, + + 'rule-low-structure-dmg-share': ( + lang: InsightLang, + vars: { name: string; role: string; avgSharePct: number; expectedPct: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — low structure damage (${vars.avgSharePct}% vs ${vars.expectedPct}% expected)`, + body: `${vars.name} contributes only ${vars.avgSharePct}% of the team's structure damage on average — below the ${vars.expectedPct}% expected for a ${vars.role}.`, + evidence: [ + `Avg structure dmg share: ${vars.avgSharePct}%`, + `Expected for ${vars.role}: ${vars.expectedPct}%`, + ], + recommendation: `${vars.name} should be more proactive pushing structures after winning fights. Identify and target the closest tower or inhibitor after each major objective.`, + }; + } + return { + title: `${vars.name} — bajo daño a estructuras (${vars.avgSharePct}% vs ${vars.expectedPct}% esperado)`, + body: `${vars.name} aporta solo el ${vars.avgSharePct}% del daño a estructuras del equipo de media — por debajo del ${vars.expectedPct}% esperado para un ${vars.role}.`, + evidence: [ + `Share de daño a estructuras: ${vars.avgSharePct}%`, + `Esperado para ${vars.role}: ${vars.expectedPct}%`, + ], + recommendation: `${vars.name} debe ser más proactivo presionando estructuras tras ganar peleas. Identificar y atacar la torre o inhibidor más cercano tras cada objetivo mayor.`, + }; + }, + + 'rule-no-objective-impact-after-lead': ( + lang: InsightLang, + vars: { name: string; niPct: number; count: number; total: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — gold lead not converted to objective pressure (${vars.niPct}%)`, + body: `In ${vars.niPct}% of games where ${vars.name} holds a high gold share (>22%), they contribute very little objective damage (<8% of team total). Resources are not translating into macro impact.`, + evidence: [ + `Pattern found in ${vars.count} of ${vars.total} applicable games`, + ], + recommendation: `When ${vars.name} is ahead, redirect their advantage toward objectives. Coordinate with the jungler to force objective plays around power spikes.`, + }; + } + return { + title: `${vars.name} — ventaja de oro sin impacto en objetivos (${vars.niPct}%)`, + body: `En el ${vars.niPct}% de las partidas donde ${vars.name} acumula un alto share de oro (>22%), aporta muy poco daño a objetivos (<8% del total del equipo). Los recursos no se están traduciendo en impacto macro.`, + evidence: [ + `Patrón encontrado en ${vars.count} de ${vars.total} partidas aplicables`, + ], + recommendation: `Cuando ${vars.name} esté adelantado, redirigir la ventaja hacia objetivos. Coordinar con el jungla para forzar jugadas de objetivo en torno a los power spikes.`, + }; + }, + + 'rule-high-objective-impact': ( + lang: InsightLang, + vars: { name: string; avgSharePct: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — top objective damage dealer (${vars.avgSharePct}% avg share)`, + body: `${vars.name} averages ${vars.avgSharePct}% of the team's objective damage — an exceptional contribution to macro control.`, + evidence: [`Avg objective dmg share: ${vars.avgSharePct}%`], + recommendation: 'Leverage this strength by building plays around objective timers. Ensure this player has peel and resources to stay healthy during objective fights.', + }; + } + return { + title: `${vars.name} — principal dealer de daño a objetivos (${vars.avgSharePct}% share)`, + body: `${vars.name} promedia el ${vars.avgSharePct}% del daño a objetivos del equipo — una contribución excepcional al control macro.`, + evidence: [`Share de daño a objetivos: ${vars.avgSharePct}%`], + recommendation: 'Aprovechar esta fortaleza construyendo jugadas alrededor de los timers de objetivo. Asegurar que este jugador tenga peel y recursos para mantenerse sano en las peleas de objetivo.', + }; + }, + + 'rule-high-structure-pressure': ( + lang: InsightLang, + vars: { name: string; avgSharePct: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — high structure pressure (${vars.avgSharePct}% avg share)`, + body: `${vars.name} contributes ${vars.avgSharePct}% of the team's structure damage on average — a key driver of the team's closing ability.`, + evidence: [`Avg structure dmg share: ${vars.avgSharePct}%`], + recommendation: 'Route post-fight rotations through this player. They are a reliable finisher — give them priority access to sieging opportunities.', + }; + } + return { + title: `${vars.name} — alta presión de estructuras (${vars.avgSharePct}% share)`, + body: `${vars.name} contribuye el ${vars.avgSharePct}% del daño a estructuras del equipo de media — un motor clave de la capacidad de cierre del equipo.`, + evidence: [`Share de daño a estructuras: ${vars.avgSharePct}%`], + recommendation: 'Enrutar las rotaciones post-pelea a través de este jugador. Es un finisher fiable — darle acceso prioritario a las oportunidades de siege.', + }; + }, + + 'rule-high-gold-low-kp': ( + lang: InsightLang, + vars: { name: string; avgGoldSharePct: number; avgKpPct: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — farming but not fighting (${vars.avgGoldSharePct}% gold, ${vars.avgKpPct}% KP)`, + body: `${vars.name} averages ${vars.avgGoldSharePct}% of team gold but only ${vars.avgKpPct}% kill participation. They are accumulating resources without converting them into teamfight presence.`, + evidence: [ + `Avg gold share: ${vars.avgGoldSharePct}%`, + `Avg kill participation: ${vars.avgKpPct}%`, + ], + recommendation: 'Ensure this player joins teamfights once farmed. Build more grouping expectations into the team's playbook for mid/late game.', + }; + } + return { + title: `${vars.name} — farmea pero no pelea (${vars.avgGoldSharePct}% oro, ${vars.avgKpPct}% KP)`, + body: `${vars.name} promedia el ${vars.avgGoldSharePct}% del oro del equipo pero solo el ${vars.avgKpPct}% de kill participation. Acumula recursos sin convertirlos en presencia en teamfight.`, + evidence: [ + `Share de oro promedio: ${vars.avgGoldSharePct}%`, + `Kill participation promedio: ${vars.avgKpPct}%`, + ], + recommendation: 'Asegurarse de que este jugador se una a los teamfights una vez farmeado. Establecer expectativas de grouping más claras para el mid/late game.', + }; + }, + + 'rule-high-gold-high-death': ( + lang: InsightLang, + vars: { name: string; avgGoldSharePct: number; avgDeathSharePct: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — fed but dying too often (${vars.avgGoldSharePct}% gold, ${vars.avgDeathSharePct}% deaths)`, + body: `${vars.name} takes ${vars.avgGoldSharePct}% of team gold but accounts for ${vars.avgDeathSharePct}% of team deaths. They are a high-value target being caught repeatedly.`, + evidence: [ + `Avg gold share: ${vars.avgGoldSharePct}%`, + `Avg death share: ${vars.avgDeathSharePct}%`, + ], + recommendation: 'Review positioning in late-game scenarios. This player is a priority target — improve peel coordination and ward depth to prevent assassinations.', + }; + } + return { + title: `${vars.name} — farmeado pero muere demasiado (${vars.avgGoldSharePct}% oro, ${vars.avgDeathSharePct}% muertes)`, + body: `${vars.name} toma el ${vars.avgGoldSharePct}% del oro del equipo pero acumula el ${vars.avgDeathSharePct}% de las muertes del equipo. Es un objetivo de alto valor que es cazado repetidamente.`, + evidence: [ + `Share de oro promedio: ${vars.avgGoldSharePct}%`, + `Share de muertes promedio: ${vars.avgDeathSharePct}%`, + ], + recommendation: 'Revisar el posicionamiento en late game. Este jugador es objetivo prioritario — mejorar la coordinación de peel y la profundidad de visión para prevenir assassinaciones.', + }; + }, + + 'rule-positive-efficiency': ( + lang: InsightLang, + vars: { name: string; avgGoldSharePct: number; avgDmgSharePct: number; gapPct: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — high damage efficiency (+${vars.gapPct}% over gold share)`, + body: `${vars.name} generates ${vars.avgDmgSharePct}% of team damage while only using ${vars.avgGoldSharePct}% of team gold — a positive efficiency gap of ${vars.gapPct} points.`, + evidence: [ + `Avg dmg share: ${vars.avgDmgSharePct}%`, + `Avg gold share: ${vars.avgGoldSharePct}%`, + ], + recommendation: 'This player produces above their resource cost. Consider slightly increasing their resource priority to amplify their output further.', + }; + } + return { + title: `${vars.name} — alta eficiencia de daño (+${vars.gapPct}% sobre el share de oro)`, + body: `${vars.name} genera el ${vars.avgDmgSharePct}% del daño del equipo usando solo el ${vars.avgGoldSharePct}% del oro — una brecha de eficiencia positiva de ${vars.gapPct} puntos.`, + evidence: [ + `Share de daño promedio: ${vars.avgDmgSharePct}%`, + `Share de oro promedio: ${vars.avgGoldSharePct}%`, + ], + recommendation: 'Este jugador produce por encima de su coste en recursos. Considerar aumentar ligeramente su prioridad de recursos para amplificar aún más su output.', + }; + }, + + 'rule-low-cs-role': ( + lang: InsightLang, + vars: { name: string; role: string; avgCS: number; expectedCS: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — low CS for ${vars.role} (avg ${vars.avgCS}, expected ≥${Math.round(vars.expectedCS * 0.65)})`, + body: `${vars.name} averages ${vars.avgCS} minions per game — well below the ${vars.expectedCS} expected for a ${vars.role}. Poor farm leads to slower item spikes.`, + evidence: [ + `Avg CS: ${vars.avgCS}`, + `Role benchmark: ${vars.expectedCS}`, + ], + recommendation: 'Add dedicated CS drills to the practice schedule. Focus on last-hitting under tower and jungle camp clearing efficiency.', + }; + } + return { + title: `${vars.name} — bajo CS para ${vars.role} (media ${vars.avgCS}, esperado ≥${Math.round(vars.expectedCS * 0.65)})`, + body: `${vars.name} promedia ${vars.avgCS} minions por partida — muy por debajo de los ${vars.expectedCS} esperados para un ${vars.role}. El mal farmeo retrasa los spikes de ítems.`, + evidence: [ + `CS promedio: ${vars.avgCS}`, + `Referencia de rol: ${vars.expectedCS}`, + ], + recommendation: 'Añadir ejercicios dedicados de CS al calendario de práctica. Enfocarse en last-hitting bajo torre y eficiencia de limpieza de campamentos de jungla.', + }; + }, + + 'rule-cs-drop': ( + lang: InsightLang, + vars: { name: string; dropPct: number; recentCS: number; prevCS: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — CS dropped ${vars.dropPct}% recently`, + body: `${vars.name}'s average CS fell from ${vars.prevCS} to ${vars.recentCS} per game in the last 5 games — a ${vars.dropPct}% reduction.`, + evidence: [ + `Recent avg CS: ${vars.recentCS}`, + `Previous avg CS: ${vars.prevCS}`, + ], + recommendation: 'Investigate if hero swaps, matchup pressures, or early grouping habits are reducing farm time. Reinforce laning fundamentals.', + }; + } + return { + title: `${vars.name} — CS caído un ${vars.dropPct}% recientemente`, + body: `La media de CS de ${vars.name} cayó de ${vars.prevCS} a ${vars.recentCS} por partida en las últimas 5 partidas — una reducción del ${vars.dropPct}%.`, + evidence: [ + `CS reciente promedio: ${vars.recentCS}`, + `CS anterior promedio: ${vars.prevCS}`, + ], + recommendation: 'Investigar si los cambios de héroe, la presión del matchup o el hábito de agruparse temprano reducen el tiempo de farmeo. Reforzar los fundamentos de laning.', + }; + }, + + 'rule-positive-farm-consistency': ( + lang: InsightLang, + vars: { name: string; avgCS: number; expectedCS: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — excellent CS consistency (avg ${vars.avgCS})`, + body: `${vars.name} averages ${vars.avgCS} CS per game in the last 10 matches — above the role benchmark of ${vars.expectedCS} — with low variance across all games.`, + evidence: [ + `Avg CS: ${vars.avgCS}`, + `Role benchmark: ${vars.expectedCS}`, + 'All 10 games within 20% of average', + ], + recommendation: 'Excellent laning foundation. This consistency allows reliable power-spike timings — leverage it in mid-game rotations.', + }; + } + return { + title: `${vars.name} — excelente consistencia de CS (media ${vars.avgCS})`, + body: `${vars.name} promedia ${vars.avgCS} CS por partida en las últimas 10 — por encima del referente de rol de ${vars.expectedCS} — con baja varianza en todas las partidas.`, + evidence: [ + `CS promedio: ${vars.avgCS}`, + `Referencia de rol: ${vars.expectedCS}`, + 'Las 10 partidas dentro del 20% de la media', + ], + recommendation: 'Excelente base de laning. Esta consistencia permite timings de power-spike fiables — aprovecharla en las rotaciones de mid game.', + }; + }, + + 'rule-low-hero-pool-depth': ( + lang: InsightLang, + vars: { name: string; heroesWithMin3: number; totalSnapshotMatches: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — shallow hero pool (${vars.heroesWithMin3} heroes with ≥3 games)`, + body: `${vars.name} has only ${vars.heroesWithMin3} hero(es) with ≥3 games in their history (out of ${vars.totalSnapshotMatches} total). They are vulnerable to targeted bans.`, + evidence: [ + `Heroes with ≥3 games: ${vars.heroesWithMin3}`, + `Total snapshot matches: ${vars.totalSnapshotMatches}`, + ], + recommendation: 'Expand the hero pool by adding at least 2 viable alternatives. Practice them in scrims before deploying in official matches.', + }; + } + return { + title: `${vars.name} — pool de héroes reducido (${vars.heroesWithMin3} héroes con ≥3 partidas)`, + body: `${vars.name} tiene solo ${vars.heroesWithMin3} héroe(s) con ≥3 partidas en su historial (de ${vars.totalSnapshotMatches} totales). Es vulnerable a bans dirigidos.`, + evidence: [ + `Héroes con ≥3 partidas: ${vars.heroesWithMin3}`, + `Partidas totales en snapshot: ${vars.totalSnapshotMatches}`, + ], + recommendation: 'Ampliar el pool de héroes añadiendo al menos 2 alternativas viables. Practicarlas en scrims antes de desplegarlas en partidas oficiales.', + }; + }, + + 'rule-comfort-overreliance': ( + lang: InsightLang, + vars: { name: string; topHero: string; topRatePct: number; totalMatches: number }, + ): InsightText => { + if (lang === 'en') { + return { + title: `${vars.name} — overreliance on ${vars.topHero} (${vars.topRatePct}% of recent games)`, + body: `${vars.name} has played ${vars.topHero} in ${vars.topRatePct}% of their last ${vars.totalMatches} games, but this hero is not among their top-3 by win rate — suggesting comfort bias rather than optimal pick.`, + evidence: [ + `${vars.topHero}: ${vars.topRatePct}% of last ${vars.totalMatches} games`, + 'Hero not in top-3 win rate heroes', + ], + recommendation: `Consider whether ${vars.topHero} is actually the best option in the current meta. Explore higher win-rate alternatives and evaluate matchup fit in each game.`, + }; + } + return { + title: `${vars.name} — sobredependencia de ${vars.topHero} (${vars.topRatePct}% de partidas recientes)`, + body: `${vars.name} ha jugado ${vars.topHero} en el ${vars.topRatePct}% de sus últimas ${vars.totalMatches} partidas, pero este héroe no está entre sus 3 mejores por winrate — lo que sugiere un sesgo de confort más que una elección óptima.`, + evidence: [ + `${vars.topHero}: ${vars.topRatePct}% de las últimas ${vars.totalMatches} partidas`, + 'Héroe no está en el top-3 de winrate', + ], + recommendation: `Evaluar si ${vars.topHero} es realmente la mejor opción en el meta actual. Explorar alternativas con mayor winrate y evaluar el matchup en cada partida.`, + }; + }, + // ── Data status insight ─────────────────────────────────────────────────────── 'data-status': ( lang: InsightLang, From 02bb072a6b5fec564cd3df913d1a7c39993d1918 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 31 May 2026 19:05:44 +0200 Subject: [PATCH 2/2] fix: escape apostrophe in insight string (syntax error in CI) --- apps/api/src/services/insight-strings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/services/insight-strings.ts b/apps/api/src/services/insight-strings.ts index 793cd68..23d860a 100644 --- a/apps/api/src/services/insight-strings.ts +++ b/apps/api/src/services/insight-strings.ts @@ -1576,7 +1576,7 @@ export const insightStrings = { `Avg gold share: ${vars.avgGoldSharePct}%`, `Avg kill participation: ${vars.avgKpPct}%`, ], - recommendation: 'Ensure this player joins teamfights once farmed. Build more grouping expectations into the team's playbook for mid/late game.', + recommendation: "Ensure this player joins teamfights once farmed. Build more grouping expectations into the team's playbook for mid/late game.", }; } return {