Files
comunidadhll/docs/HISTORICAL_MATCH_KILLS_PER_MINUTE_ANALYSIS.md
2026-06-11 14:21:45 +02:00

249 lines
7.0 KiB
Markdown

# 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:
```text
kills / (player_active_seconds / 60)
```
It does not mean:
```text
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:
```text
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 profile endpoint can aggregate:
- `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:
```text
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 now exposes:
- `player_active_seconds`
- `player_active_minutes`
- `kpm`
- `kpm_status`
- `active_time_source`
- `active_time_coverage`
If the profile read model is empty, the endpoint can safely fall back to runtime aggregation over `rcon_match_player_stats` for that single player and time window. This avoids inventing KPM while keeping the page usable before the read model is refreshed.
## 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.