Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/scorekeeper-helper.md
Original file line number Diff line number Diff line change
@@ -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.
144 changes: 144 additions & 0 deletions internal/game/scorekeeper.go
Original file line number Diff line number Diff line change
@@ -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)
}
10 changes: 10 additions & 0 deletions kit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions scorekeeper_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading