Skip to content

versus: accrue per-player stats per chapter, not per whole match (0.7.0) - #8

Merged
draxios merged 12 commits into
mainfrom
feat/versus-per-chapter-stats
Oct 3, 2026
Merged

draxios merged 12 commits into
mainfrom
feat/versus-per-chapter-stats

Conversation

@draxios

@draxios draxios commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Versus stats now accrue per chapter, not per whole match

In versus a match is a whole campaign and almost never finishes — players
leave near the end. But player_versus_stats (the per-player versus record a
profile/leaderboard reads) only rolled up at match close, so in practice it
stayed empty, match_maps mostly sat incomplete, and every match_rounds
row was round 1 (round 2 never recorded). Diagnosed live: 85 match_rounds, 412
player_round_stats rows, 0 player_versus_stats rows.

What changed

  • Per-chapter rollup. player_versus_stats now rolls up at the close of each
    chapter (a map's two halves — "play Survivor and Infected once"), which
    reliably completes. It runs inside the round-2 DB transaction so the rollup sees
    both halves' player_round_stats atomically. A completed chapter credits every
    player immediately, even if the match is later abandoned. maps_won/maps_lost
    are the win/loss record (one per chapter); streaks count consecutive chapters.
    Match close only touches matches_* (no double count).
  • Round 2 is recorded. Half detection moved from the unreliable
    versus_round_start/is_secondary_round (stays false here → Duplicate entry 'N-1') to counting the engine round_start with idempotency guards.
  • First-chapter drop fixed. The first chapter of every match was lost to an
    async race (OpenMatchMap() ran while g_MatchId was still 0); it's now opened
    from the OnMatchInserted callback.
  • Chapter winner from plugin Survivor points (the engine distance netprop
    reads 0 on this build — an engine-accurate winner is a documented follow-up).
  • Robustness: HookEvent → HookEventEx (a build missing an optional event
    like entered_checkpoint no longer aborts OnPluginStart — this was blocking
    the plugin from loading on the prod build), and GetAdminTopMenu() guarded with
    LibraryExists("adminmenu").

Verification

  • Compiles clean on SM 1.12 (no errors); loads Status: running on prod.
  • Reviewed by a 4-lens adversarial pass (compile / SQL / state-machine /
    semantics) — its findings (first-chapter race, engine-score index, round W/L
    over-count, event-ordering) are addressed here.
  • Deployed to Bizzy's Dugout (prod, that server only) to bake. Per-chapter
    recording needs a live versus match to fully validate — an empty server can't
    reproduce team flips. Once a real match confirms player_versus_stats /
    match_maps populate correctly, the compiled plugin will be vendored into the
    bizzymod-campaign monorepo PR and both merged.

draxios and others added 12 commits September 7, 2026 23:30
In versus a "match" is a whole campaign and almost never finishes (players leave
near the end), so player_versus_stats — which only rolled up at match close —
stayed empty in practice, and match_maps mostly sat 'incomplete'. This reworks
versus.sp's round/map lifecycle so completed chapters credit players immediately.

- Detect the two halves from the engine `round_start` event with a per-chapter
  counter + idempotency guards, instead of the `versus_round_start`
  is_secondary_round flag (which stays false on this build, so every half tried
  to insert as round 1 and hit the match_rounds unique key). Round 2 is now
  recorded and chapters close.
- Roll player_versus_stats up PER CHAPTER at chapter close, inside the round-2
  transaction so the rollup SELECT sees both halves' player_round_stats
  atomically. maps_won/maps_lost are the win/loss record; streaks count
  consecutive chapters. Match close only touches matches_* (no double count).
- Fix the async race that silently dropped the FIRST chapter of every match:
  open the first chapter from OnMatchInserted after g_MatchId is set, not inline
  after the async OpenMatch.
- Chapter winner from plugin Survivor points (engine distance netprop reads 0 on
  this build; engine-accurate winner is a documented future refinement). Flush
  the open chapter + a dangling half on abandon/mode-change/finale so nothing is
  lost; add a same-map re-entry guard.
- Make event hooks robust: HookEvent -> HookEventEx so a build missing an
  optional event (e.g. entered_checkpoint) doesn't abort OnPluginStart.
- Guard GetAdminTopMenu() with LibraryExists("adminmenu") so startup doesn't
  crash when the plugin loads before adminmenu (matchmode reload chain).

Verified: compiles clean on SM 1.12; loads Status=running on prod. Per-chapter
recording needs a live versus match to fully validate (empty-server can't
reproduce team flips) — deployed to the Dugout to bake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6a1P8bjmnJ45TanqwQekA
Sessions were only closed on the disconnect/mapchange path, so a server
restart (the benign sm_RestartEmpty empty-restart) or a plugin reload with
sessions still open left those rows ended_at=NULL forever — leaked rows that
never captured combat and skew session counts / "currently online" logic
(97 stale rows had accumulated on the Dugout).

Add a one-shot startup sweep that closes this server's dangling sessions the
moment the server row resolves (identity.sp OnServerLookup), mirroring
versus.sp's AbandonStaleMatchesForServer for matches. A 60s age guard keeps a
session a player opened seconds after boot untouched. Hooked at server-resolve
rather than plugin-init because the DB connect is async (nothing to sweep yet
at init, and the init-time retry timer proved unreliable).
Bizzy's Dugout realism-versus runs as mp_gamemode "mutation12"
(vscript_replacer swaps mutation12 -> bizzymodRealism), which
Bizzy_DetectGameMode filed as plain Mutation, so Bizzy_Versus_OnMapStart
returned early and no versus stats were recorded. Add the
bizzymod_stats_versus_mutations cvar (default "mutation12"): any listed
mutation gamemode is classified as RealismVersus so per-chapter versus
recording engages. (This code shipped to the Dugout 2026-09-13 but was
never committed; player_versus_stats went 0 -> populating after.)
…am (0.7.3)

Two linked state-machine defects corrupted per-chapter versus stats on the
Dugout (mutation12 realism-versus): ~50% of match_maps were empty ghosts and
survivor_team never alternated within a chapter, so maps_won/maps_lost were
~50% low and chapter winners were decided off one team's points only.

- Chapters are gated on the engine map NAME, not the async-resolved map_id
  (which is stale during the synchronous OnMapStart). The match_map is opened
  from the id-resolved callback (OnMapLookup) / OnMatchInserted, whichever lands
  last; pending latches (g_ChapterPending / g_RoundPending / g_ChapterOpening /
  g_MapIdFresh) bridge the async gaps so no chapter or half is dropped. This
  eliminates the ghost/duplicate match_maps from repeated same-map OnMapStarts.
- survivor_team + per-player side are settled: the first half's survivor letter
  is read from the settled round_end sides and the second half is FORCED to the
  opposite letter (the two halves always alternate); per-player side is derived
  from (letter == survivor-letter), robust to the round_start swap-timing race.
  Team letters are anchored once per match and cleared on disconnect so a reused
  client slot can't inherit one.
- Partial (single-half) chapters are recorded 'incomplete' with no winner /
  maps_won / maps_lost / streaks, and don't fold into the cumulative match score.
- DeriveCampaignCode distinguishes l4d_/l4d2_ campaigns (was collapsing every
  L4D1 port to 'l4d'); an empty campaign no longer forces a spurious match split.
- CloseRound sql buffer 640->1024; guards against duplicate match/chapter opens
  and stale async callbacks landing after a match close+reopen.

Reviewed adversarially over two verification passes. The corrupt historical
player_versus_stats rollup is truncated on deploy and repopulates from new
matches (only a handful of gm5 matches; not migrated).
…(0.7.4)

Step 3 (stat model):
- Match win/loss now records once >=2 chapters complete (winner = cumulative
  survivor-score lead across the campaign) instead of requiring the finale, which
  whole campaigns almost never reach. <2 completed chapters => abandoned (no W/L).
  The pure per-chapter aggregate maps_won/maps_lost (the user-facing "Round" W/L)
  is unchanged and still credited at each chapter close.
- Dropped the always-0 rounds_won/rounds_lost from the player_versus_stats rollup
  (a versus half has no individual winner). Migration 007 drops the columns and
  recomputes v_player_versus so round_winrate_pct comes from maps_won/maps_lost
  and win-rates are over decided games (wins/(wins+losses)).

Step 4 (career):
- All five player_stats upserts in the session flush hardcoded server_id=0 instead
  of g_ServerId, collapsing every server's coop career into one dangling
  server_id=0 row (player_stats PK includes server_id). Now keyed to the real
  server id; historical server_id=0 rows are left as a legacy blob.
A real live match proved only ONE of each chapter's two survivor runs was being
recorded; the second run was dropped as an empty 0-second phantom, so half the
versus play (and every chapter's true winner) was lost. Root cause: mutation12
fires extra round_start events during ready-up and the scenario restart BETWEEN
the two runs. Counting every round_start as a half let a phantom fill the round-2
slot, prematurely complete+close the chapter, and then reject the real second run
(same map name). (This pre-dated 0.7.3; that rework only exposed it by no longer
copying round 1's data into the phantom.)

A round_start now opens only a CANDIDATE window. It is counted as a real half
(consuming an ordinal slot + inserting its match_rounds row) only once it goes
LIVE: survivors leave the saferoom (player_left_start_area — the signal the
competitive stack uses, airtight against ready-up phantoms) or, as a fallback,
real combat occurs after a minimum elapsed time (so an instant/stray/queued
combat event inside a 0-second phantom window can't promote it). A phantom never
goes live, so at round_end it is discarded without touching the chapter — letting
the real second run land as round 2 of the same match_map. Both halves now record
with correct, opposite survivor_team and per-player sides (also removing the
team-letter drift). Each round logs its live/discard/close for validation.
…tion

The versus liveness gate counts a candidate round as a real half only once it
goes live. The primary signal (survivors leaving the saferoom) is airtight, but
the combat fallback — used when those events don't fire — was gated only on
elapsed >= 15s. The ready-up / scenario-restart window between a chapter's two
runs can exceed 15s, so a saferoom friendly-fire tick (or a stray/queued hit, or
an FF kill) during it could promote the between-runs phantom, consume the
chapter's second ordinal slot, and get the real second run rejected — the same
second-half-dropped symptom 0.7.5 fixed, via a different path.

Two guards:
- MaybeMarkLiveFromCombat now no-ops while IsInReady() (readyup.smx) — exactly
  the window phantoms live in. The primary saferoom-leave signal fires only after
  ready-up, so it is unaffected, and real combat after ready-up still promotes.
- Friendly fire no longer feeds the combat fallback (survivors FF in the
  saferoom; FF is never proof a round is live). FF is still counted for stats.

IsInReady is marked an optional native (AskPluginLoad2) so the plugin still loads
unchanged on coop servers with no readyup.smx. Compiled against SM 1.12.
…-team-A loss)

Root-caused from live match data: only one of a map's two survivor runs was ever
recorded (always round_index=1, survivor_team='A'), because the FIRST run of every
chapter was destroyed before it could be recorded — two async-ordering bugs:

(A) chapters 2..N: the map-id resolves ~1s after OnMapStart, but the new map's first
Round_Start has already opened a candidate; TryOpenPendingChapter's unconditional
CloseRound(0,0,0) discarded that fresh not-yet-live candidate (the new chapter's real
first run). Gate the discard on g_RoundLive so only a genuine leftover half is closed.

(B) chapter 1: the match's first Round_Start fires while the matches INSERT is still in
flight (g_MatchId==0) and was dropped by the Event_VRoundStart guard. Allow a candidate
to open while g_MatchOpening (set synchronously in OpenMatch/OnMapStart); it binds when
the id lands.

Net: both runs record (round_index 1 then 2, survivor_team A then B), chapters complete,
win/loss resolves. Known caveats to validate on a live bake: recovered first-run
duration_s includes the ready-up window (inflated, not phantom), and correct A/B->team
mapping still depends on the survivor-side read. Compiled vs SM 1.12; deployed to Dugout.
…t first-map non-bug

duration_s is now measured from when the round goes LIVE (new g_RoundLiveEpoch,
stamped in MarkRoundLive, reset in Event_VRoundStart) instead of from round_start,
so it no longer includes the pre-round ready-up window (which inflated recovered
first-run durations ~50s+). The >15s combat-fallback gate still uses g_RoundStartEpoch.
Capture logic (candidate/live/discard/insert — the 0.7.7 both-halves fix) is byte-for-
byte unchanged, so this cannot regress round capture.

Also documented (no code change): the "first map records only one run" observed live
is a match-assembly artifact, not a bug — the server plays one real run on the first
map (after a long ready-up as players load) then changelevels, so there is no second
run to capture.
… plugin change)

Most competitive L4D2 matches are decided at the penultimate map (finale is
chaotic/skipped/called early), so most of ours end 'abandoned' and the real
result is lost. These views recover it from data already stored (per-half
survivor scores), retroactively, with no plugin change:

- finale_maps: tiny reference table of official campaign finale map codes
  (is_finale/chapter/campaign columns are unpopulated, so we pin the finale by code).
- v_match_reached_finale, v_match_prefinale_scores: helpers.
- v_match_penultimate: winner going INTO the finale (matches that reached it),
  with final_winner + finale_flipped (did the finale change the result).
- v_match_minigame: a decided result for matches that NEVER reached the finale
  but where both teams played >=1 survivor half.
- v_player_penultimate / v_player_minigame / v_player_ties: per-player leaderboards.

Scoring = cumulative survivor points (same model as the full-match winner).
Validated on live data: ~triples decided matches (6 -> ~20); penultimate winner
equals the full-match winner on every completed match. Applied to the live stats
DB and recorded in schema_migrations.
…ts reference

- Two migrations shared version 007 (007_extended_stats + 007_versus_stat_model),
  so a fresh `migrate.py` run hit "Duplicate entry '007' for schema_migrations.PRIMARY"
  (the CI "Compile + migrations + integrity" failure). 007_versus_stat_model was added
  after the initial run and applied manually, so it was never tracked. Renumber it to
  016_versus_stat_model.sql (unique version). No migration 008-015 depends on what it
  changes (drops player_versus_stats.rounds_won/lost, recreates v_player_versus), so
  running it at 016 is order-safe. Recorded '016' in the live schema_migrations
  (checksum matches; content already applied) so the live harness skips re-applying.

- docs/STATS_REFERENCE.md: full catalog of the 28 views for the website — the versus
  outcome tiers (round/map/penultimate/mini-game/match), every leaderboard and match
  view with its purpose + populated status, and which views are empty (don't surface).
@draxios
draxios merged commit 590d8b3 into main Oct 3, 2026
1 check passed
@draxios
draxios deleted the feat/versus-per-chapter-stats branch October 3, 2026 03:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant