diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 90b627c..0a00be4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,7 +8,7 @@ on: # Single source of truth for the toolchain — keep in lockstep with validate.yml. env: - KIT_VERSION: "2.10.0" # shellcade-kit release (tag v$KIT_VERSION) — ABI v2 + smoke + KIT_VERSION: "2.11.1" # shellcade-kit release (tag v$KIT_VERSION) — ABI v2 + smoke TINYGO_VERSION: "0.41.1" BINARYEN_VERSION: "version_123" GO_VERSION: "1.26" @@ -140,7 +140,7 @@ jobs: # Conformance-gate the artifact that actually ships: validate.yml # checks the -opt=1 dev build, so a release-profile-only miscompile # would otherwise reach the arcade unchecked. - ./shellcade-kit check "$d/game.wasm" + ./shellcade-kit check --require-leaderboard "$d/game.wasm" digest=$(sha256sum "$d/game.wasm" | cut -d' ' -f1) echo "digest: sha256:$digest" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d75a868..c6736f3 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -8,7 +8,7 @@ on: # requires a newer kit than this pinned engine, so a stale pin is caught # mechanically instead of by this comment. env: - KIT_VERSION: "2.10.0" # shellcade-kit release (tag v$KIT_VERSION) — ABI v2 + smoke + KIT_VERSION: "2.11.1" # shellcade-kit release (tag v$KIT_VERSION) — ABI v2 + smoke TINYGO_VERSION: "0.41.1" BINARYEN_VERSION: "version_123" GO_VERSION: "1.26" @@ -197,7 +197,9 @@ jobs: else echo "::error::$d has no module marker (go.mod or Cargo.toml)"; exit 1 fi - ./shellcade-kit check "$d/game.wasm" + # --require-leaderboard: every published game must declare a + # LeaderboardSpec so its results are recorded and ranked. + ./shellcade-kit check --require-leaderboard "$d/game.wasm" # Release-profile gate (TinyGo-only): publish.yml ships an -opt=2 # wasm-opt'd binary — a DIFFERENT artifact than the -opt=1 dev @@ -208,7 +210,7 @@ jobs: if [ -f "$d/go.mod" ]; then ( cd "$d" && tinygo build -opt=2 -no-debug -gc=conservative \ -o game-release.wasm -target wasip1 -buildmode=c-shared . ) - ./shellcade-kit check "$d/game-release.wasm" + ./shellcade-kit check --require-leaderboard "$d/game-release.wasm" rm -f "$d/game-release.wasm" fi diff --git a/games/alan/chess/chess_leaderboard_test.go b/games/alan/chess/chess_leaderboard_test.go new file mode 100644 index 0000000..4960d25 --- /dev/null +++ b/games/alan/chess/chess_leaderboard_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "testing" + + kit "github.com/shellcade/kit/v2" +) + +// TestLeaderboardSpecDeclared verifies chess declares a Wins leaderboard with +// the career-tally shape: higher-is-better, summed across games, integer. +func TestLeaderboardSpecDeclared(t *testing.T) { + lb := Game{}.Meta().Leaderboard + if lb == nil { + t.Fatal("Meta().Leaderboard is nil; want a Wins board") + } + if lb.MetricLabel != "Wins" { + t.Errorf("MetricLabel=%q, want %q", lb.MetricLabel, "Wins") + } + if lb.Direction != kit.HigherBetter { + t.Errorf("Direction=%v, want HigherBetter", lb.Direction) + } + if lb.Aggregation != kit.SumResults { + t.Errorf("Aggregation=%v, want SumResults", lb.Aggregation) + } + if lb.Format != kit.Integer { + t.Errorf("Format=%v, want Integer", lb.Format) + } +} + +// metricOf returns the posted Metric for player p in a settled result. +func metricOf(t *testing.T, res kit.Result, p kit.Player) int { + t.Helper() + for _, pr := range res.Rankings { + if pr.Player.AccountID == p.AccountID { + return pr.Metric + } + } + t.Fatalf("player %v not in rankings %+v", p.Handle, res.Rankings) + return 0 +} + +// TestCheckmateWinnerMetricIsOne drives a checkmate and asserts the winner posts +// Metric 1 and the loser 0. +func TestCheckmateWinnerMetricIsOne(t *testing.T) { + rm, tr := newGame(t) + a, b := pair(t, rm, tr) + white, black := whiteBlack(rm, a, b) + + // Fool's mate: Black mates White. + mustPlay(t, rm, tr, "f2", "f3") + mustPlay(t, rm, tr, "e7", "e5") + mustPlay(t, rm, tr, "g2", "g4") + mustPlay(t, rm, tr, "d8", "h4") + + settleAfterResults(tr, rm) + if tr.Ended == nil { + t.Fatal("game did not settle") + } + if got := metricOf(t, *tr.Ended, black); got != 1 { + t.Errorf("winner (Black) metric=%d, want 1", got) + } + if got := metricOf(t, *tr.Ended, white); got != 0 { + t.Errorf("loser (White) metric=%d, want 0", got) + } +} + +// TestResignWinnerMetricIsOne drives a resignation and asserts win-count metrics. +func TestResignWinnerMetricIsOne(t *testing.T) { + rm, tr := newGame(t) + a, b := pair(t, rm, tr) + white, black := whiteBlack(rm, a, b) + + rm.OnInput(tr, white, runeInput('r')) // arm + rm.OnInput(tr, white, runeInput('r')) // confirm resign + + settleAfterResults(tr, rm) + if got := metricOf(t, *tr.Ended, black); got != 1 { + t.Errorf("winner (Black) metric=%d, want 1", got) + } + if got := metricOf(t, *tr.Ended, white); got != 0 { + t.Errorf("resigner (White) metric=%d, want 0", got) + } +} + +// TestDrawPostsZeroMetrics verifies a draw is not a win for either side. +func TestDrawPostsZeroMetrics(t *testing.T) { + rm, tr := newGame(t) + a, b := pair(t, rm, tr) + white, black := whiteBlack(rm, a, b) + + rm.OnInput(tr, white, runeInput('d')) // White offers + rm.OnInput(tr, black, runeInput('y')) // Black accepts + + settleAfterResults(tr, rm) + if got := metricOf(t, *tr.Ended, white); got != 0 { + t.Errorf("draw: White metric=%d, want 0", got) + } + if got := metricOf(t, *tr.Ended, black); got != 0 { + t.Errorf("draw: Black metric=%d, want 0", got) + } +} + +// TestForfeitByLeaveWinCounts verifies the leaver keeps DNF/Metric 0 and the +// opponent gets Metric 1 / Finished. +func TestForfeitByLeaveWinCounts(t *testing.T) { + rm, tr := newGame(t) + a, b := pair(t, rm, tr) + white, black := whiteBlack(rm, a, b) + + rm.OnLeave(tr, white) // White abandons mid-game + settleAfterResults(tr, rm) + if tr.Ended == nil { + t.Fatal("forfeit did not settle") + } + checkRankStatus(t, *tr.Ended, white, kit.StatusDNF) + checkRankStatus(t, *tr.Ended, black, kit.StatusFinished) + if got := metricOf(t, *tr.Ended, black); got != 1 { + t.Errorf("forfeit winner (Black) metric=%d, want 1", got) + } + if got := metricOf(t, *tr.Ended, white); got != 0 { + t.Errorf("leaver (White) metric=%d, want 0", got) + } +} + +// TestTimeoutWinnerMetricIsOne verifies the flag-fall winner posts Metric 1. +func TestTimeoutWinnerMetricIsOne(t *testing.T) { + rm, tr := newGame(t) + a, b := pair(t, rm, tr) + white, black := whiteBlack(rm, a, b) + + tr.Advance(mainClock + 1) + rm.OnWake(tr) + + settleAfterResults(tr, rm) + if got := metricOf(t, *tr.Ended, black); got != 1 { + t.Errorf("flag-fall winner (Black) metric=%d, want 1", got) + } + if got := metricOf(t, *tr.Ended, white); got != 0 { + t.Errorf("flagged (White) metric=%d, want 0", got) + } +} diff --git a/games/alan/chess/game.go b/games/alan/chess/game.go index 5f70f18..e9477b0 100644 --- a/games/alan/chess/game.go +++ b/games/alan/chess/game.go @@ -25,6 +25,16 @@ func (Game) Meta() kit.GameMeta { // the side-panel player lines. CtxFeatures: kit.CtxFeatCharacter, + // Wins leaderboard: each decisive game posts 1 for the winner and 0 + // for the loser (a draw is 0 for both). Summed across games, the board + // becomes a career win tally. + Leaderboard: &kit.LeaderboardSpec{ + MetricLabel: "Wins", + Direction: kit.HigherBetter, + Aggregation: kit.SumResults, + Format: kit.Integer, + }, + // Chess-appropriate lobby mode labels — the generic defaults don't fit a // turn-based duel. QuickModeLabel: "Quick match", diff --git a/games/alan/chess/room.go b/games/alan/chess/room.go index f5712ed..5725b15 100644 --- a/games/alan/chess/room.go +++ b/games/alan/chess/room.go @@ -431,10 +431,11 @@ func (rm *room) buildResult(spec *outcomeSpec) kit.Result { pr := kit.PlayerResult{Player: p, Status: kit.StatusFinished} switch { case spec.draw: - pr.Rank = 1 + pr.Rank = 1 // a draw is not a win: Metric stays 0 for both case spec.winnerSet && c == spec.winner: pr.Rank = 1 - default: // loser + pr.Metric = 1 // one win for the leaderboard tally + default: // loser (Metric stays 0) pr.Rank = 2 if spec.loserDNF && c == spec.loser { pr.Status = kit.StatusDNF diff --git a/games/alan/roulette/go.mod b/games/alan/roulette/go.mod index 3546995..6094612 100644 --- a/games/alan/roulette/go.mod +++ b/games/alan/roulette/go.mod @@ -2,7 +2,7 @@ module alan/roulette go 1.25.0 -require github.com/shellcade/kit/v2 v2.10.0 +require github.com/shellcade/kit/v2 v2.11.0 require ( github.com/extism/go-pdk v1.1.3 // indirect diff --git a/games/alan/roulette/go.sum b/games/alan/roulette/go.sum index 5a57105..80444d1 100644 --- a/games/alan/roulette/go.sum +++ b/games/alan/roulette/go.sum @@ -1,7 +1,7 @@ github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= -github.com/shellcade/kit/v2 v2.10.0 h1:Lf15Znc2i2eLA9W6n7YCBTMB2pdBKkue9sw49wyDb/E= -github.com/shellcade/kit/v2 v2.10.0/go.mod h1:6OXhYu2vu468CiBcO8ps8DVlGdvOdAFGr3ZK4Z2VNgk= +github.com/shellcade/kit/v2 v2.11.0 h1:JCdxEn7hgspkhQGsPJCGiJxm8sNBxdp4k/ujyNl8lgw= +github.com/shellcade/kit/v2 v2.11.0/go.mod h1:6OXhYu2vu468CiBcO8ps8DVlGdvOdAFGr3ZK4Z2VNgk= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= diff --git a/games/alan/roulette/room.go b/games/alan/roulette/room.go index a1f3fb9..5fd3964 100644 --- a/games/alan/roulette/room.go +++ b/games/alan/roulette/room.go @@ -36,6 +36,16 @@ const ( historyLen = 12 // recent winning numbers kept for the marquee + // peakFlushInterval throttles the periodic leaderboard flush. roulette is a + // continuous table whose rounds loop forever; a player can be seated while + // the table is abandoned mid-spin and never hit a NEW peak (so postPeak never + // fires). To keep the board "constantly saved" with seated players' current + // peaks, OnWake re-posts every tracked player on this game-time cadence — + // cheap, deterministic, gated on r.Now() (no wall clock, no timer). 10s is + // well inside the betting window so the flush never collides with a round + // one-shot, yet frequent enough that an idle table stays fresh on the board. + peakFlushInterval = 10 * time.Second + // wallet KV keys + merge rules, the casino pattern (balance: sum, the // carryable bankroll; peak: max, the high-water mark + leaderboard metric). keyBalance = "balance" @@ -123,6 +133,12 @@ type room struct { spunOnce bool history []int // recent winning numbers, newest last + // sk mirrors every posted peak so the periodic flush can re-post all seated + // players in deterministic order; lastFlush is the game-time instant of the + // last FlushAll, throttling it to peakFlushInterval. + sk *kit.ScoreKeeper + lastFlush time.Time + lastNow time.Time frame *kit.Frame groupBuf []betGroup // reused per-render scratch for the "your chips" summary @@ -139,6 +155,7 @@ func newRoom(cfg kit.RoomConfig, svc kit.Services) *room { cfg: cfg, svc: svc, players: map[string]*player{}, + sk: kit.NewScoreKeeper(kit.OnImprove), frame: kit.NewFrame(), chipBits: make([]uint8, len(masterBets)), } @@ -165,6 +182,7 @@ func (rm *room) freeColorIdx() int { func (rm *room) OnStart(r kit.Room) { rm.lastNow = r.Now() + rm.lastFlush = r.Now() rm.enterBetting(r) rm.render(r) } @@ -233,9 +251,9 @@ func (rm *room) postPeak(r kit.Room, pl *player) { return } pl.postedPeak = pl.peak - r.Post(kit.Result{Rankings: []kit.PlayerResult{{ - Player: pl.p, Metric: pl.peak, Status: kit.StatusFinished, - }}}) + // Record (cadence OnImprove) posts the new high AND registers the player with + // the keeper so the periodic FlushAll re-posts them while seated. + rm.sk.Record(r, pl.p, pl.peak) } // --- roster ---------------------------------------------------------------- @@ -302,6 +320,14 @@ func (rm *room) OnWake(r kit.Room) { rm.enterBetting(r) } } + // Periodic peak flush: on a throttled game-time cadence, re-post every + // tracked (seated) player's current peak so an abandoned, still-ticking + // table keeps the board "constantly saved". Gated purely on r.Now() — no + // wall clock, no RNG — so it stays deterministic under freeze/thaw. + if rm.lastNow.Sub(rm.lastFlush) >= peakFlushInterval { + rm.lastFlush = rm.lastNow + rm.sk.FlushAll(r, kit.StatusFinished) + } rm.render(r) } diff --git a/games/alan/roulette/roulette_leaderboard_test.go b/games/alan/roulette/roulette_leaderboard_test.go new file mode 100644 index 0000000..9a3982d --- /dev/null +++ b/games/alan/roulette/roulette_leaderboard_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "testing" + "time" + + kit "github.com/shellcade/kit/v2" +) + +// peakOf returns the metric of the most recent peak post for the given account +// in r.Posted, or (0, false) if the player has no post. +func lastPostedMetric(posted []kit.Result, id string) (int, bool) { + metric, ok := 0, false + for _, res := range posted { + for _, rk := range res.Rankings { + if rk.Player.AccountID == id { + metric, ok = rk.Metric, true + } + } + } + return metric, ok +} + +// TestPeriodicPeakFlush locks in the "constantly saved" guarantee for an +// abandoned table: a seated player whose peak does NOT change is still re-posted +// to the leaderboard on the throttled OnWake interval, so the board reflects a +// still-seated player even when the table is idle mid-round. A wake BEFORE the +// interval elapses must not re-post. +func TestPeriodicPeakFlush(t *testing.T) { + r, rm := newGame(t, "p1") + pl := rm.players["p1"] + + // Establish a peak via the normal increase path so the keeper is tracking + // the player, then clear the recorded posts. + pl.peak = 4242 + rm.postPeak(r, pl) + if _, ok := lastPostedMetric(r.Posted, "p1"); !ok { + t.Fatal("postPeak did not record an initial leaderboard post") + } + r.Posted = nil + + // A wake BEFORE the interval elapses must NOT re-post (still inside the + // open betting window, so no round one-shot fires either). + r.Advance(peakFlushInterval - time.Second) + rm.OnWake(r) + if _, ok := lastPostedMetric(r.Posted, "p1"); ok { + t.Fatalf("re-posted before the flush interval elapsed: %+v", r.Posted) + } + + // Crossing the interval (with NO new peak/bet) must re-post the current peak. + r.Advance(2 * time.Second) // now past peakFlushInterval since last flush + rm.OnWake(r) + got, ok := lastPostedMetric(r.Posted, "p1") + if !ok { + t.Fatal("periodic flush did not re-post the seated player's peak") + } + if got != pl.peak { + t.Errorf("periodic flush posted metric %d, want current peak %d", got, pl.peak) + } +} diff --git a/games/bcook/boneyard/boneyard_leaderboard_test.go b/games/bcook/boneyard/boneyard_leaderboard_test.go new file mode 100644 index 0000000..729f47d --- /dev/null +++ b/games/bcook/boneyard/boneyard_leaderboard_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "testing" + + kit "github.com/shellcade/kit/v2" + "github.com/shellcade/kit/v2/kittest" +) + +// A delver who disconnects mid-run must not lose their banked depth from the +// board until a death or the weekly collapse banks them. OnLeave banks the +// CURRENT banked depth as a DNF — the same metric (d.banked) death and collapse +// post — while the run itself persists in-world for a possible rejoin. +func TestDisconnectBanksDepthDNF(t *testing.T) { + a := bp("ada") + tr := kittest.NewRoom(a) + rm := Game{}.NewRoom(tr.Cfg, tr.Services()).(*room) + rm.OnStart(tr) + rm.OnJoin(tr, a) + d := rm.delvers[a.AccountID] + + // Bank B6 at the shrine — the same path the game uses, so banked is a real + // depth, not a fabricated value (shrines sit on B3/B6/B9...). + f6 := rm.floorAt(6) + d.floor, d.x, d.y = 6, f6.shrineX, f6.shrineY + d.bank(rm, tr) + if d.banked != 6 { + t.Fatalf("setup: banked=%d, want 6", d.banked) + } + postsAfterBank := len(tr.Posted) + + // Disconnect mid-run: OnLeave must post the banked depth as a DNF. + rm.OnLeave(tr, a) + + if len(tr.Posted) != postsAfterBank+1 { + t.Fatalf("OnLeave posted %d results, want exactly 1 (total %d)", len(tr.Posted)-postsAfterBank, len(tr.Posted)) + } + last := tr.Posted[len(tr.Posted)-1] + if len(last.Rankings) != 1 { + t.Fatalf("disconnect post rankings = %+v", last.Rankings) + } + pr := last.Rankings[0] + if pr.Metric != d.banked { + t.Fatalf("disconnect post metric = %d, want banked %d", pr.Metric, d.banked) + } + if pr.Status != kit.StatusDNF { + t.Fatalf("disconnect post status = %v, want StatusDNF", pr.Status) + } + if pr.Player.AccountID != a.AccountID { + t.Fatalf("disconnect post player = %q, want %q", pr.Player.AccountID, a.AccountID) + } + + // The run persists in-world for rejoin: the delver is NOT deleted, just + // marked offline. + if got, ok := rm.delvers[a.AccountID]; !ok || got != d { + t.Fatal("OnLeave deleted the in-memory run — it must persist for rejoin") + } + if d.online { + t.Fatal("OnLeave left the delver marked online") + } +} + +// A delver with no progress worth recording (banked == 0) should not pollute +// the board with a zero DNF on disconnect. +func TestDisconnectWithNoBankedDepthDoesNotPost(t *testing.T) { + a := bp("ada") + tr := kittest.NewRoom(a) + rm := Game{}.NewRoom(tr.Cfg, tr.Services()).(*room) + rm.OnStart(tr) + rm.OnJoin(tr, a) + d := rm.delvers[a.AccountID] + if d.banked != 0 { + t.Fatalf("setup: banked=%d, want 0", d.banked) + } + + rm.OnLeave(tr, a) + + if len(tr.Posted) != 0 { + t.Fatalf("OnLeave with banked=0 posted %d results, want 0", len(tr.Posted)) + } + if _, ok := rm.delvers[a.AccountID]; !ok { + t.Fatal("OnLeave deleted the in-memory run") + } +} diff --git a/games/bcook/boneyard/game.go b/games/bcook/boneyard/game.go index 67bfa06..9932521 100644 --- a/games/bcook/boneyard/game.go +++ b/games/bcook/boneyard/game.go @@ -130,7 +130,18 @@ func (rm *room) OnLeave(r kit.Room, p kit.Player) { } } if d, ok := rm.delvers[p.AccountID]; ok { - d.online = false // the run persists; the target does not + d.online = false // the run persists; the target does not + // A delver who disconnects mid-run keeps their run in-world for a + // rejoin, but until then their banked progress would vanish from the + // board until a death or the weekly collapse banks them. Post the + // CURRENT banked depth as a DNF (the same metric death/collapse post), + // so a disconnected run still holds its place. BestResult keeps the + // weekly max, so a later death/rejoin can only raise it. + if d.banked > 0 { + r.Post(kit.Result{Rankings: []kit.PlayerResult{{ + Player: p, Metric: d.banked, Rank: 1, Status: kit.StatusDNF, + }}}) + } rm.dirtyFloor(d.floor) // their @ vanishes from witnesses' views } } diff --git a/games/bcook/putt/putt_leaderboard_test.go b/games/bcook/putt/putt_leaderboard_test.go new file mode 100644 index 0000000..b18067f --- /dev/null +++ b/games/bcook/putt/putt_leaderboard_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "testing" + + kit "github.com/shellcade/kit/v2" +) + +// parsum returns the sum of par over hole indices [from, to). +func parsum(from, to int) int { + t := 0 + for h := from; h < to; h++ { + t += holes[h].par + } + return t +} + +// TestLeaveMidRoundPostsParExtrapolatedDNF: a golfer who has completed some +// holes and disconnects mid-round must post a FAIR full-round estimate — +// their actual strokes on completed holes plus par for every remaining hole — +// with Status DNF, never the raw partial total (which on a lower-better board +// would unfairly top the leaderboard). +func TestLeaveMidRoundPostsParExtrapolatedDNF(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + g := rm.golfers[a.AccountID] + + // Alice has finished 3 holes (scores committed) and is mid-way through the + // 4th when she disconnects. + g.scores = []int{2, 4, 3} // 9 actual strokes on holes 1..3 + rm.holeIdx = 3 + g.strokes = 5 // in-progress strokes on hole 4 — must NOT count as actual + + rm.OnLeave(tr, a) + + if len(tr.Posted) != 1 { + t.Fatalf("OnLeave should Post exactly one result, got %d", len(tr.Posted)) + } + res := tr.Posted[0] + if len(res.Rankings) != 1 { + t.Fatalf("want 1 ranking, got %d", len(res.Rankings)) + } + pr := res.Rankings[0] + // 9 actual + par for holes 4..9 (indices 3..8). + want := 9 + parsum(3, len(holes)) + if pr.Metric != want { + t.Fatalf("DNF metric = %d, want %d (9 actual + par fill of remaining holes)", pr.Metric, want) + } + if pr.Status != kit.StatusDNF { + t.Fatalf("status = %v, want StatusDNF", pr.Status) + } + if pr.Player.AccountID != a.AccountID { + t.Fatalf("posted ranking is for the wrong player") + } +} + +// TestLeaveBeforeAnyHolePostsFullPar: a golfer who disconnects having completed +// zero holes must post the full par total of the course (NOT 0), Status DNF. +func TestLeaveBeforeAnyHolePostsFullPar(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + g := rm.golfers[a.AccountID] + + // No holes completed; a few strokes flailed on hole 1 before quitting. + g.scores = nil + rm.holeIdx = 0 + g.strokes = 6 + + rm.OnLeave(tr, a) + + if len(tr.Posted) != 1 { + t.Fatalf("OnLeave should Post exactly one result, got %d", len(tr.Posted)) + } + pr := tr.Posted[0].Rankings[0] + want := parsum(0, len(holes)) // full course par + if pr.Metric != want { + t.Fatalf("DNF metric = %d, want full par total %d (must not be 0)", pr.Metric, want) + } + if pr.Metric == 0 { + t.Fatal("DNF metric must never be 0 — that would top a lower-better board") + } + if pr.Status != kit.StatusDNF { + t.Fatalf("status = %v, want StatusDNF", pr.Status) + } +} diff --git a/games/bcook/putt/room.go b/games/bcook/putt/room.go index 8632ee5..ed91589 100644 --- a/games/bcook/putt/room.go +++ b/games/bcook/putt/room.go @@ -89,6 +89,28 @@ func (rm *room) OnJoin(r kit.Room, p kit.Player) { func (rm *room) OnLeave(r kit.Room, p kit.Player) { rm.now = r.Now() + // A golfer who quits mid-round still posts a leaderboard result so their + // progress isn't silently lost. The board is LOWER-better and the reader + // ranks a DNF row exactly like a finished one, so we must NOT post the raw + // partial total (a 2-hole quitter with 6 strokes would unfairly top the + // board). Instead post a fair full-round ESTIMATE: actual strokes on the + // COMPLETED holes plus par for every hole not yet completed. + // + // "Completed" means committed to g.scores — that's exactly the holes the + // final 9-hole total (g.total) counts. A hole in progress (g.strokes on the + // current hole) is deliberately treated as "remaining" and filled with par, + // matching the existing total computation. + if g, ok := rm.golfers[p.AccountID]; ok { + est := g.total() + for h := len(g.scores); h < len(holes); h++ { + est += holes[h].par + } + r.Post(kit.Result{Rankings: []kit.PlayerResult{{ + Player: p, + Metric: est, + Status: kit.StatusDNF, + }}}) + } delete(rm.golfers, p.AccountID) delete(rm.names, p.AccountID) for i, id := range rm.order { diff --git a/games/bcook/shellracer/shellracer/room.go b/games/bcook/shellracer/shellracer/room.go index 6b1ebd7..3fdb235 100644 --- a/games/bcook/shellracer/shellracer/room.go +++ b/games/bcook/shellracer/shellracer/room.go @@ -183,9 +183,29 @@ func (rm *room) OnLeave(r kit.Room, p kit.Player) { if rm.phase == phRacing && rm.allDone(r) { rm.enterResults(r) } + // If the last racer just disconnected there is no one left to drive the + // results-hold wake or press Enter, so the result would never reach the + // board. Settle immediately once the room has emptied of present members. + // The phase guard and r.Settled() check make this safe against double-End. + if rm.phase == phResults && !r.Settled() && rm.noneRemain(r, p) { + rm.finish(r) + return + } rm.render(r) } +// noneRemain reports whether no present member is still in the room once the +// departing player (still carried in the roster during OnLeave per the ABI +// leave contract) is excluded. +func (rm *room) noneRemain(r kit.Room, leaving kit.Player) bool { + for _, m := range r.Members() { + if m.AccountID != leaving.AccountID { + return false + } + } + return true +} + func (rm *room) startCountdown(r kit.Room) { rm.phase = phCountdown rm.graceDeadline = time.Time{} diff --git a/games/bcook/shellracer/shellracer/shellracer_leaderboard_test.go b/games/bcook/shellracer/shellracer/shellracer_leaderboard_test.go new file mode 100644 index 0000000..19dc25c --- /dev/null +++ b/games/bcook/shellracer/shellracer/shellracer_leaderboard_test.go @@ -0,0 +1,64 @@ +package shellracer + +import ( + "testing" + "time" + + kit "github.com/shellcade/kit/v2" +) + +// When every racer disconnects mid-race the room must still settle (call End) +// so the DNF results reach the leaderboard, even though no one remains to drive +// the results-hold wake or press Enter. +func TestAllRacersLeaveSettlesRace(t *testing.T) { + d := newDriver(kit.ModeQuick, 5) + a, b := player("a"), player("b") + d.join(a) + d.join(b) + d.advance(countdownDur + time.Second) // -> racing + if d.rm.phase != phRacing { + t.Fatalf("phase=%q after countdown, want racing", d.rm.phase) + } + d.advance(2 * time.Second) // non-zero WPM clock + + // Both racers disconnect, one after the other. + d.leave(a) + d.leave(b) + + if d.r.Ended == nil { + t.Fatal("room did not settle (End not called) after all racers left mid-race") + } + res := *d.r.Ended + if len(res.Rankings) != 2 { + t.Fatalf("rankings=%d, want 2 (both racers present)", len(res.Rankings)) + } + for _, pr := range res.Rankings { + if pr.Status != kit.StatusDNF { + t.Fatalf("player %s status=%v, want DNF", pr.Player.AccountID, pr.Status) + } + } +} + +// Settling on the last leave must not double-settle (call End twice) if the +// room later receives another wake or input. +func TestAllRacersLeaveNoDoubleSettle(t *testing.T) { + d := newDriver(kit.ModeQuick, 5) + a, b := player("a"), player("b") + d.join(a) + d.join(b) + d.advance(countdownDur + time.Second) + d.advance(2 * time.Second) + + d.leave(a) + d.leave(b) + if d.r.Ended == nil { + t.Fatal("room did not settle after all racers left") + } + first := d.r.Ended + + // A late wake on the now-empty, already-settled room must not End again. + d.rm.OnWake(d.r) + if d.r.Ended != first { + t.Fatal("room settled a second time (End called twice)") + } +} diff --git a/games/bcook/spaceterm/room.go b/games/bcook/spaceterm/room.go index 59f23c5..1d83910 100644 --- a/games/bcook/spaceterm/room.go +++ b/games/bcook/spaceterm/room.go @@ -133,12 +133,20 @@ func (rm *room) OnJoin(r kit.Room, p kit.Player) { func (rm *room) OnLeave(r kit.Room, p kit.Player) { rm.now = r.Now() + var left *crew for i, c := range rm.crews { if c.id == p.AccountID { + left = c rm.crews = append(rm.crews[:i], rm.crews[i+1:]...) break } } + // A boarded crew member dropping mid-run banks the run's progress so a + // whole shift's sectors aren't lost if everyone beams out before the core + // dies. The core-death debrief (phOver) has already posted, so skip it. + if left != nil && left.boarded && (rm.phase == phSector || rm.phase == phWarp) { + rm.flushRun(r, left) + } if rm.phase == phSector { // Orders aimed at the leaver's panel can never complete — reroll them. for _, cw := range rm.crews { @@ -472,6 +480,26 @@ func (rm *room) gameOver(r kit.Room) { } } +// flushRun banks one boarded crew member's in-progress run when they drop +// before the core dies: the live metric is the sectors cleared so far +// (sector-1, same as gameOver's score), posted DNF and persisted to "best". +func (rm *room) flushRun(r kit.Room, c *crew) { + score := rm.sector - 1 + if score < 0 { + score = 0 + } + r.Post(kit.Result{Rankings: []kit.PlayerResult{ + {Player: c.player, Metric: score, Status: kit.StatusDNF}, + }}) + if score > c.best { + c.best = score + if acct := r.Services().Accounts.For(c.player); acct != nil { + _ = acct.Store().Set(context.Background(), "best", + []byte(strconv.Itoa(score)), kit.MergeMax) + } + } +} + func (rm *room) toLobby(r kit.Room) { rm.phase = phLobby r.SetInputContext(kit.CtxNav) diff --git a/games/bcook/spaceterm/spaceterm_leaderboard_test.go b/games/bcook/spaceterm/spaceterm_leaderboard_test.go new file mode 100644 index 0000000..0f02a5c --- /dev/null +++ b/games/bcook/spaceterm/spaceterm_leaderboard_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "testing" + + kit "github.com/shellcade/kit/v2" + "github.com/shellcade/kit/v2/kittest" +) + +// A boarded crew member who disconnects mid-run must bank the run's progress to +// the leaderboard (StatusDNF) and persist their personal best to KV — otherwise +// a whole shift's sectors vanish if everyone beams out before the core dies. +func TestLeaveFlushesRunScore(t *testing.T) { + r, rm := newGame(t, "p1", "p2") + + // Clear a couple of sectors so the live run metric (sector-1) is positive. + rm.sector = 4 // three sectors cleared + + rm.OnLeave(r, kittest.Player("p1")) + + if len(r.Posted) == 0 { + t.Fatal("mid-run leave did not post the run score to the leaderboard") + } + res := r.Posted[len(r.Posted)-1] + if len(res.Rankings) != 1 { + t.Fatalf("rankings = %d, want only the leaver", len(res.Rankings)) + } + pr := res.Rankings[0] + if pr.Player.AccountID != "p1" { + t.Errorf("posted player = %q, want the leaver p1", pr.Player.AccountID) + } + if pr.Metric != 3 { + t.Errorf("metric = %d, want the 3 sectors cleared so far", pr.Metric) + } + if pr.Status != kit.StatusDNF { + t.Errorf("status = %v, want DNF for a disconnect", pr.Status) + } + + v, ok, _ := r.Services().Accounts.For(kittest.Player("p1")).Store().Get(context.Background(), "best") + if !ok || string(v) != "3" { + t.Errorf("persisted best = %q (ok=%v), want 3", v, ok) + } +} + +// The core-death debrief already posts and persists for everyone. A crew member +// who then beams out from the debrief must not post a second, stale result. +func TestLeaveAfterGameOverDoesNotDoublePost(t *testing.T) { + r, rm := newGame(t, "p1", "p2") + rm.sector = 5 + rm.hull = 1 + r.Advance(15e9) // past any sector's order timer + rm.OnWake(r) // core dies -> gameOver posts once + if rm.phase != phOver { + t.Fatalf("phase = %v, want debrief", rm.phase) + } + posted := len(r.Posted) + + rm.OnLeave(r, kittest.Player("p1")) + + if len(r.Posted) != posted { + t.Errorf("leave during debrief posted again: %d -> %d", posted, len(r.Posted)) + } +} diff --git a/games/bcook/tic-tac-toe-rs/src/game.rs b/games/bcook/tic-tac-toe-rs/src/game.rs index e4f8808..f610b00 100644 --- a/games/bcook/tic-tac-toe-rs/src/game.rs +++ b/games/bcook/tic-tac-toe-rs/src/game.rs @@ -204,6 +204,46 @@ impl Handler for TttRoom { } } +/// One settle row: (account id, leaderboard metric, finishing rank, status). +/// The metric is the "Wins" leaderboard value — 1 for a win, 0 otherwise — +/// summed across a player's matches by the board. +type Row = (String, i64, u16, Status); + +/// Win rows: the winner posts metric 1 (rank 1, Finished), the loser metric 0 +/// (rank 2, Finished). A solo room (one account on both seats) posts a single +/// winning row rather than a duplicate winner/loser pair against itself. +fn win_rows(solo: bool, winner_id: &str, loser_id: &str) -> Vec { + if solo { + return vec![(winner_id.to_string(), 1, 1, Status::Finished)]; + } + vec![ + (winner_id.to_string(), 1, 1, Status::Finished), + (loser_id.to_string(), 0, 2, Status::Finished), + ] +} + +/// Draw rows: nobody won, so both seats post metric 0 (Finished). Solo collapses +/// to a single row against the one account. +fn draw_rows(solo: bool, x_id: &str, o_id: &str) -> Vec { + if solo { + return vec![(x_id.to_string(), 0, 1, Status::Finished)]; + } + vec![ + (x_id.to_string(), 0, 1, Status::Finished), + (o_id.to_string(), 0, 1, Status::Finished), + ] +} + +/// Forfeit rows (head-to-head only): the present player wins with metric 1 +/// (rank 1, Finished); the leaver/timed-out player posts metric 0 (rank 2, +/// Dnf — they did not finish). +fn forfeit_rows(winner_id: &str, loser_id: &str) -> Vec { + vec![ + (winner_id.to_string(), 1, 1, Status::Finished), + (loser_id.to_string(), 0, 2, Status::Dnf), + ] +} + impl TttRoom { // ---- settling ---------------------------------------------------------- @@ -214,24 +254,13 @@ impl TttRoom { self.m.winner_mark = mark; self.m.deadline = None; self.render(r); - if self.m.solo { - // One person holds both seats: a single Finished row, not a - // duplicate winner/loser pair against the same account. - self.end(r, &[(winner_id, 1, 1, Status::Finished)]); - return; - } let loser_id = if self.m.x_id == winner_id { self.m.o_id.clone() } else { self.m.x_id.clone() }; - self.end( - r, - &[ - (winner_id, 1, 1, Status::Finished), - (loser_id, 0, 2, Status::Finished), - ], - ); + let rows = win_rows(self.m.solo, &winner_id, &loser_id); + self.end(r, &rows); } fn settle_draw(&mut self, r: &mut Room) { @@ -240,17 +269,8 @@ impl TttRoom { self.m.winner_mark = EMPTY; self.m.deadline = None; self.render(r); - if self.m.solo { - self.end(r, &[(self.m.x_id.clone(), 0, 1, Status::Finished)]); - return; - } - self.end( - r, - &[ - (self.m.x_id.clone(), 0, 1, Status::Finished), - (self.m.o_id.clone(), 0, 1, Status::Finished), - ], - ); + let rows = draw_rows(self.m.solo, &self.m.x_id, &self.m.o_id); + self.end(r, &rows); } /// Head-to-head only: solo rooms never arm the forfeit clock and a solo @@ -261,13 +281,8 @@ impl TttRoom { self.m.winner_mark = if winner_id == self.m.x_id { MARK_X } else { MARK_O }; self.m.deadline = None; self.render(r); - self.end( - r, - &[ - (winner_id.to_string(), 1, 1, Status::Finished), - (loser_id.to_string(), 0, 2, Status::Dnf), - ], - ); + let rows = forfeit_rows(winner_id, loser_id); + self.end(r, &rows); } /// Build an Outcome by resolving each account id against the CURRENT @@ -424,6 +439,40 @@ mod tests { assert_eq!(m.input_mark("b"), MARK_O); } + #[test] + fn win_rows_post_one_for_winner_zero_for_loser() { + // Head-to-head: winner metric 1 / Finished, loser metric 0 / Finished. + let rows = win_rows(false, "x", "o"); + assert_eq!(rows, vec![ + ("x".to_string(), 1, 1, Status::Finished), + ("o".to_string(), 0, 2, Status::Finished), + ]); + // Solo: one account holds both seats — a single winning row, metric 1. + let solo = win_rows(true, "a", "a"); + assert_eq!(solo, vec![("a".to_string(), 1, 1, Status::Finished)]); + } + + #[test] + fn draw_rows_post_zero_for_both() { + let rows = draw_rows(false, "x", "o"); + assert_eq!(rows, vec![ + ("x".to_string(), 0, 1, Status::Finished), + ("o".to_string(), 0, 1, Status::Finished), + ]); + let solo = draw_rows(true, "a", "a"); + assert_eq!(solo, vec![("a".to_string(), 0, 1, Status::Finished)]); + } + + #[test] + fn forfeit_rows_award_the_winner_and_dnf_the_leaver() { + // Forfeit-by-leave: winner metric 1 / Finished, leaver metric 0 / Dnf. + let rows = forfeit_rows("x", "o"); + assert_eq!(rows, vec![ + ("x".to_string(), 1, 1, Status::Finished), + ("o".to_string(), 0, 2, Status::Dnf), + ]); + } + #[test] fn solo_seats_one_player_on_both_marks() { let mut m = Match::new(true); diff --git a/games/bcook/tic-tac-toe-rs/src/lib.rs b/games/bcook/tic-tac-toe-rs/src/lib.rs index 3def7b1..992511f 100644 --- a/games/bcook/tic-tac-toe-rs/src/lib.rs +++ b/games/bcook/tic-tac-toe-rs/src/lib.rs @@ -35,6 +35,17 @@ impl Game for TicTacToe { // Opt in to arcade player characters (kit v2.9.0): every roster // member's Player.character arrives populated, and the render // places each player's tile beside their name. + // Record every match to the arcade board: a player's cumulative + // win count across all games, highest first, shown as a whole + // number. Each settle posts metric 1 to the winner and 0 to + // everyone else (draws post 0 for both), and SumResults adds those + // up over a player's history. + leaderboard: Some(Leaderboard { + metric_label: "Wins", + direction: Direction::HigherBetter, + aggregation: Aggregation::SumResults, + format: MetricFormat::Integer, + }), ctx_features: CTX_FEAT_CHARACTER, // Touch deck chips (kit v2.10.0): the board is played with the // digit keys, so declare all nine — on a phone they render as a diff --git a/games/bcook/tic-tac-toe-rs/tests/leaderboard.rs b/games/bcook/tic-tac-toe-rs/tests/leaderboard.rs new file mode 100644 index 0000000..37255c3 --- /dev/null +++ b/games/bcook/tic-tac-toe-rs/tests/leaderboard.rs @@ -0,0 +1,20 @@ +//! The game declares a "Wins" leaderboard so the arcade records each match. +//! Driven through the game's own `meta()` via the SDK-generated +//! `__shellcade_game()` constructor (the native-build handle the +//! `shellcade_game!` macro emits). + +use shellcade_kit::{Aggregation, Direction, Game, MetricFormat}; +use tic_tac_toe_rs::__shellcade_game; + +#[test] +fn meta_declares_a_wins_leaderboard() { + let lb = __shellcade_game() + .meta() + .leaderboard + .expect("Meta must declare a leaderboard so results are recorded"); + assert_eq!(lb.metric_label, "Wins"); + assert_eq!(lb.direction, Direction::HigherBetter); + // Cumulative win count across every match the player has played. + assert_eq!(lb.aggregation, Aggregation::SumResults); + assert_eq!(lb.format, MetricFormat::Integer); +} diff --git a/games/luke/neon-snake/go.mod b/games/luke/neon-snake/go.mod index df6ecf5..48e84e8 100644 --- a/games/luke/neon-snake/go.mod +++ b/games/luke/neon-snake/go.mod @@ -2,7 +2,7 @@ module shellcade.games/luke/neon-snake go 1.25.0 -require github.com/shellcade/kit/v2 v2.9.0 +require github.com/shellcade/kit/v2 v2.11.0 require ( github.com/extism/go-pdk v1.1.3 // indirect diff --git a/games/luke/neon-snake/go.sum b/games/luke/neon-snake/go.sum index a375ca2..80444d1 100644 --- a/games/luke/neon-snake/go.sum +++ b/games/luke/neon-snake/go.sum @@ -1,7 +1,7 @@ github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= -github.com/shellcade/kit/v2 v2.9.0 h1:EsZ5Wf9HcU7rtqdb5eriJM8YBS75xsOUVKHneq+ygqc= -github.com/shellcade/kit/v2 v2.9.0/go.mod h1:6OXhYu2vu468CiBcO8ps8DVlGdvOdAFGr3ZK4Z2VNgk= +github.com/shellcade/kit/v2 v2.11.0 h1:JCdxEn7hgspkhQGsPJCGiJxm8sNBxdp4k/ujyNl8lgw= +github.com/shellcade/kit/v2 v2.11.0/go.mod h1:6OXhYu2vu468CiBcO8ps8DVlGdvOdAFGr3ZK4Z2VNgk= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= diff --git a/games/luke/neon-snake/main.go b/games/luke/neon-snake/main.go index 32099bb..c7bc7ca 100644 --- a/games/luke/neon-snake/main.go +++ b/games/luke/neon-snake/main.go @@ -44,6 +44,9 @@ func (Game) Meta() kit.GameMeta { func (Game) NewRoom(cfg kit.RoomConfig, svc kit.Services) kit.Handler { return &room{ services: svc, + // OnImprove: post only when a player tops their last posted score, so the + // board sees the high-water mark from live play and the disconnect flush. + sk: kit.NewScoreKeeper(kit.OnImprove), } } @@ -203,6 +206,7 @@ type Hazard struct { type room struct { kit.Base services kit.Services + sk *kit.ScoreKeeper // tracks each player's current score for live/disconnect posting pb1 int pb2 int newPB1 bool @@ -461,6 +465,21 @@ func (rm *room) OnJoin(r kit.Room, p kit.Player) { rm.render(r) } +// OnLeave records a leaving player's progress. A disconnect mid-game (before any +// crash) otherwise never reaches the game-over Post, so the player's current +// score would never be recorded. We flush it with StatusDNF and persist their +// personal best to KV for session resume. +func (rm *room) OnLeave(r kit.Room, p kit.Player) { + if rm.sk != nil && rm.gameStarted && !rm.gameOver { + // Make sure the keeper has this player's current score even if they never + // scored (Record is a no-op-ish update), then flush it as a DNF. + rm.recordScores(r) + rm.sk.FlushLeave(r, p, kit.StatusDNF) + } + rm.savePersonalBests(r) + rm.render(r) +} + func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { rm.activePlayer = p rm.activePlayerSet = true @@ -1303,6 +1322,33 @@ func (rm *room) tick(r kit.Room) { } rm.onFoodEaten(r, rm.food, eater) } + if ate1 || ate2 { + // Keep the ScoreKeeper's view of each player's current score current, so a + // mid-game disconnect (OnLeave) can flush it. Mirrors the seat→snake map: + // members[0] drives snake1, members[1] snake2; solo controls both. + rm.recordScores(r) + } +} + +// recordScores feeds each player's current score into the ScoreKeeper. In a +// head-to-head room members[0] owns snake1's score and members[1] owns snake2's; +// in a solo/co-op room the single member controls both snakes, so we record the +// better of the two (matching the game-over Post and PB rules). +func (rm *room) recordScores(r kit.Room) { + if rm.sk == nil { + return + } + members := r.Members() + if len(members) >= 2 { + rm.sk.Record(r, members[0], rm.score1) + rm.sk.Record(r, members[1], rm.score2) + } else if len(members) == 1 { + soloScore := rm.score1 + if rm.score2 > soloScore { + soloScore = rm.score2 + } + rm.sk.Record(r, members[0], soloScore) + } } // speedForScore returns the tick interval for the current combined score, diff --git a/games/luke/neon-snake/neon_snake_leaderboard_test.go b/games/luke/neon-snake/neon_snake_leaderboard_test.go new file mode 100644 index 0000000..698ae29 --- /dev/null +++ b/games/luke/neon-snake/neon_snake_leaderboard_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "testing" + + kit "github.com/shellcade/kit/v2" + "github.com/shellcade/kit/v2/kittest" +) + +// dnfPost returns the first posted result whose single ranking is for player p +// with StatusDNF, or nil. +func dnfPost(tr *kittest.Room, p kit.Player) *kit.PlayerResult { + for i := range tr.Posted { + for j := range tr.Posted[i].Rankings { + pr := &tr.Posted[i].Rankings[j] + if pr.Player.AccountID == p.AccountID && pr.Status == kit.StatusDNF { + return pr + } + } + } + return nil +} + +// TestOnLeaveFlushesSoloScore: a solo player who disconnects mid-game (before +// any crash) has their current score recorded with StatusDNF. +func TestOnLeaveFlushesSoloScore(t *testing.T) { + a := kittest.Player("a") + rm, tr := newRoom(t, a) + + // Drive snake1's head onto the food so it scores 10 this tick. Park snake2 + // out of the way along row 2. + rm.snake1 = []Point{{X: 10, Y: 9}, {X: 9, Y: 9}} + rm.entityDir1 = Point{X: 1, Y: 0} + rm.lastMovedDir1 = rm.entityDir1 + rm.snake2 = []Point{{X: 30, Y: 2}, {X: 31, Y: 2}} + rm.entityDir2 = Point{X: -1, Y: 0} + rm.lastMovedDir2 = Point{X: -1, Y: 0} + rm.food = Point{X: 11, Y: 9} + + rm.tick(tr) + + if rm.score1 != 10 { + t.Fatalf("setup: snake1 should have scored 10, got %d", rm.score1) + } + if rm.gameOver { + t.Fatal("setup: game should still be running") + } + + // Player disconnects mid-game. + rm.OnLeave(tr, a) + + pr := dnfPost(tr, a) + if pr == nil { + t.Fatal("expected a StatusDNF leaderboard Post for the leaving player") + } + if pr.Metric != 10 { + t.Fatalf("DNF post should carry the current score 10, got %d", pr.Metric) + } +} + +// TestOnLeaveFlushesHeadToHeadScore: in a two-player game, the leaving player's +// own current score is flushed with StatusDNF. +func TestOnLeaveFlushesHeadToHeadScore(t *testing.T) { + a, b := kittest.Player("a"), kittest.Player("b") + rm, tr := newRoom(t, a, b) + + // Drive snake2 (player b) onto the food; park snake1 out of the way. + rm.snake1 = []Point{{X: 30, Y: 2}, {X: 31, Y: 2}} + rm.entityDir1 = Point{X: -1, Y: 0} + rm.lastMovedDir1 = Point{X: -1, Y: 0} + rm.snake2 = []Point{{X: 10, Y: 9}, {X: 9, Y: 9}} + rm.entityDir2 = Point{X: 1, Y: 0} + rm.lastMovedDir2 = rm.entityDir2 + rm.food = Point{X: 11, Y: 9} + + rm.tick(tr) + + if rm.score2 != 10 { + t.Fatalf("setup: snake2 should have scored 10, got %d", rm.score2) + } + + rm.OnLeave(tr, b) + + pr := dnfPost(tr, b) + if pr == nil { + t.Fatal("expected a StatusDNF leaderboard Post for leaving player b") + } + if pr.Metric != 10 { + t.Fatalf("DNF post for b should carry score 10, got %d", pr.Metric) + } +} diff --git a/games/matt/voidrunners/go.mod b/games/matt/voidrunners/go.mod index 5527ee1..9f60ac5 100644 --- a/games/matt/voidrunners/go.mod +++ b/games/matt/voidrunners/go.mod @@ -2,7 +2,7 @@ module shellcade.games/matt/voidrunners go 1.25.0 -require github.com/shellcade/kit/v2 v2.9.0 +require github.com/shellcade/kit/v2 v2.11.0 require ( github.com/extism/go-pdk v1.1.3 // indirect diff --git a/games/matt/voidrunners/go.sum b/games/matt/voidrunners/go.sum index a375ca2..80444d1 100644 --- a/games/matt/voidrunners/go.sum +++ b/games/matt/voidrunners/go.sum @@ -1,7 +1,7 @@ github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= -github.com/shellcade/kit/v2 v2.9.0 h1:EsZ5Wf9HcU7rtqdb5eriJM8YBS75xsOUVKHneq+ygqc= -github.com/shellcade/kit/v2 v2.9.0/go.mod h1:6OXhYu2vu468CiBcO8ps8DVlGdvOdAFGr3ZK4Z2VNgk= +github.com/shellcade/kit/v2 v2.11.0 h1:JCdxEn7hgspkhQGsPJCGiJxm8sNBxdp4k/ujyNl8lgw= +github.com/shellcade/kit/v2 v2.11.0/go.mod h1:6OXhYu2vu468CiBcO8ps8DVlGdvOdAFGr3ZK4Z2VNgk= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= diff --git a/games/matt/voidrunners/room.go b/games/matt/voidrunners/room.go index 8f97ea0..3813f0f 100644 --- a/games/matt/voidrunners/room.go +++ b/games/matt/voidrunners/room.go @@ -44,6 +44,11 @@ type room struct { lastNow time.Time frame *kit.Frame // long-lived render buffer, reused every frame (Send copies) + + // sk posts each pilot's all-time-best kill count to the leaderboard: live on + // every new high (OnImprove), and once more on disconnect (FlushLeave/DNF) so + // a mid-game leave still records progress. + sk *kit.ScoreKeeper } func newRoom(cfg kit.RoomConfig, svc kit.Services) *room { @@ -53,6 +58,7 @@ func newRoom(cfg kit.RoomConfig, svc kit.Services) *room { ships: map[string]*ship{}, names: map[string]kit.Player{}, frame: kit.NewFrame(), + sk: kit.NewScoreKeeper(kit.OnImprove), } } @@ -121,6 +127,8 @@ func (rm *room) OnLeave(r kit.Room, p kit.Player) { rm.now = r.Now() if s := rm.ships[p.AccountID]; s != nil { rm.persistBest(r, p.AccountID) + // Record the leaving pilot's kill count even on a disconnect. + rm.sk.FlushLeave(r, p, kit.StatusDNF) delete(rm.ships, p.AccountID) } delete(rm.names, p.AccountID) @@ -374,6 +382,10 @@ func (rm *room) awardKill(r kit.Room, ownerID string, amount int) { if s.kills > s.best { s.best = s.kills rm.persistBest(r, ownerID) + // New all-time high: post it live to the leaderboard. + if p, ok := rm.names[ownerID]; ok { + rm.sk.Record(r, p, s.best) + } } } diff --git a/games/matt/voidrunners/voidrunners_leaderboard_test.go b/games/matt/voidrunners/voidrunners_leaderboard_test.go new file mode 100644 index 0000000..0e26350 --- /dev/null +++ b/games/matt/voidrunners/voidrunners_leaderboard_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "testing" + "time" + + kit "github.com/shellcade/kit/v2" +) + +// lastPostFor returns the most recent posted result row for the given account +// id across all posts recorded by the test room, if any. +func lastPostFor(posts []kit.Result, accountID string) (kit.PlayerResult, bool) { + var got kit.PlayerResult + found := false + for _, res := range posts { + for _, row := range res.Rankings { + if row.Player.AccountID == accountID { + got = row + found = true + } + } + } + return got, found +} + +// TestKillsPostToLeaderboard drives a ship to register a kill and asserts the +// kill count reaches the leaderboard live (Metric>0, StatusFinished), then +// fires OnLeave and asserts a disconnect post carries the kill count with +// kit.StatusDNF — so a mid-game leave still records progress. +func TestKillsPostToLeaderboard(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + + s := rm.ships[a.AccountID] + s.alive = true + s.invulnUntil = tr.Clock + s.x, s.y, s.heading = 40, 11, 0 // facing east + s.kills = 0 + + // One small crater dead ahead; clear the rest so nothing else interferes. + rm.craters = []crater{{x: 45, y: 11, size: 1}} + + rm.fire(tr, a, s) + for i := 0; i < 10 && s.kills == 0; i++ { + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + } + if s.kills < killCrater { + t.Fatalf("expected crater kill credit, kills=%d", s.kills) + } + + // A live post must have reached the leaderboard with the kill count. + live, ok := lastPostFor(tr.Posted, a.AccountID) + if !ok { + t.Fatalf("no leaderboard post for %s after a kill", a.AccountID) + } + if live.Metric <= 0 { + t.Fatalf("live post metric = %d, want > 0", live.Metric) + } + if live.Status != kit.StatusFinished { + t.Fatalf("live post status = %v, want StatusFinished", live.Status) + } + + before := len(tr.Posted) + rm.OnLeave(tr, a) + + if len(tr.Posted) <= before { + t.Fatalf("OnLeave did not post; posts before=%d after=%d", before, len(tr.Posted)) + } + dnf, ok := lastPostFor(tr.Posted, a.AccountID) + if !ok { + t.Fatalf("no leaderboard post for %s after OnLeave", a.AccountID) + } + if dnf.Status != kit.StatusDNF { + t.Fatalf("disconnect post status = %v, want StatusDNF", dnf.Status) + } + if dnf.Metric <= 0 { + t.Fatalf("disconnect post metric = %d, want the kill count > 0", dnf.Metric) + } +}