Repository navigation
versus: accrue per-player stats per chapter, not per whole match (0.7.0) - #8
Merged
Merged
Conversation
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).
…iews, renumbered 016)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 aprofile/leaderboard reads) only rolled up at match close, so in practice it
stayed empty,
match_mapsmostly satincomplete, and everymatch_roundsrow was round 1 (round 2 never recorded). Diagnosed live: 85
match_rounds, 412player_round_statsrows, 0player_versus_statsrows.What changed
player_versus_statsnow rolls up at the close of eachchapter (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_statsatomically. A completed chapter credits everyplayer immediately, even if the match is later abandoned.
maps_won/maps_lostare the win/loss record (one per chapter); streaks count consecutive chapters.
Match close only touches
matches_*(no double count).versus_round_start/is_secondary_round(stays false here →Duplicate entry 'N-1') to counting the engineround_startwith idempotency guards.async race (
OpenMatchMap()ran whileg_MatchIdwas still 0); it's now openedfrom the
OnMatchInsertedcallback.reads 0 on this build — an engine-accurate winner is a documented follow-up).
HookEvent → HookEventEx(a build missing an optional eventlike
entered_checkpointno longer abortsOnPluginStart— this was blockingthe plugin from loading on the prod build), and
GetAdminTopMenu()guarded withLibraryExists("adminmenu").Verification
Status: runningon prod.semantics) — its findings (first-chapter race, engine-score index, round W/L
over-count, event-ordering) are addressed here.
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_mapspopulate correctly, the compiled plugin will be vendored into thebizzymod-campaign monorepo PR and both merged.