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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

# Single source of truth for the toolchain — keep in lockstep with validate.yml.
env:
KIT_VERSION: "2.10.0" # shellcade-kit release (tag v$KIT_VERSION) — ABI v2 + smoke
KIT_VERSION: "2.11.1" # shellcade-kit release (tag v$KIT_VERSION) — ABI v2 + smoke
TINYGO_VERSION: "0.41.1"
BINARYEN_VERSION: "version_123"
GO_VERSION: "1.26"
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
# requires a newer kit than this pinned engine, so a stale pin is caught
# mechanically instead of by this comment.
env:
KIT_VERSION: "2.10.0" # shellcade-kit release (tag v$KIT_VERSION) — ABI v2 + smoke
KIT_VERSION: "2.11.1" # shellcade-kit release (tag v$KIT_VERSION) — ABI v2 + smoke
TINYGO_VERSION: "0.41.1"
BINARYEN_VERSION: "version_123"
GO_VERSION: "1.26"
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
141 changes: 141 additions & 0 deletions games/alan/chess/chess_leaderboard_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 10 additions & 0 deletions games/alan/chess/game.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions games/alan/chess/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion games/alan/roulette/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions games/alan/roulette/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
32 changes: 29 additions & 3 deletions games/alan/roulette/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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)),
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 ----------------------------------------------------------------
Expand Down Expand Up @@ -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)
}

Expand Down
60 changes: 60 additions & 0 deletions games/alan/roulette/roulette_leaderboard_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading