Files
comunidadhll/docs/HISTORICAL_MATCH_KILLS_PER_MINUTE_ANALYSIS.md

7.4 KiB

Historical Match Kills Per Minute Analysis

Scope

This document defines how real historical KPM works for the match detail served to historico-partida.html.

Why KPM Was Not Safe Before

Historical match detail already exposed:

  • per player:
    • kills
    • deaths
    • teamkills
    • weapon and matchup counters
  • per match:
    • started_at
    • ended_at
    • duration_seconds

What it did not expose was player-level active time. Because of that, dividing by total match duration would have produced a false player KPM.

Real KPM Rule

Real KPM means:

kills / (player_active_seconds / 60)

It does not mean:

kills / match_duration_minutes

Source of Truth

The current implementation uses connection intervals reconstructed from the materialized RCON AdminLog match model.

Reliable interval signals:

  • connected
  • disconnected
  • match_start
  • match_end

Supporting evidence still stored in the player fact:

  • first_seen_server_time
  • last_seen_server_time

The persisted field is:

  • player_active_seconds

The source label is:

  • active_time_source = "event_log"

Persistence

Forward-only persistence now stores on rcon_match_player_stats:

  • player_active_seconds INTEGER NULL
  • active_time_source TEXT

Legacy rows remain valid. If they were materialized before these columns existed, they keep player_active_seconds = NULL until new materialization or new matches populate the field.

Calculation

Observed active time is now:

sum(connected_interval_seconds clamped to [match_start, match_end])

Rules:

  • if the player connects during the match:
    • open interval at connected.server_time
  • if the player disconnects during the match:
    • close interval at disconnected.server_time
  • if the player was already connected before match_start and there is no later pre-match disconnect:
    • open interval at match_start
  • if the player is still connected at match_end:
    • close interval at match_end
  • if the player reconnects multiple times:
    • sum all non-overlapping intervals

This is still observed presence, not exact telemetry down to every silent second.

KPM is exposed only when:

  • player_active_seconds exists
  • player_active_seconds >= HLL_KPM_MIN_ACTIVE_SECONDS

Default:

  • HLL_KPM_MIN_ACTIVE_SECONDS = 60

Payload Contract

Historical match detail player rows may now expose:

  • player_active_seconds
  • player_active_minutes
  • kpm
  • kpm_status
  • active_time_source

active_time_source values currently used:

  • connection_intervals
  • connection_intervals_carryover
  • event_span_fallback
  • unavailable

kpm_status values:

  • ready
  • missing_active_time
  • insufficient_active_time
  • missing_connection_intervals

Rules:

  • missing active time:
    • kpm = null
    • kpm_status = "missing_active_time"
  • fallback event span without reliable connection intervals:
    • kpm = null
    • kpm_status = "missing_connection_intervals"
  • active time below threshold:
    • kpm = null
    • kpm_status = "insufficient_active_time"
  • valid active time:
    • kpm = round(kills / (player_active_seconds / 60), 2)
    • kpm_status = "ready"
    • only when active_time_source is connection_intervals or connection_intervals_carryover

Historical Matches Already Stored

Old matches are not backfilled with fake KPM.

For those rows:

  • player_active_seconds can remain null
  • kpm stays null
  • frontend must not render 0.00 as if the value were real
  • rows rematerialized without reliable connection intervals can still expose event_span_fallback, but that fallback must not be shown as real KPM

Frontend Rule

historico-partida.js renders KPM only when:

  • kpm_status == "ready"

If the value is missing or below threshold, the panel stays clean and does not show a fake metric.

Public Tables

The historical match detail now exposes the same real KPM in two public places:

  • expanded player panel in historico-partida.html
  • main players table in historico-partida.html

The main table leaves the KPM cell empty when kpm_status != "ready".

This avoids false 0.00 values for:

  • legacy rows without active time
  • rows below HLL_KPM_MIN_ACTIVE_SECONDS
  • rows that only expose event_span_fallback

Personal Player Profile

The public player profile in stats.html can now expose real KPM for the selected weekly or monthly window when the lightweight player-period read model already contains enough data for that player and scope.

The profile endpoint does not use a runtime fallback over rcon_match_player_stats anymore for weekly/monthly public reads. That fallback was disabled because it could degrade public profile response times when the read model was empty or stale.

When the read model is available, profile KPM uses:

  • SUM(player_active_seconds)
  • only over rows with:
    • active_time_source = connection_intervals
    • active_time_source = connection_intervals_carryover
    • player_active_seconds >= HLL_KPM_MIN_ACTIVE_SECONDS

Weekly and monthly profile KPM use:

sum(eligible_kills) / (sum(eligible_player_active_seconds) / 60)

This is intentionally narrower than the window total:

  • profile kills still show all kills in the selected window
  • profile KPM only becomes ready when the active-time subset is reliable enough

The profile payload exposes:

  • player_active_seconds
  • player_active_minutes
  • kpm
  • kpm_status
  • active_time_source
  • active_time_coverage

If the profile read model is empty or unavailable:

  • the endpoint keeps the profile fast
  • kpm stays null
  • kpm_status stays missing_active_time
  • external links and profile identity fields remain available

This keeps the page responsive without inventing KPM.

Kills Per Match Labels

Several public tables were showing kills_per_match under the visible label KPM.

That is no longer acceptable because:

  • kills_per_match means kills divided by matches considered
  • real KPM means kills divided by active minutes from reliable connection intervals

Public surfaces that now use Kills/partida instead of KPM for kills_per_match:

  • historical weekly and monthly leaderboard tables
  • annual stats summary table
  • annual stats comparison cards
  • ranking metric selector and ranking table label

Aggregated Real KPM

This repository does not yet expose a public weekly, monthly or annual aggregate KPM based only on:

  • active_time_source = connection_intervals
  • active_time_source = connection_intervals_carryover

That was intentionally left out of this change because the public leaderboard and ranking snapshots would need an explicit coverage contract before mixing:

  • rows with real active time
  • older rows without it
  • fallback rows blocked from kpm_status = ready

Until that contract exists, public aggregated views keep:

  • Kills/partida for kills_per_match
  • real KPM only at historical match detail player level

Limitations

  • This is observed active time from AdminLog connection evidence, not exact join/leave telemetry for every silent second.
  • Quiet players with kills/chat/team switches but no reliable connection chain can expose event_span_fallback; that span is intentionally blocked from KPM.
  • Legacy matches remain without KPM unless rematerialized from stored AdminLog evidence.
  • We do not discount time spent without squad/unit/role yet because there is no audited historical source for that dimension in this implementation.