diff --git a/.changeset/scorekeeper-helper.md b/.changeset/scorekeeper-helper.md new file mode 100644 index 0000000..206df1e --- /dev/null +++ b/.changeset/scorekeeper-helper.md @@ -0,0 +1,21 @@ +--- +"kit": minor +--- + +feat: add `ScoreKeeper` leaderboard helper + +A small, timer-free helper that standardises the three ways a game posts to the +leaderboard, replacing the bespoke per-game logic that kept getting the +disconnect/continuous cases wrong: + +- `Record(r, p, metric)` — post live per a `Cadence` (`OnImprove` for monotonic + high-water boards, `OnChange` for live scores). +- `FlushLeave(r, p, status)` — post the player's current metric on disconnect + (call from `OnLeave`); normally `StatusDNF`. +- `FlushAll(r, status)` — post every tracked player in deterministic AccountID + order; continuous games call this from `OnWake` so an abandoned, still-ticking + world keeps recording. +- `PersistBest` / `PersistWallet` — KV resume sugar (MergeMax / MergeSum). + +Pure SDK addition over the existing `Room.Post` + KV surface — no wire or ABI +change. diff --git a/internal/game/scorekeeper.go b/internal/game/scorekeeper.go new file mode 100644 index 0000000..6b691f6 --- /dev/null +++ b/internal/game/scorekeeper.go @@ -0,0 +1,144 @@ +package game + +import ( + "context" + "sort" + "strconv" + "sync" +) + +// Cadence controls when ScoreKeeper.Record auto-posts a player's metric. +type Cadence int + +const ( + // OnImprove posts only when the new metric beats the last posted value — + // the right choice for monotonic high-water boards (peak credits, best + // survival time, kill count). + OnImprove Cadence = iota + // OnChange posts whenever the metric changes from the last posted value. + OnChange +) + +// ScoreKeeper tracks each player's current leaderboard metric and standardises +// posting it three ways: live (Record), on disconnect (FlushLeave), and — for +// continuous "never-ending" games — periodically (FlushAll). +// +// It holds NO goroutines or timers: periodic flushing is driven by the game's +// own OnWake heartbeat, so behaviour stays deterministic under hibernation and +// replay. A single keeper is held on the room and is safe for the room actor. +// +// The board itself is fed only by the Post calls this makes; PersistBest / +// PersistWallet write per-account KV for session *resume*, which is separate +// from the leaderboard. +type ScoreKeeper struct { + mu sync.Mutex + cadence Cadence + cur map[string]int + posted map[string]int + players map[string]Player +} + +// NewScoreKeeper returns a ScoreKeeper with the given auto-post cadence. +func NewScoreKeeper(c Cadence) *ScoreKeeper { + return &ScoreKeeper{ + cadence: c, + cur: map[string]int{}, + posted: map[string]int{}, + players: map[string]Player{}, + } +} + +// Record updates the player's current metric and posts it per the cadence. +// Live posts always carry StatusFinished. +func (sk *ScoreKeeper) Record(r Room, p Player, metric int) { + sk.mu.Lock() + sk.cur[p.AccountID] = metric + sk.players[p.AccountID] = p + last, seen := sk.posted[p.AccountID] + should := !seen + switch sk.cadence { + case OnImprove: + should = should || metric > last + case OnChange: + should = should || metric != last + } + if should { + sk.posted[p.AccountID] = metric + } + sk.mu.Unlock() + if should { + r.Post(Result{Rankings: []PlayerResult{{Player: p, Metric: metric, Status: StatusFinished}}}) + } +} + +// FlushLeave posts the player's current tracked metric with the given status +// (normally StatusDNF) and stops tracking them. Call it from OnLeave so a +// mid-game disconnect still records the player's progress. Calling it for an +// untracked player is a no-op. +// +// IMPORTANT: the platform's leaderboard reader ranks DNF rows the same as +// finished ones. For a lower-is-better board, pass a fair full-run metric +// (e.g. par-extrapolated), never a raw partial, or a half-played run will top +// the board. +func (sk *ScoreKeeper) FlushLeave(r Room, p Player, status Status) { + sk.mu.Lock() + metric, ok := sk.cur[p.AccountID] + delete(sk.cur, p.AccountID) + delete(sk.posted, p.AccountID) + delete(sk.players, p.AccountID) + sk.mu.Unlock() + if !ok { + return + } + r.Post(Result{Rankings: []PlayerResult{{Player: p, Metric: metric, Status: status}}}) +} + +// FlushAll posts every tracked player's current metric with the given status, +// in deterministic AccountID order. Continuous games call this from OnWake on +// a throttled interval so an abandoned, still-ticking world keeps recording. +func (sk *ScoreKeeper) FlushAll(r Room, status Status) { + sk.mu.Lock() + ids := make([]string, 0, len(sk.cur)) + for id := range sk.cur { + ids = append(ids, id) + } + sort.Strings(ids) + type row struct { + p Player + m int + } + rows := make([]row, 0, len(ids)) + for _, id := range ids { + rows = append(rows, row{sk.players[id], sk.cur[id]}) + sk.posted[id] = sk.cur[id] + } + sk.mu.Unlock() + for _, rw := range rows { + r.Post(Result{Rankings: []PlayerResult{{Player: rw.p, Metric: rw.m, Status: status}}}) + } +} + +// PersistBest writes a monotonic high-water value to the player's per-account +// KV (MergeMax) for session resume. The leaderboard board is fed by +// Record/FlushLeave/FlushAll; this only preserves state across reconnects. +func (sk *ScoreKeeper) PersistBest(r Room, p Player, key string, value int) { + acct := r.Services().Accounts.For(p) + if acct == nil { + return + } + _ = acct.Store().Set(context.Background(), key, []byte(strconv.Itoa(value)), MergeMax) +} + +// PersistWallet writes a carryable balance (MergeSum) and a high-water peak +// (MergeMax) for casino-style games, replacing the duplicated persistWallet +// helpers. MergeMax/MergeSum make a KV-outage-era write unable to clobber the +// durable value at merge time. +func (sk *ScoreKeeper) PersistWallet(r Room, p Player, balanceKey string, balance int, peakKey string, peak int) { + acct := r.Services().Accounts.For(p) + if acct == nil { + return + } + st := acct.Store() + _ = st.Set(context.Background(), balanceKey, []byte(strconv.Itoa(balance)), MergeSum) + _ = st.Set(context.Background(), peakKey, []byte(strconv.Itoa(peak)), MergeMax) +} diff --git a/kit.go b/kit.go index 3579f71..3fef036 100644 --- a/kit.go +++ b/kit.go @@ -89,8 +89,15 @@ type ( Status = game.Status PlayerResult = game.PlayerResult Result = game.Result + + // ScoreKeeper standardises live/disconnect/periodic leaderboard posting. + ScoreKeeper = game.ScoreKeeper + Cadence = game.Cadence ) +// NewScoreKeeper constructs a ScoreKeeper with the given auto-post cadence. +func NewScoreKeeper(c Cadence) *ScoreKeeper { return game.NewScoreKeeper(c) } + const ( ModeQuick = game.ModeQuick ModePrivate = game.ModePrivate @@ -121,6 +128,9 @@ const ( Decimal = game.Decimal Duration = game.Duration + OnImprove = game.OnImprove + OnChange = game.OnChange + StatusFinished = game.StatusFinished StatusDNF = game.StatusDNF StatusFlagged = game.StatusFlagged diff --git a/scorekeeper_test.go b/scorekeeper_test.go new file mode 100644 index 0000000..f35ff79 --- /dev/null +++ b/scorekeeper_test.go @@ -0,0 +1,126 @@ +package kit_test + +import ( + "testing" + + kit "github.com/shellcade/kit/v2" + "github.com/shellcade/kit/v2/kittest" +) + +func TestScoreKeeperRecordOnImprovePostsOnlyOnNewHigh(t *testing.T) { + p := kittest.Player("acct-1") + r := kittest.NewRoom(p) + sk := kit.NewScoreKeeper(kit.OnImprove) + + sk.Record(r, p, 10) // first ever -> posts 10 + sk.Record(r, p, 5) // lower -> no post + sk.Record(r, p, 12) // new high -> posts 12 + + if len(r.Posted) != 2 { + t.Fatalf("want 2 posts, got %d: %+v", len(r.Posted), r.Posted) + } + if r.Posted[0].Rankings[0].Metric != 10 || r.Posted[1].Rankings[0].Metric != 12 { + t.Fatalf("unexpected metrics: %+v", r.Posted) + } + if r.Posted[1].Rankings[0].Status != kit.StatusFinished { + t.Fatalf("live posts should be StatusFinished, got %v", r.Posted[1].Rankings[0].Status) + } +} + +func TestScoreKeeperRecordOnChangePostsEveryChange(t *testing.T) { + p := kittest.Player("acct-1") + r := kittest.NewRoom(p) + sk := kit.NewScoreKeeper(kit.OnChange) + + sk.Record(r, p, 3) + sk.Record(r, p, 3) // unchanged -> no post + sk.Record(r, p, 1) // changed (even though lower) -> posts + + if len(r.Posted) != 2 { + t.Fatalf("want 2 posts, got %d: %+v", len(r.Posted), r.Posted) + } + if r.Posted[1].Rankings[0].Metric != 1 { + t.Fatalf("want last metric 1, got %+v", r.Posted[1].Rankings[0]) + } +} + +func TestScoreKeeperFlushLeavePostsDNFThenNoOp(t *testing.T) { + p := kittest.Player("acct-1") + r := kittest.NewRoom(p) + sk := kit.NewScoreKeeper(kit.OnImprove) + + sk.Record(r, p, 7) // posts 7 Finished + sk.FlushLeave(r, p, kit.StatusDNF) + + last := r.Posted[len(r.Posted)-1].Rankings[0] + if last.Metric != 7 || last.Status != kit.StatusDNF { + t.Fatalf("want metric=7 DNF, got %+v", last) + } + + before := len(r.Posted) + sk.FlushLeave(r, p, kit.StatusDNF) // untracked now -> no-op + if len(r.Posted) != before { + t.Fatalf("flush after leave should be a no-op, got %d posts", len(r.Posted)) + } +} + +func TestScoreKeeperFlushAllPostsDeterministicOrder(t *testing.T) { + a := kittest.Player("acct-b") + b := kittest.Player("acct-a") + r := kittest.NewRoom(a, b) + sk := kit.NewScoreKeeper(kit.OnChange) + + sk.Record(r, a, 1) + sk.Record(r, b, 2) + r.Posted = nil // ignore live posts; assert FlushAll alone + sk.FlushAll(r, kit.StatusDNF) + + if len(r.Posted) != 2 { + t.Fatalf("want 2 posts, got %d: %+v", len(r.Posted), r.Posted) + } + // Deterministic: sorted by AccountID -> acct-a then acct-b. + if r.Posted[0].Rankings[0].Player.AccountID != "acct-a" || + r.Posted[1].Rankings[0].Player.AccountID != "acct-b" { + t.Fatalf("FlushAll must post in AccountID order, got %q then %q", + r.Posted[0].Rankings[0].Player.AccountID, r.Posted[1].Rankings[0].Player.AccountID) + } + if r.Posted[0].Rankings[0].Status != kit.StatusDNF { + t.Fatalf("FlushAll status not propagated: %+v", r.Posted[0].Rankings[0]) + } +} + +func TestScoreKeeperPersistBestWritesMergeMax(t *testing.T) { + p := kittest.Player("acct-1") + r := kittest.NewRoom(p) + sk := kit.NewScoreKeeper(kit.OnImprove) + + sk.PersistBest(r, p, "best", 42) + + if got := string(r.KV["acct-1"]["best"]); got != "42" { + t.Fatalf(`want KV best="42", got %q`, got) + } + if rule := r.KVRules["acct-1"]["best"]; rule != kit.MergeMax { + t.Fatalf("want MergeMax, got %v", rule) + } +} + +func TestScoreKeeperPersistWalletWritesSumAndMax(t *testing.T) { + p := kittest.Player("acct-1") + r := kittest.NewRoom(p) + sk := kit.NewScoreKeeper(kit.OnImprove) + + sk.PersistWallet(r, p, "balance", 150, "peak", 900) + + if got := string(r.KV["acct-1"]["balance"]); got != "150" { + t.Fatalf(`want balance="150", got %q`, got) + } + if rule := r.KVRules["acct-1"]["balance"]; rule != kit.MergeSum { + t.Fatalf("balance want MergeSum, got %v", rule) + } + if got := string(r.KV["acct-1"]["peak"]); got != "900" { + t.Fatalf(`want peak="900", got %q`, got) + } + if rule := r.KVRules["acct-1"]["peak"]; rule != kit.MergeMax { + t.Fatalf("peak want MergeMax, got %v", rule) + } +}