Skip to content

[Codex] feat: implement analyst insights batch 2 — 22 new rules #136

Description

@saggacce

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

  • 22 nuevas reglas implementadas
  • Reglas existentes (1-9) sin cambios de comportamiento (usar rosterDeaths en lugar de heroKills)
  • npm run typecheck sin errores
  • npm test sin regresiones
  • Cada regla tiene versión OWN y RIVAL con mensajes distintos
  • reviewRequired: true en reglas de severidad critical/high para equipo OWN

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions