249 lines
7.0 KiB
Markdown
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.
|