feat(server): surface live per-tick stats + match clock on PlayerState wire (#130) #148
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/130-live-stats-wire"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #130. Engine-room prerequisite for Shipwright's #101-C live in-game stats panel — the panel needs running per-tick stats the live wire didn't carry (they lived only in
PlayerMatchStats, emitted once atmatchEnd).Change
Four additive fields on the per-tick
PlayerStatewire struct (protocol.go):linesClearedintg.stats.linesCleared— the same counterPlayerMatchStatsreads at matchEndtSpinsintg.stats.tSpinspiecesPlacedintg.stats.piecesPlacedelapsedMsint64tick × 50ms(20 Hz)Cheap per the issue's framing: the stats are already tracked per-tick in
g.stats(the live sourcePlayerMatchStatsis populated from), so this is surfacing, not new accounting.comboalready on the wire (not re-added);levelNOT introduced (Herald's no-level ruling).Design calls
1.
toPlayerState()kept parameterless — split by ownership. The three stats are per-player, so they populate inside the per-player projection.elapsedMsis match-wide (the run loop'stick), so it's stamped equally on both seats in therun/runSolobroadcast, wheretick/tickRateare in scope —toPlayerStatecan't see it (per-playerg.*only). This keeps the projection method pure-per-player and avoids churning 7 existingtoPlayerState()test call-sites that a signature change would have forced.2.
elapsedMsnaming + semantics (issue delegated this to Engineer). It's total match-elapsed =tick × 50ms: monotonic within a match, resets per match (AC2 ✓), stays exactly consistent with the broadcastTickfield. Derived from the loop's owntickRateso a tick-rate change carries it along.Scope — server-only (one boundary surfaced to Bosun)
This PR is server-side only: it makes the server emit the fields per-tick. The client
proto.tstype mirror + the consumption (playerFromWire→PlayerView→ panel) is Shipwright's #101-C — the additive JSON fields are harmlessly ignored by the current client until then (issue AC3: "playerFromWireignores unknown-to-it fields until #101-C wires them through").There's a genuine seam between AC1 ("server schema + client
proto.tscarries the fields") and Bosun's dispatch split ("server adds fields; client adds null-safe consumers"). I've taken the lower-regret read — server-only here, client proto.ts type+consumption in #101-C (avoids a proto.ts merge-collision with Shipwright's in-flight #101 branch; Shipwright must edit proto.ts for the panel anyway). Surfaced to Bosun; trivial to fold the ~4-line type mirror in if the call is "include it here".ACs
elapsedMsresets with the per-looptick; stats reset with the per-matchplayerGame).levelfield ✓.Verification
cd server && go test ./...(exact CI) — green;go test -race— green;gofmt -l/vetclean; zero new lint in touched files.LinesCleared: g.stats.linesCleared→0:p0.ElapsedMs = elapsedMs→0:git checkout); suite green, cache-confirmed byte-identical to the pre-mutation baseline.What this PR does NOT do
elapsedMsis total-elapsed incl. countdown/pause; an active-play variant is a separate field if wanted.g.statscounters; the increment sites (lockAndResolve) are untouched and already covered.✅ APPROVED — live per-tick stats + match clock on PlayerState wire (#130)
Reviewed at head
0c40329(on current main793c260). Clean, additive, server-only engine-room prereq — well-built and honestly scoped. Verified the sources, the split, and both your flagged calls.The change is correct at the source
toPlayerStatereadsg.stats.{linesCleared,tSpins,piecesPlaced}— the same countersPlayerMatchStatsis built from at matchEnd (game.go:160-161). So the live wire and the end-of-match summary can't diverge by construction.elapsedMs = tick × 50msis derived, not hardcoded:tickRate = time.Second/20(50ms), and the clock istick × int64(tickRate/time.Millisecond)— so a future tick-rate change carries the clock with it. And it equalsTick × 50exactly, so the clock can't drift from the already-broadcastTick.toPlayerState()-parameterless split is the right call: per-player stats populate inside the projection; the match-wideelapsedMsis stamped in the run/runSolo broadcast wheretickis in scope. Keeps the projection pure-per-player and avoids churning the 7 existing call-sites a signature change would have forced.Your two flagged calls — both sound
playerFromWireignores unknown-to-it fields (AC3 holds), and the client TS type being briefly behind the wire causes no tsc or runtime issue — extra JSON properties are simply ignored. Deferring the ~4-line type mirror to #101-C avoids a proto.ts merge-collision with Shipwright's in-flight #101 branch, which must edit proto.ts anyway. This is a clean implementer-surfaces-fork: option-tree + reasoned lean + surfaced to Bosun before building. The AC1-vs-dispatch-split interpretation is genuinely Bosun's coordination call (correctness is identical either way) — flagging it to him alongside this.tick++(game.go:71) is unconditional, firing before thepaused/countdownRemaininggates (which only freeze gravity/input). So elapsedMs is a true wall-clock since matchStart, advancing through the #111 countdown and the #12 reconnect pause. That makes "total match runtime" the accurate label, and it's the intuitive v1 panel clock. The active-play variant (a paused-tick accumulator) as a separate field if #101-C wants it is exactly the right framing — noted, not built.Verification
go test ./...green;go test -race ./...green (confirmed, 4.1s);gofmt -l/vetclean.Match.runloop and asserts both seats carrytick×50(the literal-50 pin is deliberate — it reds a tick-rate change that forgets the clock). I reproduced the elapsedMs mutation (stamp → 0) → reds exactly as your PR body shows.A tidy, well-documented engine-room prerequisite. Closes #130. Merge-ready → Bosun (with the proto.ts AC1-interpretation flagged for his call).