From 0b84487c9ebd29cbfb12e9c363bfc50b1f2a8eea Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 20:17:08 +1000 Subject: [PATCH 01/15] Add leaderboard coverage + DC/continuous-save design spec Audit of 18 games + design for a shared kit ScoreKeeper helper, per-game disconnect/continuous-save fixes, putt lower-better correctness, Rust tic-tac-toe spec, and a conformance guardrail. Co-Authored-By: Claude Opus 4.8 --- ...-15-leaderboard-coverage-dc-save-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-leaderboard-coverage-dc-save-design.md diff --git a/docs/superpowers/specs/2026-06-15-leaderboard-coverage-dc-save-design.md b/docs/superpowers/specs/2026-06-15-leaderboard-coverage-dc-save-design.md new file mode 100644 index 0000000..447e3c4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-leaderboard-coverage-dc-save-design.md @@ -0,0 +1,181 @@ +# Leaderboard coverage + durable disconnect/continuous save + +**Date:** 2026-06-15 +**Status:** Approved design, pending implementation plan +**Repos touched:** `kit` (helper + version bump), `games` (per-game adoption), `shellcade` (conformance guardrail + kit pin) + +## Goal + +Every game records to the leaderboard; player scores survive a mid-game +disconnect; and continuous ("never-ending") games flush periodically so an +abandoned world still records progress. Fix the recurring root cause — each game +hand-rolls its own `OnLeave` + `Post` + KV logic and gets it subtly wrong — with +a shared kit helper plus a conformance guardrail that stops future games +reshipping the bug. + +## Background / current state + +Leaderboards are already full platform infrastructure, **not** greenfield: + +- Games call `Room.Post(Result)` (publish a result anytime) and + `Room.End(Result)` (settle + close). The platform persists results durably + into the `leaderboard_results` table — idempotent by round id, retried ~30s — + aggregates per a per-game `LeaderboardSpec`, and renders them in the lobby + (all-time / weekly / daily windows). +- A game declares its board via `Meta().Leaderboard` (`LeaderboardSpec`: + `MetricLabel`, `Direction`, `Aggregation`, `Format`). Default if unset: best + single result, higher-is-better, integer, label "Score". +- Disconnect handling exists: a 120s **seat grace** holds a departed seat; on + expiry the game's `OnLeave(r, p)` fires. `End()` settlement auto-backfills DNF + for joined players the game omitted. + +### Hard constraints discovered in code (these shape the design) + +1. **Boards rank `Post()`/`End()` results only.** Per-account KV + (`MergeMax`/`MergeSum`) is for *session resume*, not the board. A game that + only writes its "best" to KV never appears on the leaderboard unless it also + `Post`s (or registers a custom `LeaderboardProvider`, which the catalog + casino games do **not**). + - `shellcade/internal/store/postgres/leaderboard.go` +2. **The Reader does NOT filter by `status`.** A `StatusDNF` row's metric is + ranked exactly like a finished one (verified across BestResult + CumulativeSum + and all-time/weekly/daily). Therefore a *partial* score posted as DNF still + counts — fine for higher-is-better boards (max keeps the best), but + **dangerous for lower-is-better** boards (a half-played round would top it). +3. **No wire/ABI change required.** The helper is pure SDK sugar over the + existing `Post` + KV surface, so there is no wire-revision bump and no + `kit/rust` wire sync obligation. + +## Audit summary (the "audit first" deliverable) + +18 games audited (16 on `origin/main` + 2 in worktrees). + +### Sound — no change (8) + +`bytebreaker`, `salvo`, `paperdrift`, `blackjack`, `floorfall`, `pokies`, +`scratchies`, `stacked`. Each declares a spec, Posts live (per-event / per-peak / +per-elimination), and flushes on `OnLeave`. + +### Gaps to fix (8) + hardening (2) + +| Game | Type | Problem | Fix | +|---|---|---|---| +| voidrunners | continuous | tracks kills in KV but **never `Post`s** — nothing reaches the board | adopt ScoreKeeper: Record on kill, FlushLeave on leave | +| chess | round | **no `LeaderboardSpec`**; winner's win never posted | declare spec (Wins, cumulative); Post +1 to winner on settle/forfeit | +| tic-tac-toe-rs | round (Rust) | **no `LeaderboardSpec`**; `End()` metric ignored | declare spec (Wins, cumulative); post win count | +| meltdown | continuous (worktree) | **never `Post`s** (KV only, on `OnClose`); no DC flush | adopt ScoreKeeper: periodic FlushAll + FlushLeave | +| neon-snake | continuous | **no `OnLeave`**; mid-game DC loses score (Posts only on crash) | add `OnLeave` → FlushLeave (DNF) | +| putt | round | **no KV / no `OnLeave` save**; mid-game DC loses everything | `OnLeave` → Post par-extrapolated total, DNF | +| spaceterm | continuous co-op | `OnLeave` doesn't flush; all-crew DC before core death loses run | FlushLeave current run score on `OnLeave` | +| boneyard | continuous | mid-run DC before death/collapse loses current depth | FlushLeave current banked depth on `OnLeave` | +| roulette | continuous | Posts peak-on-increase + wallet-on-leave; no periodic mid-spin flush | (harden) periodic FlushAll | +| shellracer | round | DNF reaches board only via `End()`; if all leave, no `End` | (harden) `End` on last-leave | + +## Design + +### Component 1 — `kit.ScoreKeeper` (Go) + +New file `kit/internal/game/scorekeeper.go`, re-exported as `kit.ScoreKeeper` via +a type alias in `kit/kit.go` (the public facade where `Room`, `Result`, +`PlayerResult`, `MergeRule`, `LeaderboardSpec`, etc. already alias from +`internal/game`). + +Responsibilities (all over existing `Room.Post` + KV): + +- `Record(p Player, metric int64)` — track the player's current metric and + `Post` it per a cadence policy (on-change, or on-improve for monotonic + boards). Replaces ad-hoc "post when peak increased" blocks. +- `FlushLeave(r Room, p Player, status Status)` — `Post` the player's current + tracked metric with the given status (normally `StatusDNF`). This is the + disconnect guarantee; games call it from `OnLeave`. +- `FlushAll(r Room, status Status)` — `Post` every tracked player. Continuous + games call this from `OnWake` on an interval to satisfy "constantly saved". +- Optional KV sugar `PersistBest` (`MergeMax`) / `PersistWallet` + (`MergeSum` + `MergeMax`) for *resume* — folds the duplicated + `persistWallet`/`persistBest` helpers (e.g. `scratchies/kv.go`) into one place. + +Design notes: + +- The keeper is **direction-agnostic**. Computing a *fair* partial metric for a + DNF on a lower-is-better board is the caller's responsibility (see C3), and is + documented on `FlushLeave`. +- Cadence policy is a small enum/option (`OnImprove` default for monotonic + high-water boards; `OnChange` for live scores). Keeps existing sound games' + behavior identical when they adopt it. +- The keeper holds no goroutines/timers; periodic flush is driven by the game's + existing `OnWake` heartbeat so it stays deterministic for hibernation/replay. + +### Component 2 — per-game fixes + +Apply the table above. Sound games optionally migrate to the helper for +consistency but are not required to change behavior. Each gap game gets a unit +test asserting `OnLeave` during active play produces a `Post` (and, for +continuous games, that a periodic `FlushAll` posts without a player present). + +### Component 3 — putt lower-is-better correctness + +Putt's board is `Direction: LowerBetter` (strokes). On disconnect, do **not** +post the raw partial total. Instead post: + +``` +metric = strokesSoFar + par * unplayedHoles // par-fill estimate +status = StatusDNF +``` + +a fair full-round estimate that cannot corrupt the strokes board. This is the +canonical example for the `FlushLeave` doc comment. + +### Component 4 — Rust (tic-tac-toe-rs) + +The Rust kit crate (`kit/rust/`) exposes `Room::post`/`Room::end`, `Leaderboard` +(== `LeaderboardSpec`), and `Status { Finished, Dnf }` (no `Flagged`, no helper +infrastructure). Scope is intentionally minimal — **no Rust ScoreKeeper**: + +- Declare `Leaderboard { label: "Wins", direction: HigherBetter, + aggregation: CumulativeSum, format: Integer }` in `Meta`. +- Ensure the settle paths post a win count (winner `1`, others `0`); the existing + `on_leave` already settles a forfeit with `Status::Dnf`. + +### Component 5 — conformance guardrail (platform) + +In `shellcade`: + +- **Static check:** a test iterating `sdk.Registry.All()` (filtered to non-hidden + via `Listed()`) asserting every game declares `Meta().Leaderboard != nil`. +- **Behavioral check:** a verdict in `internal/gameabi/conformance` — drive a game + into active play, fire `OnLeave`, and assert a `Post` (leaderboard result) is + produced. This catches "declared a spec but never records" regressions. + +## DNF semantics summary + +| Board kind | Examples | On disconnect | +|---|---|---| +| HigherBetter / BestResult | survival, kills, peak, sectors, depth, score | Post partial as-is, DNF — safe (max keeps best) | +| CumulativeSum (wins) | chess, tic-tac-toe | leaver forfeits (no win); opponent posts +1 | +| LowerBetter | putt (strokes) | par-extrapolate to full-round estimate, DNF | + +## Scope & branch strategy + +- **kit:** add `ScoreKeeper` + re-export; minor version bump (no wire change). Tag + the kit release before pinning it from games/platform (per project convention). +- **games:** one branch `leaderboard-coverage` off `origin/main` for the on-main + games. **meltdown** is an unmerged worktree game, so its fix lands on its own + branch `bcook/meltdown` (not folded into the main branch). **stacked** is + already sound — no change. +- **shellcade:** conformance test + bump the pinned kit version. + +## Testing + +- **kit:** unit tests for `ScoreKeeper` — Record cadence, FlushLeave status, + FlushAll with absent players, KV persist sugar. +- **games:** per-game test that `OnLeave` posts during active play; putt test that + the DNF metric is par-extrapolated; a couple of live smokes (smoke harness) for + a continuous and a round game. +- **shellcade:** static + behavioral conformance suite green over the registry. + +## Out of scope + +- ELO / skill rating (chess & tic-tac-toe use win count). +- New leaderboard UI / display changes. +- Any wire or ABI change. +- Migrating already-sound games beyond optional consistency cleanup. From 283e9c7445ef56ff54850c42d52bbf20583340c8 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 20:21:54 +1000 Subject: [PATCH 02/15] Add leaderboard coverage + DC-save implementation plan 18 TDD tasks across kit (ScoreKeeper helper), games (10 per-game fixes), and shellcade (conformance guardrail), with go.work-based local dev and a kit-tag-before-pin release phase. Co-Authored-By: Claude Opus 4.8 --- ...2026-06-15-leaderboard-coverage-dc-save.md | 675 ++++++++++++++++++ 1 file changed, 675 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-leaderboard-coverage-dc-save.md diff --git a/docs/superpowers/plans/2026-06-15-leaderboard-coverage-dc-save.md b/docs/superpowers/plans/2026-06-15-leaderboard-coverage-dc-save.md new file mode 100644 index 0000000..a8c6ec2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-leaderboard-coverage-dc-save.md @@ -0,0 +1,675 @@ +# Leaderboard Coverage + DC/Continuous-Save Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every Shellcade game records to the leaderboard, scores survive a mid-game disconnect, and continuous games flush periodically — built on a shared `kit.ScoreKeeper` helper plus a conformance guardrail. + +**Architecture:** Add a small, timer-free `ScoreKeeper` to the `kit` SDK that standardises live posting, disconnect flush, and periodic flush over the existing `Room.Post` + per-account KV surface (no wire/ABI change). Adopt it in the 8 gap games + 2 hardening games; declare a `LeaderboardSpec` for the two unranked games (chess Go, tic-tac-toe Rust). Add static + behavioral conformance checks in the platform so future games can't reship the bug. + +**Tech Stack:** Go (kit SDK + most games, TinyGo→wasm), Rust (tic-tac-toe-rs), PostgreSQL-backed leaderboard reader (platform). Repos: `/Users/bcook/dev/shellcade/{kit,games,shellcade}`. + +**Spec:** `docs/superpowers/specs/2026-06-15-leaderboard-coverage-dc-save-design.md` + +--- + +## File structure + +**kit** (worktree, new branch `scorekeeper` off `kit` main): +- Create: `kit/internal/game/scorekeeper.go` — the helper (one responsibility: track + post metrics). +- Create: `kit/internal/game/scorekeeper_test.go` — unit tests using the `kittest` double. +- Modify: `kit/kit.go` — re-export `ScoreKeeper`, `NewScoreKeeper`, `Cadence`, `OnImprove`, `OnChange`. +- Modify: `kit/CHANGELOG.md` — minor version entry. + +**games** (worktree `leaderboard-coverage` off `origin/main`; meltdown on `bcook/meltdown`): +- Modify each gap game's `room.go`/`main.go` + add a `*_leaderboard_test.go` per game. + +**shellcade** (worktree, new branch `leaderboard-conformance` off `shellcade` main): +- Create: `shellcade/internal/sdk/leaderboard_conformance_test.go` — static "spec declared" check. +- Modify: `shellcade/internal/gameabi/conformance/conformance.go` (+ test) — behavioral "posts on leave" verdict. + +--- + +## Phase 0 — Workspace setup + +### Task 0: Local go.work so games resolve the local kit + +**Files:** +- Create: `/Users/bcook/dev/shellcade/go.work` (uncommitted; lives above all repos) + +- [ ] **Step 1: Create the kit worktree** + +```bash +cd /Users/bcook/dev/shellcade/kit +git worktree add -b scorekeeper .worktrees/scorekeeper main +git -C .worktrees/scorekeeper rev-parse --short HEAD +``` + +- [ ] **Step 2: Create a go.work tying the kit worktree to the game modules** + +```bash +cd /Users/bcook/dev/shellcade +go work init +go work use ./kit/.worktrees/scorekeeper +# add each game module as it is touched, e.g.: +go work use ./games/.worktrees/leaderboard/games/matt/voidrunners +``` + +Expected: `go.work` lists the kit worktree + game modules. This makes `go test`/`go build` resolve `github.com/shellcade/kit/v2` from the local worktree without editing any committed `go.mod`. (The committed version bump is the release step, Task 17.) + +- [ ] **Step 3: Confirm it resolves** + +Run: `cd /Users/bcook/dev/shellcade && go list -m github.com/shellcade/kit/v2` +Expected: points at the local `kit/.worktrees/scorekeeper` path. + +> Note: `go.work` is intentionally NOT committed to any repo. Add `go.work*` to your local ignore if needed. + +--- + +## Phase A — kit ScoreKeeper helper + +### Task 1: ScoreKeeper core + Record cadence + +**Files:** +- Create: `kit/.worktrees/scorekeeper/internal/game/scorekeeper.go` +- Test: `kit/.worktrees/scorekeeper/internal/game/scorekeeper_test.go` + +- [ ] **Step 1: Read the kit test double to learn the fake Room API** + +Run: `sed -n '1,120p' kit/.worktrees/scorekeeper/kittest/*.go` and inspect how existing tests construct a fake `Room`, capture `Post` calls, and build a `Player`. Mirror that exact construction in the test below (the names in the test stub here are placeholders to be matched to `kittest`). + +- [ ] **Step 2: Write the failing test (Record with OnImprove only posts on a new high)** + +```go +package game + +import "testing" + +func TestScoreKeeperRecordOnImprovePostsOnlyOnNewHigh(t *testing.T) { + r := newFakeRoom(t) // from kittest — match its real constructor + p := fakePlayer("acct-1") // from kittest + sk := NewScoreKeeper(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 + + posts := r.Posts() // captured Result slice + if len(posts) != 2 { + t.Fatalf("want 2 posts, got %d", len(posts)) + } + if posts[0].Rankings[0].Metric != 10 || posts[1].Rankings[0].Metric != 12 { + t.Fatalf("unexpected metrics: %+v", posts) + } + if posts[1].Rankings[0].Status != StatusFinished { + t.Fatalf("live posts should be StatusFinished") + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run TestScoreKeeperRecord -v` +Expected: FAIL (undefined `NewScoreKeeper`/`ScoreKeeper`). + +- [ ] **Step 4: Write minimal implementation** + +```go +package game + +import ( + "context" + "sort" + "strconv" + "sync" +) + +// Cadence controls when Record auto-posts a player's metric. +type Cadence int + +const ( + // OnImprove posts only when the new metric beats the last posted value — + // for monotonic high-water boards (peak credits, best survival, kills). + OnImprove Cadence = iota + // OnChange posts whenever the metric changes. + OnChange +) + +// ScoreKeeper tracks each player's current leaderboard metric and standardises +// posting it live (Record), on disconnect (FlushLeave), and — for continuous +// 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/replay. +type ScoreKeeper struct { + mu sync.Mutex + cadence Cadence + cur map[string]int64 + posted map[string]int64 + 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]int64{}, + posted: map[string]int64{}, + players: map[string]Player{}, + } +} + +// Record updates the player's current metric and posts it per the cadence. +func (sk *ScoreKeeper) Record(r Room, p Player, metric int64) { + 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}}}) + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run TestScoreKeeperRecord -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +cd kit/.worktrees/scorekeeper +git add internal/game/scorekeeper.go internal/game/scorekeeper_test.go +git commit -m "feat(scorekeeper): core type + Record cadence" +``` + +### Task 2: FlushLeave + FlushAll + +**Files:** +- Modify: `kit/.worktrees/scorekeeper/internal/game/scorekeeper.go` +- Test: `kit/.worktrees/scorekeeper/internal/game/scorekeeper_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +func TestScoreKeeperFlushLeavePostsDNF(t *testing.T) { + r := newFakeRoom(t) + p := fakePlayer("acct-1") + sk := NewScoreKeeper(OnImprove) + sk.Record(r, p, 7) // posts 7 (Finished) + sk.FlushLeave(r, p, StatusDNF) + + posts := r.Posts() + last := posts[len(posts)-1].Rankings[0] + if last.Metric != 7 || last.Status != StatusDNF { + t.Fatalf("want metric=7 DNF, got %+v", last) + } + // Leaving again is a no-op (player untracked). + before := len(r.Posts()) + sk.FlushLeave(r, p, StatusDNF) + if len(r.Posts()) != before { + t.Fatalf("flush after leave should be a no-op") + } +} + +func TestScoreKeeperFlushAllPostsAllSortedDeterministic(t *testing.T) { + r := newFakeRoom(t) + a, b := fakePlayer("acct-b"), fakePlayer("acct-a") + sk := NewScoreKeeper(OnChange) + sk.Record(r, a, 1) + sk.Record(r, b, 2) + r.ResetPosts() // ignore live posts; assert FlushAll only + sk.FlushAll(r, StatusDNF) + + posts := r.Posts() + if len(posts) != 2 { + t.Fatalf("want 2 posts, got %d", len(posts)) + } + // Deterministic order: sorted by AccountID -> acct-a then acct-b. + if posts[0].Rankings[0].Player.AccountID != "acct-a" || + posts[1].Rankings[0].Player.AccountID != "acct-b" { + t.Fatalf("FlushAll must post in AccountID order, got %+v", posts) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run 'TestScoreKeeperFlush' -v` +Expected: FAIL (undefined `FlushLeave`/`FlushAll`). + +- [ ] **Step 3: Implement** + +```go +// FlushLeave posts the player's current tracked metric with the given status +// (normally StatusDNF) and stops tracking them. Call from OnLeave. +// +// IMPORTANT: the platform ranks DNF rows the same as finished ones. For a +// lower-is-better board, pass a fair full-run metric (e.g. par-extrapolated), +// not 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 +// an interval so an abandoned world still records progress. +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 int64 + } + 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}}}) + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run 'TestScoreKeeperFlush' -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/scorekeeper.go internal/game/scorekeeper_test.go +git commit -m "feat(scorekeeper): FlushLeave + deterministic FlushAll" +``` + +### Task 3: KV persist sugar + +**Files:** +- Modify: `kit/.worktrees/scorekeeper/internal/game/scorekeeper.go` +- Test: `kit/.worktrees/scorekeeper/internal/game/scorekeeper_test.go` + +- [ ] **Step 1: Write failing test (PersistBest writes MergeMax int)** + +```go +func TestScoreKeeperPersistBestWritesMergeMax(t *testing.T) { + r := newFakeRoom(t) + p := fakePlayer("acct-1") + sk := NewScoreKeeper(OnImprove) + sk.PersistBest(r, p, "best", 42) + + got, ok := r.KVOf("acct-1", "best") // kittest accessor for stored KV + rule + if !ok || string(got.Value) != "42" || got.Rule != MergeMax { + t.Fatalf("want best=42 MergeMax, got %+v ok=%v", got, ok) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run TestScoreKeeperPersist -v` +Expected: FAIL (undefined `PersistBest`). + +- [ ] **Step 3: Implement** + +```go +// PersistBest writes a monotonic high-water value to the player's per-account KV +// (MergeMax) for session resume. The leaderboard board itself is fed by +// Record/FlushLeave/FlushAll; this only preserves state across reconnects. +func (sk *ScoreKeeper) PersistBest(r Room, p Player, key string, value int64) { + acct := r.Services().Accounts.For(p) + if acct == nil { + return + } + _ = acct.Store().Set(context.Background(), key, []byte(strconv.FormatInt(value, 10)), MergeMax) +} + +// PersistWallet writes a carryable balance (MergeSum) and a high-water peak +// (MergeMax). Replaces the duplicated persistWallet helpers in casino games. +func (sk *ScoreKeeper) PersistWallet(r Room, p Player, balanceKey string, balance int64, peakKey string, peak int64) { + acct := r.Services().Accounts.For(p) + if acct == nil { + return + } + st := acct.Store() + _ = st.Set(context.Background(), balanceKey, []byte(strconv.FormatInt(balance, 10)), MergeSum) + _ = st.Set(context.Background(), peakKey, []byte(strconv.FormatInt(peak, 10)), MergeMax) +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run TestScoreKeeperPersist -v` +Expected: PASS. Then run the whole package: `go test ./internal/game/...` — Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/scorekeeper.go internal/game/scorekeeper_test.go +git commit -m "feat(scorekeeper): PersistBest/PersistWallet KV sugar" +``` + +### Task 4: Re-export in kit.go + changelog + +**Files:** +- Modify: `kit/.worktrees/scorekeeper/kit.go` +- Modify: `kit/.worktrees/scorekeeper/CHANGELOG.md` + +- [ ] **Step 1: Add re-exports** + +In `kit.go`, alongside the existing `type (...)` aliases (the block that aliases `Result`, `PlayerResult`, `MergeRule`, etc.): + +```go +// ScoreKeeper standardises live/disconnect/periodic leaderboard posting. +type ( + ScoreKeeper = game.ScoreKeeper + Cadence = game.Cadence +) + +const ( + OnImprove = game.OnImprove + OnChange = game.OnChange +) + +// NewScoreKeeper constructs a ScoreKeeper with the given auto-post cadence. +func NewScoreKeeper(c Cadence) *ScoreKeeper { return game.NewScoreKeeper(c) } +``` + +- [ ] **Step 2: Add changelog entry** + +Prepend a new minor version section to `CHANGELOG.md` (bump minor from current 2.10.0 → 2.11.0): + +```markdown +## v2.11.0 + +- Add `ScoreKeeper` helper: standardises live (`Record`), disconnect + (`FlushLeave`), and periodic (`FlushAll`) leaderboard posting plus + `PersistBest`/`PersistWallet` KV sugar. No wire/ABI change. +``` + +- [ ] **Step 3: Build + vet the whole module** + +Run: `cd kit/.worktrees/scorekeeper && go build ./... && go vet ./... && go test ./...` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add kit.go CHANGELOG.md +git commit -m "feat(scorekeeper): re-export public API; changelog v2.11.0" +``` + +> Release note (not a code step): the actual `git tag kit/v2.11.0` + push happens before games/platform pin (Task 17), per the kit-tag-before-pin convention. + +--- + +## Phase B — per-game adoption (games repo) + +> All Phase B tasks are in worktree `games/.worktrees/leaderboard` (branch +> `leaderboard-coverage`) EXCEPT Task 14 (meltdown) which is on `bcook/meltdown`. +> For each game: `go work use ./games/.worktrees/leaderboard/games//` +> first so the local kit resolves. Each task: write a leaderboard test, make it +> pass, build the module, commit. The executor reads the game's current source to +> produce the exact diff — the steps below specify the precise behavior + test. + +### Task 5: voidrunners — Post kills live + on leave + +**Files:** +- Modify: `games/matt/voidrunners/room.go` (+ `main.go` if keeper is held on room struct) +- Test: `games/matt/voidrunners/voidrunners_leaderboard_test.go` + +Audit: declares spec (Kills), tracks `best` in KV, but **never `Post`s**. `OnLeave` exists and persists best to KV. + +- [ ] **Step 1: Write failing test** — drive a ship to a kill, fire `OnLeave`, assert a leaderboard `Post` with the kill count + `StatusDNF` was produced (capture via the game's test harness / fake room). Also assert a kill bumps a live `Post`. +- [ ] **Step 2: Run → FAIL** (`go test ./... -run Leaderboard -v`). +- [ ] **Step 3: Implement** — add `sk *kit.ScoreKeeper` (`NewScoreKeeper(kit.OnImprove)`) to the room; on each kill call `rm.sk.Record(r, p, best)`; in `OnLeave` call `rm.sk.FlushLeave(r, p, kit.StatusDNF)` (keep the existing KV persist or switch to `rm.sk.PersistBest`). +- [ ] **Step 4: Run → PASS**; then `go build ./...`. +- [ ] **Step 5: Commit** — `feat(voidrunners): post kills to leaderboard live + on disconnect`. + +### Task 6: neon-snake — add OnLeave flush + +**Files:** +- Modify: `games/luke/neon-snake/main.go` +- Test: `games/luke/neon-snake/neon_snake_leaderboard_test.go` + +Audit: posts only on crash (`gameOver`); **no `OnLeave`** → mid-game DC loses score. + +- [ ] **Step 1: Write failing test** — start a game, advance to a non-zero score, fire `OnLeave` for a player, assert a `Post` with current score + `StatusDNF`. +- [ ] **Step 2: Run → FAIL**. +- [ ] **Step 3: Implement** — add a `ScoreKeeper(kit.OnImprove)`; call `Record` where the score updates (or just before the existing crash `Post`); add an `OnLeave(r, p)` that calls `FlushLeave(r, p, kit.StatusDNF)` and persists PB (`PersistBest`). +- [ ] **Step 4: Run → PASS**; `go build ./...`. +- [ ] **Step 5: Commit** — `feat(neon-snake): flush score on disconnect`. + +### Task 7: spaceterm — flush run score on OnLeave + +**Files:** +- Modify: `games/bcook/spaceterm/room.go` +- Test: `games/bcook/spaceterm/spaceterm_leaderboard_test.go` + +Audit: co-op; persists best to KV + `Post`s only in `endRun()` (core death). If all crew DC before death, run lost. `OnLeave` removes crew but doesn't flush. + +- [ ] **Step 1: Write failing test** — board a crew member, advance `rm.score`, fire `OnLeave`, assert a `Post` with the shared run score + `StatusDNF` (and KV best updated). +- [ ] **Step 2: Run → FAIL**. +- [ ] **Step 3: Implement** — in `OnLeave`, before removing the crew member, `FlushLeave(r, p, kit.StatusDNF)` with `rm.score` (and `PersistBest` "best"). Keep `endRun()` posting for finishers. Use a keeper seeded via `Record(r, c.player, rm.score)` when score changes, OR flush directly with the current run score (co-op shared metric). +- [ ] **Step 4: Run → PASS**; `go build ./...`. +- [ ] **Step 5: Commit** — `feat(spaceterm): flush run score on crew disconnect`. + +### Task 8: boneyard — flush banked depth on OnLeave + +**Files:** +- Modify: `games/bcook/boneyard/game.go` (`OnLeave`) and/or `bones.go` +- Test: `games/bcook/boneyard/boneyard_leaderboard_test.go` + +Audit: resident roguelike; `Post`s on death + collapse grace-bank. Mid-run DC before death/collapse → current depth not banked. `OnLeave` only sets `d.online=false`. + +- [ ] **Step 1: Write failing test** — seat a delver, descend (raise `banked`/current depth), fire `OnLeave`, assert a `Post` with the delver's banked depth + `StatusDNF`. +- [ ] **Step 2: Run → FAIL**. +- [ ] **Step 3: Implement** — in `OnLeave`, when a delver has progress, `Post` their banked depth with `StatusDNF` (reuse the existing death-post path with DNF status) in addition to marking `online=false`. Keep the run in-world for rejoin. +- [ ] **Step 4: Run → PASS**; `go build ./...`. +- [ ] **Step 5: Commit** — `feat(boneyard): bank current depth on disconnect`. + +### Task 9: putt — par-extrapolated DNF on OnLeave + +**Files:** +- Modify: `games/bcook/putt/room.go` (`OnLeave`) +- Test: `games/bcook/putt/putt_leaderboard_test.go` + +Audit: `LowerBetter` (Strokes); only `End()` after hole 9; **`OnLeave` deletes golfer, no save**. Lower-better + unfiltered DNF means a raw partial would top the board → must par-extrapolate. + +- [ ] **Step 1: Write failing test** — golfer plays N of 9 holes with K strokes, disconnects; assert `Post` metric == `K + par*(9-N)` and `StatusDNF`. Include a guard test: a golfer who has played 0 holes posts the full par-round estimate (not 0). +- [ ] **Step 2: Run → FAIL**. +- [ ] **Step 3: Implement** — in `OnLeave`, before deleting the golfer, compute `est = strokesSoFar + parTotalOfUnplayedHoles` and `r.Post(kit.Result{Rankings: []kit.PlayerResult{{Player: p, Metric: est, Status: kit.StatusDNF}}})`. Derive per-hole par from the existing course definition. +- [ ] **Step 4: Run → PASS**; `go build ./...`. +- [ ] **Step 5: Commit** — `feat(putt): record par-extrapolated DNF on disconnect`. + +### Task 10: chess — declare spec + post wins + +**Files:** +- Modify: `games/alan/chess/game.go` (`Meta()`), `games/alan/chess/room.go` (settle/forfeit paths) +- Test: `games/alan/chess/chess_leaderboard_test.go` + +Audit: **no `LeaderboardSpec`**; `End()` only, winner's win never reaches a board. + +- [ ] **Step 1: Write failing test** — assert `Meta().Leaderboard` is non-nil with `MetricLabel:"Wins"`, `Direction:HigherBetter`, `Aggregation:CumulativeSum`, `Format:Integer`. Then play a game to checkmate (and separately a forfeit-by-leave) and assert the winner gets a result with `Metric:1` and the loser `Metric:0` / `StatusDNF`. +- [ ] **Step 2: Run → FAIL**. +- [ ] **Step 3: Implement** — add the `Leaderboard` spec to `Meta()`; in `finishGame`/settle and the `OnLeave` forfeit path, set winner `Metric:1` and loser `Metric:0`. (`End(Result)` already carries rankings; just ensure metrics encode wins.) +- [ ] **Step 4: Run → PASS**; `go build ./...`. +- [ ] **Step 5: Commit** — `feat(chess): declare wins leaderboard + post win counts`. + +### Task 11: roulette — periodic FlushAll (hardening) + +**Files:** +- Modify: `games/alan/roulette/room.go` (`OnWake`) +- Test: `games/alan/roulette/roulette_leaderboard_test.go` + +Audit: continuous; posts peak-on-increase + persists wallet on leave; no periodic flush for a mid-spin abandoned player. + +- [ ] **Step 1: Write failing test** — seat a player, raise peak, simulate the room ticking with the player abandoned mid-spin (no peak change), assert a periodic `Post` of the current peak occurs on the wake interval. +- [ ] **Step 2: Run → FAIL**. +- [ ] **Step 3: Implement** — add a `ScoreKeeper(kit.OnImprove)` fed by the existing `postPeak`; on a throttled `OnWake` interval (e.g. every N seconds of game time) call `sk.FlushAll(r, kit.StatusFinished)`. Keep wallet persistence as-is. +- [ ] **Step 4: Run → PASS**; `go build ./...`. +- [ ] **Step 5: Commit** — `feat(roulette): periodic peak flush for abandoned tables`. + +### Task 12: shellracer — End on last-leave (hardening) + +**Files:** +- Modify: `games/bcook/shellracer/shellracer/room.go` (`OnLeave`) +- Test: `games/bcook/shellracer/shellracer/shellracer_leaderboard_test.go` + +Audit: round-based; `OnLeave` snaps DNF WPM and calls `enterResults` only if `allDone`. If everyone leaves mid-race, no `End`, nothing posts. + +- [ ] **Step 1: Write failing test** — two racers racing; both leave; assert the room reaches `End` with both as `StatusDNF` (their snapped WPM), i.e. results are flushed when the last racer leaves. +- [ ] **Step 2: Run → FAIL**. +- [ ] **Step 3: Implement** — in `OnLeave`, after marking DNF, if `phRacing` and no racers remain (roster empty of players), call `enterResults(r)` (which leads to `End`). Guard against double-end. +- [ ] **Step 4: Run → PASS**; `go build ./...`. +- [ ] **Step 5: Commit** — `feat(shellracer): settle race when the last racer disconnects`. + +### Task 13: tic-tac-toe-rs — declare spec + post wins (Rust) + +**Files:** +- Modify: `games/bcook/tic-tac-toe-rs/src/lib.rs` (`Meta`), `games/bcook/tic-tac-toe-rs/src/game.rs` (settle paths) +- Test: `games/bcook/tic-tac-toe-rs/src/game.rs` (`#[cfg(test)]`) or `tests/` + +Audit: **no `Leaderboard`**; `end()` carries metrics already; `on_leave` settles forfeit with `Status::Dnf`. + +- [ ] **Step 1: Write failing test** — assert `Meta` declares `Leaderboard { label:"Wins", direction:HigherBetter, aggregation:CumulativeSum, format:Integer }`; play to a win and assert the winner's `PlayerResult.metric == 1`, loser `0`. +- [ ] **Step 2: Run → FAIL** (`cargo test`). +- [ ] **Step 3: Implement** — add the `Leaderboard` to `Meta`; ensure `settle_win`/`settle_forfeit`/`settle_draw` set winner metric `1`, others `0`. +- [ ] **Step 4: Run → PASS**; `cargo build --release` (and the wasm target the game ships, e.g. `cargo build --target wasm32-unknown-unknown --release`). +- [ ] **Step 5: Commit** — `feat(tic-tac-toe): declare wins leaderboard + post win counts`. + +### Task 14: meltdown — Post survival live + on leave (branch bcook/meltdown) + +**Files:** +- Modify: `games/bcook/meltdown/room.go` (in worktree `games/.claude/worktrees/agent-a235e4c859c78fe48`, branch `bcook/meltdown`) +- Test: `games/bcook/meltdown/meltdown_leaderboard_test.go` + +Audit: continuous co-op; declares spec (Survival) but **never `Post`s** (KV only, in `OnClose`); no DC flush. + +- [ ] **Step 1:** `go work use` the meltdown module path. Write failing test — board crew, advance survival seconds, fire `OnLeave`, assert a `Post` of survival seconds + `StatusDNF`; and that a periodic `OnWake` flush posts while crew are present. +- [ ] **Step 2: Run → FAIL**. +- [ ] **Step 3: Implement** — add `ScoreKeeper(kit.OnImprove)`; `Record(r, p, survivedSeconds)` for each crew on score change; periodic `FlushAll(r, kit.StatusFinished)` on a throttled `OnWake`; `FlushLeave(r, p, kit.StatusDNF)` in `OnLeave`; keep `recordResults` in `OnClose`. +- [ ] **Step 4: Run → PASS**; `go build ./...`. +- [ ] **Step 5: Commit on `bcook/meltdown`** — `feat(meltdown): post survival live + on disconnect`. + +--- + +## Phase C — platform conformance (shellcade repo) + +> Worktree: `shellcade/.worktrees/leaderboard-conformance` (branch off `shellcade` main). +> Create it: `cd /Users/bcook/dev/shellcade/shellcade && git worktree add -b leaderboard-conformance .worktrees/leaderboard-conformance main`. + +### Task 15: Static check — every listed game declares a spec + +**Files:** +- Create: `shellcade/.worktrees/leaderboard-conformance/internal/sdk/leaderboard_conformance_test.go` + +- [ ] **Step 1: Write failing test** + +```go +package sdk_test + +import ( + "testing" + // import the registry constructor used by other sdk tests +) + +func TestAllListedGamesDeclareLeaderboard(t *testing.T) { + reg := testRegistry(t) // match the helper other sdk tests use + for _, g := range reg.Listed() { // Listed() excludes Hidden + if g.Meta().Leaderboard == nil { + t.Errorf("game %q must declare Meta().Leaderboard", g.Meta().Slug) + } + } +} +``` + +- [ ] **Step 2: Run → FAIL** for any game still missing a spec (confirms the check works), `go test ./internal/sdk/ -run TestAllListedGamesDeclareLeaderboard -v`. +- [ ] **Step 3: Implement** — no production code; this check passes once Phase B games declare specs. If the registry under test pins old game wasm, note the dependency: it goes green after the games are rebuilt/pinned (Task 17). +- [ ] **Step 4: Run → PASS** (after games updated). +- [ ] **Step 5: Commit** — `test(leaderboard): assert all listed games declare a spec`. + +### Task 16: Behavioral verdict — OnLeave during play posts a result + +**Files:** +- Modify: `shellcade/.worktrees/leaderboard-conformance/internal/gameabi/conformance/conformance.go` +- Test: `shellcade/.worktrees/leaderboard-conformance/internal/gameabi/conformance/conformance_test.go` + +- [ ] **Step 1: Read** `conformance.go` to learn how it loads a game, drives Join/Wake/Input, and records verdicts. +- [ ] **Step 2: Write failing test** — load a game, Join two players, drive into active play, fire a Leave for one, assert the conformance run records a `Post` (leaderboard result) within the seat-grace/Leave path. Add the verdict to `Report.Verdicts`. +- [ ] **Step 3: Run → FAIL**. +- [ ] **Step 4: Implement** the verdict: capture `Post` calls during the Leave step; verdict passes if ≥1 result was posted for a game whose `Meta().Leaderboard != nil` and that was in active play. (Round-based 2-player games that settle via `End` count as posting.) +- [ ] **Step 5: Run → PASS**; commit — `feat(conformance): verify games post on player leave`. + +--- + +## Phase D — release + integration + +### Task 17: Tag kit, pin from games + platform, full build + +**Files:** +- Modify: each touched game's `go.mod` (`games///go.mod`) — `require github.com/shellcade/kit/v2 v2.11.0` +- Modify: `shellcade` go.mod pin if it references kit directly + +- [ ] **Step 1: Tag + push kit** + +```bash +cd /Users/bcook/dev/shellcade/kit/.worktrees/scorekeeper +# merge/PR scorekeeper to kit main per project flow, then: +git tag kit/v2.11.0 && git push origin kit/v2.11.0 +``` + +- [ ] **Step 2: Bump each touched game module** + +For each game touched in Phase B: +```bash +cd games/.worktrees/leaderboard/games// +go get github.com/shellcade/kit/v2@v2.11.0 +go mod tidy +``` + +- [ ] **Step 3: Remove the local go.work** (so builds use the pinned version) and rebuild each game module: `go build ./...` (+ the wasm build the publish pipeline uses). +- [ ] **Step 4: Bump platform kit pin** if applicable and run `go build ./... && go test ./...` in shellcade. +- [ ] **Step 5: Commit** the go.mod bumps in games (one commit) and shellcade. + +### Task 18: Full verification sweep + +- [ ] **Step 1:** `cd kit/.worktrees/scorekeeper && go test ./...` → PASS. +- [ ] **Step 2:** For every touched game module: `go test ./... && go build ./...` (Rust: `cargo test && cargo build --release`) → PASS. +- [ ] **Step 3:** `cd shellcade/.worktrees/leaderboard-conformance && go test ./internal/sdk/... ./internal/gameabi/...` → PASS (both conformance checks green). +- [ ] **Step 4:** Live smoke (smoke skill): boot `serve --dev`, play one continuous game (e.g. voidrunners) and one round game (e.g. putt), disconnect mid-game, verify a score row appears on the lobby leaderboard. +- [ ] **Step 5:** Final commit / open PRs per repo (kit, games, shellcade; meltdown separately). + +--- + +## Self-review notes + +- **Spec coverage:** C1 helper → Tasks 1–4; C2 per-game → Tasks 5–14; C3 putt extrapolation → Task 9; C4 Rust → Task 13; C5 conformance → Tasks 15–16; scope/branch + release → Task 0/14/17. All spec sections mapped. +- **DNF semantics:** higher-better games pass raw partials (Tasks 5–8,14); cumulative wins pass 1/0 (Tasks 10,13); lower-better putt par-extrapolates (Task 9). Consistent with the spec table. +- **Type consistency:** `ScoreKeeper`, `NewScoreKeeper(Cadence)`, `OnImprove`/`OnChange`, `Record(r,p,metric)`, `FlushLeave(r,p,status)`, `FlushAll(r,status)`, `PersistBest`, `PersistWallet` used identically across Phase A definitions and Phase B call sites. +- **Open dependency:** the kittest fake-room accessor names (`Posts()`, `ResetPosts()`, `KVOf`) are placeholders — Task 1 Step 1 matches them to the real `kittest` API before writing tests. From 31dd05ecd62d73cf9da1e89bf6f0bd542ea8a9b0 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 20:29:54 +1000 Subject: [PATCH 03/15] feat(voidrunners): post kills to leaderboard live + on disconnect Co-Authored-By: Claude Opus 4.8 --- games/matt/voidrunners/room.go | 12 +++ .../voidrunners_leaderboard_test.go | 81 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 games/matt/voidrunners/voidrunners_leaderboard_test.go 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) + } +} From eaa14365a4bf080a05208254d3d5f8d60e7ec926 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 20:34:24 +1000 Subject: [PATCH 04/15] feat(neon-snake): flush score on disconnect Add an OnLeave handler that posts the leaving player's current score with StatusDNF and persists their personal best to KV. Adopt kit.ScoreKeeper (OnImprove cadence) to track each player's current score, fed when a snake eats. Handles both the solo/co-op (single seat controls both snakes) and head-to-head (members[0]=snake1, members[1]=snake2) shapes. Co-Authored-By: Claude Opus 4.8 --- games/luke/neon-snake/main.go | 46 ++++++++++ .../neon-snake/neon_snake_leaderboard_test.go | 91 +++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 games/luke/neon-snake/neon_snake_leaderboard_test.go 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) + } +} From 3e114bb4a335386184aa202d010e4a4c43311ddd Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 20:36:19 +1000 Subject: [PATCH 05/15] feat(spaceterm): flush run score on crew disconnect Co-Authored-By: Claude Opus 4.8 --- games/bcook/spaceterm/room.go | 28 ++++++++ .../spaceterm/spaceterm_leaderboard_test.go | 64 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 games/bcook/spaceterm/spaceterm_leaderboard_test.go 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)) + } +} From f09da937ce410487101b6a26be7661518466f02a Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 20:38:27 +1000 Subject: [PATCH 06/15] feat(boneyard): bank current depth on disconnect Co-Authored-By: Claude Opus 4.8 --- .../boneyard/boneyard_leaderboard_test.go | 84 +++++++++++++++++++ games/bcook/boneyard/game.go | 13 ++- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 games/bcook/boneyard/boneyard_leaderboard_test.go 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 } } From d96ce2eb319a768a57cc114028fff1a2ed8a71d3 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 20:41:08 +1000 Subject: [PATCH 07/15] feat(putt): record par-extrapolated DNF on disconnect Co-Authored-By: Claude Opus 4.8 --- games/bcook/putt/putt_leaderboard_test.go | 87 +++++++++++++++++++++++ games/bcook/putt/room.go | 22 ++++++ 2 files changed, 109 insertions(+) create mode 100644 games/bcook/putt/putt_leaderboard_test.go 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 { From 9148030d7d60bd4a934c14be828259c5d893010b Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 20:43:52 +1000 Subject: [PATCH 08/15] feat(chess): declare wins leaderboard + post win counts Co-Authored-By: Claude Opus 4.8 --- games/alan/chess/chess_leaderboard_test.go | 141 +++++++++++++++++++++ games/alan/chess/game.go | 10 ++ games/alan/chess/room.go | 5 +- 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 games/alan/chess/chess_leaderboard_test.go 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 From 1fe82e0e24d5301775e1a17ded80acc28fb75a5b Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 20:49:38 +1000 Subject: [PATCH 09/15] feat(roulette): periodic peak flush for abandoned tables Co-Authored-By: Claude Opus 4.8 --- games/alan/roulette/room.go | 32 +++++++++- .../roulette/roulette_leaderboard_test.go | 60 +++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 games/alan/roulette/roulette_leaderboard_test.go 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) + } +} From 9b50bad2fa16c7858e1eec787a864ab0d56df0b6 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 20:53:03 +1000 Subject: [PATCH 10/15] feat(shellracer): settle race when the last racer disconnects Co-Authored-By: Claude Opus 4.8 --- games/bcook/shellracer/shellracer/room.go | 20 ++++++ .../shellracer/shellracer_leaderboard_test.go | 64 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 games/bcook/shellracer/shellracer/shellracer_leaderboard_test.go 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)") + } +} From 253f478f6140eebdbe52db1cc21bc46a9f7d4f83 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 20:56:52 +1000 Subject: [PATCH 11/15] feat(tic-tac-toe): declare wins leaderboard + post win counts Co-Authored-By: Claude Opus 4.8 --- games/bcook/tic-tac-toe-rs/src/game.rs | 111 +++++++++++++----- games/bcook/tic-tac-toe-rs/src/lib.rs | 11 ++ .../bcook/tic-tac-toe-rs/tests/leaderboard.rs | 20 ++++ 3 files changed, 111 insertions(+), 31 deletions(-) create mode 100644 games/bcook/tic-tac-toe-rs/tests/leaderboard.rs 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); +} From 5a1ab6ac0ef550337381c8e40aa4d25714947108 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 21:40:29 +1000 Subject: [PATCH 12/15] Remove internal design/plan docs from public games repo These reference shellcade private internals (store/postgres, conformance harness, seat-grace) and violate the games-repo hard rule against shellcade-internal material. They move to the private platform repo. Co-Authored-By: Claude Opus 4.8 --- ...2026-06-15-leaderboard-coverage-dc-save.md | 675 ------------------ ...-15-leaderboard-coverage-dc-save-design.md | 181 ----- 2 files changed, 856 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-15-leaderboard-coverage-dc-save.md delete mode 100644 docs/superpowers/specs/2026-06-15-leaderboard-coverage-dc-save-design.md diff --git a/docs/superpowers/plans/2026-06-15-leaderboard-coverage-dc-save.md b/docs/superpowers/plans/2026-06-15-leaderboard-coverage-dc-save.md deleted file mode 100644 index a8c6ec2..0000000 --- a/docs/superpowers/plans/2026-06-15-leaderboard-coverage-dc-save.md +++ /dev/null @@ -1,675 +0,0 @@ -# Leaderboard Coverage + DC/Continuous-Save Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Every Shellcade game records to the leaderboard, scores survive a mid-game disconnect, and continuous games flush periodically — built on a shared `kit.ScoreKeeper` helper plus a conformance guardrail. - -**Architecture:** Add a small, timer-free `ScoreKeeper` to the `kit` SDK that standardises live posting, disconnect flush, and periodic flush over the existing `Room.Post` + per-account KV surface (no wire/ABI change). Adopt it in the 8 gap games + 2 hardening games; declare a `LeaderboardSpec` for the two unranked games (chess Go, tic-tac-toe Rust). Add static + behavioral conformance checks in the platform so future games can't reship the bug. - -**Tech Stack:** Go (kit SDK + most games, TinyGo→wasm), Rust (tic-tac-toe-rs), PostgreSQL-backed leaderboard reader (platform). Repos: `/Users/bcook/dev/shellcade/{kit,games,shellcade}`. - -**Spec:** `docs/superpowers/specs/2026-06-15-leaderboard-coverage-dc-save-design.md` - ---- - -## File structure - -**kit** (worktree, new branch `scorekeeper` off `kit` main): -- Create: `kit/internal/game/scorekeeper.go` — the helper (one responsibility: track + post metrics). -- Create: `kit/internal/game/scorekeeper_test.go` — unit tests using the `kittest` double. -- Modify: `kit/kit.go` — re-export `ScoreKeeper`, `NewScoreKeeper`, `Cadence`, `OnImprove`, `OnChange`. -- Modify: `kit/CHANGELOG.md` — minor version entry. - -**games** (worktree `leaderboard-coverage` off `origin/main`; meltdown on `bcook/meltdown`): -- Modify each gap game's `room.go`/`main.go` + add a `*_leaderboard_test.go` per game. - -**shellcade** (worktree, new branch `leaderboard-conformance` off `shellcade` main): -- Create: `shellcade/internal/sdk/leaderboard_conformance_test.go` — static "spec declared" check. -- Modify: `shellcade/internal/gameabi/conformance/conformance.go` (+ test) — behavioral "posts on leave" verdict. - ---- - -## Phase 0 — Workspace setup - -### Task 0: Local go.work so games resolve the local kit - -**Files:** -- Create: `/Users/bcook/dev/shellcade/go.work` (uncommitted; lives above all repos) - -- [ ] **Step 1: Create the kit worktree** - -```bash -cd /Users/bcook/dev/shellcade/kit -git worktree add -b scorekeeper .worktrees/scorekeeper main -git -C .worktrees/scorekeeper rev-parse --short HEAD -``` - -- [ ] **Step 2: Create a go.work tying the kit worktree to the game modules** - -```bash -cd /Users/bcook/dev/shellcade -go work init -go work use ./kit/.worktrees/scorekeeper -# add each game module as it is touched, e.g.: -go work use ./games/.worktrees/leaderboard/games/matt/voidrunners -``` - -Expected: `go.work` lists the kit worktree + game modules. This makes `go test`/`go build` resolve `github.com/shellcade/kit/v2` from the local worktree without editing any committed `go.mod`. (The committed version bump is the release step, Task 17.) - -- [ ] **Step 3: Confirm it resolves** - -Run: `cd /Users/bcook/dev/shellcade && go list -m github.com/shellcade/kit/v2` -Expected: points at the local `kit/.worktrees/scorekeeper` path. - -> Note: `go.work` is intentionally NOT committed to any repo. Add `go.work*` to your local ignore if needed. - ---- - -## Phase A — kit ScoreKeeper helper - -### Task 1: ScoreKeeper core + Record cadence - -**Files:** -- Create: `kit/.worktrees/scorekeeper/internal/game/scorekeeper.go` -- Test: `kit/.worktrees/scorekeeper/internal/game/scorekeeper_test.go` - -- [ ] **Step 1: Read the kit test double to learn the fake Room API** - -Run: `sed -n '1,120p' kit/.worktrees/scorekeeper/kittest/*.go` and inspect how existing tests construct a fake `Room`, capture `Post` calls, and build a `Player`. Mirror that exact construction in the test below (the names in the test stub here are placeholders to be matched to `kittest`). - -- [ ] **Step 2: Write the failing test (Record with OnImprove only posts on a new high)** - -```go -package game - -import "testing" - -func TestScoreKeeperRecordOnImprovePostsOnlyOnNewHigh(t *testing.T) { - r := newFakeRoom(t) // from kittest — match its real constructor - p := fakePlayer("acct-1") // from kittest - sk := NewScoreKeeper(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 - - posts := r.Posts() // captured Result slice - if len(posts) != 2 { - t.Fatalf("want 2 posts, got %d", len(posts)) - } - if posts[0].Rankings[0].Metric != 10 || posts[1].Rankings[0].Metric != 12 { - t.Fatalf("unexpected metrics: %+v", posts) - } - if posts[1].Rankings[0].Status != StatusFinished { - t.Fatalf("live posts should be StatusFinished") - } -} -``` - -- [ ] **Step 3: Run test to verify it fails** - -Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run TestScoreKeeperRecord -v` -Expected: FAIL (undefined `NewScoreKeeper`/`ScoreKeeper`). - -- [ ] **Step 4: Write minimal implementation** - -```go -package game - -import ( - "context" - "sort" - "strconv" - "sync" -) - -// Cadence controls when Record auto-posts a player's metric. -type Cadence int - -const ( - // OnImprove posts only when the new metric beats the last posted value — - // for monotonic high-water boards (peak credits, best survival, kills). - OnImprove Cadence = iota - // OnChange posts whenever the metric changes. - OnChange -) - -// ScoreKeeper tracks each player's current leaderboard metric and standardises -// posting it live (Record), on disconnect (FlushLeave), and — for continuous -// 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/replay. -type ScoreKeeper struct { - mu sync.Mutex - cadence Cadence - cur map[string]int64 - posted map[string]int64 - 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]int64{}, - posted: map[string]int64{}, - players: map[string]Player{}, - } -} - -// Record updates the player's current metric and posts it per the cadence. -func (sk *ScoreKeeper) Record(r Room, p Player, metric int64) { - 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}}}) - } -} -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run TestScoreKeeperRecord -v` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -cd kit/.worktrees/scorekeeper -git add internal/game/scorekeeper.go internal/game/scorekeeper_test.go -git commit -m "feat(scorekeeper): core type + Record cadence" -``` - -### Task 2: FlushLeave + FlushAll - -**Files:** -- Modify: `kit/.worktrees/scorekeeper/internal/game/scorekeeper.go` -- Test: `kit/.worktrees/scorekeeper/internal/game/scorekeeper_test.go` - -- [ ] **Step 1: Write failing tests** - -```go -func TestScoreKeeperFlushLeavePostsDNF(t *testing.T) { - r := newFakeRoom(t) - p := fakePlayer("acct-1") - sk := NewScoreKeeper(OnImprove) - sk.Record(r, p, 7) // posts 7 (Finished) - sk.FlushLeave(r, p, StatusDNF) - - posts := r.Posts() - last := posts[len(posts)-1].Rankings[0] - if last.Metric != 7 || last.Status != StatusDNF { - t.Fatalf("want metric=7 DNF, got %+v", last) - } - // Leaving again is a no-op (player untracked). - before := len(r.Posts()) - sk.FlushLeave(r, p, StatusDNF) - if len(r.Posts()) != before { - t.Fatalf("flush after leave should be a no-op") - } -} - -func TestScoreKeeperFlushAllPostsAllSortedDeterministic(t *testing.T) { - r := newFakeRoom(t) - a, b := fakePlayer("acct-b"), fakePlayer("acct-a") - sk := NewScoreKeeper(OnChange) - sk.Record(r, a, 1) - sk.Record(r, b, 2) - r.ResetPosts() // ignore live posts; assert FlushAll only - sk.FlushAll(r, StatusDNF) - - posts := r.Posts() - if len(posts) != 2 { - t.Fatalf("want 2 posts, got %d", len(posts)) - } - // Deterministic order: sorted by AccountID -> acct-a then acct-b. - if posts[0].Rankings[0].Player.AccountID != "acct-a" || - posts[1].Rankings[0].Player.AccountID != "acct-b" { - t.Fatalf("FlushAll must post in AccountID order, got %+v", posts) - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run 'TestScoreKeeperFlush' -v` -Expected: FAIL (undefined `FlushLeave`/`FlushAll`). - -- [ ] **Step 3: Implement** - -```go -// FlushLeave posts the player's current tracked metric with the given status -// (normally StatusDNF) and stops tracking them. Call from OnLeave. -// -// IMPORTANT: the platform ranks DNF rows the same as finished ones. For a -// lower-is-better board, pass a fair full-run metric (e.g. par-extrapolated), -// not 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 -// an interval so an abandoned world still records progress. -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 int64 - } - 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}}}) - } -} -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run 'TestScoreKeeperFlush' -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/game/scorekeeper.go internal/game/scorekeeper_test.go -git commit -m "feat(scorekeeper): FlushLeave + deterministic FlushAll" -``` - -### Task 3: KV persist sugar - -**Files:** -- Modify: `kit/.worktrees/scorekeeper/internal/game/scorekeeper.go` -- Test: `kit/.worktrees/scorekeeper/internal/game/scorekeeper_test.go` - -- [ ] **Step 1: Write failing test (PersistBest writes MergeMax int)** - -```go -func TestScoreKeeperPersistBestWritesMergeMax(t *testing.T) { - r := newFakeRoom(t) - p := fakePlayer("acct-1") - sk := NewScoreKeeper(OnImprove) - sk.PersistBest(r, p, "best", 42) - - got, ok := r.KVOf("acct-1", "best") // kittest accessor for stored KV + rule - if !ok || string(got.Value) != "42" || got.Rule != MergeMax { - t.Fatalf("want best=42 MergeMax, got %+v ok=%v", got, ok) - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run TestScoreKeeperPersist -v` -Expected: FAIL (undefined `PersistBest`). - -- [ ] **Step 3: Implement** - -```go -// PersistBest writes a monotonic high-water value to the player's per-account KV -// (MergeMax) for session resume. The leaderboard board itself is fed by -// Record/FlushLeave/FlushAll; this only preserves state across reconnects. -func (sk *ScoreKeeper) PersistBest(r Room, p Player, key string, value int64) { - acct := r.Services().Accounts.For(p) - if acct == nil { - return - } - _ = acct.Store().Set(context.Background(), key, []byte(strconv.FormatInt(value, 10)), MergeMax) -} - -// PersistWallet writes a carryable balance (MergeSum) and a high-water peak -// (MergeMax). Replaces the duplicated persistWallet helpers in casino games. -func (sk *ScoreKeeper) PersistWallet(r Room, p Player, balanceKey string, balance int64, peakKey string, peak int64) { - acct := r.Services().Accounts.For(p) - if acct == nil { - return - } - st := acct.Store() - _ = st.Set(context.Background(), balanceKey, []byte(strconv.FormatInt(balance, 10)), MergeSum) - _ = st.Set(context.Background(), peakKey, []byte(strconv.FormatInt(peak, 10)), MergeMax) -} -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `cd kit/.worktrees/scorekeeper && go test ./internal/game/ -run TestScoreKeeperPersist -v` -Expected: PASS. Then run the whole package: `go test ./internal/game/...` — Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/game/scorekeeper.go internal/game/scorekeeper_test.go -git commit -m "feat(scorekeeper): PersistBest/PersistWallet KV sugar" -``` - -### Task 4: Re-export in kit.go + changelog - -**Files:** -- Modify: `kit/.worktrees/scorekeeper/kit.go` -- Modify: `kit/.worktrees/scorekeeper/CHANGELOG.md` - -- [ ] **Step 1: Add re-exports** - -In `kit.go`, alongside the existing `type (...)` aliases (the block that aliases `Result`, `PlayerResult`, `MergeRule`, etc.): - -```go -// ScoreKeeper standardises live/disconnect/periodic leaderboard posting. -type ( - ScoreKeeper = game.ScoreKeeper - Cadence = game.Cadence -) - -const ( - OnImprove = game.OnImprove - OnChange = game.OnChange -) - -// NewScoreKeeper constructs a ScoreKeeper with the given auto-post cadence. -func NewScoreKeeper(c Cadence) *ScoreKeeper { return game.NewScoreKeeper(c) } -``` - -- [ ] **Step 2: Add changelog entry** - -Prepend a new minor version section to `CHANGELOG.md` (bump minor from current 2.10.0 → 2.11.0): - -```markdown -## v2.11.0 - -- Add `ScoreKeeper` helper: standardises live (`Record`), disconnect - (`FlushLeave`), and periodic (`FlushAll`) leaderboard posting plus - `PersistBest`/`PersistWallet` KV sugar. No wire/ABI change. -``` - -- [ ] **Step 3: Build + vet the whole module** - -Run: `cd kit/.worktrees/scorekeeper && go build ./... && go vet ./... && go test ./...` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add kit.go CHANGELOG.md -git commit -m "feat(scorekeeper): re-export public API; changelog v2.11.0" -``` - -> Release note (not a code step): the actual `git tag kit/v2.11.0` + push happens before games/platform pin (Task 17), per the kit-tag-before-pin convention. - ---- - -## Phase B — per-game adoption (games repo) - -> All Phase B tasks are in worktree `games/.worktrees/leaderboard` (branch -> `leaderboard-coverage`) EXCEPT Task 14 (meltdown) which is on `bcook/meltdown`. -> For each game: `go work use ./games/.worktrees/leaderboard/games//` -> first so the local kit resolves. Each task: write a leaderboard test, make it -> pass, build the module, commit. The executor reads the game's current source to -> produce the exact diff — the steps below specify the precise behavior + test. - -### Task 5: voidrunners — Post kills live + on leave - -**Files:** -- Modify: `games/matt/voidrunners/room.go` (+ `main.go` if keeper is held on room struct) -- Test: `games/matt/voidrunners/voidrunners_leaderboard_test.go` - -Audit: declares spec (Kills), tracks `best` in KV, but **never `Post`s**. `OnLeave` exists and persists best to KV. - -- [ ] **Step 1: Write failing test** — drive a ship to a kill, fire `OnLeave`, assert a leaderboard `Post` with the kill count + `StatusDNF` was produced (capture via the game's test harness / fake room). Also assert a kill bumps a live `Post`. -- [ ] **Step 2: Run → FAIL** (`go test ./... -run Leaderboard -v`). -- [ ] **Step 3: Implement** — add `sk *kit.ScoreKeeper` (`NewScoreKeeper(kit.OnImprove)`) to the room; on each kill call `rm.sk.Record(r, p, best)`; in `OnLeave` call `rm.sk.FlushLeave(r, p, kit.StatusDNF)` (keep the existing KV persist or switch to `rm.sk.PersistBest`). -- [ ] **Step 4: Run → PASS**; then `go build ./...`. -- [ ] **Step 5: Commit** — `feat(voidrunners): post kills to leaderboard live + on disconnect`. - -### Task 6: neon-snake — add OnLeave flush - -**Files:** -- Modify: `games/luke/neon-snake/main.go` -- Test: `games/luke/neon-snake/neon_snake_leaderboard_test.go` - -Audit: posts only on crash (`gameOver`); **no `OnLeave`** → mid-game DC loses score. - -- [ ] **Step 1: Write failing test** — start a game, advance to a non-zero score, fire `OnLeave` for a player, assert a `Post` with current score + `StatusDNF`. -- [ ] **Step 2: Run → FAIL**. -- [ ] **Step 3: Implement** — add a `ScoreKeeper(kit.OnImprove)`; call `Record` where the score updates (or just before the existing crash `Post`); add an `OnLeave(r, p)` that calls `FlushLeave(r, p, kit.StatusDNF)` and persists PB (`PersistBest`). -- [ ] **Step 4: Run → PASS**; `go build ./...`. -- [ ] **Step 5: Commit** — `feat(neon-snake): flush score on disconnect`. - -### Task 7: spaceterm — flush run score on OnLeave - -**Files:** -- Modify: `games/bcook/spaceterm/room.go` -- Test: `games/bcook/spaceterm/spaceterm_leaderboard_test.go` - -Audit: co-op; persists best to KV + `Post`s only in `endRun()` (core death). If all crew DC before death, run lost. `OnLeave` removes crew but doesn't flush. - -- [ ] **Step 1: Write failing test** — board a crew member, advance `rm.score`, fire `OnLeave`, assert a `Post` with the shared run score + `StatusDNF` (and KV best updated). -- [ ] **Step 2: Run → FAIL**. -- [ ] **Step 3: Implement** — in `OnLeave`, before removing the crew member, `FlushLeave(r, p, kit.StatusDNF)` with `rm.score` (and `PersistBest` "best"). Keep `endRun()` posting for finishers. Use a keeper seeded via `Record(r, c.player, rm.score)` when score changes, OR flush directly with the current run score (co-op shared metric). -- [ ] **Step 4: Run → PASS**; `go build ./...`. -- [ ] **Step 5: Commit** — `feat(spaceterm): flush run score on crew disconnect`. - -### Task 8: boneyard — flush banked depth on OnLeave - -**Files:** -- Modify: `games/bcook/boneyard/game.go` (`OnLeave`) and/or `bones.go` -- Test: `games/bcook/boneyard/boneyard_leaderboard_test.go` - -Audit: resident roguelike; `Post`s on death + collapse grace-bank. Mid-run DC before death/collapse → current depth not banked. `OnLeave` only sets `d.online=false`. - -- [ ] **Step 1: Write failing test** — seat a delver, descend (raise `banked`/current depth), fire `OnLeave`, assert a `Post` with the delver's banked depth + `StatusDNF`. -- [ ] **Step 2: Run → FAIL**. -- [ ] **Step 3: Implement** — in `OnLeave`, when a delver has progress, `Post` their banked depth with `StatusDNF` (reuse the existing death-post path with DNF status) in addition to marking `online=false`. Keep the run in-world for rejoin. -- [ ] **Step 4: Run → PASS**; `go build ./...`. -- [ ] **Step 5: Commit** — `feat(boneyard): bank current depth on disconnect`. - -### Task 9: putt — par-extrapolated DNF on OnLeave - -**Files:** -- Modify: `games/bcook/putt/room.go` (`OnLeave`) -- Test: `games/bcook/putt/putt_leaderboard_test.go` - -Audit: `LowerBetter` (Strokes); only `End()` after hole 9; **`OnLeave` deletes golfer, no save**. Lower-better + unfiltered DNF means a raw partial would top the board → must par-extrapolate. - -- [ ] **Step 1: Write failing test** — golfer plays N of 9 holes with K strokes, disconnects; assert `Post` metric == `K + par*(9-N)` and `StatusDNF`. Include a guard test: a golfer who has played 0 holes posts the full par-round estimate (not 0). -- [ ] **Step 2: Run → FAIL**. -- [ ] **Step 3: Implement** — in `OnLeave`, before deleting the golfer, compute `est = strokesSoFar + parTotalOfUnplayedHoles` and `r.Post(kit.Result{Rankings: []kit.PlayerResult{{Player: p, Metric: est, Status: kit.StatusDNF}}})`. Derive per-hole par from the existing course definition. -- [ ] **Step 4: Run → PASS**; `go build ./...`. -- [ ] **Step 5: Commit** — `feat(putt): record par-extrapolated DNF on disconnect`. - -### Task 10: chess — declare spec + post wins - -**Files:** -- Modify: `games/alan/chess/game.go` (`Meta()`), `games/alan/chess/room.go` (settle/forfeit paths) -- Test: `games/alan/chess/chess_leaderboard_test.go` - -Audit: **no `LeaderboardSpec`**; `End()` only, winner's win never reaches a board. - -- [ ] **Step 1: Write failing test** — assert `Meta().Leaderboard` is non-nil with `MetricLabel:"Wins"`, `Direction:HigherBetter`, `Aggregation:CumulativeSum`, `Format:Integer`. Then play a game to checkmate (and separately a forfeit-by-leave) and assert the winner gets a result with `Metric:1` and the loser `Metric:0` / `StatusDNF`. -- [ ] **Step 2: Run → FAIL**. -- [ ] **Step 3: Implement** — add the `Leaderboard` spec to `Meta()`; in `finishGame`/settle and the `OnLeave` forfeit path, set winner `Metric:1` and loser `Metric:0`. (`End(Result)` already carries rankings; just ensure metrics encode wins.) -- [ ] **Step 4: Run → PASS**; `go build ./...`. -- [ ] **Step 5: Commit** — `feat(chess): declare wins leaderboard + post win counts`. - -### Task 11: roulette — periodic FlushAll (hardening) - -**Files:** -- Modify: `games/alan/roulette/room.go` (`OnWake`) -- Test: `games/alan/roulette/roulette_leaderboard_test.go` - -Audit: continuous; posts peak-on-increase + persists wallet on leave; no periodic flush for a mid-spin abandoned player. - -- [ ] **Step 1: Write failing test** — seat a player, raise peak, simulate the room ticking with the player abandoned mid-spin (no peak change), assert a periodic `Post` of the current peak occurs on the wake interval. -- [ ] **Step 2: Run → FAIL**. -- [ ] **Step 3: Implement** — add a `ScoreKeeper(kit.OnImprove)` fed by the existing `postPeak`; on a throttled `OnWake` interval (e.g. every N seconds of game time) call `sk.FlushAll(r, kit.StatusFinished)`. Keep wallet persistence as-is. -- [ ] **Step 4: Run → PASS**; `go build ./...`. -- [ ] **Step 5: Commit** — `feat(roulette): periodic peak flush for abandoned tables`. - -### Task 12: shellracer — End on last-leave (hardening) - -**Files:** -- Modify: `games/bcook/shellracer/shellracer/room.go` (`OnLeave`) -- Test: `games/bcook/shellracer/shellracer/shellracer_leaderboard_test.go` - -Audit: round-based; `OnLeave` snaps DNF WPM and calls `enterResults` only if `allDone`. If everyone leaves mid-race, no `End`, nothing posts. - -- [ ] **Step 1: Write failing test** — two racers racing; both leave; assert the room reaches `End` with both as `StatusDNF` (their snapped WPM), i.e. results are flushed when the last racer leaves. -- [ ] **Step 2: Run → FAIL**. -- [ ] **Step 3: Implement** — in `OnLeave`, after marking DNF, if `phRacing` and no racers remain (roster empty of players), call `enterResults(r)` (which leads to `End`). Guard against double-end. -- [ ] **Step 4: Run → PASS**; `go build ./...`. -- [ ] **Step 5: Commit** — `feat(shellracer): settle race when the last racer disconnects`. - -### Task 13: tic-tac-toe-rs — declare spec + post wins (Rust) - -**Files:** -- Modify: `games/bcook/tic-tac-toe-rs/src/lib.rs` (`Meta`), `games/bcook/tic-tac-toe-rs/src/game.rs` (settle paths) -- Test: `games/bcook/tic-tac-toe-rs/src/game.rs` (`#[cfg(test)]`) or `tests/` - -Audit: **no `Leaderboard`**; `end()` carries metrics already; `on_leave` settles forfeit with `Status::Dnf`. - -- [ ] **Step 1: Write failing test** — assert `Meta` declares `Leaderboard { label:"Wins", direction:HigherBetter, aggregation:CumulativeSum, format:Integer }`; play to a win and assert the winner's `PlayerResult.metric == 1`, loser `0`. -- [ ] **Step 2: Run → FAIL** (`cargo test`). -- [ ] **Step 3: Implement** — add the `Leaderboard` to `Meta`; ensure `settle_win`/`settle_forfeit`/`settle_draw` set winner metric `1`, others `0`. -- [ ] **Step 4: Run → PASS**; `cargo build --release` (and the wasm target the game ships, e.g. `cargo build --target wasm32-unknown-unknown --release`). -- [ ] **Step 5: Commit** — `feat(tic-tac-toe): declare wins leaderboard + post win counts`. - -### Task 14: meltdown — Post survival live + on leave (branch bcook/meltdown) - -**Files:** -- Modify: `games/bcook/meltdown/room.go` (in worktree `games/.claude/worktrees/agent-a235e4c859c78fe48`, branch `bcook/meltdown`) -- Test: `games/bcook/meltdown/meltdown_leaderboard_test.go` - -Audit: continuous co-op; declares spec (Survival) but **never `Post`s** (KV only, in `OnClose`); no DC flush. - -- [ ] **Step 1:** `go work use` the meltdown module path. Write failing test — board crew, advance survival seconds, fire `OnLeave`, assert a `Post` of survival seconds + `StatusDNF`; and that a periodic `OnWake` flush posts while crew are present. -- [ ] **Step 2: Run → FAIL**. -- [ ] **Step 3: Implement** — add `ScoreKeeper(kit.OnImprove)`; `Record(r, p, survivedSeconds)` for each crew on score change; periodic `FlushAll(r, kit.StatusFinished)` on a throttled `OnWake`; `FlushLeave(r, p, kit.StatusDNF)` in `OnLeave`; keep `recordResults` in `OnClose`. -- [ ] **Step 4: Run → PASS**; `go build ./...`. -- [ ] **Step 5: Commit on `bcook/meltdown`** — `feat(meltdown): post survival live + on disconnect`. - ---- - -## Phase C — platform conformance (shellcade repo) - -> Worktree: `shellcade/.worktrees/leaderboard-conformance` (branch off `shellcade` main). -> Create it: `cd /Users/bcook/dev/shellcade/shellcade && git worktree add -b leaderboard-conformance .worktrees/leaderboard-conformance main`. - -### Task 15: Static check — every listed game declares a spec - -**Files:** -- Create: `shellcade/.worktrees/leaderboard-conformance/internal/sdk/leaderboard_conformance_test.go` - -- [ ] **Step 1: Write failing test** - -```go -package sdk_test - -import ( - "testing" - // import the registry constructor used by other sdk tests -) - -func TestAllListedGamesDeclareLeaderboard(t *testing.T) { - reg := testRegistry(t) // match the helper other sdk tests use - for _, g := range reg.Listed() { // Listed() excludes Hidden - if g.Meta().Leaderboard == nil { - t.Errorf("game %q must declare Meta().Leaderboard", g.Meta().Slug) - } - } -} -``` - -- [ ] **Step 2: Run → FAIL** for any game still missing a spec (confirms the check works), `go test ./internal/sdk/ -run TestAllListedGamesDeclareLeaderboard -v`. -- [ ] **Step 3: Implement** — no production code; this check passes once Phase B games declare specs. If the registry under test pins old game wasm, note the dependency: it goes green after the games are rebuilt/pinned (Task 17). -- [ ] **Step 4: Run → PASS** (after games updated). -- [ ] **Step 5: Commit** — `test(leaderboard): assert all listed games declare a spec`. - -### Task 16: Behavioral verdict — OnLeave during play posts a result - -**Files:** -- Modify: `shellcade/.worktrees/leaderboard-conformance/internal/gameabi/conformance/conformance.go` -- Test: `shellcade/.worktrees/leaderboard-conformance/internal/gameabi/conformance/conformance_test.go` - -- [ ] **Step 1: Read** `conformance.go` to learn how it loads a game, drives Join/Wake/Input, and records verdicts. -- [ ] **Step 2: Write failing test** — load a game, Join two players, drive into active play, fire a Leave for one, assert the conformance run records a `Post` (leaderboard result) within the seat-grace/Leave path. Add the verdict to `Report.Verdicts`. -- [ ] **Step 3: Run → FAIL**. -- [ ] **Step 4: Implement** the verdict: capture `Post` calls during the Leave step; verdict passes if ≥1 result was posted for a game whose `Meta().Leaderboard != nil` and that was in active play. (Round-based 2-player games that settle via `End` count as posting.) -- [ ] **Step 5: Run → PASS**; commit — `feat(conformance): verify games post on player leave`. - ---- - -## Phase D — release + integration - -### Task 17: Tag kit, pin from games + platform, full build - -**Files:** -- Modify: each touched game's `go.mod` (`games///go.mod`) — `require github.com/shellcade/kit/v2 v2.11.0` -- Modify: `shellcade` go.mod pin if it references kit directly - -- [ ] **Step 1: Tag + push kit** - -```bash -cd /Users/bcook/dev/shellcade/kit/.worktrees/scorekeeper -# merge/PR scorekeeper to kit main per project flow, then: -git tag kit/v2.11.0 && git push origin kit/v2.11.0 -``` - -- [ ] **Step 2: Bump each touched game module** - -For each game touched in Phase B: -```bash -cd games/.worktrees/leaderboard/games// -go get github.com/shellcade/kit/v2@v2.11.0 -go mod tidy -``` - -- [ ] **Step 3: Remove the local go.work** (so builds use the pinned version) and rebuild each game module: `go build ./...` (+ the wasm build the publish pipeline uses). -- [ ] **Step 4: Bump platform kit pin** if applicable and run `go build ./... && go test ./...` in shellcade. -- [ ] **Step 5: Commit** the go.mod bumps in games (one commit) and shellcade. - -### Task 18: Full verification sweep - -- [ ] **Step 1:** `cd kit/.worktrees/scorekeeper && go test ./...` → PASS. -- [ ] **Step 2:** For every touched game module: `go test ./... && go build ./...` (Rust: `cargo test && cargo build --release`) → PASS. -- [ ] **Step 3:** `cd shellcade/.worktrees/leaderboard-conformance && go test ./internal/sdk/... ./internal/gameabi/...` → PASS (both conformance checks green). -- [ ] **Step 4:** Live smoke (smoke skill): boot `serve --dev`, play one continuous game (e.g. voidrunners) and one round game (e.g. putt), disconnect mid-game, verify a score row appears on the lobby leaderboard. -- [ ] **Step 5:** Final commit / open PRs per repo (kit, games, shellcade; meltdown separately). - ---- - -## Self-review notes - -- **Spec coverage:** C1 helper → Tasks 1–4; C2 per-game → Tasks 5–14; C3 putt extrapolation → Task 9; C4 Rust → Task 13; C5 conformance → Tasks 15–16; scope/branch + release → Task 0/14/17. All spec sections mapped. -- **DNF semantics:** higher-better games pass raw partials (Tasks 5–8,14); cumulative wins pass 1/0 (Tasks 10,13); lower-better putt par-extrapolates (Task 9). Consistent with the spec table. -- **Type consistency:** `ScoreKeeper`, `NewScoreKeeper(Cadence)`, `OnImprove`/`OnChange`, `Record(r,p,metric)`, `FlushLeave(r,p,status)`, `FlushAll(r,status)`, `PersistBest`, `PersistWallet` used identically across Phase A definitions and Phase B call sites. -- **Open dependency:** the kittest fake-room accessor names (`Posts()`, `ResetPosts()`, `KVOf`) are placeholders — Task 1 Step 1 matches them to the real `kittest` API before writing tests. diff --git a/docs/superpowers/specs/2026-06-15-leaderboard-coverage-dc-save-design.md b/docs/superpowers/specs/2026-06-15-leaderboard-coverage-dc-save-design.md deleted file mode 100644 index 447e3c4..0000000 --- a/docs/superpowers/specs/2026-06-15-leaderboard-coverage-dc-save-design.md +++ /dev/null @@ -1,181 +0,0 @@ -# Leaderboard coverage + durable disconnect/continuous save - -**Date:** 2026-06-15 -**Status:** Approved design, pending implementation plan -**Repos touched:** `kit` (helper + version bump), `games` (per-game adoption), `shellcade` (conformance guardrail + kit pin) - -## Goal - -Every game records to the leaderboard; player scores survive a mid-game -disconnect; and continuous ("never-ending") games flush periodically so an -abandoned world still records progress. Fix the recurring root cause — each game -hand-rolls its own `OnLeave` + `Post` + KV logic and gets it subtly wrong — with -a shared kit helper plus a conformance guardrail that stops future games -reshipping the bug. - -## Background / current state - -Leaderboards are already full platform infrastructure, **not** greenfield: - -- Games call `Room.Post(Result)` (publish a result anytime) and - `Room.End(Result)` (settle + close). The platform persists results durably - into the `leaderboard_results` table — idempotent by round id, retried ~30s — - aggregates per a per-game `LeaderboardSpec`, and renders them in the lobby - (all-time / weekly / daily windows). -- A game declares its board via `Meta().Leaderboard` (`LeaderboardSpec`: - `MetricLabel`, `Direction`, `Aggregation`, `Format`). Default if unset: best - single result, higher-is-better, integer, label "Score". -- Disconnect handling exists: a 120s **seat grace** holds a departed seat; on - expiry the game's `OnLeave(r, p)` fires. `End()` settlement auto-backfills DNF - for joined players the game omitted. - -### Hard constraints discovered in code (these shape the design) - -1. **Boards rank `Post()`/`End()` results only.** Per-account KV - (`MergeMax`/`MergeSum`) is for *session resume*, not the board. A game that - only writes its "best" to KV never appears on the leaderboard unless it also - `Post`s (or registers a custom `LeaderboardProvider`, which the catalog - casino games do **not**). - - `shellcade/internal/store/postgres/leaderboard.go` -2. **The Reader does NOT filter by `status`.** A `StatusDNF` row's metric is - ranked exactly like a finished one (verified across BestResult + CumulativeSum - and all-time/weekly/daily). Therefore a *partial* score posted as DNF still - counts — fine for higher-is-better boards (max keeps the best), but - **dangerous for lower-is-better** boards (a half-played round would top it). -3. **No wire/ABI change required.** The helper is pure SDK sugar over the - existing `Post` + KV surface, so there is no wire-revision bump and no - `kit/rust` wire sync obligation. - -## Audit summary (the "audit first" deliverable) - -18 games audited (16 on `origin/main` + 2 in worktrees). - -### Sound — no change (8) - -`bytebreaker`, `salvo`, `paperdrift`, `blackjack`, `floorfall`, `pokies`, -`scratchies`, `stacked`. Each declares a spec, Posts live (per-event / per-peak / -per-elimination), and flushes on `OnLeave`. - -### Gaps to fix (8) + hardening (2) - -| Game | Type | Problem | Fix | -|---|---|---|---| -| voidrunners | continuous | tracks kills in KV but **never `Post`s** — nothing reaches the board | adopt ScoreKeeper: Record on kill, FlushLeave on leave | -| chess | round | **no `LeaderboardSpec`**; winner's win never posted | declare spec (Wins, cumulative); Post +1 to winner on settle/forfeit | -| tic-tac-toe-rs | round (Rust) | **no `LeaderboardSpec`**; `End()` metric ignored | declare spec (Wins, cumulative); post win count | -| meltdown | continuous (worktree) | **never `Post`s** (KV only, on `OnClose`); no DC flush | adopt ScoreKeeper: periodic FlushAll + FlushLeave | -| neon-snake | continuous | **no `OnLeave`**; mid-game DC loses score (Posts only on crash) | add `OnLeave` → FlushLeave (DNF) | -| putt | round | **no KV / no `OnLeave` save**; mid-game DC loses everything | `OnLeave` → Post par-extrapolated total, DNF | -| spaceterm | continuous co-op | `OnLeave` doesn't flush; all-crew DC before core death loses run | FlushLeave current run score on `OnLeave` | -| boneyard | continuous | mid-run DC before death/collapse loses current depth | FlushLeave current banked depth on `OnLeave` | -| roulette | continuous | Posts peak-on-increase + wallet-on-leave; no periodic mid-spin flush | (harden) periodic FlushAll | -| shellracer | round | DNF reaches board only via `End()`; if all leave, no `End` | (harden) `End` on last-leave | - -## Design - -### Component 1 — `kit.ScoreKeeper` (Go) - -New file `kit/internal/game/scorekeeper.go`, re-exported as `kit.ScoreKeeper` via -a type alias in `kit/kit.go` (the public facade where `Room`, `Result`, -`PlayerResult`, `MergeRule`, `LeaderboardSpec`, etc. already alias from -`internal/game`). - -Responsibilities (all over existing `Room.Post` + KV): - -- `Record(p Player, metric int64)` — track the player's current metric and - `Post` it per a cadence policy (on-change, or on-improve for monotonic - boards). Replaces ad-hoc "post when peak increased" blocks. -- `FlushLeave(r Room, p Player, status Status)` — `Post` the player's current - tracked metric with the given status (normally `StatusDNF`). This is the - disconnect guarantee; games call it from `OnLeave`. -- `FlushAll(r Room, status Status)` — `Post` every tracked player. Continuous - games call this from `OnWake` on an interval to satisfy "constantly saved". -- Optional KV sugar `PersistBest` (`MergeMax`) / `PersistWallet` - (`MergeSum` + `MergeMax`) for *resume* — folds the duplicated - `persistWallet`/`persistBest` helpers (e.g. `scratchies/kv.go`) into one place. - -Design notes: - -- The keeper is **direction-agnostic**. Computing a *fair* partial metric for a - DNF on a lower-is-better board is the caller's responsibility (see C3), and is - documented on `FlushLeave`. -- Cadence policy is a small enum/option (`OnImprove` default for monotonic - high-water boards; `OnChange` for live scores). Keeps existing sound games' - behavior identical when they adopt it. -- The keeper holds no goroutines/timers; periodic flush is driven by the game's - existing `OnWake` heartbeat so it stays deterministic for hibernation/replay. - -### Component 2 — per-game fixes - -Apply the table above. Sound games optionally migrate to the helper for -consistency but are not required to change behavior. Each gap game gets a unit -test asserting `OnLeave` during active play produces a `Post` (and, for -continuous games, that a periodic `FlushAll` posts without a player present). - -### Component 3 — putt lower-is-better correctness - -Putt's board is `Direction: LowerBetter` (strokes). On disconnect, do **not** -post the raw partial total. Instead post: - -``` -metric = strokesSoFar + par * unplayedHoles // par-fill estimate -status = StatusDNF -``` - -a fair full-round estimate that cannot corrupt the strokes board. This is the -canonical example for the `FlushLeave` doc comment. - -### Component 4 — Rust (tic-tac-toe-rs) - -The Rust kit crate (`kit/rust/`) exposes `Room::post`/`Room::end`, `Leaderboard` -(== `LeaderboardSpec`), and `Status { Finished, Dnf }` (no `Flagged`, no helper -infrastructure). Scope is intentionally minimal — **no Rust ScoreKeeper**: - -- Declare `Leaderboard { label: "Wins", direction: HigherBetter, - aggregation: CumulativeSum, format: Integer }` in `Meta`. -- Ensure the settle paths post a win count (winner `1`, others `0`); the existing - `on_leave` already settles a forfeit with `Status::Dnf`. - -### Component 5 — conformance guardrail (platform) - -In `shellcade`: - -- **Static check:** a test iterating `sdk.Registry.All()` (filtered to non-hidden - via `Listed()`) asserting every game declares `Meta().Leaderboard != nil`. -- **Behavioral check:** a verdict in `internal/gameabi/conformance` — drive a game - into active play, fire `OnLeave`, and assert a `Post` (leaderboard result) is - produced. This catches "declared a spec but never records" regressions. - -## DNF semantics summary - -| Board kind | Examples | On disconnect | -|---|---|---| -| HigherBetter / BestResult | survival, kills, peak, sectors, depth, score | Post partial as-is, DNF — safe (max keeps best) | -| CumulativeSum (wins) | chess, tic-tac-toe | leaver forfeits (no win); opponent posts +1 | -| LowerBetter | putt (strokes) | par-extrapolate to full-round estimate, DNF | - -## Scope & branch strategy - -- **kit:** add `ScoreKeeper` + re-export; minor version bump (no wire change). Tag - the kit release before pinning it from games/platform (per project convention). -- **games:** one branch `leaderboard-coverage` off `origin/main` for the on-main - games. **meltdown** is an unmerged worktree game, so its fix lands on its own - branch `bcook/meltdown` (not folded into the main branch). **stacked** is - already sound — no change. -- **shellcade:** conformance test + bump the pinned kit version. - -## Testing - -- **kit:** unit tests for `ScoreKeeper` — Record cadence, FlushLeave status, - FlushAll with absent players, KV persist sugar. -- **games:** per-game test that `OnLeave` posts during active play; putt test that - the DNF metric is par-extrapolated; a couple of live smokes (smoke harness) for - a continuous and a round game. -- **shellcade:** static + behavioral conformance suite green over the registry. - -## Out of scope - -- ELO / skill rating (chess & tic-tac-toe use win count). -- New leaderboard UI / display changes. -- Any wire or ABI change. -- Migrating already-sound games beyond optional consistency cleanup. From 89ffca1ce83f469085924f6b7a73dfd343dd5019 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 21:43:33 +1000 Subject: [PATCH 13/15] chore: pin voidrunners/neon-snake/roulette to kit v2.11.0 These three adopt kit.ScoreKeeper (shipped in v2.11.0). Bump + tidy so they build against the published kit instead of the local dev workspace. Co-Authored-By: Claude Opus 4.8 --- games/alan/roulette/go.mod | 2 +- games/alan/roulette/go.sum | 4 ++-- games/luke/neon-snake/go.mod | 2 +- games/luke/neon-snake/go.sum | 4 ++-- games/matt/voidrunners/go.mod | 2 +- games/matt/voidrunners/go.sum | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) 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/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/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= From 1ad7d592a7382d05cfd47d23e729aa7c426be416 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 21:51:36 +1000 Subject: [PATCH 14/15] ci: bump shellcade-kit toolchain pin to 2.11.0 Games now adopt kit v2.11.0 (ScoreKeeper). Point validate.yml + publish.yml at the shellcade-kit 2.11.0 release so conformance exercises the kit features these games use. Goes green once the shellcade-kit v2.11.0 binary is published (the shellcade lockstep release). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/publish.yml | 2 +- .github/workflows/validate.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 90b627c..506e974 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.0" # shellcade-kit release (tag v$KIT_VERSION) — ABI v2 + smoke TINYGO_VERSION: "0.41.1" BINARYEN_VERSION: "version_123" GO_VERSION: "1.26" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d75a868..eaf8a41 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.0" # shellcade-kit release (tag v$KIT_VERSION) — ABI v2 + smoke TINYGO_VERSION: "0.41.1" BINARYEN_VERSION: "version_123" GO_VERSION: "1.26" From b6b2e6d9bdcd492ae543ac3a0829a1affefa9e92 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Mon, 15 Jun 2026 23:23:58 +1000 Subject: [PATCH 15/15] ci: kit 2.11.1 + enforce --require-leaderboard Bump the shellcade-kit toolchain pin to 2.11.1 (carries the conformance publishing-gate fix) and pass --require-leaderboard to `check` in validate.yml and publish.yml so every published game must declare a leaderboard. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/publish.yml | 4 ++-- .github/workflows/validate.yml | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 506e974..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.11.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 eaf8a41..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.11.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