Objetivo
Añadir 22 nuevas reglas de insights al motor determinista en apps/api/src/services/analyst-service.ts. Todas usan datos ya disponibles en BD.
Contexto del sistema
Ver docs/reglas_insights_automaticos.md para la descripción de las reglas existentes y el patrón de implementación. Las reglas nuevas siguen exactamente el mismo patrón: calcular umbral → push a insights[] con { id, severity, category, title, body, evidence, recommendation, reviewRequired }.
Cada regla tiene versión OWN (isRival === false) y versión RIVAL (isRival === true) con mensajes distintos. Ver reglas existentes en el archivo para el tono y formato.
Cambios necesarios en las queries de datos
1. Ampliar heroKills query (línea ~98)
Antes:
db.heroKill.findMany({
where: { matchId: { in: eventMatchIds }, killedPlayerId: { in: rosterPlayerIds } },
select: { matchId: true, gameTime: true, killedPlayerId: true, killerTeam: true, killedTeam: true },
})
Después:
db.heroKill.findMany({
where: { matchId: { in: eventMatchIds } },
select: { matchId: true, gameTime: true, killedPlayerId: true, killerPlayerId: true, killerTeam: true, killedTeam: true },
})
Luego, justo después de la destructuring:
const rosterDeaths = heroKills.filter(k => k.killedPlayerId && rosterPlayerIds.includes(k.killedPlayerId));
En las reglas existentes (1-9), sustituir todos los usos de heroKills por rosterDeaths. El comportamiento no cambia.
2. Añadir al inicio (después de const insights: Insight[] = [])
// Role lookup from roster
const playerRole = new Map(team.roster.map(r => [r.player.id, r.role?.toLowerCase() ?? '']));
// HeroMeta lookup for draft rule
const heroMetaList = await db.heroMeta.findMany({ select: { slug: true, classes: true } });
const heroMetaClasses = new Map(heroMetaList.map(h => [h.slug, h.classes as string[]]));
Reglas a implementar
Grupo A — Muertes por rol antes de objetivo
Umbral: ≥40% de las partidas. Ventana: 60s antes del objetivo. Datos: rosterDeaths filtrado por playerRole.
| ID |
Severidad |
Rol filtrado |
rule-jungler-death-obj |
high |
jungle |
rule-support-death-obj |
high |
support |
rule-carry-death-obj |
high |
carry |
rule-multi-death-obj |
critical |
≥2 muertes de cualquier rol en misma ventana |
Título OWN: "[Rol] muerto antes de objetivos mayores (X% de partidas)"
Título RIVAL: "Rival pierde [rol] antes de objetivos (X%) — contestar"
Recomendación OWN: que el rol se posicione 90s antes. RIVAL: explotar el momento.
Grupo B — Visión sub-reglas
Datos: wardEvents (con campos eventType: 'PLACEMENT'|'DESTRUCTION', team, gameTime).
rule-late-vision-setup (medium)
- Condición: ≥5 objetivos con wards PLACEMENT en 0-30s previos (no 30-90s). ≥40% de objetivos.
- Evidencia: "X objetivos con setup de visión <30s antes"
- OWN: "El setup de visión llega tarde". RIVAL: "El rival coloca wards en el último momento — flanquear antes".
rule-no-backup-vision (medium)
- Condición: Un ward OWN es DESTRUCTION → no hay PLACEMENT del mismo team en los 60s siguientes → ≥30% de destrucciones sin reposición.
- Evidencia: "X de Y wards destruidas sin reposición inmediata".
rule-vision-lost-no-recovery (high)
- Igual que arriba pero ventana de 90s y en los 120s previos a un objetivo mayor. ≥40% de objetivos.
Grupo C — Conversiones adicionales
Datos: objKills, structDestructions. Mismo patrón que rule-prime-no-conv (Rule 4).
rule-fangtooth-no-structure (medium)
- Condición: ≥3 Fangtoots OWN, ≥50% sin estructura en 120s posteriores.
- Tipos de objetivo:
['FANGTOOTH', 'PRIMAL_FANGTOOTH'].
- OWN: "Fangtooth sin presión de estructura". RIVAL: "Rival no presiona tras Fangtooth".
rule-shaper-no-structure (medium)
- Ídem para
entityType === 'SHAPER'. Ventana: 150s.
Grupo D — Cadenas objetivo-muerte
Datos: rosterDeaths, heroKills (kills completos), objKills.
rule-obj-lost-after-death (high)
- Condición: muerte de jugador OWN → en los 90s siguientes el RIVAL toma un objetivo mayor → en ≥2 partidas.
for (const death of rosterDeaths) {
const side = teamSideMap.get(death.matchId);
const rivalSide = side === 'DUSK' ? 'DAWN' : 'DUSK';
const objAfter = objKills.find(o =>
o.matchId === death.matchId &&
o.killerTeam === rivalSide &&
MAJOR_OBJECTIVES.includes(o.entityType) &&
o.gameTime > death.gameTime &&
o.gameTime <= death.gameTime + 90
);
if (objAfter) { /* count this match */ }
}
rule-obj-taken-after-kill (positive)
- Condición: un jugador OWN mata al rival → en los 90s siguientes OWN toma objetivo → en ≥3 ocurrencias.
- Datos:
heroKills.filter(k => k.killerPlayerId && rosterPlayerIds.includes(k.killerPlayerId))
- OWN: "El equipo convierte kills en objetivos" (positive). RIVAL: "El rival convierte kills en objetivos" (high alert).
Grupo E — Rendimiento individual (slumps)
Datos: mpByPlayer, recentMPs. Usar match.duration para rates/min.
rule-gpm-slump (medium) — por jugador con ≥10 partidas
- GPM =
gold / (match.duration / 60). Comparar últimas 10 vs anteriores 10-20.
- Disparo: GPM reciente <80% del GPM histórico Y GPM reciente < 300.
- Evidencia: "GPM reciente X vs histórico Y".
rule-dpm-slump (medium) — por jugador con ≥10 partidas
- DPM =
heroDamage / (match.duration / 60). Misma estructura.
- Disparo: DPM reciente <75% del histórico Y DPM < 400.
rule-kp-low (medium) — por jugador con ≥8 partidas
- KP =
(kills + assists) / teamKills. Para calcular teamKills: sumar kills de todos los recentMPs del mismo matchId y team.
- Construir
teamKillsPerMatch = new Map<matchId, number> iterando recentMPs.
- Umbral: KP promedio < 40% en últimas 10 partidas.
rule-death-share-high (medium) — por jugador con ≥8 partidas
- Death share =
deaths / teamDeaths por partida. Construir teamDeathsPerMatch igual que teamKills.
- Umbral: death share promedio >35%.
rule-gold-low-dmg (medium) — por jugador con ≥8 partidas
- Gold share =
gold / teamGold. Damage share = heroDamage / teamDmg.
- Umbral: gold share > daño share + 0.10 (tiene 10pp más de oro que de impacto de daño).
- Solo para roles carry/midlane/jungle.
rule-positive-player-form (positive) — por jugador con ≥10 partidas
- Condición inversa del slump: KDA últimas 10 ≥ histórico + 0.8 AND KDA > 3.0.
- Máximo 1 insight positivo de forma (el jugador con mayor mejora).
Grupo F — Draft: desbalance de daño
Datos: mpByPlayer, heroMetaClasses.
const PHYSICAL_CLASSES = ['Sharpshooter', 'Executioner', 'Assassin', 'Fighter'];
const MAGICAL_CLASSES = ['Mage'];
// For each player: top hero in last 20 matches → get damage type
const playerDmgType = new Map<string, 'physical' | 'magical' | 'utility'>();
for (const [playerId, mps] of mpByPlayer) {
if (mps.length < 5) continue;
const heroCount = new Map<string, number>();
for (const mp of mps.slice(0, 20)) heroCount.set(mp.heroSlug, (heroCount.get(mp.heroSlug) ?? 0) + 1);
const topHero = [...heroCount.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
const classes = heroMetaClasses.get(topHero ?? '') ?? [];
if (classes.some(c => MAGICAL_CLASSES.includes(c))) playerDmgType.set(playerId, 'magical');
else if (classes.some(c => PHYSICAL_CLASSES.includes(c))) playerDmgType.set(playerId, 'physical');
else playerDmgType.set(playerId, 'utility');
}
const magicalCount = [...playerDmgType.values()].filter(t => t === 'magical').length;
const physicalCount = [...playerDmgType.values()].filter(t => t === 'physical').length;
rule-draft-dmg-imbalance (medium)
- Disparo:
magicalCount === 0 (sin daño mágico) OR physicalCount === 0 (sin daño físico).
- OWN: "La composición carece de daño mágico/físico". RIVAL: "El rival sin daño [tipo] — draftar counters de ese tipo".
Grupo G — Rival scouting
Solo visible con datos del equipo rival.
rule-rival-obj-focused (high, solo si isRival)
- Condición: rival controla >55% de objetivos totales disputados.
- Datos:
objControlMap ya construido.
- Calcular total de todos los objetivos del mapa y % del rival (=
objControlMap donde rival = el equipo analizado = team side).
- Título: "Rival centrado en control de objetivos". Recomendación: diseñar draft para contestar objetivos.
rule-rival-weak-defense (positive para OWN si es rival, o high si es OWN analizándose)
- Condición: el rival pierde >60% de los objetivos disputados (= el equipo OWN gana >60%).
- Datos:
objControlMap — si el team (rival) controla <40% de objetivos.
- Solo tiene sentido cuando
isRival === true.
- Título: "Rival vulnerable en control de objetivos". Recomendación: priorizar todos los objetivos.
Grupo H — Positivos
rule-positive-vision-setup (positive)
- Condición inversa de rule-low-vision-obj: ≥5 objetivos con ≥1 ward en 90s previos en >70% de casos.
- Solo si
rule-low-vision-obj NO se activó.
rule-positive-prime-conv (positive)
- Condición inversa de rule-prime-no-conv: ≥3 primes propios, >70% convertidos en estructura.
- Solo si
rule-prime-no-conv NO se activó.
Orden de los nuevos insights
Añadirlos en este orden, justo antes del bloque data-status (al final del archivo, antes del sort):
Grupo A → Grupo B → Grupo C → Grupo D → Grupo E → Grupo F → Grupo G → Grupo H
Criterios de aceptación
Objetivo
Añadir 22 nuevas reglas de insights al motor determinista en
apps/api/src/services/analyst-service.ts. Todas usan datos ya disponibles en BD.Contexto del sistema
Ver
docs/reglas_insights_automaticos.mdpara la descripción de las reglas existentes y el patrón de implementación. Las reglas nuevas siguen exactamente el mismo patrón: calcular umbral → push ainsights[]con{ id, severity, category, title, body, evidence, recommendation, reviewRequired }.Cada regla tiene versión OWN (
isRival === false) y versión RIVAL (isRival === true) con mensajes distintos. Ver reglas existentes en el archivo para el tono y formato.Cambios necesarios en las queries de datos
1. Ampliar
heroKillsquery (línea ~98)Antes:
Después:
Luego, justo después de la destructuring:
En las reglas existentes (1-9), sustituir todos los usos de
heroKillsporrosterDeaths. El comportamiento no cambia.2. Añadir al inicio (después de
const insights: Insight[] = [])Reglas a implementar
Grupo A — Muertes por rol antes de objetivo
Umbral: ≥40% de las partidas. Ventana: 60s antes del objetivo. Datos:
rosterDeathsfiltrado porplayerRole.rule-jungler-death-objjunglerule-support-death-objsupportrule-carry-death-objcarryrule-multi-death-objTítulo OWN:
"[Rol] muerto antes de objetivos mayores (X% de partidas)"Título RIVAL:
"Rival pierde [rol] antes de objetivos (X%) — contestar"Recomendación OWN: que el rol se posicione 90s antes. RIVAL: explotar el momento.
Grupo B — Visión sub-reglas
Datos:
wardEvents(con camposeventType: 'PLACEMENT'|'DESTRUCTION',team,gameTime).rule-late-vision-setup(medium)rule-no-backup-vision(medium)rule-vision-lost-no-recovery(high)Grupo C — Conversiones adicionales
Datos:
objKills,structDestructions. Mismo patrón querule-prime-no-conv(Rule 4).rule-fangtooth-no-structure(medium)['FANGTOOTH', 'PRIMAL_FANGTOOTH'].rule-shaper-no-structure(medium)entityType === 'SHAPER'. Ventana: 150s.Grupo D — Cadenas objetivo-muerte
Datos:
rosterDeaths,heroKills(kills completos),objKills.rule-obj-lost-after-death(high)rule-obj-taken-after-kill(positive)heroKills.filter(k => k.killerPlayerId && rosterPlayerIds.includes(k.killerPlayerId))Grupo E — Rendimiento individual (slumps)
Datos:
mpByPlayer,recentMPs. Usarmatch.durationpara rates/min.rule-gpm-slump(medium) — por jugador con ≥10 partidasgold / (match.duration / 60). Comparar últimas 10 vs anteriores 10-20.rule-dpm-slump(medium) — por jugador con ≥10 partidasheroDamage / (match.duration / 60). Misma estructura.rule-kp-low(medium) — por jugador con ≥8 partidas(kills + assists) / teamKills. Para calcular teamKills: sumar kills de todos losrecentMPsdel mismo matchId y team.teamKillsPerMatch = new Map<matchId, number>iterandorecentMPs.rule-death-share-high(medium) — por jugador con ≥8 partidasdeaths / teamDeathspor partida. ConstruirteamDeathsPerMatchigual que teamKills.rule-gold-low-dmg(medium) — por jugador con ≥8 partidasgold / teamGold. Damage share =heroDamage / teamDmg.rule-positive-player-form(positive) — por jugador con ≥10 partidasGrupo F — Draft: desbalance de daño
Datos:
mpByPlayer,heroMetaClasses.rule-draft-dmg-imbalance(medium)magicalCount === 0(sin daño mágico) ORphysicalCount === 0(sin daño físico).Grupo G — Rival scouting
Solo visible con datos del equipo rival.
rule-rival-obj-focused(high, solo siisRival)objControlMapya construido.objControlMapdonde rival = el equipo analizado =teamside).rule-rival-weak-defense(positive para OWN si es rival, o high si es OWN analizándose)objControlMap— si elteam(rival) controla <40% de objetivos.isRival === true.Grupo H — Positivos
rule-positive-vision-setup(positive)rule-low-vision-objNO se activó.rule-positive-prime-conv(positive)rule-prime-no-convNO se activó.Orden de los nuevos insights
Añadirlos en este orden, justo antes del bloque
data-status(al final del archivo, antes del sort):Grupo A → Grupo B → Grupo C → Grupo D → Grupo E → Grupo F → Grupo G → Grupo H
Criterios de aceptación
rosterDeathsen lugar deheroKills)npm run typechecksin erroresnpm testsin regresionesreviewRequired: trueen reglas de severidad critical/high para equipo OWN