From 0de7a38b614dc2d685b837dbad6d57f71688f395 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Tue, 16 Jun 2026 07:44:07 +1000 Subject: [PATCH 01/10] host: extract canvas + sdk into the kit module Move the two seam-free foundation packages from the private shellcade repo (internal/canvas, internal/sdk) into the public kit module under host/. canvas is a stdlib-only leaf; sdk depends only on canvas plus github.com/google/uuid. Import paths rewritten to github.com/shellcade/kit/v2/host/canvas. No private packages are referenced (go list -deps ./host/... has no shellcade/shellcade). Co-Authored-By: Claude Opus 4.8 --- go.mod | 7 +- go.sum | 3 + host/canvas/ascii.go | 27 ++ host/canvas/ascii_test.go | 43 ++ host/canvas/canvas.go | 123 +++++ host/canvas/color.go | 49 ++ host/sdk/account.go | 52 ++ host/sdk/checkpoint_test.go | 194 ++++++++ host/sdk/config.go | 16 + host/sdk/controls.go | 89 ++++ host/sdk/controls_test.go | 114 +++++ host/sdk/errors.go | 17 + host/sdk/game.go | 161 +++++++ host/sdk/leaderboard.go | 416 ++++++++++++++++ host/sdk/leaderboard_test.go | 264 ++++++++++ host/sdk/lifecycle_test.go | 172 +++++++ host/sdk/registry.go | 108 +++++ host/sdk/registry_test.go | 131 +++++ host/sdk/room.go | 900 +++++++++++++++++++++++++++++++++++ host/sdk/room_test.go | 288 +++++++++++ host/sdk/roomid.go | 30 ++ host/sdk/roomid_test.go | 75 +++ host/sdk/services.go | 40 ++ host/sdk/testroom.go | 239 ++++++++++ host/sdk/types.go | 307 ++++++++++++ 25 files changed, 3861 insertions(+), 4 deletions(-) create mode 100644 host/canvas/ascii.go create mode 100644 host/canvas/ascii_test.go create mode 100644 host/canvas/canvas.go create mode 100644 host/canvas/color.go create mode 100644 host/sdk/account.go create mode 100644 host/sdk/checkpoint_test.go create mode 100644 host/sdk/config.go create mode 100644 host/sdk/controls.go create mode 100644 host/sdk/controls_test.go create mode 100644 host/sdk/errors.go create mode 100644 host/sdk/game.go create mode 100644 host/sdk/leaderboard.go create mode 100644 host/sdk/leaderboard_test.go create mode 100644 host/sdk/lifecycle_test.go create mode 100644 host/sdk/registry.go create mode 100644 host/sdk/registry_test.go create mode 100644 host/sdk/room.go create mode 100644 host/sdk/room_test.go create mode 100644 host/sdk/roomid.go create mode 100644 host/sdk/roomid_test.go create mode 100644 host/sdk/services.go create mode 100644 host/sdk/testroom.go create mode 100644 host/sdk/types.go diff --git a/go.mod b/go.mod index e27bb93..c4207f4 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,9 @@ go 1.25.0 require ( github.com/extism/go-pdk v1.1.3 + github.com/google/uuid v1.6.0 golang.org/x/term v0.43.0 + gopkg.in/yaml.v3 v3.0.1 ) -require ( - golang.org/x/sys v0.44.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) +require golang.org/x/sys v0.44.0 // indirect diff --git a/go.sum b/go.sum index da63828..beca894 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,12 @@ 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/host/canvas/ascii.go b/host/canvas/ascii.go new file mode 100644 index 0000000..aae4a06 --- /dev/null +++ b/host/canvas/ascii.go @@ -0,0 +1,27 @@ +package canvas + +import "strings" + +// GridToASCII renders g as a deterministic, color-independent text block: every +// cell's rune (rune 0 → space), trailing spaces trimmed per row, and the 24 rows +// joined with "\n" (no trailing newline). It is stable across runs for identical +// grid content — color and attribute differences do not affect the output — so it +// is suitable for before/after snapshot artifacts and golden tests. +func GridToASCII(g Grid) string { + var sb strings.Builder + for r := 0; r < Rows; r++ { + var row strings.Builder + for c := 0; c < Cols; c++ { + ch := g.Cells[r][c].Rune + if ch == 0 { + ch = ' ' + } + row.WriteRune(ch) + } + sb.WriteString(strings.TrimRight(row.String(), " ")) + if r < Rows-1 { + sb.WriteByte('\n') + } + } + return sb.String() +} diff --git a/host/canvas/ascii_test.go b/host/canvas/ascii_test.go new file mode 100644 index 0000000..17afc07 --- /dev/null +++ b/host/canvas/ascii_test.go @@ -0,0 +1,43 @@ +package canvas + +import ( + "strings" + "testing" +) + +func TestGridToASCIIIsColorIndependent(t *testing.T) { + a := New() + b := New() + a.Text(3, 5, "hello", Style{FG: Red, Attr: AttrBold}) + b.Text(3, 5, "hello", Style{FG: Green}) // different color/attr, same runes + if GridToASCII(a) != GridToASCII(b) { + t.Fatal("GridToASCII must ignore color/attr differences") + } +} + +func TestGridToASCIIShapeAndTrim(t *testing.T) { + g := New() + g.Text(0, 0, "top", styleNone()) + g.Text(Rows-1, 0, "bottom", styleNone()) + out := GridToASCII(g) + + rows := strings.Split(out, "\n") + if len(rows) != Rows { + t.Fatalf("expected %d rows, got %d", Rows, len(rows)) + } + if rows[0] != "top" { + t.Errorf("row 0: trailing spaces not trimmed: %q", rows[0]) + } + if rows[Rows-1] != "bottom" { + t.Errorf("last row: got %q", rows[Rows-1]) + } + if strings.HasSuffix(out, "\n") { + t.Error("output must not end with a trailing newline") + } + // A blank grid is 23 newlines and nothing else. + if got := GridToASCII(New()); got != strings.Repeat("\n", Rows-1) { + t.Errorf("blank grid: got %q", got) + } +} + +func styleNone() Style { return Style{} } diff --git a/host/canvas/canvas.go b/host/canvas/canvas.go new file mode 100644 index 0000000..e11f9b6 --- /dev/null +++ b/host/canvas/canvas.go @@ -0,0 +1,123 @@ +// Package canvas owns the fixed 80x24 cell grid that the lobby and every game +// render into. The Cell type and the Grid type are defined here and aliased by +// the game SDK; a single shared definition governs every composed frame. +package canvas + +// The fixed drawable canvas. This is a system-wide invariant in v1. +const ( + Cols = 80 + Rows = 24 +) + +// Attr is a bitset of text attributes applied to a cell. +type Attr uint8 + +const ( + AttrBold Attr = 1 << iota + AttrDim + AttrUnderline + AttrReverse +) + +// Cell is a single drawable position: a rune plus foreground/background color +// and attributes. A double-width rune occupies two columns; the trailing column +// is a continuation cell (Cont == true, Rune == 0). +// +// In ABI v2 a cell may carry up to three code points of a grapheme cluster: +// Rune is the base, Cp2/Cp3 the extra code points (0 = unused; e.g. a VS16 +// selector, a skin-tone modifier, a ZWJ piece, or a keycap U+20E3). Renderers +// emit base+Cp2+Cp3 as one contiguous UTF-8 burst. Single-code-point cells +// leave Cp2/Cp3 zero by zero-value, so existing content is unchanged. +type Cell struct { + Rune rune + Cp2 rune // second grapheme code point (0 = unused) + Cp3 rune // third grapheme code point (0 = unused) + FG Color + BG Color + Attr Attr + Cont bool // continuation column of a wide rune to the left +} + +// Style bundles the styling applied when writing text into the grid. +type Style struct { + FG Color + BG Color + Attr Attr +} + +// Grid is the fixed Rows x Cols cell grid. It is intrinsically 80x24, so a game +// can never accidentally exceed the canvas. It is passed by value as a Frame. +type Grid struct { + Cells [Rows][Cols]Cell +} + +// blank is a space with default colors. +func blank() Cell { return Cell{Rune: ' '} } + +// New returns a grid filled with blank cells. +func New() Grid { + var g Grid + for r := 0; r < Rows; r++ { + for c := 0; c < Cols; c++ { + g.Cells[r][c] = blank() + } + } + return g +} + +// inBounds reports whether (row, col) is on the canvas. +func inBounds(row, col int) bool { + return row >= 0 && row < Rows && col >= 0 && col < Cols +} + +// Set writes a single cell, clamping (silently dropping) out-of-bounds writes. +func (g *Grid) Set(row, col int, cell Cell) { + if !inBounds(row, col) { + return + } + g.Cells[row][col] = cell +} + +// SetRune writes one rune with a style. Out-of-bounds is dropped. Width is +// treated as 1 (v1 corpus is ASCII); callers wanting wide-rune handling should +// pre-account for the continuation column. +func (g *Grid) SetRune(row, col int, r rune, st Style) { + g.Set(row, col, Cell{Rune: r, FG: st.FG, BG: st.BG, Attr: st.Attr}) +} + +// Text blits a string starting at (row, col), left to right, clamping any +// portion that would exceed the grid. Returns the column just past the written +// text (may be off-canvas). Tabs/newlines are not interpreted. +func (g *Grid) Text(row, col int, s string, st Style) int { + c := col + for _, r := range s { + if r == '\n' || r == '\t' { + r = ' ' + } + g.SetRune(row, c, r, st) + c++ + } + return c +} + +// TextRight blits a string so that it ends at column end-1 (right-aligned), +// clamping on the left if needed. +func (g *Grid) TextRight(row, end int, s string, st Style) { + g.Text(row, end-len([]rune(s)), s, st) +} + +// Fill sets every cell in the inclusive rectangle to cell (clamped). +func (g *Grid) Fill(r0, c0, r1, c1 int, cell Cell) { + for r := r0; r <= r1; r++ { + for c := c0; c <= c1; c++ { + g.Set(r, c, cell) + } + } +} + +// ClearRow blanks an entire row. +func (g *Grid) ClearRow(row int) { + for c := 0; c < Cols; c++ { + g.Set(row, c, blank()) + } +} diff --git a/host/canvas/color.go b/host/canvas/color.go new file mode 100644 index 0000000..88ac8f7 --- /dev/null +++ b/host/canvas/color.go @@ -0,0 +1,49 @@ +package canvas + +// Color is a truecolor source value. The renderer downgrades it per the +// session's color depth at encode time; the canvas itself only stores the +// authoritative RGB (or "default / unset"). +type Color struct { + set bool + r, g, b uint8 +} + +// Default is the unset color (terminal default fg/bg). +func Default() Color { return Color{} } + +// RGB constructs a truecolor value. +func RGB(r, g, b uint8) Color { return Color{set: true, r: r, g: g, b: b} } + +// Gray is a convenience for an equal-channel gray. +func Gray(v uint8) Color { return RGB(v, v, v) } + +// IsSet reports whether the color is set (vs. terminal default). +func (c Color) IsSet() bool { return c.set } + +// RGB returns the color's red/green/blue channels (the renderer downgrades +// these per session color depth). +func (c Color) RGB() (uint8, uint8, uint8) { return c.r, c.g, c.b } + +// Equal reports color equality (used by the cell diff). +func (c Color) Equal(o Color) bool { + if c.set != o.set { + return false + } + if !c.set { + return true + } + return c.r == o.r && c.g == o.g && c.b == o.b +} + +// Some shared palette entries used by the lobby and games. +var ( + White = RGB(0xff, 0xff, 0xff) + Black = RGB(0x00, 0x00, 0x00) + Red = RGB(0xff, 0x55, 0x55) + Green = RGB(0x55, 0xff, 0x55) + Yellow = RGB(0xff, 0xff, 0x55) + Blue = RGB(0x55, 0x99, 0xff) + Cyan = RGB(0x55, 0xff, 0xff) + Magenta = RGB(0xff, 0x77, 0xff) + DimGray = Gray(0x6c) +) diff --git a/host/sdk/account.go b/host/sdk/account.go new file mode 100644 index 0000000..cece467 --- /dev/null +++ b/host/sdk/account.go @@ -0,0 +1,52 @@ +package sdk + +import "context" + +// MergeRule governs how one per-user KV key is reconciled when two accounts +// merge. The zero value is the empty string; callers SHOULD pass an explicit +// rule, and the storage layer treats an empty/unknown rule as MergeKeepWinner. +type MergeRule string + +const ( + // MergeKeepWinner keeps the surviving account's value on a key collision + // (the default) and moves a loser-only key to the winner unchanged. + MergeKeepWinner MergeRule = "keep-winner" + // MergeKeepLoser takes the merged-away account's value on a collision. + MergeKeepLoser MergeRule = "keep-loser" + // MergeSum writes the integer sum of the two values (both must be integers). + MergeSum MergeRule = "sum" + // MergeMax writes the integer maximum of the two values (both integers). + MergeMax MergeRule = "max" +) + +// KVStore is a durable per-user key/value store, already namespaced to one game +// and one account. Values are opaque to the platform; the MergeSum/MergeMax +// rules additionally require the value to be a base-10 integer. A game obtains a +// KVStore via Account.Store(). +type KVStore interface { + // Get returns the value for key and whether it was present. + Get(ctx context.Context, key string) ([]byte, bool, error) + // Set writes value for key, recording the merge rule that governs how the + // key reconciles on a future account merge. + Set(ctx context.Context, key string, value []byte, rule MergeRule) error + // Delete removes key (a no-op if absent). + Delete(ctx context.Context, key string) error +} + +// Account is a live, account-scoped handle a game obtains for a Player. It +// exposes the account's identity plus a per-user KVStore namespaced to the +// calling game. It is distinct from Player (a value-comparable, per-connection +// membership token): Account is fetched on demand and is not a map key. +type Account interface { + ID() string // immutable account UUID + Handle() string // current display handle + Kind() Kind + Store() KVStore // per-user KV, auto-namespaced to this game's slug +} + +// AccountStore yields an Account for a Player, with the returned KVStore +// auto-namespaced to the game whose room owns this Services bundle. It is part +// of Services; games reach it via Room.Services().Accounts. +type AccountStore interface { + For(p Player) Account +} diff --git a/host/sdk/checkpoint_test.go b/host/sdk/checkpoint_test.go new file mode 100644 index 0000000..723d81b --- /dev/null +++ b/host/sdk/checkpoint_test.go @@ -0,0 +1,194 @@ +package sdk + +import ( + "errors" + "sync" + "testing" + "time" +) + +// checkpointHandler counts callbacks so a test can prove the room keeps running +// (still accepts input, still ticks) after a non-destructive Checkpoint. +type checkpointHandler struct { + Base + inputs int +} + +func (h *checkpointHandler) OnInput(r Room, p Player, in Input) { h.inputs++ } + +// Checkpoint runs fn ON the actor with the live Handler and does NOT dispose the +// room: the room keeps accepting input afterward, and fn sees the same handler +// instance the actor drives (no race). +func TestCheckpointNonDestructive(t *testing.T) { + cfg := RoomConfig{Mode: ModeQuick, Capacity: 5} + h := &checkpointHandler{} + ctl := NewRoomRuntime("cp1", h, cfg, Services{}) + p := mkPlayer("a") + if err := ctl.Join(p); err != nil { + t.Fatalf("join: %v", err) + } + + var seen Handler + if err := ctl.Checkpoint(func(hh Handler) error { seen = hh; return nil }); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + if seen != h { + t.Fatalf("Checkpoint handed a different handler than the runtime drives") + } + + // The room MUST still be alive: not done, still accepting input. + select { + case <-ctl.Done(): + t.Fatal("Checkpoint disposed the room (must be non-destructive)") + default: + } + ctl.Input(p, Input{Kind: InputRune, Rune: 'x'}) + // A second checkpoint observes the input the live room processed — proving the + // room kept running on the same handler. + deadline := time.After(time.Second) + for { + done := make(chan int, 1) + _ = ctl.Checkpoint(func(hh Handler) error { done <- hh.(*checkpointHandler).inputs; return nil }) + if n := <-done; n >= 1 { + break + } + select { + case <-deadline: + t.Fatal("input not processed after checkpoint; room not running") + default: + } + } +} + +// A fn error is returned to the caller and the room is NOT disposed (distinct +// from Hibernate, whose fn error leaves disposal to the caller too but whose +// success disposes). +func TestCheckpointErrorDoesNotDispose(t *testing.T) { + cfg := RoomConfig{Mode: ModeQuick, Capacity: 5} + ctl := NewRoomRuntime("cp2", &checkpointHandler{}, cfg, Services{}) + want := errors.New("snapshot failed") + if err := ctl.Checkpoint(func(Handler) error { return want }); !errors.Is(err, want) { + t.Fatalf("Checkpoint err = %v, want %v", err, want) + } + select { + case <-ctl.Done(): + t.Fatal("a failed checkpoint disposed the room") + default: + } +} + +// A settled room returns ErrRoomClosed without calling fn. +func TestCheckpointOnSettledRoom(t *testing.T) { + cfg := RoomConfig{Mode: ModeQuick, Capacity: 5} + ctl := NewRoomRuntime("cp3", endGame{}.NewRoom(cfg, Services{}), cfg, Services{}) + p := mkPlayer("a") + _ = ctl.Join(p) + ctl.Input(p, Input{Kind: InputRune, Rune: 'q'}) // ends the room + <-ctl.Done() + called := false + if err := ctl.Checkpoint(func(Handler) error { called = true; return nil }); err == nil { + t.Fatal("Checkpoint on a settled room should error") + } + if called { + t.Fatal("Checkpoint called fn on a settled room") + } +} + +// Regression (review finding #5): Checkpoint/Hibernate called concurrently with +// the room ENDING must never deadlock. A command can land in the buffered cmds +// channel an instant before settle cancels the ctx and exits the loop, so the +// loop never processes it — a bare `<-reply` would block forever. awaitReply +// turns that into ErrRoomClosed. The test stresses the exact boundary under +// -race/-count and a per-call timeout guard FAILS instead of hanging. +func TestCheckpointHibernateDisposalRaceNoDeadlock(t *testing.T) { + const rooms = 40 + var wg sync.WaitGroup + for i := 0; i < rooms; i++ { + wg.Add(1) + go func() { + defer wg.Done() + cfg := RoomConfig{Mode: ModeQuick, Capacity: 5} + ctl := NewRoomRuntime("race", endGame{}.NewRoom(cfg, Services{}), cfg, Services{}) + p := mkPlayer("a") + _ = ctl.Join(p) + + // End the room and hammer Checkpoint/Hibernate around the disposal so a + // call frequently races the loop exit (the window awaitReply must close). + go ctl.Input(p, Input{Kind: InputRune, Rune: 'q'}) // settle, concurrently + + for j := 0; j < 8; j++ { + callWithDeadline(t, func() { _ = ctl.Checkpoint(func(Handler) error { return nil }) }) + callWithDeadline(t, func() { _ = ctl.Hibernate(func(Handler) error { return nil }) }) + } + <-ctl.Done() + // Post-disposal calls must also return promptly (not block on a reply the + // dead loop will never send). + callWithDeadline(t, func() { _ = ctl.Checkpoint(func(Handler) error { return nil }) }) + callWithDeadline(t, func() { _ = ctl.Hibernate(func(Handler) error { return nil }) }) + }() + } + wg.Wait() +} + +// hibHandler opts into hibernation so explicit Hibernate freezes and disposes. +type hibHandler struct{ Base } + +func (hibHandler) CanHibernate() bool { return true } + +// Regression: a Hibernate the actor ACTUALLY PROCESSED (fn ran, returned nil) +// must return nil — never a false ErrRoomClosed. The actor's doHibernate cancels +// the room ctx as part of disposal; if that cancel becomes visible to awaitReply +// before the success reply does, awaitReply's ctx-done branch could find an empty +// reply chan and report ErrRoomClosed even though hibernation SUCCEEDED. That +// false negative makes HibernateAll count the room as not-frozen ("froze 0 rooms" +// / drain "hibernate: room is closed"). Each room is freshly created and frozen +// exactly once with no concurrent settle, so the ONLY ctx cancellation is the +// hibernate's own disposal — any ErrRoomClosed here is the bug. Stressed under +// -race/-count to widen the cancel-vs-reply window. +func TestHibernateSuccessNeverFalseClosed(t *testing.T) { + const rooms = 200 + var wg sync.WaitGroup + errs := make(chan error, rooms) + for i := 0; i < rooms; i++ { + wg.Add(1) + go func() { + defer wg.Done() + cfg := RoomConfig{Mode: ModeQuick, Capacity: 5} + ctl := NewRoomRuntime("hibwin", &hibHandler{}, cfg, Services{}) + p := mkPlayer("a") + if err := ctl.Join(p); err != nil { + errs <- err + return + } + ran := false + err := ctl.Hibernate(func(Handler) error { ran = true; return nil }) + if !ran { + errs <- errors.New("freeze fn never ran") + return + } + if err != nil { + errs <- err + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + // fn ran (room processed the command) but Hibernate reported an error: + // the false ErrRoomClosed. + t.Fatalf("Hibernate returned %v for a successfully-processed freeze", err) + } +} + +// callWithDeadline runs fn and fails the test if it does not return within a +// generous budget — surfacing a reply-deadlock as a failure, not a hung suite. +func callWithDeadline(t *testing.T, fn func()) { + t.Helper() + done := make(chan struct{}) + go func() { fn(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Checkpoint/Hibernate deadlocked racing room disposal (bare <-reply regression)") + } +} diff --git a/host/sdk/config.go b/host/sdk/config.go new file mode 100644 index 0000000..417a156 --- /dev/null +++ b/host/sdk/config.go @@ -0,0 +1,16 @@ +package sdk + +import "context" + +// ConfigStore is a durable, read-only per-game configuration surface, already +// namespaced to one game's slug (the binding supplies the slug; the game names +// only the key, so it can neither read nor write another game's config). Values +// are opaque to the platform — a game parses its own document. Mutation is not +// exposed here: config is written only through the lobby's admin-gated path. A +// game obtains a ConfigStore via Room.Services().Config. +type ConfigStore interface { + // Get returns the value for key and whether it was present. A missing key + // reads as not-found (ok=false) so the game can fall back to its compiled + // default. + Get(ctx context.Context, key string) ([]byte, bool, error) +} diff --git a/host/sdk/controls.go b/host/sdk/controls.go new file mode 100644 index 0000000..6979acd --- /dev/null +++ b/host/sdk/controls.go @@ -0,0 +1,89 @@ +package sdk + +// Action is a resolved, semantic input action. It is the canonical vocabulary +// every lobby screen and game interprets, decoupled from the raw key or rune. +type Action uint8 + +const ( + ActNone Action = iota + ActUp + ActDown + ActLeft + ActRight + ActConfirm + ActBack +) + +// InputContext selects how an Input is interpreted. It governs whether the +// mobile-friendly letter aliases (j/k/h/l, q) are active or whether the runes +// are literal input the caller handles itself. +type InputContext uint8 + +const ( + // CtxNav is for menus and list/value screens: all letter aliases are active. + // It is the zero value, so a room defaults to Nav until a game says otherwise. + CtxNav InputContext = iota + // CtxCommand is for screens whose letters are domain commands (e.g. blackjack + // h/s/d/p/r): arrows still navigate, q still backs out, but h/j/k/l are NOT + // directions — the caller reads in.Rune for its command. + CtxCommand + // CtxText is for typing screens: only Esc/Ctrl-C resolve (to Back); every + // other input, including q/j/k and printable runes, is ActNone and the caller + // reads the raw Input. + CtxText +) + +// Resolve maps an Input to a semantic Action for the given context. It is the +// single source of truth for the canonical control vocabulary: +// +// Up=↑/k Down=↓/j Left=←/h Right=→/l Confirm=Enter/Space Back=Esc/q/Ctrl-C +// +// The letter aliases are active only where letters are not literal input; q is +// Back in every context except CtxText. +func Resolve(in Input, ctx InputContext) Action { + // Esc/Ctrl-C are Back in every context, including text entry. + if in.Kind == InputKey && (in.Key == KeyEsc || in.Key == KeyCtrlC) { + return ActBack + } + if ctx == CtxText { + return ActNone + } + + if in.Kind == InputKey { + switch in.Key { + case KeyUp: + return ActUp + case KeyDown: + return ActDown + case KeyLeft: + return ActLeft + case KeyRight: + return ActRight + case KeyEnter: + return ActConfirm + } + return ActNone + } + + // Printable runes. Space and q are honored in both Nav and Command; the + // directional letters are honored only in Nav (in Command they are commands). + switch in.Rune { + case ' ': + return ActConfirm + case 'q': + return ActBack + } + if ctx == CtxNav { + switch in.Rune { + case 'k': + return ActUp + case 'j': + return ActDown + case 'h': + return ActLeft + case 'l': + return ActRight + } + } + return ActNone +} diff --git a/host/sdk/controls_test.go b/host/sdk/controls_test.go new file mode 100644 index 0000000..e5f93dc --- /dev/null +++ b/host/sdk/controls_test.go @@ -0,0 +1,114 @@ +package sdk + +import ( + "testing" + "time" +) + +// ctxHandler publishes a given InputContext on join. +type ctxHandler struct { + Base + ctx InputContext +} + +func (h ctxHandler) OnJoin(r Room, _ Player) { r.SetInputContext(h.ctx) } + +func TestRoomCtlInputContextDefaultsToNav(t *testing.T) { + ctl := NewRoomRuntime("r-default", Base{}, RoomConfig{}, Services{}) + defer ctl.Close() + if got := ctl.InputContext(); got != CtxNav { + t.Fatalf("default InputContext = %v, want CtxNav", got) + } +} + +func TestRoomCtlInputContextPublished(t *testing.T) { + ctl := NewRoomRuntime("r-pub", ctxHandler{ctx: CtxText}, RoomConfig{Capacity: 1}, Services{}) + defer ctl.Close() + if err := ctl.Join(Player{Conn: "c1"}); err != nil { + t.Fatalf("join: %v", err) + } + // The actor processes OnJoin asynchronously; poll briefly for publication. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if ctl.InputContext() == CtxText { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("InputContext = %v, want CtxText after game published it", ctl.InputContext()) +} + +func TestResolveNav(t *testing.T) { + cases := []struct { + in Input + want Action + }{ + {KeyInput(KeyUp), ActUp}, + {RuneInput('k'), ActUp}, + {KeyInput(KeyDown), ActDown}, + {RuneInput('j'), ActDown}, + {KeyInput(KeyLeft), ActLeft}, + {RuneInput('h'), ActLeft}, + {KeyInput(KeyRight), ActRight}, + {RuneInput('l'), ActRight}, + {KeyInput(KeyEnter), ActConfirm}, + {RuneInput(' '), ActConfirm}, + {KeyInput(KeyEsc), ActBack}, + {KeyInput(KeyCtrlC), ActBack}, + {RuneInput('q'), ActBack}, + {RuneInput('x'), ActNone}, + {KeyInput(KeyBackspace), ActNone}, + } + for _, c := range cases { + if got := Resolve(c.in, CtxNav); got != c.want { + t.Errorf("Resolve(%+v, CtxNav) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestResolveCommand(t *testing.T) { + // Arrows still navigate; Enter/Space confirm; Esc/Ctrl-C/q back out. + nav := []struct { + in Input + want Action + }{ + {KeyInput(KeyUp), ActUp}, + {KeyInput(KeyDown), ActDown}, + {KeyInput(KeyLeft), ActLeft}, + {KeyInput(KeyRight), ActRight}, + {KeyInput(KeyEnter), ActConfirm}, + {RuneInput(' '), ActConfirm}, + {KeyInput(KeyEsc), ActBack}, + {KeyInput(KeyCtrlC), ActBack}, + {RuneInput('q'), ActBack}, + } + for _, c := range nav { + if got := Resolve(c.in, CtxCommand); got != c.want { + t.Errorf("Resolve(%+v, CtxCommand) = %v, want %v", c.in, got, c.want) + } + } + // Letter aliases are domain commands here, NOT directions. + for _, r := range []rune{'h', 'j', 'k', 'l', 's', 'd', 'p', 'r', 'y', 'n'} { + if got := Resolve(RuneInput(r), CtxCommand); got != ActNone { + t.Errorf("Resolve(%q, CtxCommand) = %v, want ActNone (domain command)", r, got) + } + } +} + +func TestResolveText(t *testing.T) { + // Only Esc/Ctrl-C resolve; every printable rune (incl. q/j/k) passes raw. + if got := Resolve(KeyInput(KeyEsc), CtxText); got != ActBack { + t.Errorf("Resolve(Esc, CtxText) = %v, want ActBack", got) + } + if got := Resolve(KeyInput(KeyCtrlC), CtxText); got != ActBack { + t.Errorf("Resolve(Ctrl-C, CtxText) = %v, want ActBack", got) + } + for _, in := range []Input{ + RuneInput('q'), RuneInput('j'), RuneInput('k'), RuneInput('a'), + KeyInput(KeyEnter), KeyInput(KeyBackspace), KeyInput(KeyUp), + } { + if got := Resolve(in, CtxText); got != ActNone { + t.Errorf("Resolve(%+v, CtxText) = %v, want ActNone", in, got) + } + } +} diff --git a/host/sdk/errors.go b/host/sdk/errors.go new file mode 100644 index 0000000..d865af6 --- /dev/null +++ b/host/sdk/errors.go @@ -0,0 +1,17 @@ +package sdk + +import "errors" + +// Admission errors returned by RoomCtl.Join. +var ( + // ErrRoomFull is returned when the room is at capacity. + ErrRoomFull = errors.New("room is full") + // ErrRoomClosed is returned when the room is settling or disposed. + ErrRoomClosed = errors.New("room is closed") +) + +// internal aliases kept for the actor loop's brevity. +var ( + errRoomFull = ErrRoomFull + errRoomClosed = ErrRoomClosed +) diff --git a/host/sdk/game.go b/host/sdk/game.go new file mode 100644 index 0000000..96e3944 --- /dev/null +++ b/host/sdk/game.go @@ -0,0 +1,161 @@ +package sdk + +import ( + "log/slog" + "math/rand" + "time" +) + +// Game is the registry entry. NewRoom returns the game's behavior (a Handler); +// the engine builds the Room runtime around it. sealed() keeps the interface +// growable without breaking out-of-tree games. +type Game interface { + Meta() GameMeta + NewRoom(cfg RoomConfig, svc Services) Handler + sealed() +} + +// Handler is the game's per-room behavior. The engine invokes these callbacks +// one at a time on a single actor goroutine, passing a Room handle valid only +// for the duration of the call. Embed Base for no-op defaults + the seal. +type Handler interface { + OnStart(r Room) + OnJoin(r Room, p Player) + OnLeave(r Room, p Player) + OnInput(r Room, p Player, in Input) + OnTick(r Room, now time.Time) + OnFrame(r Room, snap Snapshot) + OnClose(r Room) + handlerSeal() +} + +// TimerID identifies a scheduled timer. +type TimerID uint64 + +// Room is the engine-provided handle the game drives. It is valid only inside a +// callback; a stale handle used afterward (or off-thread) is a logged no-op. +type Room interface { + // roster / config / clock + Members() []Player + Has(p Player) bool + Count() int + Config() RoomConfig + Rand() *rand.Rand + Now() time.Time + + // push frames (engine hides coalescing, single-writer, close) + Send(p Player, f Frame) + Identical(f Frame) + BroadcastFunc(compose func(p Player) Frame) + + // engine-owned timing + After(d time.Duration, fn func(r Room)) TimerID + Every(d time.Duration, fn func(r Room)) TimerID + Cancel(id TimerID) + SetSimRate(d time.Duration) + SetFrameRate(d time.Duration) + + // phase publication for the lobby + SetPhase(name string, open bool, deadline time.Time) + + // input-context publication for the lobby's play loop: the game declares + // which InputContext applies to its current phase so Back (q/Esc) resolves + // consistently for every game. Defaults to CtxNav until first set. + SetInputContext(ctx InputContext) + + // settle exactly once + End(res Result) + Result() (Result, bool) + + Services() Services + Log() *slog.Logger +} + +// RoomCtl is the engine control surface held by the lobby/hub. The game never +// sees it; the lobby never sees Room. +type RoomCtl interface { + Join(p Player) error + Leave(p Player) + Input(p Player, in Input) + Members() []Player + Frames(p Player) <-chan Frame + Done() <-chan struct{} + Snapshot() Phase + // InputContext is the game's currently published input context, so the lobby + // play loop resolves Back (q/Esc) appropriately. Defaults to CtxNav. + InputContext() InputContext + Result() (Result, bool) + Close() error + + // Hibernatable reports whether the room's Handler can be frozen and resumed + // (the lobby/drain path only hibernates rooms that say yes). It is answered + // off the actor goroutine from the immutable Handler reference, so it is + // safe to call any time. + Hibernatable() bool + + // Hibernate quiesces the room and runs fn ON the actor goroutine at a point + // with no Handler callback on the stack, handing fn the live Handler so the + // caller can freeze it (e.g. gameabi.SnapshotHandler). After fn returns the + // room is disposed WITHOUT delivering a normal end: player frame streams + // close (players see the room go away, not a settled result), no Result is + // published, no leaderboard post or DNF backfill runs — the room is paused, + // not finished. fn runs exactly once; a settled/already-hibernated room + // returns errRoomClosed without calling it. Hibernate blocks until fn has + // run (or the room is gone). + Hibernate(fn func(h Handler) error) error + + // Checkpoint runs fn ON the actor goroutine at a quiescent point (no Handler + // callback on the stack), handing fn the live Handler so the caller can take a + // NON-destructive snapshot (e.g. gameabi.CheckpointHandler + CheckpointStore) + // — the room keeps running afterward. This is the durability seam for periodic + // checkpoints and drain snapshots (room-hosting spec "Periodic Room + // Checkpoints", design D5), distinct from the disposing Hibernate. A + // settled/ended/hibernated room returns errRoomClosed without calling fn; an + // fn error is returned and the room stays live. Checkpoint blocks until fn has + // run (or the room is gone). + Checkpoint(fn func(h Handler) error) error +} + +// HibernationCapable is the capability a Handler advertises to opt into +// hibernation. The engine asserts it against the room's Handler to answer +// RoomCtl.Hibernatable; a Handler that does not implement it is never frozen +// (its room ends normally on abandonment / drain instead). gameabi's wasm +// handler implements it; in-process Go games do not (their state is not +// portable across a process restart), so they are unaffected. +type HibernationCapable interface { + // CanHibernate reports whether this handler is, right now, in a state that + // can be frozen (e.g. a live, un-faulted wasm instance). A handler that + // implements the interface but returns false is treated as not hibernatable. + CanHibernate() bool +} + +// Resumed is the capability a RESTORED Handler advertises so the engine resumes +// it WITHOUT re-running OnStart (which would re-instantiate and clobber the +// restored state). A room built with WithResumed calls OnResume in place of +// OnStart exactly once, at loop entry; the handler uses it to re-establish +// engine-owned timing (sim/frame rate) it would normally set in OnStart, with +// no fresh instantiation. A handler that does not implement it falls back to +// OnStart (harmless for a never-resumed handler). +type Resumed interface { + OnResume(r Room) +} + +// GameBase is embedded by a Game implementation to satisfy the unexported +// sealed() method, keeping Game growable. +type GameBase struct{} + +func (GameBase) sealed() {} + +// Base is embedded by a Handler implementation. It satisfies the unexported +// handlerSeal() and supplies a no-op default for every callback, so a minimal +// game overrides only what it needs. +type Base struct{} + +func (Base) handlerSeal() {} +func (Base) OnStart(Room) {} +func (Base) OnJoin(Room, Player) {} +func (Base) OnLeave(Room, Player) {} +func (Base) OnInput(Room, Player, Input) {} +func (Base) OnTick(Room, time.Time) {} +func (Base) OnFrame(Room, Snapshot) {} +func (Base) OnClose(Room) {} diff --git a/host/sdk/leaderboard.go b/host/sdk/leaderboard.go new file mode 100644 index 0000000..161fb4c --- /dev/null +++ b/host/sdk/leaderboard.go @@ -0,0 +1,416 @@ +package sdk + +import ( + "context" + "sort" + "sync" + "time" +) + +// Direction is whether a higher or lower metric ranks better. +type Direction uint8 + +const ( + HigherBetter Direction = iota // default: bigger metric wins (WPM, chips) + LowerBetter // smaller metric wins (time trials) +) + +// Aggregation is how the default provider folds an account's own metrics. +type Aggregation uint8 + +const ( + BestResult Aggregation = iota // default: best single metric (MAX/MIN by direction) + CumulativeSum // sum of metrics +) + +// MetricFormat is how the display layer renders a metric value. +type MetricFormat uint8 + +const ( + Integer MetricFormat = iota // plain integer (default) + Decimal1 // value/10 with one decimal place + Duration // seconds rendered as m:ss +) + +// LeaderboardSpec is a game's optional declaration of how its board behaves. A +// nil *LeaderboardSpec on GameMeta means the defaults: best single result, +// higher is better, integer formatting. The spec carries no behavior of its own; +// the leaderboard service reads it to aggregate (default provider), order, and +// format that game's standings. +type LeaderboardSpec struct { + MetricLabel string `json:"metricLabel"` // column header, e.g. "WPM", "Chips", "Time" + Direction Direction `json:"direction"` + Aggregation Aggregation `json:"aggregation"` + Format MetricFormat `json:"format"` +} + +// DefaultLeaderboardSpec is the spec applied when a game declares none. +var DefaultLeaderboardSpec = LeaderboardSpec{ + MetricLabel: "Score", + Direction: HigherBetter, + Aggregation: BestResult, + Format: Integer, +} + +// ResolveLeaderboardSpec returns the effective spec for a possibly-nil +// declaration, applying the defaults for a nil spec. +func ResolveLeaderboardSpec(s *LeaderboardSpec) LeaderboardSpec { + if s == nil { + return DefaultLeaderboardSpec + } + out := *s + if out.MetricLabel == "" { + out.MetricLabel = DefaultLeaderboardSpec.MetricLabel + } + return out +} + +// Window is a leaderboard time window. +type Window uint8 + +const ( + AllTime Window = iota + Daily + Weekly +) + +// WindowStart returns the inclusive lower bound for a window computed in UTC, +// and whether the window is bounded at all. AllTime is unbounded. Daily starts +// at UTC midnight today; Weekly at Monday 00:00 UTC of the current week. No +// scheduled reset is involved — the boundary is derived from now at query time. +func WindowStart(w Window, now time.Time) (time.Time, bool) { + u := now.UTC() + day := time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC) + switch w { + case Daily: + return day, true + case Weekly: + // Weekday(): Sunday=0..Saturday=6; days since Monday: + offset := (int(u.Weekday()) + 6) % 7 + return day.AddDate(0, 0, -offset), true + default: + return time.Time{}, false + } +} + +// Score is one account's value for a window, account-keyed and NOT ranked, +// carrying no handle. A LeaderboardProvider returns these; the platform resolves +// handles, excludes merged accounts, ranks, and pages. +type Score struct { + AccountID string + Value int + Achieved time.Time +} + +// Standing is one resolved, ranked leaderboard row for display. +type Standing struct { + Rank int + AccountID string + Handle string // resolved live at read time + Value int + Achieved time.Time +} + +// LeaderboardProvider produces a game's ranked values. A game may supply one to +// decide where/how its board's data is stored and computed; a game that supplies +// none uses the default provider that aggregates recorded results. A provider +// reads only its own game's data and MUST NOT rank or resolve handles. +type LeaderboardProvider interface { + // Scores returns each account's value for the window (account-keyed, + // unranked, handle-less). + Scores(ctx context.Context, w Window) ([]Score, error) +} + +// LeaderboardReader is the read side used by the lobby/UI (never handed to +// games). Implementations compose a game's LeaderboardProvider with platform +// identity resolution: handles are resolved live, merged/tombstoned accounts +// excluded, rows ordered by the spec's direction, ranked, and paged. +type LeaderboardReader interface { + // Spec returns the resolved (nil-default-applied) spec for a game. + Spec(slug string) LeaderboardSpec + // Standings returns a ranked, handle-resolved page for a game + window. + Standings(ctx context.Context, slug string, w Window, limit, offset int) ([]Standing, error) + // PlayerStanding returns one account's rank + value for a game + window, or + // ok=false when the account has no standing on that board. + PlayerStanding(ctx context.Context, slug, accountID string, w Window) (Standing, bool, error) +} + +// LeaderboardData is the read surface a provider/reader uses to reach durable +// data. Both the production Postgres store and the in-memory test store +// implement it, so the reader and the built-in providers are storage-agnostic. +type LeaderboardData interface { + // ResultScores aggregates recorded results per account for a game + window + // per the spec (the default provider uses this). Returns one Score per + // account (Value = the aggregated metric, Achieved = when it was reached). + ResultScores(ctx context.Context, slug string, spec LeaderboardSpec, w Window) ([]Score, error) + // KVIntValues returns each account's integer value for (slug, key), used by + // KV-backed providers such as the casino peak board. + KVIntValues(ctx context.Context, slug, key string) ([]Score, error) + // ResolveHandles maps account ids to their current live handle, OMITTING any + // account that is merged-away/tombstoned or unknown (so it is excluded from + // boards). The platform — not a provider — owns this. + ResolveHandles(ctx context.Context, ids []string) (map[string]string, error) +} + +// LeaderboardCustom is the OPTIONAL interface a Game implements to supply its own +// LeaderboardProvider (deciding where/how its board's values are stored and +// computed). A Game that does not implement it uses the default provider that +// aggregates recorded results. The provider is constructed with LeaderboardData +// so it can perform its own (own-game-scoped) reads. +type LeaderboardCustom interface { + LeaderboardProvider(data LeaderboardData) LeaderboardProvider +} + +// defaultProvider aggregates a game's recorded results per its spec. +type defaultProvider struct { + data LeaderboardData + slug string + spec LeaderboardSpec +} + +func (p *defaultProvider) Scores(ctx context.Context, w Window) ([]Score, error) { + return p.data.ResultScores(ctx, p.slug, p.spec, w) +} + +// reader is the generic LeaderboardReader composing a per-game provider with +// platform identity resolution. Shared by every factory. +type reader struct { + data LeaderboardData + specs map[string]LeaderboardSpec + providers map[string]LeaderboardProvider +} + +// NewReader builds a LeaderboardReader over the given data backend, resolving +// each registered game's spec and provider (a game's own provider if it +// implements LeaderboardCustom, else the default results provider). +func NewReader(data LeaderboardData, reg *Registry) LeaderboardReader { + r := &reader{data: data, specs: map[string]LeaderboardSpec{}, providers: map[string]LeaderboardProvider{}} + for _, g := range reg.All() { + slug := g.Meta().Slug + spec := ResolveLeaderboardSpec(g.Meta().Leaderboard) + r.specs[slug] = spec + if c, ok := g.(LeaderboardCustom); ok { + r.providers[slug] = c.LeaderboardProvider(data) + } else { + r.providers[slug] = &defaultProvider{data: data, slug: slug, spec: spec} + } + } + return r +} + +func (r *reader) Spec(slug string) LeaderboardSpec { + if s, ok := r.specs[slug]; ok { + return s + } + return DefaultLeaderboardSpec +} + +func (r *reader) provider(slug string) LeaderboardProvider { + if p, ok := r.providers[slug]; ok { + return p + } + return &defaultProvider{data: r.data, slug: slug, spec: r.Spec(slug)} +} + +func (r *reader) Standings(ctx context.Context, slug string, w Window, limit, offset int) ([]Standing, error) { + scores, err := r.provider(slug).Scores(ctx, w) + if err != nil { + return nil, err + } + ids := make([]string, 0, len(scores)) + seen := map[string]struct{}{} + for _, s := range scores { + if _, ok := seen[s.AccountID]; ok { + continue + } + seen[s.AccountID] = struct{}{} + ids = append(ids, s.AccountID) + } + handles, err := r.data.ResolveHandles(ctx, ids) + if err != nil { + return nil, err + } + resolve := func(id string) (string, bool) { + h, ok := handles[id] + return h, ok + } + return RankStandings(scores, r.Spec(slug).Direction, resolve, limit, offset), nil +} + +func (r *reader) PlayerStanding(ctx context.Context, slug, accountID string, w Window) (Standing, bool, error) { + all, err := r.Standings(ctx, slug, w, 0, 0) + if err != nil { + return Standing{}, false, err + } + for _, s := range all { + if s.AccountID == accountID { + return s, true, nil + } + } + return Standing{}, false, nil +} + +// RankStandings turns provider Scores into ranked, handle-resolved Standings. It +// is the single place ordering, ranking, paging, and merge-exclusion live, so +// every reader (durable or in-memory) behaves identically regardless of where +// the Scores came from. resolve returns an account's live handle and whether it +// is live; a false drops the row (a merged/tombstoned or unknown account never +// appears). Ordering honors dir; ties break by earliest Achieved, then +// AccountID. Ranks are assigned before paging, so a later page keeps true ranks. +// limit <= 0 means no limit. +func RankStandings(scores []Score, dir Direction, resolve func(accountID string) (string, bool), limit, offset int) []Standing { + type row struct { + s Score + handle string + } + rows := make([]row, 0, len(scores)) + for _, s := range scores { + h, live := resolve(s.AccountID) + if !live { + continue + } + rows = append(rows, row{s, h}) + } + sort.SliceStable(rows, func(i, j int) bool { + a, b := rows[i].s, rows[j].s + if a.Value != b.Value { + if dir == LowerBetter { + return a.Value < b.Value + } + return a.Value > b.Value + } + if !a.Achieved.Equal(b.Achieved) { + return a.Achieved.Before(b.Achieved) + } + return a.AccountID < b.AccountID + }) + out := make([]Standing, len(rows)) + for i, r := range rows { + out[i] = Standing{Rank: i + 1, AccountID: r.s.AccountID, Handle: r.handle, Value: r.s.Value, Achieved: r.s.Achieved} + } + return pageStandings(out, limit, offset) +} + +// pageStandings slices one page out of an already-ranked board. Ranks were +// assigned before paging, so a later page keeps true ranks. limit <= 0 means +// no limit. +func pageStandings(out []Standing, limit, offset int) []Standing { + if offset < 0 { + offset = 0 + } + if offset >= len(out) { + return []Standing{} + } + out = out[offset:] + if limit > 0 && limit < len(out) { + out = out[:limit] + } + return out +} + +// NewCachedReader wraps inner with a short-TTL per-(slug, window) cache of the +// FULL ranked board (the limit-0 Standings slice). One cached slice serves +// every page flip, window revisit, and PlayerStanding rank lookup within the +// TTL — collapsing the lobby's repeated full-table aggregations (each of which +// otherwise holds one of the few pool connections) into one query per board +// per TTL. Boards are read-mostly and writes land asynchronously anyway, so a +// stale-by-seconds board is indistinguishable from a slightly-earlier read. +// Concurrent misses on one key are single-flighted. Errors are never cached. +// A ttl <= 0 returns inner unchanged (no caching). +func NewCachedReader(inner LeaderboardReader, ttl time.Duration) LeaderboardReader { + if ttl <= 0 { + return inner + } + return &cachedReader{inner: inner, ttl: ttl, now: time.Now, boards: map[boardKey]*boardEntry{}} +} + +type boardKey struct { + slug string + w Window +} + +// boardEntry is one (slug, window) cache slot. ready is closed once the fetch +// that created the slot has filled standings/err — late arrivals block on it +// instead of issuing a duplicate query (single-flight). +type boardEntry struct { + ready chan struct{} + standings []Standing // full ranked board; never mutated after ready closes + err error + expires time.Time +} + +type cachedReader struct { + inner LeaderboardReader + ttl time.Duration + now func() time.Time // injectable clock (tests) + + mu sync.Mutex + boards map[boardKey]*boardEntry +} + +func (c *cachedReader) Spec(slug string) LeaderboardSpec { return c.inner.Spec(slug) } + +func (c *cachedReader) Standings(ctx context.Context, slug string, w Window, limit, offset int) ([]Standing, error) { + full, err := c.full(ctx, slug, w) + if err != nil { + return nil, err + } + return pageStandings(full, limit, offset), nil +} + +func (c *cachedReader) PlayerStanding(ctx context.Context, slug, accountID string, w Window) (Standing, bool, error) { + full, err := c.full(ctx, slug, w) + if err != nil { + return Standing{}, false, err + } + for _, s := range full { + if s.AccountID == accountID { + return s, true, nil + } + } + return Standing{}, false, nil +} + +// full returns the (possibly cached) full ranked board for one (slug, window). +func (c *cachedReader) full(ctx context.Context, slug string, w Window) ([]Standing, error) { + key := boardKey{slug, w} + c.mu.Lock() + if e := c.boards[key]; e != nil { + select { + case <-e.ready: // filled: serve if still fresh, else fall through to refetch + if c.now().Before(e.expires) { + c.mu.Unlock() + return e.standings, nil + } + default: // a fetch is in flight: wait on it instead of duplicating it + c.mu.Unlock() + select { + case <-e.ready: + return e.standings, e.err + case <-ctx.Done(): + return nil, ctx.Err() + } + } + } + // Miss (or expired): this caller fetches; the map slot single-flights any + // concurrent caller onto e.ready. The lock is held from lookup through + // insert, so exactly one fetcher replaces an expired entry. + e := &boardEntry{ready: make(chan struct{})} + c.boards[key] = e + c.mu.Unlock() + + standings, err := c.inner.Standings(ctx, slug, w, 0, 0) + c.mu.Lock() + e.standings, e.err = standings, err + e.expires = c.now().Add(c.ttl) + if err != nil { + // Never cache an error: drop the slot so the next caller retries + // (current waiters still observe this err via ready). + if c.boards[key] == e { + delete(c.boards, key) + } + } + close(e.ready) + c.mu.Unlock() + return standings, err +} diff --git a/host/sdk/leaderboard_test.go b/host/sdk/leaderboard_test.go new file mode 100644 index 0000000..e6e37e7 --- /dev/null +++ b/host/sdk/leaderboard_test.go @@ -0,0 +1,264 @@ +package sdk + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestResolveLeaderboardSpecDefaults(t *testing.T) { + got := ResolveLeaderboardSpec(nil) + if got.Direction != HigherBetter || got.Aggregation != BestResult || got.Format != Integer { + t.Fatalf("nil spec = %+v, want best/higher/integer", got) + } + if got.MetricLabel == "" { + t.Fatalf("nil spec has empty label") + } + // Explicit label is kept; empty label falls back to the default. + g2 := ResolveLeaderboardSpec(&LeaderboardSpec{MetricLabel: "Chips", Aggregation: CumulativeSum}) + if g2.MetricLabel != "Chips" || g2.Aggregation != CumulativeSum { + t.Fatalf("explicit spec lost: %+v", g2) + } +} + +func TestRankStandingsOrderTiesAndExclusion(t *testing.T) { + t0 := time.Unix(1000, 0) + scores := []Score{ + {AccountID: "a", Value: 50, Achieved: t0}, + {AccountID: "b", Value: 80, Achieved: t0}, + {AccountID: "c", Value: 80, Achieved: t0.Add(-time.Minute)}, // same value, earlier -> ranks ahead of b + {AccountID: "gone", Value: 999, Achieved: t0}, // not live -> excluded + } + resolve := func(id string) (string, bool) { + if id == "gone" { + return "", false + } + return "H-" + id, true + } + rows := RankStandings(scores, HigherBetter, resolve, 0, 0) + if len(rows) != 3 { + t.Fatalf("rows=%d want 3 (excluded one)", len(rows)) + } + if rows[0].AccountID != "c" || rows[1].AccountID != "b" || rows[2].AccountID != "a" { + t.Fatalf("order=%v want c,b,a", []string{rows[0].AccountID, rows[1].AccountID, rows[2].AccountID}) + } + if rows[0].Rank != 1 || rows[2].Rank != 3 { + t.Fatalf("ranks not assigned: %+v", rows) + } + if rows[0].Handle != "H-c" { + t.Fatalf("handle=%q want H-c", rows[0].Handle) + } + + // Lower-better flips order; paging keeps true ranks. + low := RankStandings(scores, LowerBetter, resolve, 1, 1) + if len(low) != 1 || low[0].AccountID != "c" || low[0].Rank != 2 { + t.Fatalf("lower-better page=%+v want c at rank 2", low) + } +} + +func TestWindowStartUTC(t *testing.T) { + // 2026-06-03 is a Wednesday. + now := time.Date(2026, 6, 3, 15, 30, 0, 0, time.UTC) + if _, bounded := WindowStart(AllTime, now); bounded { + t.Fatalf("all-time should be unbounded") + } + day, _ := WindowStart(Daily, now) + if !day.Equal(time.Date(2026, 6, 3, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("daily start=%v want UTC midnight", day) + } + week, _ := WindowStart(Weekly, now) + if !week.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) { // Monday + t.Fatalf("weekly start=%v want Monday 2026-06-01", week) + } +} + +// ---- cached reader ------------------------------------------------------------- + +// countingReaderFake is a LeaderboardReader whose full-board fetches are +// counted, so the cache tests can assert exactly how many hit the backend. +type countingReaderFake struct { + mu sync.Mutex + calls int + rows []Standing + err error +} + +func (f *countingReaderFake) Spec(string) LeaderboardSpec { return DefaultLeaderboardSpec } + +func (f *countingReaderFake) Standings(_ context.Context, _ string, _ Window, limit, offset int) ([]Standing, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.err != nil { + return nil, f.err + } + return pageStandings(f.rows, limit, offset), nil +} + +func (f *countingReaderFake) PlayerStanding(context.Context, string, string, Window) (Standing, bool, error) { + panic("cached reader must serve PlayerStanding from the cached board") +} + +func (f *countingReaderFake) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +func cachedWithClock(inner LeaderboardReader, ttl time.Duration, now *time.Time) *cachedReader { + c := NewCachedReader(inner, ttl).(*cachedReader) + c.now = func() time.Time { return *now } + return c +} + +// One backend aggregation serves every page flip, the PlayerStanding rank +// lookup, and repeat views within the TTL; expiry refetches; distinct +// (slug, window) keys do not share entries. +func TestCachedReaderCollapsesReadsAndExpires(t *testing.T) { + rows := []Standing{ + {Rank: 1, AccountID: "b", Handle: "B", Value: 80}, + {Rank: 2, AccountID: "a", Handle: "A", Value: 50}, + } + fake := &countingReaderFake{rows: rows} + now := time.Unix(1000, 0) + c := cachedWithClock(fake, 15*time.Second, &now) + ctx := context.Background() + + // First read fetches; page flips + rank lookup + repeats all hit the cache. + page, err := c.Standings(ctx, "g", AllTime, 1, 0) + if err != nil || len(page) != 1 || page[0].AccountID != "b" { + t.Fatalf("page=%+v err=%v want b", page, err) + } + page2, _ := c.Standings(ctx, "g", AllTime, 1, 1) + if len(page2) != 1 || page2[0].AccountID != "a" || page2[0].Rank != 2 { + t.Fatalf("page2=%+v want a at true rank 2", page2) + } + st, ok, err := c.PlayerStanding(ctx, "g", "a", AllTime) + if err != nil || !ok || st.Rank != 2 || st.Value != 50 { + t.Fatalf("standing=%+v ok=%v err=%v want a rank2/50", st, ok, err) + } + if _, ok, _ := c.PlayerStanding(ctx, "g", "nobody", AllTime); ok { + t.Fatal("unknown account must have no standing") + } + if n := fake.count(); n != 1 { + t.Fatalf("backend fetches=%d want 1 (cache collapses the rest)", n) + } + + // A different window is its own key. + if _, err := c.Standings(ctx, "g", Daily, 0, 0); err != nil { + t.Fatal(err) + } + if n := fake.count(); n != 2 { + t.Fatalf("backend fetches=%d want 2 (distinct window key)", n) + } + + // Within the TTL nothing refetches; past it, one read refetches. + now = now.Add(14 * time.Second) + _, _ = c.Standings(ctx, "g", AllTime, 0, 0) + if n := fake.count(); n != 2 { + t.Fatalf("backend fetches=%d want 2 (still fresh)", n) + } + now = now.Add(2 * time.Second) + _, _ = c.Standings(ctx, "g", AllTime, 0, 0) + if n := fake.count(); n != 3 { + t.Fatalf("backend fetches=%d want 3 (expired -> refetch)", n) + } +} + +// Errors are returned but never cached: the next read retries the backend. +func TestCachedReaderDoesNotCacheErrors(t *testing.T) { + fake := &countingReaderFake{err: context.DeadlineExceeded} + now := time.Unix(1000, 0) + c := cachedWithClock(fake, 15*time.Second, &now) + ctx := context.Background() + + if _, err := c.Standings(ctx, "g", AllTime, 0, 0); err == nil { + t.Fatal("want backend error surfaced") + } + fake.mu.Lock() + fake.err = nil + fake.rows = []Standing{{Rank: 1, AccountID: "a", Handle: "A", Value: 1}} + fake.mu.Unlock() + got, err := c.Standings(ctx, "g", AllTime, 0, 0) + if err != nil || len(got) != 1 { + t.Fatalf("got=%+v err=%v want recovered read", got, err) + } + if n := fake.count(); n != 2 { + t.Fatalf("backend fetches=%d want 2 (error not cached)", n) + } +} + +// Concurrent misses on one key are single-flighted: every reader gets the +// board, the backend sees one fetch. +func TestCachedReaderSingleFlight(t *testing.T) { + release := make(chan struct{}) + fake := &gateReaderFake{release: release, rows: []Standing{{Rank: 1, AccountID: "a", Handle: "A", Value: 9}}} + now := time.Unix(1000, 0) + c := cachedWithClock(fake, 15*time.Second, &now) + + const readers = 8 + var wg sync.WaitGroup + errs := make(chan error, readers) + for i := 0; i < readers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + rows, err := c.Standings(context.Background(), "g", AllTime, 0, 0) + if err == nil && (len(rows) != 1 || rows[0].AccountID != "a") { + err = context.Canceled // any sentinel: wrong rows + } + errs <- err + }() + } + // Let the goroutines pile onto the one in-flight fetch, then release it. + time.Sleep(50 * time.Millisecond) + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("reader failed: %v", err) + } + } + if n := fake.count(); n != 1 { + t.Fatalf("backend fetches=%d want 1 (single-flight)", n) + } +} + +// gateReaderFake blocks every Standings call until release is closed, counting +// calls — the single-flight test's controllable slow backend. +type gateReaderFake struct { + mu sync.Mutex + calls int + release chan struct{} + rows []Standing +} + +func (f *gateReaderFake) Spec(string) LeaderboardSpec { return DefaultLeaderboardSpec } + +func (f *gateReaderFake) Standings(context.Context, string, Window, int, int) ([]Standing, error) { + f.mu.Lock() + f.calls++ + f.mu.Unlock() + <-f.release + return f.rows, nil +} + +func (f *gateReaderFake) PlayerStanding(context.Context, string, string, Window) (Standing, bool, error) { + return Standing{}, false, nil +} + +func (f *gateReaderFake) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +// ttl <= 0 disables caching entirely (the inner reader is returned as-is). +func TestCachedReaderZeroTTLPassthrough(t *testing.T) { + fake := &countingReaderFake{} + if r := NewCachedReader(fake, 0); r != LeaderboardReader(fake) { + t.Fatal("ttl<=0 must return the inner reader unchanged") + } +} diff --git a/host/sdk/lifecycle_test.go b/host/sdk/lifecycle_test.go new file mode 100644 index 0000000..12a8931 --- /dev/null +++ b/host/sdk/lifecycle_test.go @@ -0,0 +1,172 @@ +package sdk + +import ( + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" +) + +// lcHandler counts callbacks; Base supplies no-ops. +type lcHandler struct { + Base + ticks int +} + +func (h *lcHandler) OnTick(r Room, now time.Time) { h.ticks++ } + +// CanHibernate marks the stub hibernation-capable (the wasm handler posture), +// so Hibernatable() reflects only the lifecycle wiring under test. +func (h *lcHandler) CanHibernate() bool { return true } + +func lcPlayer() Player { + return Player{AccountID: "a1", Handle: "ada", Kind: KindMember, Conn: "c1"} +} + +// An ephemeral room survives the grace window (a rejoin finds it alive) and +// ENDS at expiry — no hibernation, no snapshot fn ever wired. +func TestEphemeralEndsAfterGrace(t *testing.T) { + h := &lcHandler{} + cfg := RoomConfig{Mode: ModeQuick, Capacity: 4, MinPlayers: 1, Seed: 1, SeedSet: true, Lifecycle: LifecycleEphemeral} + ctl := NewRoomRuntime("eph-1", h, cfg, Services{Log: slog.New(slog.NewTextHandler(io.Discard, nil))}, WithAbandonGrace(40*time.Millisecond)) + defer ctl.Close() + + p := lcPlayer() + if err := ctl.Join(p); err != nil { + t.Fatalf("join: %v", err) + } + ctl.Leave(p) + + // Within the grace: the room is alive and a rejoin works. + time.Sleep(10 * time.Millisecond) + if err := ctl.Join(p); err != nil { + t.Fatalf("rejoin within grace: %v", err) + } + ctl.Leave(p) + + // Past the grace: the room ends (Done closes) — and was never hibernated. + select { + case <-ctl.Done(): + case <-time.After(2 * time.Second): + t.Fatal("ephemeral room did not end after the abandon grace") + } + if ctl.Hibernatable() { + t.Fatal("ephemeral room reports hibernatable") + } +} + +// A resident room ignores abandonment entirely: empty past the grace, it is +// still alive and still ticking. +func TestResidentIgnoresAbandonment(t *testing.T) { + h := &lcHandler{} + cfg := RoomConfig{Mode: ModeQuick, Capacity: 4, MinPlayers: 1, Seed: 1, SeedSet: true, Lifecycle: LifecycleResident} + froze := false + ctl := NewRoomRuntime("resident-test", h, cfg, Services{Log: slog.New(slog.NewTextHandler(io.Discard, nil))}, + WithAbandonHibernate(func(Handler) error { froze = true; return nil }, 30*time.Millisecond)) + defer ctl.Close() + + p := lcPlayer() + if err := ctl.Join(p); err != nil { + t.Fatalf("join: %v", err) + } + ctl.Leave(p) + + time.Sleep(150 * time.Millisecond) + select { + case <-ctl.Done(): + t.Fatal("resident room ended on abandonment") + default: + } + if froze { + t.Fatal("resident room hibernated on abandonment") + } + // Drain still works: the freeze fn is wired for the explicit path. + if !ctl.Hibernatable() { + t.Fatal("resident room must stay drain-freezable") + } +} + +// gateHandler blocks the actor inside the FIRST OnJoin callback until released, +// so a test can deterministically queue commands behind a disposing one while +// the loop is parked. +type gateHandler struct { + Base + entered chan struct{} // closed once the actor is inside OnJoin + release chan struct{} // close to let the actor proceed + once sync.Once +} + +func (h *gateHandler) OnJoin(r Room, p Player) { + h.once.Do(func() { + close(h.entered) + <-h.release + }) +} + +func (h *gateHandler) CanHibernate() bool { return true } + +// Regression: a cmdJoin queued in the buffered cmds channel behind a disposing +// command must return ErrRoomClosed once the loop exits — not block its caller +// forever. (Join used a bare `<-reply` instead of awaitReply, so a join racing +// the abandonment-grace hibernate wedged the session's update goroutine for the +// process lifetime.) The actor is gated inside OnJoin so channel FIFO ordering +// deterministically places cmdJoin behind the disposing cmdHibernate. +func TestJoinQueuedBehindDisposalReturnsRoomClosed(t *testing.T) { + h := &gateHandler{entered: make(chan struct{}), release: make(chan struct{})} + cfg := RoomConfig{Mode: ModeQuick, Capacity: 4, MinPlayers: 1, Seed: 1, SeedSet: true} + ctl := NewRoomRuntime("join-vs-dispose", h, cfg, Services{Log: slog.New(slog.NewTextHandler(io.Discard, nil))}) + defer ctl.Close() + rt := ctl.(*roomRuntime) + + // Join p1: the success reply is sent before OnJoin runs, then the actor + // parks inside the gated callback. + if err := ctl.Join(lcPlayer()); err != nil { + t.Fatalf("join p1: %v", err) + } + <-h.entered + + // With the actor parked, enqueue the disposing Hibernate, THEN the Join — + // waiting on the buffer depth between sends pins the FIFO order. + hibErr := make(chan error, 1) + go func() { hibErr <- ctl.Hibernate(func(Handler) error { return nil }) }() + waitQueued(t, rt, 1) + + joinErr := make(chan error, 1) + go func() { + joinErr <- ctl.Join(Player{AccountID: "a2", Handle: "bob", Kind: KindMember, Conn: "c2"}) + }() + waitQueued(t, rt, 2) + + close(h.release) + + select { + case err := <-hibErr: + if err != nil { + t.Fatalf("hibernate: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("hibernate did not return") + } + select { + case err := <-joinErr: + if !errors.Is(err, ErrRoomClosed) { + t.Fatalf("queued join: got %v, want ErrRoomClosed", err) + } + case <-time.After(2 * time.Second): + t.Fatal("join queued behind disposal hung — the deadlock this guards against") + } +} + +// waitQueued blocks until n commands sit in the room's cmds buffer. +func waitQueued(t *testing.T, rt *roomRuntime, n int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for len(rt.cmds) < n { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %d queued commands (have %d)", n, len(rt.cmds)) + } + time.Sleep(time.Millisecond) + } +} diff --git a/host/sdk/registry.go b/host/sdk/registry.go new file mode 100644 index 0000000..d6d678a --- /dev/null +++ b/host/sdk/registry.go @@ -0,0 +1,108 @@ +package sdk + +import ( + "fmt" + "sync" +) + +// Registry is the game roster the hub is constructed with. It is a real value so +// tests and serve --dev can build a curated roster without global mutation. +// It is safe for concurrent use: the lobby reads the LIVE view while dynamic +// games (wasm catalog, quarantine) add and remove entries at runtime. +type Registry struct { + mu sync.RWMutex + games map[string]Game + order []string +} + +// NewRegistry returns an empty registry. +func NewRegistry() *Registry { + return &Registry{games: map[string]Game{}} +} + +// Add registers a game. A duplicate slug is an error. +func (r *Registry) Add(g Game) error { + slug := g.Meta().Slug + if slug == "" { + return fmt.Errorf("game has empty slug") + } + r.mu.Lock() + defer r.mu.Unlock() + if _, dup := r.games[slug]; dup { + return fmt.Errorf("duplicate game slug %q", slug) + } + r.games[slug] = g + r.order = append(r.order, slug) + return nil +} + +// MustAdd registers a game, panicking on a duplicate slug (fatal). +func (r *Registry) MustAdd(g Game) { + if err := r.Add(g); err != nil { + panic(err) + } +} + +// Remove unregisters a game by slug, returning it (so a quarantine or admin +// flow can restore it later). Rooms already running the game are untouched — +// they hold their own Game reference; removal only stops NEW rooms and lobby +// listing. Re-adding preserves nothing of the old position: it appends. +func (r *Registry) Remove(slug string) (Game, bool) { + r.mu.Lock() + defer r.mu.Unlock() + g, ok := r.games[slug] + if !ok { + return nil, false + } + delete(r.games, slug) + for i, s := range r.order { + if s == slug { + r.order = append(r.order[:i:i], r.order[i+1:]...) + break + } + } + return g, true +} + +// Get looks up a game by slug. +func (r *Registry) Get(slug string) (Game, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + g, ok := r.games[slug] + return g, ok +} + +// All returns the games in stable registration order, INCLUDING hidden ones — +// the set quick-match-by-slug, direct entry, and admin reach. +func (r *Registry) All() []Game { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Game, 0, len(r.order)) + for _, slug := range r.order { + out = append(out, r.games[slug]) + } + return out +} + +// Listed returns the games for the lobby's player-facing menu in stable +// registration order, EXCLUDING any with Meta().Hidden set (add-loadtest-harness). +func (r *Registry) Listed() []Game { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Game, 0, len(r.order)) + for _, slug := range r.order { + if g := r.games[slug]; !g.Meta().Hidden { + out = append(out, g) + } + } + return out +} + +// defaultRegistry backs sdk.Register so games can register in init(). +var defaultRegistry = NewRegistry() + +// Register writes a game into the default registry. A duplicate slug is fatal. +func Register(g Game) { defaultRegistry.MustAdd(g) } + +// Default returns the package-level default registry. +func Default() *Registry { return defaultRegistry } diff --git a/host/sdk/registry_test.go b/host/sdk/registry_test.go new file mode 100644 index 0000000..5570008 --- /dev/null +++ b/host/sdk/registry_test.go @@ -0,0 +1,131 @@ +package sdk + +import ( + "fmt" + "sync" + "testing" +) + +type stubGame struct { + GameBase + meta GameMeta +} + +func (g *stubGame) Meta() GameMeta { return g.meta } +func (g *stubGame) NewRoom(cfg RoomConfig, svc Services) Handler { return Base{} } + +func stub(slug string) Game { + return &stubGame{meta: GameMeta{Slug: slug, Name: slug, MinPlayers: 1, MaxPlayers: 2}} +} + +func hiddenStub(slug string) Game { + return &stubGame{meta: GameMeta{Slug: slug, Name: slug, MinPlayers: 1, MaxPlayers: 2, Hidden: true}} +} + +// Listed returns only non-hidden games (the lobby's player-facing menu), while +// All / Get still include hidden games so quick-match-by-slug and admin reach +// them (add-loadtest-harness). +func TestRegistryListedExcludesHidden(t *testing.T) { + r := NewRegistry() + r.MustAdd(stub("a")) + r.MustAdd(hiddenStub("shellcade/loadtest")) + r.MustAdd(stub("b")) + + var listed []string + for _, g := range r.Listed() { + listed = append(listed, g.Meta().Slug) + } + if len(listed) != 2 || listed[0] != "a" || listed[1] != "b" { + t.Fatalf("Listed() = %v, want [a b] (hidden excluded)", listed) + } + if len(r.All()) != 3 { + t.Fatalf("All() = %d games, want 3 (hidden included)", len(r.All())) + } + if _, ok := r.Get("shellcade/loadtest"); !ok { + t.Fatal("hidden game must still be resolvable by slug") + } +} + +func TestRegistryAddRemove(t *testing.T) { + r := NewRegistry() + r.MustAdd(stub("a")) + r.MustAdd(stub("b")) + r.MustAdd(stub("c")) + + if err := r.Add(stub("b")); err == nil { + t.Fatal("duplicate slug accepted") + } + g, ok := r.Remove("b") + if !ok || g.Meta().Slug != "b" { + t.Fatalf("Remove(b) = %v %v", g, ok) + } + if _, ok := r.Remove("b"); ok { + t.Fatal("double remove succeeded") + } + if _, ok := r.Get("b"); ok { + t.Fatal("removed game still gettable") + } + var slugs []string + for _, g := range r.All() { + slugs = append(slugs, g.Meta().Slug) + } + if len(slugs) != 2 || slugs[0] != "a" || slugs[1] != "c" { + t.Fatalf("order after remove = %v, want [a c]", slugs) + } + // Re-adding appends. + r.MustAdd(stub("b")) + if all := r.All(); all[len(all)-1].Meta().Slug != "b" { + t.Fatal("re-added game not appended") + } +} + +// TestRegistryConcurrentAccess exercises live reads during add/remove churn — +// meaningful under -race (task 4.3). +func TestRegistryConcurrentAccess(t *testing.T) { + r := NewRegistry() + for i := 0; i < 8; i++ { + r.MustAdd(stub(fmt.Sprintf("seed-%d", i))) + } + var readers, writers sync.WaitGroup + stop := make(chan struct{}) + for w := 0; w < 4; w++ { // readers: the lobby view + readers.Add(1) + go func() { + defer readers.Done() + for { + select { + case <-stop: + return + default: + } + for _, g := range r.All() { + _ = g.Meta().Slug + } + _, _ = r.Get("seed-3") + } + }() + } + for w := 0; w < 2; w++ { // writers: catalog add/remove churn + writers.Add(1) + go func(w int) { + defer writers.Done() + for i := 0; i < 500; i++ { + slug := fmt.Sprintf("churn-%d-%d", w, i) + _ = r.Add(stub(slug)) + _, _ = r.Remove(slug) + } + }(w) + } + writers.Add(1) + go func() { // quarantine-style remove/re-add of a seed game + defer writers.Done() + for i := 0; i < 500; i++ { + if g, ok := r.Remove("seed-7"); ok { + _ = r.Add(g) + } + } + }() + writers.Wait() + close(stop) + readers.Wait() +} diff --git a/host/sdk/room.go b/host/sdk/room.go new file mode 100644 index 0000000..323fb62 --- /dev/null +++ b/host/sdk/room.go @@ -0,0 +1,900 @@ +package sdk + +import ( + "context" + "hash/fnv" + "log/slog" + "math/rand" + "runtime/debug" + "sync" + "sync/atomic" + "time" +) + +// DefaultAbandonGrace is how long an emptied, hibernate-capable room waits for a +// rejoin before it auto-hibernates instead of ending (D9). A non-hibernatable +// room ignores this and ends immediately, as before. +const DefaultAbandonGrace = 60 * time.Second + +// RoomOption tunes a room runtime at construction. Options are host-only (the +// lobby/matchmaker set them); games never see them. +type RoomOption func(*roomRuntime) + +// WithAbandonHibernate wires the abandonment + drain hibernation path. fn is run +// ON the actor goroutine at a quiescent point with the live Handler (the same +// contract as RoomCtl.Hibernate's caller fn) and must freeze + persist it; +// grace is the empty-room reprieve before an abandoned room auto-hibernates +// (<=0 ⇒ DefaultAbandonGrace). Without this option a room is never hibernated: +// Hibernatable reports false and an abandoned room ends normally. +func WithAbandonHibernate(fn func(h Handler) error, grace time.Duration) RoomOption { + if grace <= 0 { + grace = DefaultAbandonGrace + } + return func(rt *roomRuntime) { + rt.hibernateFn = fn + rt.abandonGrace = grace + } +} + +// WithAbandonGrace overrides the empty-room reprieve window without wiring +// hibernation — the knob ephemeral rooms (and tests) use: a rejoin within the +// window finds the room alive; at expiry the lifecycle's abandonment action +// runs (<=0 ⇒ DefaultAbandonGrace). +func WithAbandonGrace(grace time.Duration) RoomOption { + if grace <= 0 { + grace = DefaultAbandonGrace + } + return func(rt *roomRuntime) { rt.abandonGrace = grace } +} + +// WithResumed marks the runtime as resuming a RESTORED handler: the loop calls +// the handler's OnResume (if it implements Resumed) instead of OnStart, so the +// already-instantiated, memory-restored handler is not re-instantiated. Used by +// the lobby resume flow. A handler that does not implement Resumed falls back to +// OnStart. +func WithResumed() RoomOption { + return func(rt *roomRuntime) { rt.resumed = true } +} + +// NewRoomRuntime builds the engine runtime around a game's Handler and starts +// the single actor goroutine. It returns the lobby-facing RoomCtl. +func NewRoomRuntime(roomID string, h Handler, cfg RoomConfig, svc Services, opts ...RoomOption) RoomCtl { + ctx, cancel := context.WithCancel(context.Background()) + start := time.Now() + seed := cfg.Seed + if !cfg.SeedSet { + hh := fnv.New64a() + _, _ = hh.Write([]byte(roomID)) + seed = int64(hh.Sum64()) ^ start.UnixNano() + } + rt := &roomRuntime{ + roomID: roomID, + h: h, + cfg: cfg, + svc: svc, + start: start, + abandonGrace: DefaultAbandonGrace, + rng: rand.New(rand.NewSource(seed)), + cmds: make(chan command, 256), + done: make(chan struct{}), + ctx: ctx, + cancel: cancel, + out: map[Player]chan Frame{}, + timers: map[TimerID]timerEntry{}, + } + for _, o := range opts { + o(rt) + } + var empty []Player + rt.membersPub.Store(&empty) + rt.phase.Store(&Phase{Name: "init", Open: false}) + go rt.loop() + return rt +} + +type cmdKind int + +const ( + cmdJoin cmdKind = iota + cmdLeave + cmdInput + cmdTimer + cmdClose + cmdHibernate + cmdCheckpoint +) + +type command struct { + kind cmdKind + p Player + in Input + timerID TimerID + reply chan error + freeze func(h Handler) error // cmdHibernate: caller's snapshot fn, run on the actor +} + +type timerEntry struct { + fn func(r Room) + once bool +} + +type roomRuntime struct { + roomID string + h Handler + cfg RoomConfig + svc Services + start time.Time + rng *rand.Rand + + // hibernation wiring (host-only, set via WithAbandonHibernate): the snapshot + // fn run on the actor for the abandonment + drain triggers, and the empty-room + // grace before auto-hibernation. hibernateFn nil ⇒ the room is not + // hibernatable (abandonment ends it normally, as before). + hibernateFn func(h Handler) error + abandonGrace time.Duration + resumed bool // built via WithResumed: call OnResume, not OnStart, at loop entry + + cmds chan command + done chan struct{} + ctx context.Context + cancel context.CancelFunc + + // actor-owned state (touched only by the actor goroutine) ---------------- + members []Player + joined []Player + out map[Player]chan Frame + curEpoch int64 + settled bool + ended bool + hibernated bool // disposed via Hibernate (paused, not finished — no Result) + endReason string // why requestEnd fired, for the settle log + pending Result + simTicker *time.Ticker + frameRate *time.Ticker + timers map[TimerID]timerEntry + nextTimer TimerID + + // abandonment grace window: when an empty hibernate-capable room is given a + // reprieve before it auto-ends, this is the live timer id (0 ⇒ none). A + // rejoin cancels it; firing while still empty+unsettled hibernates the room. + graceTimer TimerID + + // published for external (lobby) reads ----------------------------------- + membersPub atomic.Pointer[[]Player] + outPub sync.Map // Player -> chan Frame + phase atomic.Pointer[Phase] + inputCtx atomic.Int32 // current InputContext; zero value is CtxNav + resultPub atomic.Pointer[Result] + staleLogged atomic.Bool +} + +// ---- actor goroutine ------------------------------------------------------ + +func (rt *roomRuntime) loop() { + if rt.resumed { + if rh, ok := rt.h.(Resumed); ok { + rt.call(rh.OnResume) + } else { + rt.call(rt.h.OnStart) // handler can't resume: fall back to a fresh start + } + } else { + rt.call(rt.h.OnStart) + } + rt.maybeSettle() + for !rt.settled && !rt.hibernated { + select { + case <-rt.ctx.Done(): + // Defensive: rt.ctx is rooted at Background today, so this branch (and its + // "context-canceled" end reason) cannot fire until a parent is wired. + rt.requestEnd(Result{Mode: rt.cfg.Mode}, "context-canceled") + case c := <-rt.cmds: + rt.handle(c) + case now := <-tickerC(rt.simTicker): + rt.call(func(r Room) { rt.h.OnTick(r, now) }) + case <-tickerC(rt.frameRate): + rt.pushFrame() + } + rt.maybeSettle() + } +} + +// hibernatable reports whether this room's Handler opts into hibernation AND the +// host wired a snapshot fn. Read-only on the Handler reference (immutable), so +// it is safe both on and off the actor goroutine. +func (rt *roomRuntime) hibernatable() bool { + if rt.hibernateFn == nil { + return false + } + hc, ok := rt.h.(HibernationCapable) + return ok && hc.CanHibernate() +} + +func tickerC(t *time.Ticker) <-chan time.Time { + if t == nil { + return nil + } + return t.C +} + +func (rt *roomRuntime) handle(c command) { + switch c.kind { + case cmdJoin: + if rt.settled || rt.ended { + c.reply <- errRoomClosed + return + } + if rt.cfg.Capacity > 0 && len(rt.members) >= rt.cfg.Capacity { + c.reply <- errRoomFull + return + } + rt.cancelGrace() // a rejoin cancels any pending abandonment hibernation + ch := make(chan Frame, 1) + rt.members = append(rt.members, c.p) + rt.joined = append(rt.joined, c.p) + rt.out[c.p] = ch + rt.outPub.Store(c.p, ch) + rt.publishMembers() + c.reply <- nil + rt.logInfo("room: player joined", + slog.String("handle", c.p.Handle), slog.String("kind", string(c.p.Kind)), + slog.Int("members", len(rt.members))) + rt.call(func(r Room) { rt.h.OnJoin(r, c.p) }) + case cmdLeave: + if !rt.isMember(c.p) { + return + } + rt.removeMember(c.p) + rt.logInfo("room: player left", + slog.String("handle", c.p.Handle), slog.Int("members", len(rt.members))) + rt.call(func(r Room) { rt.h.OnLeave(r, c.p) }) + if len(rt.members) == 0 && len(rt.joined) > 0 { + // Abandonment, by lifecycle: a resident room ignores it entirely + // (the world keeps ticking); an ephemeral room gets the same + // grace reprieve but ENDS at its expiry (no snapshot, no resume + // entry); a resumable hibernate-capable room gets the grace then + // auto-hibernates; otherwise it ends now, as it always has. + switch { + case rt.cfg.Lifecycle == LifecycleResident: + // keep running + case rt.cfg.Lifecycle == LifecycleEphemeral: + rt.logInfo("room: abandoned — ending after grace", slog.Duration("grace", rt.abandonGrace)) + rt.armGrace(func() { rt.requestEnd(Result{Mode: rt.cfg.Mode}, "abandoned") }) + case rt.hibernatable(): + rt.logInfo("room: abandoned — hibernating after grace", slog.Duration("grace", rt.abandonGrace)) + rt.armGrace(func() { rt.doHibernate(rt.hibernateFn) }) + default: + rt.requestEnd(Result{Mode: rt.cfg.Mode}, "abandoned") + } + } + case cmdInput: + if rt.settled || rt.ended || !rt.isMember(c.p) { + return + } + rt.call(func(r Room) { rt.h.OnInput(r, c.p, c.in) }) + case cmdTimer: + e, ok := rt.timers[c.timerID] + if !ok { + return + } + if e.once { + delete(rt.timers, c.timerID) + } + rt.call(e.fn) + case cmdClose: + rt.requestEnd(Result{Mode: rt.cfg.Mode}, "closed") + case cmdHibernate: + rt.handleHibernate(c) + case cmdCheckpoint: + rt.handleCheckpoint(c) + } +} + +// armGrace schedules an abandonment-hibernation attempt after abandonGrace. It +// reuses the timer machinery (a once-timer firing on the actor) so cancellation +// and ctx teardown are already correct. The fire checks the room is still +// empty + unsettled before acting (a rejoin between arm and fire cancels it, but +// this is the belt-and-braces check). Caller is on the actor goroutine. +// armGrace schedules the abandonment action (hibernate or end, by lifecycle) +// after the grace window; a rejoin within the window cancels it. +func (rt *roomRuntime) armGrace(action func()) { + rt.nextTimer++ + id := rt.nextTimer + rt.graceTimer = id + rt.timers[id] = timerEntry{once: true, fn: func(Room) { + if rt.graceTimer != id { + return // superseded/cancelled + } + rt.graceTimer = 0 + if rt.settled || rt.ended || rt.hibernated || len(rt.members) != 0 { + return + } + action() // empty + unsettled: the lifecycle's abandonment action + }} + rt.scheduleTimer(id, rt.abandonGrace) +} + +// cancelGrace stops a pending abandonment grace timer (a rejoin or disposal). +// Caller is on the actor goroutine. +func (rt *roomRuntime) cancelGrace() { + if rt.graceTimer != 0 { + delete(rt.timers, rt.graceTimer) + rt.graceTimer = 0 + } +} + +// handleHibernate runs an explicit RoomCtl.Hibernate request on the actor at a +// quiescent point (the command loop guarantees no Handler callback is on the +// stack here). It replies on c.reply exactly once. +// +// Reply-before-dispose ordering matters: a successful hibernate disposes the room +// (disposeHibernated cancels rt.ctx). awaitReply unblocks on EITHER the reply or +// rt.ctx.Done(); if disposal's cancel became visible before the success reply was +// sent, awaitReply's ctx-done branch could observe an empty reply chan and report +// a FALSE ErrRoomClosed for a hibernation that actually succeeded (drain then +// logs "room is closed" / "froze 0 rooms"). So we send the success reply FIRST, +// then dispose — the reply send happens-before the cancel, so awaitReply's +// primary reply case is guaranteed to have a value waiting. (A failed freeze does +// NOT dispose, so its reply ordering is immaterial; settled/ended/hibernated is +// the genuine closed case.) +func (rt *roomRuntime) handleHibernate(c command) { + if rt.settled || rt.ended || rt.hibernated { + c.reply <- errRoomClosed + return + } + if c.freeze == nil { + c.reply <- errRoomClosed + return + } + rt.cancelGrace() + if err := c.freeze(rt.h); err != nil { + c.reply <- err // freeze failed: room stays live, ordering immaterial + return + } + c.reply <- nil // success reply BEFORE disposal cancels the ctx + rt.disposeHibernated() // cancels rt.ctx; reply already delivered +} + +// handleCheckpoint runs a NON-destructive checkpoint request on the actor at a +// quiescent point (no Handler callback on the stack), handing fn the live +// Handler so the caller can snapshot it (e.g. gameabi.CheckpointHandler + +// CheckpointStore.Write). Unlike Hibernate, the room is NOT disposed: it keeps +// running afterward (room-hosting spec "Periodic Room Checkpoints" / drain +// snapshots, design D5). A settled/ended/hibernated room replies ErrRoomClosed +// without calling fn; an fn error is returned to the caller and the room stays +// live. It replies on c.reply exactly once. +func (rt *roomRuntime) handleCheckpoint(c command) { + if rt.settled || rt.ended || rt.hibernated { + c.reply <- errRoomClosed + return + } + if c.freeze == nil { + c.reply <- errRoomClosed + return + } + c.reply <- c.freeze(rt.h) +} + +// doHibernate freezes the room via fn (run with the live Handler, no callback on +// the stack) then disposes the room WITHOUT a normal end: no Result, no +// leaderboard post, no DNF backfill — player streams just close. If fn fails the +// room is NOT disposed by this path (the caller decides; for the grace/drain +// path the room then ends normally on the next abandonment check or stays put). +// Caller is on the actor goroutine. +func (rt *roomRuntime) doHibernate(fn func(h Handler) error) error { + if fn == nil { + return errRoomClosed + } + rt.cancelGrace() + if err := fn(rt.h); err != nil { + return err + } + rt.logInfo("room: hibernated", slog.Duration("age", time.Since(rt.start))) + rt.disposeHibernated() + return nil +} + +// disposeHibernated tears the room down as paused (not finished): close every +// player stream so sessions see the room go away, stop tickers, cancel the ctx, +// and signal Done — but publish NO result and run NO OnClose settle path. The +// loop exits because hibernated is set. Caller is on the actor goroutine. +func (rt *roomRuntime) disposeHibernated() { + if rt.hibernated || rt.settled { + return + } + rt.hibernated = true + rt.phase.Store(&Phase{Name: "hibernated", Open: false}) + for p, ch := range rt.out { + close(ch) + rt.outPub.Delete(p) + } + rt.out = map[Player]chan Frame{} + rt.stopTickers() + rt.cancel() + close(rt.done) +} + +// call invokes a Handler callback with a fresh, epoch-stamped Room handle that +// becomes stale the moment the callback returns. +// call runs one handler callback on the actor goroutine, wrapped in a recover so +// a panic in host code (a game's host function, a frame send to a vanished +// player, a bug in the engine's per-callback plumbing) faults ONLY this room +// instead of unwinding the actor goroutine and crashing the whole process — +// which would take every other live room on the peer down with it. Every +// callback funnels through here (OnStart/Join/Leave/Input/Tick/Frame/Close and +// timers), so this is the one place isolation has to hold. A recovered room is +// ended; the loop's maybeSettle then tears it down on the normal path (and a +// re-panic inside the resulting OnClose is caught here too, so teardown still +// completes). +func (rt *roomRuntime) call(fn func(r Room)) { + rt.curEpoch++ + defer func() { + rt.curEpoch++ + if v := recover(); v != nil { + if rt.svc.Log != nil { + rt.svc.Log.Error("room actor panic — settling room", + slog.String("room", rt.roomID), + slog.Any("panic", v), + slog.String("stack", string(debug.Stack())), + ) + } + rt.requestEnd(Result{Mode: rt.cfg.Mode}, "panic") + } + }() + h := &roomHandle{rt: rt, epoch: rt.curEpoch} + fn(h) +} + +// logInfo emits an engine lifecycle event on the room's logger (nil in some +// tests). svc.Log already carries room+slug attrs (services.Factory.For). +func (rt *roomRuntime) logInfo(msg string, args ...any) { + if rt.svc.Log != nil { + rt.svc.Log.Info(msg, args...) + } +} + +func (rt *roomRuntime) requestEnd(res Result, reason string) { + if rt.ended || rt.settled { + return + } + rt.ended = true + rt.pending = res + rt.endReason = reason +} + +func (rt *roomRuntime) maybeSettle() { + if rt.ended && !rt.settled { + rt.settle() + } +} + +func (rt *roomRuntime) settle() { + res := rt.finalize(rt.pending) + rt.settled = true + rt.logInfo("room: settled", + slog.String("reason", rt.endReason), + slog.Duration("age", time.Since(rt.start)), + slog.Int("joined", len(rt.joined))) + rt.resultPub.Store(&res) + rt.phase.Store(&Phase{Name: "settled", Open: false, Settled: true, Result: &res}) + // close every player's stream exactly once + for p, ch := range rt.out { + close(ch) + rt.outPub.Delete(p) + } + rt.out = map[Player]chan Frame{} + rt.call(func(r Room) { rt.h.OnClose(r) }) + rt.stopTickers() + rt.cancel() + close(rt.done) +} + +// finalize backfills a dnf PlayerResult for every joined player the game omitted +// (the roster-of-record guarantee). +func (rt *roomRuntime) finalize(res Result) Result { + res.Mode = rt.cfg.Mode + have := map[Player]bool{} + for _, pr := range res.Rankings { + have[pr.Player] = true + } + for _, p := range rt.joined { + if !have[p] { + res.Rankings = append(res.Rankings, PlayerResult{Player: p, Status: StatusDNF}) + } + } + return res +} + +func (rt *roomRuntime) pushFrame() { + snap := frozen{members: rt.copyMembers(), cfg: rt.cfg, now: time.Now()} + rt.call(func(r Room) { rt.h.OnFrame(r, snap) }) +} + +// ---- membership helpers (actor-only) -------------------------------------- + +func (rt *roomRuntime) isMember(p Player) bool { + for _, m := range rt.members { + if m == p { + return true + } + } + return false +} + +func (rt *roomRuntime) removeMember(p Player) { + next := rt.members[:0] + for _, m := range rt.members { + if m != p { + next = append(next, m) + } + } + rt.members = next + if ch, ok := rt.out[p]; ok { + close(ch) + delete(rt.out, p) + rt.outPub.Delete(p) + } + rt.publishMembers() +} + +func (rt *roomRuntime) copyMembers() []Player { + cp := make([]Player, len(rt.members)) + copy(cp, rt.members) + return cp +} + +func (rt *roomRuntime) publishMembers() { + cp := rt.copyMembers() + rt.membersPub.Store(&cp) +} + +func (rt *roomRuntime) send(p Player, f Frame) { + ch, ok := rt.out[p] + if !ok { + return + } + coalesceSend(ch, f) +} + +// coalesceSend performs a non-blocking, depth-1, drop/coalesce-newest send: if +// the buffer already holds an undelivered frame, the stale one is discarded and +// the newest kept. A slow consumer never blocks the caller. +func coalesceSend(ch chan Frame, f Frame) { + select { + case ch <- f: + default: + select { + case <-ch: + default: + } + select { + case ch <- f: + default: + } + } +} + +func (rt *roomRuntime) stopTickers() { + if rt.simTicker != nil { + rt.simTicker.Stop() + rt.simTicker = nil + } + if rt.frameRate != nil { + rt.frameRate.Stop() + rt.frameRate = nil + } +} + +func (rt *roomRuntime) enqueue(c command) { + select { + case rt.cmds <- c: + case <-rt.ctx.Done(): + } +} + +// ---- RoomCtl (lobby-facing, called off the actor goroutine) --------------- + +func (rt *roomRuntime) Join(p Player) error { + reply := make(chan error, 1) + select { + case rt.cmds <- command{kind: cmdJoin, p: p, reply: reply}: + case <-rt.ctx.Done(): + return errRoomClosed + } + // awaitReply, not a bare <-reply: a cmdJoin can land in the buffered cmds + // channel an instant before the loop exits on settle/hibernate (e.g. the + // abandonment-grace timer fires first), and the loop never drains queued + // commands — a bare receive would wedge the calling session goroutine + // forever. The join success reply is sent before any disposal cancels the + // ctx, so no false ErrRoomClosed is possible. + return rt.awaitReply(reply) +} + +func (rt *roomRuntime) Leave(p Player) { rt.enqueue(command{kind: cmdLeave, p: p}) } +func (rt *roomRuntime) Input(p Player, in Input) { rt.enqueue(command{kind: cmdInput, p: p, in: in}) } + +func (rt *roomRuntime) Members() []Player { + if pp := rt.membersPub.Load(); pp != nil { + return *pp + } + return nil +} + +func (rt *roomRuntime) Frames(p Player) <-chan Frame { + if ch, ok := rt.outPub.Load(p); ok { + return ch.(chan Frame) + } + return nil +} + +func (rt *roomRuntime) Done() <-chan struct{} { return rt.done } + +func (rt *roomRuntime) InputContext() InputContext { return InputContext(rt.inputCtx.Load()) } + +func (rt *roomRuntime) Snapshot() Phase { + ph := rt.phase.Load() + if ph == nil { + return Phase{} + } + out := *ph + if !out.Settled && !out.Deadline.IsZero() { + out.Remaining = time.Until(out.Deadline) + if out.Remaining < 0 { + out.Remaining = 0 + } + } + return out +} + +func (rt *roomRuntime) Result() (Result, bool) { + if rp := rt.resultPub.Load(); rp != nil { + return *rp, true + } + return Result{}, false +} + +func (rt *roomRuntime) Close() error { + rt.enqueue(command{kind: cmdClose}) + return nil +} + +func (rt *roomRuntime) Hibernatable() bool { return rt.hibernatable() } + +// Hibernate enqueues a quiesce request and blocks until fn has run on the actor +// (with the live Handler, no callback on the stack) and the room is disposed, or +// the room is already gone. fn is the caller's snapshot step (e.g. +// gameabi.SnapshotHandler + a store Put); after it returns nil the room is +// disposed as paused, not finished (no Result, no leaderboard post). A +// settled/already-hibernated room returns ErrRoomClosed without calling fn. +func (rt *roomRuntime) Hibernate(fn func(h Handler) error) error { + reply := make(chan error, 1) + select { + case rt.cmds <- command{kind: cmdHibernate, freeze: fn, reply: reply}: + case <-rt.ctx.Done(): + return errRoomClosed + } + return rt.awaitReply(reply) +} + +// Checkpoint enqueues a NON-destructive quiesce request and blocks until fn has +// run on the actor (with the live Handler, no callback on the stack) — the room +// keeps running afterward. fn is the caller's snapshot step (e.g. +// gameabi.CheckpointHandler + CheckpointStore.Write at the room's next epoch). +// A settled/ended/hibernated room returns ErrRoomClosed without calling fn; an +// fn error is returned and the room stays live. This is the durability seam for +// periodic checkpoints and drain snapshots (room-hosting spec, design D5). +func (rt *roomRuntime) Checkpoint(fn func(h Handler) error) error { + reply := make(chan error, 1) + select { + case rt.cmds <- command{kind: cmdCheckpoint, freeze: fn, reply: reply}: + case <-rt.ctx.Done(): + return errRoomClosed + } + return rt.awaitReply(reply) +} + +// awaitReply blocks for a command's reply, but unblocks with ErrRoomClosed if +// the room disposes first. A command can land in the buffered cmds channel an +// instant before settle/hibernate cancels the ctx and exits the loop, so the +// loop never processes it and would never reply — awaitReply turns that into the +// room-closed contract instead of a deadlock. The buffered reply is preferred so +// a value the loop already produced is still observed even as ctx closes. +func (rt *roomRuntime) awaitReply(reply chan error) error { + select { + case err := <-reply: + return err + case <-rt.ctx.Done(): + // Prefer a reply that raced in just before ctx closed. + select { + case err := <-reply: + return err + default: + return errRoomClosed + } + } +} + +// frozen is the read-only Snapshot handed to OnFrame. +type frozen struct { + members []Player + cfg RoomConfig + now time.Time +} + +func (f frozen) Members() []Player { return f.members } +func (f frozen) Config() RoomConfig { return f.cfg } +func (f frozen) Now() time.Time { return f.now } + +// ---- the Room handle (game-facing, valid only inside a callback) ---------- + +type roomHandle struct { + rt *roomRuntime + epoch int64 +} + +func (h *roomHandle) valid() bool { + if h.epoch != h.rt.curEpoch { + if h.rt.svc.Log != nil && !h.rt.staleLogged.Swap(true) { + h.rt.svc.Log.Warn("stale room handle used outside its callback", slog.String("room", h.rt.roomID)) + } + return false + } + return true +} + +func (h *roomHandle) Members() []Player { + if !h.valid() { + return nil + } + return h.rt.copyMembers() +} + +func (h *roomHandle) Has(p Player) bool { + if !h.valid() { + return false + } + return h.rt.isMember(p) +} + +func (h *roomHandle) Count() int { + if !h.valid() { + return 0 + } + return len(h.rt.members) +} + +func (h *roomHandle) Config() RoomConfig { return h.rt.cfg } +func (h *roomHandle) Rand() *rand.Rand { return h.rt.rng } +func (h *roomHandle) Now() time.Time { return time.Now() } + +func (h *roomHandle) Send(p Player, f Frame) { + if !h.valid() { + return + } + h.rt.send(p, f) +} + +func (h *roomHandle) Identical(f Frame) { + if !h.valid() { + return + } + for _, p := range h.rt.members { + h.rt.send(p, f) + } +} + +func (h *roomHandle) BroadcastFunc(compose func(p Player) Frame) { + if !h.valid() { + return + } + for _, p := range h.rt.members { + h.rt.send(p, compose(p)) + } +} + +func (h *roomHandle) After(d time.Duration, fn func(r Room)) TimerID { + if !h.valid() { + return 0 + } + rt := h.rt + rt.nextTimer++ + id := rt.nextTimer + rt.timers[id] = timerEntry{fn: fn, once: true} + rt.scheduleTimer(id, d) + return id +} + +// scheduleTimer fires once-timer id onto the actor after d (or never, if the +// room's ctx is cancelled first). The timer entry must already be registered. +// Used by After and by the abandonment grace window. +func (rt *roomRuntime) scheduleTimer(id TimerID, d time.Duration) { + go func() { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-t.C: + rt.enqueue(command{kind: cmdTimer, timerID: id}) + case <-rt.ctx.Done(): + } + }() +} + +func (h *roomHandle) Every(d time.Duration, fn func(r Room)) TimerID { + if !h.valid() { + return 0 + } + rt := h.rt + rt.nextTimer++ + id := rt.nextTimer + rt.timers[id] = timerEntry{fn: fn, once: false} + go func() { + tk := time.NewTicker(d) + defer tk.Stop() + for { + select { + case <-tk.C: + rt.enqueue(command{kind: cmdTimer, timerID: id}) + case <-rt.ctx.Done(): + return + } + } + }() + return id +} + +func (h *roomHandle) Cancel(id TimerID) { + if !h.valid() { + return + } + delete(h.rt.timers, id) +} + +func (h *roomHandle) SetSimRate(d time.Duration) { + if !h.valid() { + return + } + if h.rt.simTicker != nil { + h.rt.simTicker.Stop() + h.rt.simTicker = nil + } + if d > 0 { + h.rt.simTicker = time.NewTicker(d) + } +} + +func (h *roomHandle) SetFrameRate(d time.Duration) { + if !h.valid() { + return + } + if h.rt.frameRate != nil { + h.rt.frameRate.Stop() + h.rt.frameRate = nil + } + if d > 0 { + h.rt.frameRate = time.NewTicker(d) + } +} + +func (h *roomHandle) SetPhase(name string, open bool, deadline time.Time) { + if !h.valid() { + return + } + h.rt.phase.Store(&Phase{Name: name, Open: open, Deadline: deadline}) +} + +func (h *roomHandle) SetInputContext(ctx InputContext) { + if !h.valid() { + return + } + h.rt.inputCtx.Store(int32(ctx)) +} + +func (h *roomHandle) End(res Result) { + if !h.valid() { + return + } + h.rt.requestEnd(res, "game") +} + +func (h *roomHandle) Result() (Result, bool) { return h.rt.Result() } +func (h *roomHandle) Services() Services { return h.rt.svc } +func (h *roomHandle) Log() *slog.Logger { return h.rt.svc.Log } diff --git a/host/sdk/room_test.go b/host/sdk/room_test.go new file mode 100644 index 0000000..b18e477 --- /dev/null +++ b/host/sdk/room_test.go @@ -0,0 +1,288 @@ +package sdk + +import ( + "bytes" + "log/slog" + "strings" + "sync" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/canvas" +) + +func frameWith(r rune) Frame { + f := canvas.New() + f.SetRune(0, 0, r, canvas.Style{}) + return f +} + +// coalescing: depth-1, drop/coalesce-newest. +func TestCoalesceSendKeepsNewest(t *testing.T) { + ch := make(chan Frame, 1) + coalesceSend(ch, frameWith('a')) + coalesceSend(ch, frameWith('b')) + coalesceSend(ch, frameWith('c')) + if len(ch) != 1 { + t.Fatalf("buffer holds %d frames, want 1", len(ch)) + } + got := <-ch + if got.Cells[0][0].Rune != 'c' { + t.Fatalf("got %q, want newest 'c'", got.Cells[0][0].Rune) + } +} + +// a game that ends on the rune 'q', ranking current members as finished. +type endGame struct{ GameBase } + +func (endGame) Meta() GameMeta { return GameMeta{Slug: "end", MaxPlayers: 5} } +func (endGame) NewRoom(RoomConfig, Services) Handler { return &endHandler{} } + +type endHandler struct{ Base } + +func (endHandler) OnInput(r Room, p Player, in Input) { + r.Send(p, frameWith(in.Rune)) + if in.Rune == 'q' { + var rk []PlayerResult + for i, m := range r.Members() { + rk = append(rk, PlayerResult{Player: m, Rank: i + 1, Metric: 100, Status: StatusFinished}) + } + r.End(Result{Rankings: rk}) + } +} + +func mkPlayer(id string) Player { return Player{AccountID: id, Handle: id, Kind: KindMember} } + +// a game that panics inside a callback on the rune 'p' — standing in for a +// host-side fault (a bad host fn, a frame send to a vanished player). +type panicGame struct{ GameBase } + +func (panicGame) Meta() GameMeta { return GameMeta{Slug: "panic", MaxPlayers: 5} } +func (panicGame) NewRoom(RoomConfig, Services) Handler { return &panicHandler{} } + +type panicHandler struct{ Base } + +func (panicHandler) OnInput(r Room, p Player, in Input) { + if in.Rune == 'p' { + panic("boom inside a callback") + } +} + +// A panic on the actor goroutine must fault ONLY its room (settle it), never +// unwind the goroutine and crash the process — which would take every other +// live room on the peer down. The room ends; a brand-new room still works +// afterward, proving the process survived. +func TestActorPanicSettlesRoomNotProcess(t *testing.T) { + cfg := RoomConfig{Mode: ModeQuick, Capacity: 5} + ctl := NewRoomRuntime("rp", panicGame{}.NewRoom(cfg, Services{}), cfg, Services{}) + if err := ctl.Join(mkPlayer("a")); err != nil { + t.Fatalf("join: %v", err) + } + ctl.Input(mkPlayer("a"), RuneInput('p')) // panics inside OnInput + select { + case <-ctl.Done(): + case <-time.After(2 * time.Second): + t.Fatal("panicking room did not settle — recover did not fault it") + } + + // The process is still alive (we got here), and a fresh room still runs. + cfg2 := RoomConfig{Mode: ModeQuick, Capacity: 1} + ok := NewRoomRuntime("rp2", endGame{}.NewRoom(cfg2, Services{}), cfg2, Services{}) + if err := ok.Join(mkPlayer("b")); err != nil { + t.Fatalf("a new room must still work after another room panicked: %v", err) + } + ok.Input(mkPlayer("b"), RuneInput('q')) + <-ok.Done() +} + +// roster-of-record: a joined-then-left player still appears as dnf, and the +// game ranking the rest is enough — the engine backfills. +func TestRosterOfRecordBackfillsDNF(t *testing.T) { + ctl := NewRoomRuntime("r1", endGame{}.NewRoom(RoomConfig{Mode: ModeQuick, Capacity: 5}, Services{}), RoomConfig{Mode: ModeQuick, Capacity: 5}, Services{}) + a, b, c := mkPlayer("a"), mkPlayer("b"), mkPlayer("c") + for _, p := range []Player{a, b, c} { + if err := ctl.Join(p); err != nil { + t.Fatalf("join %s: %v", p.AccountID, err) + } + } + ctl.Leave(c) // enqueued before the input below (FIFO) + ctl.Input(a, RuneInput('q')) // game ends ranking the live members (a, b) + <-ctl.Done() + + res, ok := ctl.Result() + if !ok { + t.Fatal("no result after Done") + } + if len(res.Rankings) != 3 { + t.Fatalf("rankings=%d, want 3 (every joined player)", len(res.Rankings)) + } + var cStatus Status + finished := 0 + for _, pr := range res.Rankings { + if pr.Player == c { + cStatus = pr.Status + } + if pr.Status == StatusFinished { + finished++ + } + } + if cStatus != StatusDNF { + t.Fatalf("left player c status=%q, want dnf", cStatus) + } + if finished != 2 { + t.Fatalf("finished=%d, want 2 (a,b)", finished) + } +} + +// End fires exactly once; Done is closed; further input is a no-op. +func TestSettleOnce(t *testing.T) { + cfg := RoomConfig{Mode: ModeSolo, Capacity: 1} + ctl := NewRoomRuntime("r2", endGame{}.NewRoom(cfg, Services{}), cfg, Services{}) + a := mkPlayer("a") + if err := ctl.Join(a); err != nil { + t.Fatal(err) + } + ctl.Input(a, RuneInput('q')) + <-ctl.Done() + // further input must not panic or reopen the room + ctl.Input(a, RuneInput('x')) + if _, ok := ctl.Result(); !ok { + t.Fatal("result missing") + } + // Done channel is closed (second receive returns immediately) + select { + case <-ctl.Done(): + default: + t.Fatal("Done not closed") + } +} + +// capacity is enforced atomically. +func TestCapacityEnforced(t *testing.T) { + cfg := RoomConfig{Mode: ModeQuick, Capacity: 2} + ctl := NewRoomRuntime("r3", endGame{}.NewRoom(cfg, Services{}), cfg, Services{}) + if err := ctl.Join(mkPlayer("a")); err != nil { + t.Fatal(err) + } + if err := ctl.Join(mkPlayer("b")); err != nil { + t.Fatal(err) + } + if err := ctl.Join(mkPlayer("c")); err == nil { + t.Fatal("third join should fail (capacity 2)") + } + _ = ctl.Close() + <-ctl.Done() +} + +func recvFrame(t *testing.T, ch <-chan Frame) Frame { + t.Helper() + select { + case f := <-ch: + return f + case <-time.After(time.Second): + t.Fatal("no frame received within 1s") + return Frame{} + } +} + +// Two connections of the SAME account (same AccountID/Handle, different Conn) are +// distinct memberships with their own frame streams. Regression for the same-SSH +// -key-twice freeze. +func TestSameAccountDistinctConnections(t *testing.T) { + cfg := RoomConfig{Mode: ModeQuick, Capacity: 5} + ctl := NewRoomRuntime("rc", endGame{}.NewRoom(cfg, Services{}), cfg, Services{}) + p1 := Player{AccountID: "a", Handle: "h", Kind: KindMember, Conn: "1"} + p2 := Player{AccountID: "a", Handle: "h", Kind: KindMember, Conn: "2"} + if err := ctl.Join(p1); err != nil { + t.Fatal(err) + } + if err := ctl.Join(p2); err != nil { + t.Fatal(err) + } + if len(ctl.Members()) != 2 { + t.Fatalf("members=%d, want 2 distinct seats", len(ctl.Members())) + } + ch1, ch2 := ctl.Frames(p1), ctl.Frames(p2) + if ch1 == nil || ch2 == nil { + t.Fatal("missing per-connection frame stream") + } + // each connection receives ITS OWN frame (the endGame echoes the typed rune). + ctl.Input(p1, RuneInput('x')) + ctl.Input(p2, RuneInput('y')) + if got := recvFrame(t, ch1).Cells[0][0].Rune; got != 'x' { + t.Fatalf("conn1 frame rune=%q, want 'x' (its channel was orphaned?)", got) + } + if got := recvFrame(t, ch2).Cells[0][0].Rune; got != 'y' { + t.Fatalf("conn2 frame rune=%q, want 'y'", got) + } + _ = ctl.Close() + <-ctl.Done() +} + +// concurrency smoke: drain frames while inputs arrive; -race must stay clean. +func TestConcurrentDrainRace(t *testing.T) { + cfg := RoomConfig{Mode: ModeQuick, Capacity: 4} + ctl := NewRoomRuntime("r4", endGame{}.NewRoom(cfg, Services{}), cfg, Services{}) + a := mkPlayer("a") + if err := ctl.Join(a); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + ch := ctl.Frames(a) + for range ch { // drain until closed at settle + } + }() + for i := 0; i < 200; i++ { + ctl.Input(a, RuneInput('x')) + } + ctl.Input(a, RuneInput('q')) + <-ctl.Done() + wg.Wait() +} + +// syncLogBuf is a goroutine-safe sink for asserting engine log lines (the +// actor goroutine writes them). +type syncLogBuf struct { + mu sync.Mutex + b bytes.Buffer +} + +func (s *syncLogBuf) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} + +func (s *syncLogBuf) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.String() +} + +// lifecycle INFO logs: join/leave/settle with an end reason, asserted through +// the real actor goroutine. +func TestLifecycleLogs(t *testing.T) { + buf := &syncLogBuf{} + svc := Services{Log: slog.New(slog.NewTextHandler(buf, nil))} + cfg := RoomConfig{Mode: ModeQuick} + ctl := NewRoomRuntime("room-log-test", endGame{}.NewRoom(cfg, svc), cfg, svc) + p := Player{Handle: "alice", Kind: KindMember, Conn: "c1"} + if err := ctl.Join(p); err != nil { + t.Fatalf("join: %v", err) + } + ctl.Leave(p) + <-ctl.Done() // empty non-resident, non-hibernatable room ends immediately + got := buf.String() + for _, want := range []string{ + `msg="room: player joined"`, `handle=alice`, + `msg="room: player left"`, + `msg="room: settled"`, `reason=abandoned`, + } { + if !strings.Contains(got, want) { + t.Errorf("log output missing %q\n--- got:\n%s", want, got) + } + } +} diff --git a/host/sdk/roomid.go b/host/sdk/roomid.go new file mode 100644 index 0000000..517da94 --- /dev/null +++ b/host/sdk/roomid.go @@ -0,0 +1,30 @@ +package sdk + +import "github.com/google/uuid" + +// NewRoomID mints a room identifier: a UUIDv7 string — globally unique with no +// cross-machine coordination, stable across process restarts, and time-ordered +// (so it indexes well as the directory primary key and sorts by creation time +// as the checkpoint key prefix). uuid.NewV7 only errors on reader entropy +// failure, which is unrecoverable, so this treats it like uuid.Must and panics +// rather than returning an error. +func NewRoomID() string { + id, err := uuid.NewV7() + if err != nil { + panic("sdk: room id entropy failure: " + err.Error()) + } + return id.String() +} + +// ValidRoomID reports whether s is a well-formed room id — a UUID of version 7 +// in canonical form. It rejects the legacy "-" id format, UUIDs of +// other versions, and the non-canonical spellings uuid.Parse otherwise accepts +// (braced, urn:uuid: prefix, undashed, upper-case): the id is a directory +// primary key, so requiring id.String() == s keeps one byte-exact key per room. +func ValidRoomID(s string) bool { + id, err := uuid.Parse(s) + if err != nil { + return false + } + return id.Version() == 7 && id.String() == s +} diff --git a/host/sdk/roomid_test.go b/host/sdk/roomid_test.go new file mode 100644 index 0000000..d49fd7c --- /dev/null +++ b/host/sdk/roomid_test.go @@ -0,0 +1,75 @@ +package sdk + +import ( + "sort" + "strings" + "testing" + + "github.com/google/uuid" +) + +// NewRoomID mints distinct ids across many calls, including across simulated +// process restarts (two independent batches must not collide) — the directory +// PK and checkpoint prefix depend on no cross-machine/cross-restart counter. +func TestNewRoomIDUnique(t *testing.T) { + const n = 10000 + seen := make(map[string]struct{}, 2*n) + mint := func() { + for i := 0; i < n; i++ { + id := NewRoomID() + if _, dup := seen[id]; dup { + t.Fatalf("duplicate room id %q", id) + } + seen[id] = struct{}{} + } + } + mint() // batch one + mint() // batch two — stands in for a fresh process + if len(seen) != 2*n { + t.Fatalf("got %d distinct ids, want %d", len(seen), 2*n) + } +} + +// ValidRoomID accepts canonical v7 mints and rejects the legacy "-" +// id format, wrong-version UUIDs, and non-canonical UUID spellings (the room id +// is a directory primary key, so only the exact canonical form is valid). +func TestValidRoomID(t *testing.T) { + canonical := NewRoomID() + if !ValidRoomID(canonical) { + t.Fatalf("ValidRoomID(%q) = false, want true for a fresh mint", canonical) + } + for _, bad := range []string{ + "type-racer-1", + "preview-type-racer-2", + "", + "not-a-uuid", + // A valid UUID of the wrong version (v4) is not a room id. + uuid.New().String(), + // Non-canonical forms uuid.Parse accepts but a PK must not. + "{" + canonical + "}", // braced + "urn:uuid:" + canonical, // urn-prefixed + strings.ReplaceAll(canonical, "-", ""), // undashed + strings.ToUpper(canonical), // upper-case hex + } { + if ValidRoomID(bad) { + t.Fatalf("ValidRoomID(%q) = true, want false", bad) + } + } +} + +// V7 ids are time-ordered: ids minted in sequence sort by creation time, so the +// directory PK is index-friendly. +func TestNewRoomIDTimeOrdered(t *testing.T) { + const n = 256 + ids := make([]string, n) + for i := range ids { + ids[i] = NewRoomID() + } + sorted := append([]string(nil), ids...) + sort.Strings(sorted) + for i := range ids { + if ids[i] != sorted[i] { + t.Fatalf("ids not time-ordered: position %d is %q in mint order but %q when sorted", i, ids[i], sorted[i]) + } + } +} diff --git a/host/sdk/services.go b/host/sdk/services.go new file mode 100644 index 0000000..331a909 --- /dev/null +++ b/host/sdk/services.go @@ -0,0 +1,40 @@ +package sdk + +import "log/slog" + +// Services is the per-room bundle of shared concerns, constructed by a +// ServicesFactory. Games reach shared concerns ONLY via Room.Services() and +// MUST drop the reference in OnClose. +type Services struct { + Leaderboard LeaderboardClient + Accounts AccountStore + Config ConfigStore // slug-bound, read-only per-game config (may be nil) + Chat ChatClient + Spectate SpectatorClient + Log *slog.Logger +} + +// ServicesFactory constructs a per-room Services. It has distinct +// implementations for production (durable) and dev (in-memory). +type ServicesFactory interface { + // For builds the Services for a room, tagging the logger with room + slug. + For(roomID, slug string) Services +} + +// LeaderboardClient is the game-facing write side. Games ALWAYS call Post and +// never branch on eligibility; the implementation records every account-bound +// result tagged with mode + status (dropping only guests). Reads are NOT here — +// they live on LeaderboardReader, which games never receive. +type LeaderboardClient interface { + Post(slug string, r Result) +} + +// ChatClient is a room-local chat hook (no-op stub in v1). +type ChatClient interface { + Broadcast(roomID, from, msg string) +} + +// SpectatorClient is a read-only join hook (no-op stub in v1). +type SpectatorClient interface { + Open(roomID string) error +} diff --git a/host/sdk/testroom.go b/host/sdk/testroom.go new file mode 100644 index 0000000..a5efc22 --- /dev/null +++ b/host/sdk/testroom.go @@ -0,0 +1,239 @@ +package sdk + +import ( + "log/slog" + "math/rand" + "sort" + "time" +) + +// TestRoom is a published Room test double. It drives a Handler's callbacks +// synchronously with an injectable clock and records emitted frames, published +// phases, and the settled Result — so a game's logic can be unit-tested with no +// goroutine, channel, or socket. +type TestRoom struct { + cfg RoomConfig + h Handler + svc Services + rng *rand.Rand + + Clock time.Time + members []Player + joined []Player + + Frames map[Player][]Frame // every frame pushed, per player + Phases []Phase // every SetPhase call + InputCtx InputContext // latest SetInputContext value (zero = CtxNav) + Ended bool + Res Result + + timers map[TimerID]testTimer + nextID TimerID +} + +type testTimer struct { + fireAt time.Time + period time.Duration + fn func(r Room) + every bool +} + +// NewTestRoom builds a TestRoom for game g with the given config. The clock +// starts at an arbitrary fixed instant; use Advance to move it. +func NewTestRoom(g Game, cfg RoomConfig, svc Services) *TestRoom { + return NewTestRoomFor(g.NewRoom(cfg, svc), cfg, svc) +} + +// NewTestRoomFor builds a TestRoom driving an explicit Handler. Useful for +// white-box tests that need direct access to the handler's state. +func NewTestRoomFor(h Handler, cfg RoomConfig, svc Services) *TestRoom { + if svc.Log == nil { + svc.Log = slog.Default() + } + seed := cfg.Seed + if !cfg.SeedSet { + seed = 1 + } + return &TestRoom{ + cfg: cfg, + h: h, + svc: svc, + rng: rand.New(rand.NewSource(seed)), + Clock: time.Unix(1_700_000_000, 0), + Frames: map[Player][]Frame{}, + timers: map[TimerID]testTimer{}, + } +} + +// Start invokes OnStart. +func (t *TestRoom) Start() { t.h.OnStart(t) } + +// Join admits a player and invokes OnJoin. +func (t *TestRoom) Join(p Player) { + if t.Ended { + return + } + t.members = append(t.members, p) + t.joined = append(t.joined, p) + t.h.OnJoin(t, p) +} + +// Leave removes a player and invokes OnLeave. +func (t *TestRoom) Leave(p Player) { + out := t.members[:0] + for _, m := range t.members { + if m != p { + out = append(out, m) + } + } + t.members = out + t.h.OnLeave(t, p) +} + +// Input delivers an input and invokes OnInput. +func (t *TestRoom) Input(p Player, in Input) { + if t.Ended { + return + } + t.h.OnInput(t, p, in) +} + +// Tick invokes OnTick at the current clock. +func (t *TestRoom) Tick() { t.h.OnTick(t, t.Clock) } + +// Frame invokes OnFrame with a frozen snapshot at the current clock. +func (t *TestRoom) Frame() { + snap := frozen{members: append([]Player(nil), t.members...), cfg: t.cfg, now: t.Clock} + t.h.OnFrame(t, snap) +} + +// Advance moves the clock forward and fires any due timers (in time order). +func (t *TestRoom) Advance(d time.Duration) { + target := t.Clock.Add(d) + for { + var nextID TimerID + var next testTimer + found := false + for id, tm := range t.timers { + if !tm.fireAt.After(target) && (!found || tm.fireAt.Before(next.fireAt)) { + nextID, next, found = id, tm, true + } + } + if !found { + break + } + t.Clock = next.fireAt + if next.every { + next.fireAt = next.fireAt.Add(next.period) + t.timers[nextID] = next + } else { + delete(t.timers, nextID) + } + next.fn(t) + if t.Ended { + break + } + } + t.Clock = target +} + +// LastFrame returns the most recent frame pushed to p, if any. +func (t *TestRoom) LastFrame(p Player) (Frame, bool) { + fs := t.Frames[p] + if len(fs) == 0 { + return Frame{}, false + } + return fs[len(fs)-1], true +} + +// ---- Room implementation -------------------------------------------------- + +func (t *TestRoom) Members() []Player { return append([]Player(nil), t.members...) } +func (t *TestRoom) Has(p Player) bool { + for _, m := range t.members { + if m == p { + return true + } + } + return false +} +func (t *TestRoom) Count() int { return len(t.members) } +func (t *TestRoom) Config() RoomConfig { return t.cfg } +func (t *TestRoom) Rand() *rand.Rand { return t.rng } +func (t *TestRoom) Now() time.Time { return t.Clock } + +func (t *TestRoom) Send(p Player, f Frame) { t.Frames[p] = append(t.Frames[p], f) } +func (t *TestRoom) Identical(f Frame) { + for _, p := range t.members { + t.Frames[p] = append(t.Frames[p], f) + } +} +func (t *TestRoom) BroadcastFunc(compose func(p Player) Frame) { + for _, p := range t.members { + t.Frames[p] = append(t.Frames[p], compose(p)) + } +} + +func (t *TestRoom) After(d time.Duration, fn func(r Room)) TimerID { + t.nextID++ + t.timers[t.nextID] = testTimer{fireAt: t.Clock.Add(d), fn: fn} + return t.nextID +} +func (t *TestRoom) Every(d time.Duration, fn func(r Room)) TimerID { + t.nextID++ + t.timers[t.nextID] = testTimer{fireAt: t.Clock.Add(d), period: d, fn: fn, every: true} + return t.nextID +} +func (t *TestRoom) Cancel(id TimerID) { delete(t.timers, id) } +func (t *TestRoom) SetSimRate(time.Duration) {} +func (t *TestRoom) SetFrameRate(time.Duration) {} + +func (t *TestRoom) SetPhase(name string, open bool, deadline time.Time) { + ph := Phase{Name: name, Open: open, Deadline: deadline} + if !deadline.IsZero() { + ph.Remaining = deadline.Sub(t.Clock) + } + t.Phases = append(t.Phases, ph) +} + +// SetInputContext records the latest published input context, exposed via +// InputCtx for assertions. +func (t *TestRoom) SetInputContext(ctx InputContext) { t.InputCtx = ctx } + +func (t *TestRoom) End(res Result) { + if t.Ended { + return + } + t.Ended = true + // backfill dnf vs roster-of-record, mirroring the engine + res.Mode = t.cfg.Mode + have := map[Player]bool{} + for _, pr := range res.Rankings { + have[pr.Player] = true + } + for _, p := range t.joined { + if !have[p] { + res.Rankings = append(res.Rankings, PlayerResult{Player: p, Status: StatusDNF}) + } + } + sort.SliceStable(res.Rankings, func(i, j int) bool { return res.Rankings[i].Rank < res.Rankings[j].Rank }) + t.Res = res + t.h.OnClose(t) +} + +func (t *TestRoom) Result() (Result, bool) { + if t.Ended { + return t.Res, true + } + return Result{}, false +} +func (t *TestRoom) Services() Services { return t.svc } +func (t *TestRoom) Log() *slog.Logger { return t.svc.Log } + +// LastPhase returns the most recently published phase. +func (t *TestRoom) LastPhase() (Phase, bool) { + if len(t.Phases) == 0 { + return Phase{}, false + } + return t.Phases[len(t.Phases)-1], true +} diff --git a/host/sdk/types.go b/host/sdk/types.go new file mode 100644 index 0000000..bfbc7f7 --- /dev/null +++ b/host/sdk/types.go @@ -0,0 +1,307 @@ +// Package sdk is the game engine boundary. A game implements a Handler (event +// callbacks); the engine owns the Room runtime and hands the game a Room handle +// to drive output. The lobby holds a RoomCtl. The game never sees channels, the +// actor goroutine, or any bubbletea type. +package sdk + +import ( + "time" + + "github.com/shellcade/kit/v2/host/canvas" +) + +// Frame and Cell are aliases of the render/canvas types — the SDK never +// redefines them, so a game cannot exceed the fixed 80x24 canvas. +type ( + Frame = canvas.Grid + Cell = canvas.Cell + Style = canvas.Style +) + +// Kind distinguishes a keyless guest from a member (an account holding ≥1 +// credential — any mix of SSH keys and passkeys). It is computed from credential +// count, never stored; credential TYPE is not a property of the account. +type Kind string + +const ( + KindGuest Kind = "guest" + KindMember Kind = "member" +) + +// Character is the resolved player character (player-character capability): +// one single-cell glyph, resolved ink/bg RGB (palette IDs never cross this +// boundary), and the single-byte ASCII fallback for non-UTF8 sessions. It is +// value-comparable, populated by the host BEFORE the player is admitted, and +// FIXED for the life of the connection — the Character Builder is reachable +// only from the lobby, so a character change always arrives as a NEW Player +// on the next join. +type Character struct { + Glyph string // exactly one code point, width 1 everywhere + InkR, InkG, InkB uint8 + BgR, BgG, BgB uint8 + Fallback byte // printable ASCII shown on non-UTF8 sessions +} + +// Player is a value-comparable membership token. It is usable as a map key and +// carries NO Session / io.ReadWriter reference. A reconnect yields a NEW Player. +type Player struct { + AccountID string + Handle string + Kind Kind + // Conn uniquely identifies this CONNECTION, so two concurrent sessions of the + // same account (e.g. the same SSH key opened in two terminals) are DISTINCT + // memberships rather than colliding on one map key. It is an opaque token, not + // a Session reference; Player remains value-comparable. AccountID/Handle/Kind + // still identify the account for leaderboard and identity policy. + Conn string + // Character is the resolved player character, fixed per connection (see + // Character). Value-comparable, so Player remains usable as a map key. + Character Character + // IsSynthetic marks a load-test player (an account minted via the synthetic + // token). Host-side only — it labels metrics so synthetic load is separable + // from real traffic; the game guest never sees it (add-loadtest-harness). + IsSynthetic bool +} + +// Guest reports whether the player is a keyless guest. +func (p Player) Guest() bool { return p.Kind == KindGuest } + +// DisplayName is the handle with a "(guest)" marker for guests. +func (p Player) DisplayName() string { + if p.Kind == KindGuest { + return p.Handle + " (guest)" + } + return p.Handle +} + +// InputKind distinguishes a printable rune from a named key. +type InputKind uint8 + +const ( + InputRune InputKind = iota + InputKey +) + +// Key is a named (non-printable) key. +type Key uint8 + +const ( + KeyNone Key = iota + KeyEnter + KeyBackspace + KeyEsc + KeyTab + KeyUp + KeyDown + KeyLeft + KeyRight + KeyCtrlC +) + +// Input is the SDK-neutral input event, translated from the transport at the +// Session boundary. It is NEVER a bubbletea message. +type Input struct { + Kind InputKind + Rune rune + Key Key +} + +// RuneInput builds a printable-rune input. +func RuneInput(r rune) Input { return Input{Kind: InputRune, Rune: r} } + +// KeyInput builds a named-key input. +func KeyInput(k Key) Input { return Input{Kind: InputKey, Key: k} } + +// Mode is the matchmaking + timing classifier. It is NOT eligibility policy. +type Mode string + +const ( + ModeQuick Mode = "quick" + ModePrivate Mode = "private" + ModeSolo Mode = "solo" +) + +// Status is a player's terminal outcome. +type Status string + +const ( + StatusFinished Status = "finished" + StatusDNF Status = "dnf" + StatusFlagged Status = "flagged" +) + +// RoomConfig carries the matchmaking/timing classifier and the reproducibility +// seed. Mode must never drive leaderboard eligibility (that lives in Services). +type RoomConfig struct { + Mode Mode + Capacity int + MinPlayers int + Seed int64 + SeedSet bool + + // Lifecycle is the room's resolved end-of-life mode (declaration ∧ + // grant, resolved by the matchmaker at construction). It is engine + // state, not wire state: callbacks don't carry it and snapshots don't + // record it — a restore re-resolves it from the game's meta and the + // grant list. + Lifecycle Lifecycle +} + +// Lifecycle is a room's end-of-life mode (mirrors the kit declaration). +type Lifecycle uint8 + +const ( + LifecycleResumable Lifecycle = 0 // hibernate on abandon (default) + LifecycleEphemeral Lifecycle = 1 // end + dispose after the abandon grace + LifecycleResident Lifecycle = 2 // granted singleton; ticks while empty +) + +// GameMeta is static game metadata referenced by slug. +type GameMeta struct { + Slug string `json:"slug"` + Name string `json:"name"` + ShortDescription string `json:"shortDescription"` + MinPlayers int `json:"minPlayers"` + MaxPlayers int `json:"maxPlayers"` + Tags []string `json:"tags,omitempty"` + + // Optional per-game lobby mode labels. An empty value means "use the lobby's + // game-agnostic default", so the lobby carries no game-specific wording. + QuickModeLabel string `json:"quickModeLabel,omitempty"` // mode-picker label for Quick; "" -> "Quick match" + SoloModeLabel string `json:"soloModeLabel,omitempty"` // mode-picker label for Solo; "" -> "Solo practice" + PrivateInviteLine string `json:"privateInviteLine,omitempty"` // trailing line in the private-create flash; "" -> "Play begins when a second player joins." + + // Leaderboard optionally declares how this game's board behaves (label, + // direction, aggregation, format). Nil means the defaults (best single + // result, higher is better, integer) — see ResolveLeaderboardSpec. + Leaderboard *LeaderboardSpec `json:"leaderboard,omitempty"` + + // Config optionally declares the game's admin-settable config keys so the + // admin Game settings area can render typed get/edit forms. Nil/empty + // means no declared surface (the generic editor still works). + Config []ConfigKeySpec `json:"config,omitempty"` + + // CtxFeatures is the game's declared negotiated-callback-encoding bitset + // (wire.CtxFeat*; 0 = none). The host honors bits it implements and + // ignores the rest. + CtxFeatures uint32 `json:"ctxFeatures,omitempty"` + + // HeartbeatMS is the game's declared wake cadence in milliseconds + // (0 = no declaration). Precedence at room creation: admin + // host.heartbeat_ms config > this declaration > the platform default, + // clamped to the host envelope. + HeartbeatMS int `json:"heartbeatMS,omitempty"` + + // Lifecycle is the game's declared end-of-life shape (resumable 0 / + // ephemeral 1 / resident 2). Resident takes effect only when the + // platform grants it; an undeclared or unknown value reads as + // resumable. + Lifecycle Lifecycle `json:"lifecycle,omitempty"` + + // WireRevision is the wire revision (wire.Revision) of the kit the + // artifact was built against, stamped by the SDK encoders — the + // mechanical anchor for the deploy-order rule. 0 = unknown (the meta + // predates the field, kit ≤ v2.7.x). A value ABOVE the host's compiled-in + // wire.Revision means the artifact assumes wire semantics this host does + // not implement; the catalog warns when it sees one. + WireRevision uint16 `json:"wireRevision,omitempty"` + + // Controls is the game's declared extra controls (decoded from the meta + // payload's trailing declared-controls section): inputs beyond the + // canonical vocabulary, each with a short display label, surfaced by + // touch front ends as tappable affordances that send exactly the + // declared input. Presentation metadata only — declarations change no + // input interpretation. Nil/empty = none declared. + Controls []ControlDecl `json:"controls,omitempty"` + + // Hidden is a HOST-SET flag (json:"-", never decoded from a guest's declared + // meta) marking a game live-but-unlisted: it is registered and reachable by + // exact slug (quick-match, direct entry, admin), but the lobby's player-facing + // games menu omits it. The built-in load-test game uses this so real players + // never land in bot rooms (add-loadtest-harness). + Hidden bool `json:"-"` +} + +// ControlDecl is one game-declared extra control: the exact input it sends +// (a printable rune or a named key) and a short display label. +type ControlDecl struct { + Kind InputKind `json:"kind"` // InputRune or InputKey + Rune rune `json:"rune,omitempty"` // the printable rune (Kind == InputRune) + Key Key `json:"key,omitempty"` // the named key (Kind == InputKey) + Label string `json:"label"` +} + +// ConfigType tells the admin surface how a declared config value is edited +// and validated (mirrors the kit/wire type codes). +type ConfigType uint8 + +const ( + ConfigText ConfigType = iota // single-line string + ConfigNumber // decimal number + ConfigBool // true/false + ConfigJSON // JSON document (multiline / rich form) +) + +// ConfigEntry is one stored per-game config row (key, opaque value, write +// provenance) — the admin "what is set" listing shape returned by the store. +type ConfigEntry struct { + Key string + Value []byte + UpdatedAt time.Time + UpdatedBy string +} + +// ConfigKeySpec is one game-declared admin-settable config key (decoded from +// the meta payload's trailing config-spec section). +type ConfigKeySpec struct { + Key string `json:"key"` // the ConfigStore key the game reads + Title string `json:"title"` // short admin-facing label + Description string `json:"description,omitempty"` // one or two sentences for the admin screen + Type ConfigType `json:"type"` // how the value is edited/validated + Default string `json:"default,omitempty"` // value the game uses when unset ("" = not declared) + Schema string `json:"schema,omitempty"` // JSON Schema document (ConfigJSON only; "" = none) +} + +// PlayerResult is one player's outcome in a settled room. +type PlayerResult struct { + Player Player + Metric int + Rank int + Status Status +} + +// Result is the room-level outcome. Rankings contains exactly one PlayerResult +// for every player that joined (the engine backfills dnf for omissions). +type Result struct { + Mode Mode + Rankings []PlayerResult + + // RoundSeq is the host-assigned, room-scoped 1-based sequence of this + // leaderboard post (the gameabi host stamps it; the counter survives + // hibernation). The durable leaderboard derives an idempotent round id from + // (roomID, RoundSeq), so a post-restore re-settle of the same round — or a + // retried write — dedupes instead of double-counting. 0 means unassigned + // (a poster without replay determinism); the writer falls back to a random + // round id, which still dedupes its own retries. + RoundSeq uint64 +} + +// Phase is the engine-published, lobby-visible game phase. The game owns the +// values (via Room.SetPhase); the engine derives Remaining at read time. +type Phase struct { + Name string + Open bool + Deadline time.Time + Remaining time.Duration + Settled bool + Result *Result +} + +// Snapshot is the frozen, read-only room state handed to OnFrame and to the +// BroadcastFunc closure. Per-viewer composition reads only this (plus the +// viewing Player), never live room state. +type Snapshot interface { + Members() []Player + Config() RoomConfig + Now() time.Time +} From 1825812bfc8b1721d933d3acdf3923eac44f979e Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Tue, 16 Jun 2026 07:48:44 +1000 Subject: [PATCH 02/10] host: extract generic blobstore (no S3/AWS) into the kit module Co-Authored-By: Claude Opus 4.8 --- host/blobstore/blobstore.go | 89 +++++++++++++++ host/blobstore/blobstore_test.go | 173 ++++++++++++++++++++++++++++++ host/blobstore/checkpoint.go | 106 ++++++++++++++++++ host/blobstore/checkpoint_test.go | 101 +++++++++++++++++ host/blobstore/dir.go | 133 +++++++++++++++++++++++ host/blobstore/instrument.go | 47 ++++++++ host/blobstore/instrument_test.go | 106 ++++++++++++++++++ 7 files changed, 755 insertions(+) create mode 100644 host/blobstore/blobstore.go create mode 100644 host/blobstore/blobstore_test.go create mode 100644 host/blobstore/checkpoint.go create mode 100644 host/blobstore/checkpoint_test.go create mode 100644 host/blobstore/dir.go create mode 100644 host/blobstore/instrument.go create mode 100644 host/blobstore/instrument_test.go diff --git a/host/blobstore/blobstore.go b/host/blobstore/blobstore.go new file mode 100644 index 0000000..f1c5c8e --- /dev/null +++ b/host/blobstore/blobstore.go @@ -0,0 +1,89 @@ +// Package blobstore is the arcade's wasm blob storage: published catalog +// artifacts (`artifacts/.wasm`, immutable, content-addressed) and +// room state under the `snapshots/` prefix, in two coexisting schemes: +// - flat hibernation snapshots (`snapshots/`, deleted on restore) — +// the original disposing park/resume path (internal/gameabi); +// - versioned, MAC'd room checkpoints (`snapshots//` with a +// `snapshots//latest` pointer; see CheckpointKey / Sealer) — the +// non-destructive periodic-durability path added for regional bastions. +// +// Both live under `snapshots/` and share the bucket lifecycle TTL on that +// prefix; Phase 0 keeps them side by side and Track C / task G.5 unifies room +// durability onto the versioned scheme. Production is a Fly-attached Tigris +// bucket via the standard S3 env contract; dev and tests use the in-memory +// double. Only the arcade ever holds bucket credentials — catalog CI +// publishes to GitHub releases and never writes here. +package blobstore + +import ( + "context" + "sort" + "sync" +) + +// Store is the blob surface the catalog pipeline and the hibernation store +// build on. Keys are slash-separated paths; values are whole blobs (wasm +// artifacts and zstd snapshots are small enough that streaming buys nothing +// on a 32 MiB-capped guest). +type Store interface { + // Get returns the blob at key; ok=false when it does not exist. + Get(ctx context.Context, key string) (data []byte, ok bool, err error) + // Put writes the blob at key, overwriting any existing object. + Put(ctx context.Context, key string, data []byte) error + // Delete removes the blob at key; deleting a missing key is not an error. + Delete(ctx context.Context, key string) error + // List returns the keys under prefix in lexical order. + List(ctx context.Context, prefix string) ([]string, error) +} + +// Memory is the in-memory Store double for dev mode and tests. +type Memory struct { + mu sync.RWMutex + blobs map[string][]byte +} + +// NewMemory returns an empty in-memory store. +func NewMemory() *Memory { + return &Memory{blobs: map[string][]byte{}} +} + +func (m *Memory) Get(ctx context.Context, key string) ([]byte, bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + b, ok := m.blobs[key] + if !ok { + return nil, false, nil + } + out := make([]byte, len(b)) + copy(out, b) + return out, true, nil +} + +func (m *Memory) Put(ctx context.Context, key string, data []byte) error { + cp := make([]byte, len(data)) + copy(cp, data) + m.mu.Lock() + defer m.mu.Unlock() + m.blobs[key] = cp + return nil +} + +func (m *Memory) Delete(ctx context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.blobs, key) + return nil +} + +func (m *Memory) List(ctx context.Context, prefix string) ([]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var keys []string + for k := range m.blobs { + if len(k) >= len(prefix) && k[:len(prefix)] == prefix { + keys = append(keys, k) + } + } + sort.Strings(keys) + return keys, nil +} diff --git a/host/blobstore/blobstore_test.go b/host/blobstore/blobstore_test.go new file mode 100644 index 0000000..5fa6565 --- /dev/null +++ b/host/blobstore/blobstore_test.go @@ -0,0 +1,173 @@ +package blobstore + +import ( + "bytes" + "context" + "testing" +) + +func testStore(t *testing.T, s Store) { + t.Helper() + ctx := context.Background() + + // Missing key: ok=false, no error. + if _, ok, err := s.Get(ctx, "artifacts/missing.wasm"); ok || err != nil { + t.Fatalf("Get missing = ok=%v err=%v, want false nil", ok, err) + } + + // Put / Get round-trip. + blob := []byte("\x00asm fake artifact bytes") + if err := s.Put(ctx, "artifacts/abc.wasm", blob); err != nil { + t.Fatalf("Put: %v", err) + } + got, ok, err := s.Get(ctx, "artifacts/abc.wasm") + if err != nil || !ok || !bytes.Equal(got, blob) { + t.Fatalf("Get = %q ok=%v err=%v, want round-trip", got, ok, err) + } + + // Overwrite wins. + if err := s.Put(ctx, "artifacts/abc.wasm", []byte("v2")); err != nil { + t.Fatalf("Put overwrite: %v", err) + } + if got, _, _ := s.Get(ctx, "artifacts/abc.wasm"); string(got) != "v2" { + t.Fatalf("Get after overwrite = %q, want v2", got) + } + + // List by prefix, lexical order. + for _, k := range []string{"snapshots/room-2", "snapshots/room-1", "artifacts/zzz.wasm"} { + if err := s.Put(ctx, k, []byte(k)); err != nil { + t.Fatalf("Put %s: %v", k, err) + } + } + keys, err := s.List(ctx, "snapshots/") + if err != nil { + t.Fatalf("List: %v", err) + } + want := []string{"snapshots/room-1", "snapshots/room-2"} + if len(keys) != 2 || keys[0] != want[0] || keys[1] != want[1] { + t.Fatalf("List = %v, want %v", keys, want) + } + + // Delete: gone after; deleting missing is not an error. + if err := s.Delete(ctx, "snapshots/room-1"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, ok, _ := s.Get(ctx, "snapshots/room-1"); ok { + t.Fatal("deleted key still readable") + } + if err := s.Delete(ctx, "snapshots/never-existed"); err != nil { + t.Fatalf("Delete missing: %v", err) + } +} + +func TestMemoryStore(t *testing.T) { + testStore(t, NewMemory()) +} + +func TestDirStore(t *testing.T) { + s, err := NewDir(t.TempDir()) + if err != nil { + t.Fatalf("NewDir: %v", err) + } + testStore(t, s) +} + +// TestDirStorePersistsAcrossReopen is the property the dev hibernation demo +// rides on: a snapshot written by one Dir (one `serve` run) is readable by a +// fresh Dir over the same root (the restarted `serve`). It also covers a +// slash-bearing slug key (snapshots//-) becoming nested +// directories on disk. +func TestDirStorePersistsAcrossReopen(t *testing.T) { + root := t.TempDir() + ctx := context.Background() + + first, err := NewDir(root) + if err != nil { + t.Fatalf("NewDir first: %v", err) + } + key := "snapshots/dev/fixture-1" + blob := []byte("hibernated room state") + if err := first.Put(ctx, key, blob); err != nil { + t.Fatalf("Put: %v", err) + } + + // Restart: a brand-new store over the same directory must see the snapshot. + second, err := NewDir(root) + if err != nil { + t.Fatalf("NewDir second: %v", err) + } + got, ok, err := second.Get(ctx, key) + if err != nil || !ok || !bytes.Equal(got, blob) { + t.Fatalf("Get after reopen = %q ok=%v err=%v, want round-trip", got, ok, err) + } + keys, err := second.List(ctx, "snapshots/") + if err != nil || len(keys) != 1 || keys[0] != key { + t.Fatalf("List after reopen = %v err=%v, want [%s]", keys, err, key) + } +} + +// TestDirStoreRejectsEscape: a key with .. must not escape the root. +func TestDirStoreRejectsEscape(t *testing.T) { + s, err := NewDir(t.TempDir()) + if err != nil { + t.Fatalf("NewDir: %v", err) + } + if err := s.Put(context.Background(), "../escape", []byte("x")); err == nil { + t.Fatal("Put with escaping key succeeded, want error") + } +} + +// TestSlashSlugSnapshotKeys proves the blob store handles keys built from a +// namespaced room id, e.g. snapshots/- where the slug itself is +// / ("bcook/pokies"). The resulting key carries an extra slash +// segment ("snapshots/bcook/pokies-1"); it must still round-trip and surface +// under both the broad "snapshots/" prefix and the narrower per-author prefix. +func TestSlashSlugSnapshotKeys(t *testing.T) { + s := NewMemory() + ctx := context.Background() + + // Room ids for two namespaced games: "-". + keyA := "snapshots/bcook/pokies-1" + keyB := "snapshots/alan/chess-3" + for _, k := range []string{keyA, keyB} { + if err := s.Put(ctx, k, []byte(k)); err != nil { + t.Fatalf("Put %s: %v", k, err) + } + } + + got, ok, err := s.Get(ctx, keyA) + if err != nil || !ok || !bytes.Equal(got, []byte(keyA)) { + t.Fatalf("Get %s = %q ok=%v err=%v, want round-trip", keyA, got, ok, err) + } + + // The boot-time TTL rule expires everything under "snapshots/"; a slash slug + // must not escape that prefix. + keys, err := s.List(ctx, "snapshots/") + if err != nil { + t.Fatalf("List snapshots/: %v", err) + } + want := []string{keyB, keyA} // lexical: alan/ before bcook/ + if len(keys) != 2 || keys[0] != want[0] || keys[1] != want[1] { + t.Fatalf("List(snapshots/) = %v, want %v", keys, want) + } + + // A per-author prefix selects only that author's snapshots. + bcook, err := s.List(ctx, "snapshots/bcook/") + if err != nil { + t.Fatalf("List snapshots/bcook/: %v", err) + } + if len(bcook) != 1 || bcook[0] != keyA { + t.Fatalf("List(snapshots/bcook/) = %v, want [%s]", bcook, keyA) + } + + // Delete-on-restore removes exactly the one snapshot. + if err := s.Delete(ctx, keyA); err != nil { + t.Fatalf("Delete %s: %v", keyA, err) + } + if _, ok, _ := s.Get(ctx, keyA); ok { + t.Fatalf("%s still readable after delete", keyA) + } + if _, ok, _ := s.Get(ctx, keyB); !ok { + t.Fatalf("%s wrongly removed when deleting %s", keyB, keyA) + } +} diff --git a/host/blobstore/checkpoint.go b/host/blobstore/checkpoint.go new file mode 100644 index 0000000..ee3ce34 --- /dev/null +++ b/host/blobstore/checkpoint.go @@ -0,0 +1,106 @@ +package blobstore + +import ( + "crypto/hmac" + "crypto/sha256" + "errors" + "fmt" +) + +// This file is the room-checkpoint contract (design D5, room-hosting spec +// "Periodic Room Checkpoints"): the versioned checkpoint key scheme and the +// Sealer that MACs a checkpoint blob with a server-side key held outside the +// wasm sandbox. Checkpoint payloads (encode/decode, cadence, restore) live in +// the checkpoint track; this file owns only keys and integrity. +// +// Namespace note: these versioned keys (snapshots//) share the +// "snapshots/" prefix — and the bucket lifecycle TTL on it (s3.go) — with the +// existing FLAT hibernation key snapshots/ (internal/gameabi +// HibernationStore.key). The two schemes coexist deliberately in Phase 0; Track +// C / task G.5 unifies room durability onto this versioned scheme and retires +// the flat key. A roomID is a UUIDv7, so snapshots// can never +// collide with the flat snapshots/ object (one ends at the id, the +// other has a trailing "/epoch" segment). + +// CheckpointKey returns the blobstore key for a room's checkpoint at the given +// epoch: snapshots//. The epoch is zero-padded to 20 digits (the +// width of a uint64's max decimal value) so the lexical key order returned by +// Store.List matches numeric epoch order — checkpoints sort oldest-to-newest. +// Keys are written never-overwrite-in-place so a slow in-flight periodic PUT +// cannot clobber a later drain PUT at a higher epoch. +// +// epoch MUST be >= 0: a negative epoch would emit a leading '-' that breaks the +// lexical = numeric ordering invariant, so a negative epoch is a programmer +// error and panics (epochs are monotonic from 0). +func CheckpointKey(roomID string, epoch int64) string { + if epoch < 0 { + panic(fmt.Sprintf("blobstore: CheckpointKey: negative epoch %d", epoch)) + } + return fmt.Sprintf("snapshots/%s/%020d", roomID, epoch) +} + +// LatestPointerKey returns the key of a room's atomic latest-checkpoint pointer: +// snapshots//latest. The pointer is swapped atomically to the newest +// epoch; "latest" sorts after every zero-padded numeric epoch so it never +// shadows a checkpoint under the room prefix. +func LatestPointerKey(roomID string) string { + return fmt.Sprintf("snapshots/%s/latest", roomID) +} + +// ErrSealVerify is returned by Sealer.Open when a sealed blob's MAC does not +// verify (tampered payload, tampered MAC, wrong key, or truncated blob). A +// failed verification MUST refuse the restore before any guest-memory write. +var ErrSealVerify = errors.New("blobstore: checkpoint seal verification failed") + +// Sealer authenticates checkpoint blobs with a server-side key held outside the +// wasm sandbox: artifact-digest equality is NOT blob integrity. Seal produces a +// blob that Open verifies before returning any payload, so a re-hydration +// always proves integrity before writing guest memory. +type Sealer interface { + // Seal returns payload with an integrity tag appended. + Seal(payload []byte) []byte + // Open verifies the tag and returns the original payload, or ErrSealVerify. + Open(sealed []byte) ([]byte, error) +} + +// hmacSealer is the HMAC-SHA256 Sealer: it appends a 32-byte MAC over the +// payload and verifies it in constant time on Open. +type hmacSealer struct { + key []byte +} + +// NewHMACSealer returns a Sealer that MACs blobs with key using HMAC-SHA256. +func NewHMACSealer(key []byte) Sealer { + cp := make([]byte, len(key)) + copy(cp, key) + return &hmacSealer{key: cp} +} + +const hmacTagLen = sha256.Size + +func (s *hmacSealer) mac(payload []byte) []byte { + m := hmac.New(sha256.New, s.key) + m.Write(payload) + return m.Sum(nil) +} + +func (s *hmacSealer) Seal(payload []byte) []byte { + out := make([]byte, 0, len(payload)+hmacTagLen) + out = append(out, payload...) + out = append(out, s.mac(payload)...) + return out +} + +func (s *hmacSealer) Open(sealed []byte) ([]byte, error) { + if len(sealed) < hmacTagLen { + return nil, ErrSealVerify + } + split := len(sealed) - hmacTagLen + payload, tag := sealed[:split], sealed[split:] + if !hmac.Equal(tag, s.mac(payload)) { + return nil, ErrSealVerify + } + out := make([]byte, len(payload)) + copy(out, payload) + return out, nil +} diff --git a/host/blobstore/checkpoint_test.go b/host/blobstore/checkpoint_test.go new file mode 100644 index 0000000..3b2c890 --- /dev/null +++ b/host/blobstore/checkpoint_test.go @@ -0,0 +1,101 @@ +package blobstore + +import ( + "bytes" + "errors" + "sort" + "testing" +) + +// CheckpointKey formats snapshots// with a 20-digit zero-padded +// epoch, and LatestPointerKey points at the per-room latest pointer. +func TestCheckpointKeyFormat(t *testing.T) { + const room = "0190b8a0-1234-7abc-8def-0123456789ab" + if got, want := CheckpointKey(room, 0), "snapshots/"+room+"/00000000000000000000"; got != want { + t.Errorf("CheckpointKey(%q, 0) = %q, want %q", room, got, want) + } + if got, want := CheckpointKey(room, 42), "snapshots/"+room+"/00000000000000000042"; got != want { + t.Errorf("CheckpointKey(%q, 42) = %q, want %q", room, got, want) + } + if got, want := LatestPointerKey(room), "snapshots/"+room+"/latest"; got != want { + t.Errorf("LatestPointerKey(%q) = %q, want %q", room, got, want) + } +} + +// A negative epoch is a contract violation (the '-' sign would break lexical +// ordering): CheckpointKey panics rather than mint a misordering key. +func TestCheckpointKeyNegativeEpochPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("CheckpointKey(room, -1) did not panic") + } + }() + _ = CheckpointKey("room-uuid", -1) +} + +// Zero-padding makes lexical key order match numeric epoch order, so List under +// the room prefix returns checkpoints oldest-to-newest. +func TestCheckpointKeyLexicalOrder(t *testing.T) { + const room = "room-uuid" + epochs := []int64{0, 1, 2, 9, 10, 99, 100, 1000, 999999999} + keys := make([]string, len(epochs)) + for i, e := range epochs { + keys[i] = CheckpointKey(room, e) + } + sorted := append([]string(nil), keys...) + sort.Strings(sorted) + for i := range keys { + if keys[i] != sorted[i] { + t.Fatalf("lexical order != numeric order at %d: %q vs %q", i, keys[i], sorted[i]) + } + } +} + +// Seal/Open round-trips: an opened sealed blob equals the original payload. +func TestSealerRoundTrip(t *testing.T) { + s := NewHMACSealer([]byte("server-side-key")) + payload := []byte("room checkpoint bytes") + sealed := s.Seal(payload) + if bytes.Equal(sealed, payload) { + t.Fatal("Seal returned the payload unchanged (no MAC appended)") + } + got, err := s.Open(sealed) + if err != nil { + t.Fatalf("Open: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("Open = %q, want %q", got, payload) + } +} + +// A tampered sealed blob fails Open with ErrSealVerify before any payload is +// returned (spec: verified before restore writes guest memory). +func TestSealerTamperRejected(t *testing.T) { + s := NewHMACSealer([]byte("server-side-key")) + sealed := s.Seal([]byte("checkpoint")) + + flip := append([]byte(nil), sealed...) + flip[0] ^= 0xff // corrupt the payload region + if _, err := s.Open(flip); !errors.Is(err, ErrSealVerify) { + t.Fatalf("Open(tampered payload) err = %v, want ErrSealVerify", err) + } + + flipMac := append([]byte(nil), sealed...) + flipMac[len(flipMac)-1] ^= 0xff // corrupt the MAC region + if _, err := s.Open(flipMac); !errors.Is(err, ErrSealVerify) { + t.Fatalf("Open(tampered mac) err = %v, want ErrSealVerify", err) + } + + if _, err := s.Open([]byte("short")); !errors.Is(err, ErrSealVerify) { + t.Fatalf("Open(too short) err = %v, want ErrSealVerify", err) + } +} + +// A blob sealed under one key does not Open under another — artifact-SHA +// equality is NOT blob integrity; the server-side key is. +func TestSealerWrongKeyRejected(t *testing.T) { + sealed := NewHMACSealer([]byte("key-a")).Seal([]byte("checkpoint")) + if _, err := NewHMACSealer([]byte("key-b")).Open(sealed); !errors.Is(err, ErrSealVerify) { + t.Fatalf("Open under wrong key err = %v, want ErrSealVerify", err) + } +} diff --git a/host/blobstore/dir.go b/host/blobstore/dir.go new file mode 100644 index 0000000..1ba2ef6 --- /dev/null +++ b/host/blobstore/dir.go @@ -0,0 +1,133 @@ +package blobstore + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// Dir is a file-backed Store rooted at a directory: each slash-separated key +// maps to a file under root, with key segments becoming subdirectories. It +// exists for dev mode — set SHELLCADE_BLOB_DIR so hibernation snapshots (and +// the sideloaded catalog) survive a `serve` restart, which the in-memory +// double cannot do. Production still uses S3; this is never wired in prod. +type Dir struct { + root string +} + +// NewDir returns a Dir store rooted at root, creating it if needed. +func NewDir(root string) (*Dir, error) { + if root == "" { + return nil, errors.New("blobstore: dir: empty root") + } + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("blobstore: dir: mkdir root: %w", err) + } + abs, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("blobstore: dir: abs root: %w", err) + } + return &Dir{root: abs}, nil +} + +// path maps a slash-separated key to an absolute file path under root, and +// guards against keys that would escape root (e.g. "../etc/passwd"). +func (d *Dir) path(key string) (string, error) { + p := filepath.Join(d.root, filepath.FromSlash(key)) + if p != d.root && !strings.HasPrefix(p, d.root+string(os.PathSeparator)) { + return "", fmt.Errorf("blobstore: dir: key escapes root: %q", key) + } + return p, nil +} + +func (d *Dir) Get(ctx context.Context, key string) ([]byte, bool, error) { + p, err := d.path(key) + if err != nil { + return nil, false, err + } + data, err := os.ReadFile(p) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + return nil, false, fmt.Errorf("blobstore: dir: get %s: %w", key, err) + } + return data, true, nil +} + +func (d *Dir) Put(ctx context.Context, key string, data []byte) error { + p, err := d.path(key) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return fmt.Errorf("blobstore: dir: put mkdir %s: %w", key, err) + } + // Write to a temp file then rename so a crash mid-write never leaves a + // truncated snapshot that resume would choke on. + tmp, err := os.CreateTemp(filepath.Dir(p), ".tmp-*") + if err != nil { + return fmt.Errorf("blobstore: dir: put temp %s: %w", key, err) + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("blobstore: dir: put write %s: %w", key, err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("blobstore: dir: put close %s: %w", key, err) + } + if err := os.Rename(tmpName, p); err != nil { + os.Remove(tmpName) + return fmt.Errorf("blobstore: dir: put rename %s: %w", key, err) + } + return nil +} + +func (d *Dir) Delete(ctx context.Context, key string) error { + p, err := d.path(key) + if err != nil { + return err + } + if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("blobstore: dir: delete %s: %w", key, err) + } + return nil +} + +func (d *Dir) List(ctx context.Context, prefix string) ([]string, error) { + var keys []string + err := filepath.WalkDir(d.root, func(p string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + rel, err := filepath.Rel(d.root, p) + if err != nil { + return err + } + key := filepath.ToSlash(rel) + // Skip in-flight temp files from interrupted Put calls. + if strings.HasPrefix(filepath.Base(p), ".tmp-") { + return nil + } + if strings.HasPrefix(key, prefix) { + keys = append(keys, key) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("blobstore: dir: list %s: %w", prefix, err) + } + sort.Strings(keys) + return keys, nil +} diff --git a/host/blobstore/instrument.go b/host/blobstore/instrument.go new file mode 100644 index 0000000..2403f11 --- /dev/null +++ b/host/blobstore/instrument.go @@ -0,0 +1,47 @@ +package blobstore + +import "context" + +// Instrument wraps s so every operation reports its outcome through rec — the +// recording seam for shellcade_blobstore_ops_total (production passes +// metrics.Metrics.BlobstoreOp at boot, where the backend is chosen). The +// blobstore stays metrics-agnostic, matching how the catalog reports its store +// latency through an injected recorder. op is one of get|put|delete|list; ok is +// err == nil (a Get of a missing key is ok=true — absence is an answer, not a +// store failure). A nil s or rec returns s unchanged so callers can wire it +// unconditionally. +func Instrument(s Store, rec func(op string, ok bool)) Store { + if s == nil || rec == nil { + return s + } + return &instrumentedStore{s: s, rec: rec} +} + +type instrumentedStore struct { + s Store + rec func(op string, ok bool) +} + +func (i *instrumentedStore) Get(ctx context.Context, key string) ([]byte, bool, error) { + data, ok, err := i.s.Get(ctx, key) + i.rec("get", err == nil) + return data, ok, err +} + +func (i *instrumentedStore) Put(ctx context.Context, key string, data []byte) error { + err := i.s.Put(ctx, key, data) + i.rec("put", err == nil) + return err +} + +func (i *instrumentedStore) Delete(ctx context.Context, key string) error { + err := i.s.Delete(ctx, key) + i.rec("delete", err == nil) + return err +} + +func (i *instrumentedStore) List(ctx context.Context, prefix string) ([]string, error) { + keys, err := i.s.List(ctx, prefix) + i.rec("list", err == nil) + return keys, err +} diff --git a/host/blobstore/instrument_test.go b/host/blobstore/instrument_test.go new file mode 100644 index 0000000..c39fb46 --- /dev/null +++ b/host/blobstore/instrument_test.go @@ -0,0 +1,106 @@ +package blobstore + +import ( + "context" + "errors" + "testing" +) + +// failingStore errors every operation — the outcome=fail half of the seam. +type failingStore struct{} + +var errInjected = errors.New("injected store failure") + +func (failingStore) Get(context.Context, string) ([]byte, bool, error) { + return nil, false, errInjected +} +func (failingStore) Put(context.Context, string, []byte) error { return errInjected } +func (failingStore) Delete(context.Context, string) error { return errInjected } +func (failingStore) List(context.Context, string) ([]string, error) { + return nil, errInjected +} + +// opRecorder counts rec calls by op/outcome, standing in for +// metrics.Metrics.BlobstoreOp. +type opRecorder map[string]int + +func (r opRecorder) rec(op string, ok bool) { + outcome := "fail" + if ok { + outcome = "ok" + } + r[op+"/"+outcome]++ +} + +// Instrument records every op with its outcome and passes results through +// untouched; a Get of a missing key is ok (absence is an answer, not a store +// failure). +func TestInstrumentRecordsOps(t *testing.T) { + ctx := context.Background() + rec := opRecorder{} + s := Instrument(NewMemory(), rec.rec) + + if err := s.Put(ctx, "snapshots/r1", []byte("blob")); err != nil { + t.Fatalf("Put: %v", err) + } + if data, ok, err := s.Get(ctx, "snapshots/r1"); err != nil || !ok || string(data) != "blob" { + t.Fatalf("Get = %q ok=%v err=%v", data, ok, err) + } + if _, ok, err := s.Get(ctx, "snapshots/missing"); err != nil || ok { + t.Fatalf("Get(missing) = ok=%v err=%v, want false nil", ok, err) + } + if keys, err := s.List(ctx, "snapshots/"); err != nil || len(keys) != 1 { + t.Fatalf("List = %v err=%v", keys, err) + } + if err := s.Delete(ctx, "snapshots/r1"); err != nil { + t.Fatalf("Delete: %v", err) + } + + want := opRecorder{"put/ok": 1, "get/ok": 2, "list/ok": 1, "delete/ok": 1} + for k, n := range want { + if rec[k] != n { + t.Errorf("rec[%s] = %d, want %d (all: %v)", k, rec[k], n, rec) + } + } + for k := range rec { + if want[k] == 0 { + t.Errorf("unexpected recording %s=%d", k, rec[k]) + } + } +} + +func TestInstrumentRecordsFailures(t *testing.T) { + ctx := context.Background() + rec := opRecorder{} + s := Instrument(failingStore{}, rec.rec) + + if _, _, err := s.Get(ctx, "k"); !errors.Is(err, errInjected) { + t.Fatalf("Get err = %v, want injected", err) + } + if err := s.Put(ctx, "k", nil); !errors.Is(err, errInjected) { + t.Fatalf("Put err = %v, want injected", err) + } + if err := s.Delete(ctx, "k"); !errors.Is(err, errInjected) { + t.Fatalf("Delete err = %v, want injected", err) + } + if _, err := s.List(ctx, "k"); !errors.Is(err, errInjected) { + t.Fatalf("List err = %v, want injected", err) + } + for _, k := range []string{"get/fail", "put/fail", "delete/fail", "list/fail"} { + if rec[k] != 1 { + t.Errorf("rec[%s] = %d, want 1 (all: %v)", k, rec[k], rec) + } + } +} + +// A nil recorder (or store) wires through unchanged — callers instrument +// unconditionally. +func TestInstrumentNilPassthrough(t *testing.T) { + mem := NewMemory() + if got := Instrument(mem, nil); got != Store(mem) { + t.Fatal("Instrument(s, nil) must return s unchanged") + } + if got := Instrument(nil, opRecorder{}.rec); got != nil { + t.Fatal("Instrument(nil, rec) must return nil") + } +} From 77210d5b48cec5e40080e4b3d1d48d342b0128ff Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Tue, 16 Jun 2026 07:54:36 +1000 Subject: [PATCH 03/10] host: add in-memory ServicesFactory (host/memsvc) for CLI + conformance Co-Authored-By: Claude Opus 4.8 --- host/memsvc/memsvc.go | 448 +++++++++++++++++++++++++++++++++++++ host/memsvc/memsvc_test.go | 319 ++++++++++++++++++++++++++ 2 files changed, 767 insertions(+) create mode 100644 host/memsvc/memsvc.go create mode 100644 host/memsvc/memsvc_test.go diff --git a/host/memsvc/memsvc.go b/host/memsvc/memsvc.go new file mode 100644 index 0000000..f8b2003 --- /dev/null +++ b/host/memsvc/memsvc.go @@ -0,0 +1,448 @@ +// Package memsvc is a public, in-memory implementation of the host-side +// [sdk.ServicesFactory] (and the matching [sdk.AccountStore], [sdk.KVStore], +// [sdk.ConfigStore], and leaderboard surfaces). It lets the CLI dev runner and +// the conformance suite run games WITHOUT the platform's private Postgres / +// identity services: everything lives in process memory. +// +// It implements the SAME sdk interfaces the production durable (Postgres) +// factory does — a fire-and-forget [sdk.LeaderboardClient].Post, a per-user KV +// behind an [sdk.AccountStore], a slug-bound read-only [sdk.ConfigStore], and an +// [sdk.LeaderboardData] backend a generic [sdk.LeaderboardReader] composes — and +// matches their observable contract: +// +// - Leaderboard recording records every account-bound result tagged with mode +// - status, dropping only guests (an empty AccountID). It lives here, not in +// game code; games always Post and never branch on eligibility. +// - Per-user KV is namespaced to (slug, account, key). A key written with the +// [sdk.MergeMax] rule is kept MONOTONIC on write — the stored value only ever +// rises — mirroring the durable store, so out-of-order writers can never +// regress a max key. All other rules overwrite last-writer-wins; the +// sum/max/keep-loser accumulation ACROSS accounts happens only at an account +// merge ([Factory.Merge]). +// - Per-game config is a slug-bound, read-only surface seeded by the harness +// ([Factory.SetConfig]); games can read only their own slug's keys. +// +// The package imports only github.com/shellcade/kit/v2/host/sdk — no shellcade +// private code — which is the property that lets the kit ship a runnable host. +package memsvc + +import ( + "context" + "log/slog" + "strconv" + "strings" + "sync" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// Factory is an in-memory [sdk.ServicesFactory] plus an [sdk.LeaderboardData] +// backend (reachable via [Factory.Reader]). It is safe for concurrent use. +type Factory struct { + log *slog.Logger + reg *sdk.Registry + + mu sync.Mutex + results map[string][]rec // slug -> recorded results + handles map[string]string // accountID -> latest seen handle (all "live" in memory) + + kv *memKV + cfg *memConfig +} + +// rec is one recorded leaderboard result row. +type rec struct { + accountID string + metric int + achieved time.Time + mode sdk.Mode + status sdk.Status +} + +// New returns an in-memory [sdk.ServicesFactory] over the package-level default +// game registry ([sdk.Default]). This is the drop-in for the CLI dev runner and +// conformance: the same shape as the durable factory's constructor minus the +// platform-only Postgres store argument. Use [NewFactory] when you need the +// concrete *Factory (for [Factory.Reader], [Factory.SetConfig], or +// [Factory.Merge]) or a curated registry. +func New() sdk.ServicesFactory { return NewFactory(nil, nil) } + +// NewWithRegistry returns an in-memory [sdk.ServicesFactory] over an explicit +// registry (a curated roster for the CLI or a conformance fixture). +func NewWithRegistry(log *slog.Logger, reg *sdk.Registry) sdk.ServicesFactory { + return NewFactory(log, reg) +} + +// NewFactory returns the concrete in-memory *Factory over an explicit logger +// and registry. A nil log defaults to [slog.Default]; a nil registry defaults to +// [sdk.Default]. Prefer [New] when you only need the [sdk.ServicesFactory] +// interface; use this when a test or the CLI also needs the read side +// ([Factory.Reader]), config seeding ([Factory.SetConfig]), or account merge +// ([Factory.Merge]). +func NewFactory(log *slog.Logger, reg *sdk.Registry) *Factory { + if log == nil { + log = slog.Default() + } + if reg == nil { + reg = sdk.Default() + } + return &Factory{ + log: log, + reg: reg, + results: map[string][]rec{}, + handles: map[string]string{}, + kv: &memKV{m: map[kvKey]kvVal{}}, + cfg: &memConfig{m: map[cfgKey][]byte{}}, + } +} + +// For builds a [sdk.Services] bundle tagged with the room id and game slug. The +// returned AccountStore, ConfigStore, and LeaderboardClient are all bound to the +// slug, so a game can reach only its own per-game state. +func (f *Factory) For(roomID, slug string) sdk.Services { + return sdk.Services{ + Leaderboard: &memLeaderboard{f: f}, + Accounts: &memAccounts{f: f, slug: slug}, + Config: &memConfigStore{cfg: f.cfg, slug: slug}, + Chat: noopChat{}, + Spectate: noopSpectate{}, + Log: f.log.With("room", roomID, "slug", slug), + } +} + +// Reader exposes the leaderboard read side (the lobby/UI surface, never handed +// to games), composing this factory's in-memory data backend with the registry's +// per-game specs and providers. Reads reflect every result recorded via the +// LeaderboardClient.Post returned by [Factory.For]. +func (f *Factory) Reader() sdk.LeaderboardReader { + return sdk.NewReader(&memData{f: f}, f.reg) +} + +// record stores the latest handle seen for an account (every in-memory account +// is treated as live, so handle resolution never excludes it). Callers hold f.mu. +func (f *Factory) record(accountID, handle string) { + if accountID == "" { + return + } + f.handles[accountID] = handle +} + +// memLeaderboard is the fire-and-forget write side: record every account-bound +// result tagged with mode + status, dropping only guests. +type memLeaderboard struct{ f *Factory } + +func (l *memLeaderboard) Post(slug string, r sdk.Result) { + l.f.mu.Lock() + defer l.f.mu.Unlock() + now := time.Now() + for _, pr := range r.Rankings { + if pr.Player.AccountID == "" { + continue // guests carry no account: never recorded + } + l.f.results[slug] = append(l.f.results[slug], rec{ + accountID: pr.Player.AccountID, + metric: pr.Metric, + achieved: now, + mode: r.Mode, + status: pr.Status, + }) + l.f.record(pr.Player.AccountID, pr.Player.Handle) + } +} + +// ---- per-user KV ---- + +type kvKey struct{ slug, account, key string } +type kvVal struct { + val []byte + rule sdk.MergeRule +} + +type memKV struct { + mu sync.Mutex + m map[kvKey]kvVal +} + +type memAccounts struct { + f *Factory + slug string +} + +func (a *memAccounts) For(p sdk.Player) sdk.Account { + a.f.mu.Lock() + a.f.record(p.AccountID, p.Handle) + a.f.mu.Unlock() + return &memAccount{kv: a.f.kv, slug: a.slug, p: p} +} + +type memAccount struct { + kv *memKV + slug string + p sdk.Player +} + +func (a *memAccount) ID() string { return a.p.AccountID } +func (a *memAccount) Handle() string { return a.p.Handle } +func (a *memAccount) Kind() sdk.Kind { return a.p.Kind } +func (a *memAccount) Store() sdk.KVStore { + return &memKVStore{kv: a.kv, slug: a.slug, account: a.p.AccountID} +} + +type memKVStore struct { + kv *memKV + slug string + account string +} + +func (s *memKVStore) Get(ctx context.Context, key string) ([]byte, bool, error) { + s.kv.mu.Lock() + defer s.kv.mu.Unlock() + v, ok := s.kv.m[kvKey{s.slug, s.account, key}] + if !ok { + return nil, false, nil + } + return append([]byte(nil), v.val...), true, nil +} + +func (s *memKVStore) Set(ctx context.Context, key string, val []byte, rule sdk.MergeRule) error { + s.kv.mu.Lock() + defer s.kv.mu.Unlock() + k := kvKey{s.slug, s.account, key} + // Mirror the durable store: a `max` key is kept monotonic on write — the + // stored value only ever rises, so an out-of-order writer can't regress it. + if rule == sdk.MergeMax { + if cur, ok := s.kv.m[k]; ok { + curN, e1 := strconv.Atoi(strings.TrimSpace(string(cur.val))) + newN, e2 := strconv.Atoi(strings.TrimSpace(string(val))) + if e1 == nil && e2 == nil && curN >= newN { + return nil + } + } + } + s.kv.m[k] = kvVal{val: append([]byte(nil), val...), rule: rule} + return nil +} + +func (s *memKVStore) Delete(ctx context.Context, key string) error { + s.kv.mu.Lock() + defer s.kv.mu.Unlock() + delete(s.kv.m, kvKey{s.slug, s.account, key}) + return nil +} + +// Merge folds the loser account's per-user KV into the winner, applying each +// key's recorded [sdk.MergeRule] on a collision and moving non-colliding keys +// across unchanged, then dropping the loser's rows. It is the in-memory twin of +// the durable store's account-merge reconciliation; the CLI/conformance can call +// it to exercise the merge-rule semantics without an identity service. The +// KVStore games hold has no merge method (the rule is only RECORDED on Set) — +// this models the platform-owned merge that consumes those recorded rules. +// +// Per the ABI contract: the winner's recorded rule governs the collision +// (falling back to the loser's when the winner has none); keep-winner leaves the +// winner's value untouched; keep-loser takes the loser's; sum/max combine the two +// base-10 integers, and a non-integer value under sum/max DEGRADES to keep-winner +// (so poisoned game data can never break an account merge). An empty/unknown rule +// is keep-winner. +func (f *Factory) Merge(winnerID, loserID string) { + if winnerID == "" || loserID == "" || winnerID == loserID { + return + } + f.kv.mu.Lock() + defer f.kv.mu.Unlock() + for k, lr := range f.kv.m { + if k.account != loserID { + continue + } + wk := kvKey{k.slug, winnerID, k.key} + win, collision := f.kv.m[wk] + if !collision { + // No collision: move the loser's row (value + rule) to the winner. + f.kv.m[wk] = lr + delete(f.kv.m, k) + continue + } + // Collision: the winner's recorded rule wins, falling back to the loser's. + rule := win.rule + if rule == "" { + rule = lr.rule + } + if v, ok := mergedValue(rule, win.val, lr.val); ok { + win.val = v + } + // keep-winner (and a degraded sum/max) leaves the winner's value as-is. + win.rule = rule + f.kv.m[wk] = win + delete(f.kv.m, k) + } +} + +// mergedValue applies one merge rule to a colliding (winner, loser) pair, +// returning the new winner value and whether it changed. keep-winner — and a +// sum/max collision whose values are not both base-10 integers — returns +// ok=false (the winner's value is kept). +func mergedValue(rule sdk.MergeRule, winVal, loserVal []byte) ([]byte, bool) { + switch rule { + case sdk.MergeKeepLoser: + return append([]byte(nil), loserVal...), true + case sdk.MergeSum, sdk.MergeMax: + wn, e1 := strconv.Atoi(strings.TrimSpace(string(winVal))) + ln, e2 := strconv.Atoi(strings.TrimSpace(string(loserVal))) + if e1 != nil || e2 != nil { + return nil, false // degrade to keep-winner on non-integer data + } + if rule == sdk.MergeSum { + return []byte(strconv.Itoa(wn + ln)), true + } + if ln > wn { + return []byte(strconv.Itoa(ln)), true + } + return []byte(strconv.Itoa(wn)), true + default: // keep-winner / empty / unknown + return nil, false + } +} + +// ---- per-game config ---- + +type cfgKey struct{ slug, key string } + +type memConfig struct { + mu sync.Mutex + m map[cfgKey][]byte +} + +// SetConfig seeds a per-game config value into the in-memory backend, so the CLI +// or a conformance test can exercise a game's config-driven behavior without a +// database. It mirrors the durable ConfigSet: last-write-wins, global per +// (slug, key). +func (f *Factory) SetConfig(slug, key string, value []byte) { + f.cfg.mu.Lock() + defer f.cfg.mu.Unlock() + f.cfg.m[cfgKey{slug, key}] = append([]byte(nil), value...) +} + +// memConfigStore is the slug-bound, read-only config surface (the in-memory twin +// of the durable config store): the binding owns the slug, the game names only +// the key, so it can neither read nor write another game's config. +type memConfigStore struct { + cfg *memConfig + slug string +} + +func (s *memConfigStore) Get(ctx context.Context, key string) ([]byte, bool, error) { + s.cfg.mu.Lock() + defer s.cfg.mu.Unlock() + v, ok := s.cfg.m[cfgKey{s.slug, key}] + if !ok { + return nil, false, nil + } + return append([]byte(nil), v...), true, nil +} + +// ---- LeaderboardData backend ---- + +type memData struct{ f *Factory } + +func (d *memData) ResultScores(ctx context.Context, slug string, spec sdk.LeaderboardSpec, w sdk.Window) ([]sdk.Score, error) { + d.f.mu.Lock() + defer d.f.mu.Unlock() + start, bounded := sdk.WindowStart(w, time.Now()) + type agg struct { + value int + achieved time.Time + set bool + } + m := map[string]*agg{} + for _, r := range d.f.results[slug] { + if bounded && r.achieved.Before(start) { + continue + } + a := m[r.accountID] + if a == nil { + a = &agg{} + m[r.accountID] = a + } + switch spec.Aggregation { + case sdk.CumulativeSum: + a.value += r.metric + if !a.set || r.achieved.Before(a.achieved) { + a.achieved = r.achieved + } + a.set = true + default: // BestResult + better := !a.set + if a.set { + if spec.Direction == sdk.LowerBetter { + better = r.metric < a.value + } else { + better = r.metric > a.value + } + } + switch { + case better: + a.value = r.metric + a.achieved = r.achieved + a.set = true + case r.metric == a.value && r.achieved.Before(a.achieved): + a.achieved = r.achieved + } + } + } + out := make([]sdk.Score, 0, len(m)) + for id, a := range m { + out = append(out, sdk.Score{AccountID: id, Value: a.value, Achieved: a.achieved}) + } + return out, nil +} + +func (d *memData) KVIntValues(ctx context.Context, slug, key string) ([]sdk.Score, error) { + d.f.kv.mu.Lock() + defer d.f.kv.mu.Unlock() + var out []sdk.Score + for k, v := range d.f.kv.m { + if k.slug != slug || k.key != key { + continue + } + n, err := strconv.Atoi(strings.TrimSpace(string(v.val))) + if err != nil { + continue + } + out = append(out, sdk.Score{AccountID: k.account, Value: n}) + } + return out, nil +} + +func (d *memData) ResolveHandles(ctx context.Context, ids []string) (map[string]string, error) { + d.f.mu.Lock() + defer d.f.mu.Unlock() + out := make(map[string]string, len(ids)) + for _, id := range ids { + if h, ok := d.f.handles[id]; ok { + out[id] = h + } + } + return out, nil +} + +// ---- v1 no-op stubs ---- + +type noopChat struct{} + +func (noopChat) Broadcast(roomID, from, msg string) {} + +type noopSpectate struct{} + +func (noopSpectate) Open(roomID string) error { return nil } + +// Interface guards: memsvc implements the host-side sdk surfaces. +var ( + _ sdk.ServicesFactory = (*Factory)(nil) + _ sdk.LeaderboardClient = (*memLeaderboard)(nil) + _ sdk.AccountStore = (*memAccounts)(nil) + _ sdk.Account = (*memAccount)(nil) + _ sdk.KVStore = (*memKVStore)(nil) + _ sdk.ConfigStore = (*memConfigStore)(nil) + _ sdk.LeaderboardData = (*memData)(nil) +) diff --git a/host/memsvc/memsvc_test.go b/host/memsvc/memsvc_test.go new file mode 100644 index 0000000..f0f5222 --- /dev/null +++ b/host/memsvc/memsvc_test.go @@ -0,0 +1,319 @@ +package memsvc_test + +import ( + "context" + "io" + "log/slog" + "testing" + + "github.com/shellcade/kit/v2/host/memsvc" + "github.com/shellcade/kit/v2/host/sdk" +) + +func quietFactory(t *testing.T) *memsvc.Factory { + t.Helper() + return memsvc.NewFactory(slog.New(slog.NewTextHandler(io.Discard, nil)), sdk.NewRegistry()) +} + +func member(id, handle string) sdk.Player { + return sdk.Player{AccountID: id, Handle: handle, Kind: sdk.KindMember} +} + +// New returns a usable factory backed by the default registry: For yields a +// fully-populated Services bundle (no nil concern) tagged to the slug. +func TestNewBuildsServices(t *testing.T) { + f := memsvc.New() + svc := f.For("room-1", "demo") + if svc.Leaderboard == nil || svc.Accounts == nil || svc.Config == nil || + svc.Chat == nil || svc.Spectate == nil || svc.Log == nil { + t.Fatalf("For returned a Services with a nil concern: %+v", svc) + } +} + +// A per-user KVStore round-trips, is namespaced per (slug, account, key), and a +// Get returns an independent copy (mutating it must not corrupt the store). +func TestKVNamespacingAndRoundTrip(t *testing.T) { + ctx := context.Background() + f := quietFactory(t) + + storeFor := func(slug string, p sdk.Player) sdk.KVStore { + return f.For("r", slug).Accounts.For(p).Store() + } + ada := member("acct-ada", "ada") + bob := member("acct-bob", "bob") + + if err := storeFor("g1", ada).Set(ctx, "k", []byte("ada-g1"), sdk.MergeKeepWinner); err != nil { + t.Fatal(err) + } + if err := storeFor("g2", ada).Set(ctx, "k", []byte("ada-g2"), sdk.MergeKeepWinner); err != nil { + t.Fatal(err) + } + if err := storeFor("g1", bob).Set(ctx, "k", []byte("bob-g1"), sdk.MergeKeepWinner); err != nil { + t.Fatal(err) + } + + cases := []struct { + slug string + p sdk.Player + want string + }{ + {"g1", ada, "ada-g1"}, + {"g2", ada, "ada-g2"}, // different slug, same account+key: isolated + {"g1", bob, "bob-g1"}, // different account, same slug+key: isolated + } + for _, tc := range cases { + got, ok, err := storeFor(tc.slug, tc.p).Get(ctx, "k") + if err != nil || !ok { + t.Fatalf("Get(%s/%s) ok=%v err=%v", tc.slug, tc.p.AccountID, ok, err) + } + if string(got) != tc.want { + t.Fatalf("Get(%s/%s)=%q want %q", tc.slug, tc.p.AccountID, got, tc.want) + } + got[0] = 'X' // mutate the returned copy; the store must not see it + } + got, _, _ := storeFor("g1", ada).Get(ctx, "k") + if string(got) != "ada-g1" { + t.Fatalf("Get returned an aliased slice: store corrupted to %q", got) + } + + // A missing key reads not-found (so the game falls back to its default). + if _, ok, err := storeFor("g1", ada).Get(ctx, "absent"); ok || err != nil { + t.Fatalf("missing key ok=%v err=%v, want false,nil", ok, err) + } + // Delete removes the key; a second Delete is a no-op. + if err := storeFor("g1", ada).Delete(ctx, "k"); err != nil { + t.Fatal(err) + } + if _, ok, _ := storeFor("g1", ada).Get(ctx, "k"); ok { + t.Fatal("key present after Delete") + } + if err := storeFor("g1", ada).Delete(ctx, "k"); err != nil { + t.Fatalf("Delete of absent key = %v, want nil (no-op)", err) + } +} + +// A `max` key is kept monotonic on WRITE: a lower (or equal) write never +// regresses the stored value, but a higher one wins. Non-integer values fall +// through to last-writer-wins (no monotonic guard to apply). +func TestKVMaxMonotonicOnWrite(t *testing.T) { + ctx := context.Background() + f := quietFactory(t) + kv := f.For("r", "g").Accounts.For(member("a", "a")).Store() + + steps := []struct { + write string + want string + }{ + {"5", "5"}, + {"3", "5"}, // lower: ignored + {"5", "5"}, // equal: ignored + {"9", "9"}, // higher: wins + {"7", "9"}, // lower again: ignored + {"12", "12"}, // higher: wins + } + for i, s := range steps { + if err := kv.Set(ctx, "peak", []byte(s.write), sdk.MergeMax); err != nil { + t.Fatal(err) + } + got, _, _ := kv.Get(ctx, "peak") + if string(got) != s.want { + t.Fatalf("step %d write %s: got %q want %q", i, s.write, got, s.want) + } + } + + // Non-integer under max: no monotonic guard, last write wins. + if err := kv.Set(ctx, "name", []byte("zed"), sdk.MergeMax); err != nil { + t.Fatal(err) + } + if err := kv.Set(ctx, "name", []byte("abe"), sdk.MergeMax); err != nil { + t.Fatal(err) + } + if got, _, _ := kv.Get(ctx, "name"); string(got) != "abe" { + t.Fatalf("non-integer max: got %q want last-writer-wins abe", got) + } +} + +// Merge folds the loser's KV into the winner. On a key collision the winner's +// recorded rule governs; a loser-only key moves across unchanged. This is the +// table proving the merge-rule semantics (MergeMax keeps the larger int, +// MergeSum adds, keep-winner/keep-loser pick a side, non-integer sum/max +// degrades to keep-winner). +func TestMergeRuleSemantics(t *testing.T) { + ctx := context.Background() + + cases := []struct { + name string + winnerVal string // "" => winner has no such key (loser-only) + rule sdk.MergeRule + loserVal string + want string + }{ + {"keep-winner default", "win", sdk.MergeKeepWinner, "lose", "win"}, + {"empty rule is keep-winner", "win", sdk.MergeRule(""), "lose", "win"}, + {"unknown rule is keep-winner", "win", sdk.MergeRule("bogus"), "lose", "win"}, + {"keep-loser", "win", sdk.MergeKeepLoser, "lose", "lose"}, + {"sum adds ints", "10", sdk.MergeSum, "7", "17"}, + {"sum negative", "10", sdk.MergeSum, "-3", "7"}, + {"max keeps larger (loser bigger)", "5", sdk.MergeMax, "9", "9"}, + {"max keeps larger (winner bigger)", "9", sdk.MergeMax, "5", "9"}, + {"max equal", "4", sdk.MergeMax, "4", "4"}, + {"sum non-integer degrades to keep-winner", "win", sdk.MergeSum, "3", "win"}, + {"max non-integer degrades to keep-winner", "5", sdk.MergeMax, "lose", "5"}, + {"loser-only key moves across", "", sdk.MergeSum, "42", "42"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := quietFactory(t) + winner := member("winner", "W") + loser := member("loser", "L") + winStore := f.For("r", "g").Accounts.For(winner).Store() + loseStore := f.For("r", "g").Accounts.For(loser).Store() + + if tc.winnerVal != "" { + // Seed the winner's key with the rule under test. Use keep-winner to + // seed so the `max` write-time monotonic guard never rewrites the seed. + if err := winStore.Set(ctx, "k", []byte(tc.winnerVal), tc.rule); err != nil { + t.Fatal(err) + } + } + if err := loseStore.Set(ctx, "k", []byte(tc.loserVal), tc.rule); err != nil { + t.Fatal(err) + } + + f.Merge(winner.AccountID, loser.AccountID) + + got, ok, err := winStore.Get(ctx, "k") + if err != nil || !ok { + t.Fatalf("winner key after merge ok=%v err=%v", ok, err) + } + if string(got) != tc.want { + t.Fatalf("merged value = %q, want %q", got, tc.want) + } + // The loser's row is always consumed by the merge. + if _, ok, _ := loseStore.Get(ctx, "k"); ok { + t.Fatal("loser key still present after merge") + } + }) + } +} + +// Merge is namespaced per slug and a no-op for degenerate account ids. +func TestMergeIsolationAndGuards(t *testing.T) { + ctx := context.Background() + f := quietFactory(t) + winner := member("w", "w") + loser := member("l", "l") + + // A loser key under slug g2 must not bleed into the winner under g1. + if err := f.For("r", "g1").Accounts.For(winner).Store().Set(ctx, "k", []byte("1"), sdk.MergeSum); err != nil { + t.Fatal(err) + } + if err := f.For("r", "g2").Accounts.For(loser).Store().Set(ctx, "k", []byte("99"), sdk.MergeSum); err != nil { + t.Fatal(err) + } + f.Merge(winner.AccountID, loser.AccountID) + if got, _, _ := f.For("r", "g1").Accounts.For(winner).Store().Get(ctx, "k"); string(got) != "1" { + t.Fatalf("cross-slug bleed: g1 winner = %q want untouched 1", got) + } + // The g2 loser key moved to the g2 winner unchanged (loser-only there). + if got, ok, _ := f.For("r", "g2").Accounts.For(winner).Store().Get(ctx, "k"); !ok || string(got) != "99" { + t.Fatalf("g2 loser-only key = %q ok=%v, want 99", got, ok) + } + + // Guards: self-merge and empty ids do nothing (and don't panic). + f.Merge("w", "w") + f.Merge("", "l") + f.Merge("w", "") +} + +// Account identity passes through Player; Kind distinguishes guest from member. +func TestAccountIdentity(t *testing.T) { + f := quietFactory(t) + acc := f.For("r", "g").Accounts.For(member("acct-1", "ada")) + if acc.ID() != "acct-1" || acc.Handle() != "ada" || acc.Kind() != sdk.KindMember { + t.Fatalf("account identity = (%q,%q,%q)", acc.ID(), acc.Handle(), acc.Kind()) + } + guest := f.For("r", "g").Accounts.For(sdk.Player{AccountID: "", Handle: "anon", Kind: sdk.KindGuest}) + if guest.Kind() != sdk.KindGuest { + t.Fatalf("guest kind = %q want %q", guest.Kind(), sdk.KindGuest) + } +} + +// Per-game config is slug-bound and read-only: SetConfig seeds it, a game reads +// only its own slug's keys, and a missing key reads not-found. +func TestConfigSlugBound(t *testing.T) { + ctx := context.Background() + f := quietFactory(t) + f.SetConfig("pokies", "odds", []byte(`{"rtp":0.95}`)) + f.SetConfig("other", "odds", []byte(`{"rtp":0.10}`)) + + got, ok, err := f.For("r", "pokies").Config.Get(ctx, "odds") + if err != nil || !ok { + t.Fatalf("config Get ok=%v err=%v", ok, err) + } + if string(got) != `{"rtp":0.95}` { + t.Fatalf("config = %q want pokies value", got) + } + got[0] = 'X' // returned copy must be independent + if again, _, _ := f.For("r", "pokies").Config.Get(ctx, "odds"); string(again) != `{"rtp":0.95}` { + t.Fatalf("config Get returned an aliased slice: store corrupted to %q", again) + } + // A different game's config store cannot read pokies' key. + if _, ok, _ := f.For("r", "thirdgame").Config.Get(ctx, "odds"); ok { + t.Fatal("config leaked across slug boundary") + } + // A missing key reads not-found. + if _, ok, _ := f.For("r", "pokies").Config.Get(ctx, "absent"); ok { + t.Fatal("absent config key read as present") + } +} + +// Leaderboard recording: every account-bound result is recorded tagged by mode + +// status and surfaces through the Reader; guests (empty AccountID) are dropped. +func TestLeaderboardRecordingAndReader(t *testing.T) { + ctx := context.Background() + reg := sdk.NewRegistry() + if err := reg.Add(testGame{slug: "race"}); err != nil { + t.Fatal(err) + } + f := memsvc.NewFactory(slog.New(slog.NewTextHandler(io.Discard, nil)), reg) + lb := f.For("room-x", "race").Leaderboard + + lb.Post("race", sdk.Result{ + Mode: sdk.ModeQuick, + Rankings: []sdk.PlayerResult{ + {Player: member("acct-ada", "ada"), Metric: 60, Status: sdk.StatusFinished}, + {Player: member("acct-bob", "bob"), Metric: 90, Status: sdk.StatusFinished}, + {Player: sdk.Player{Kind: sdk.KindGuest, Handle: "anon"}, Metric: 999, Status: sdk.StatusFinished}, + }, + }) + + st, err := f.Reader().Standings(ctx, "race", sdk.AllTime, 0, 0) + if err != nil { + t.Fatal(err) + } + if len(st) != 2 { + t.Fatalf("standings len=%d want 2 (guest dropped)", len(st)) + } + // Default spec is HigherBetter: bob (90) ranks above ada (60). + if st[0].AccountID != "acct-bob" || st[0].Rank != 1 || st[0].Value != 90 { + t.Fatalf("rank 1 = %+v, want bob/1/90", st[0]) + } + if st[1].AccountID != "acct-ada" || st[1].Handle != "ada" { + t.Fatalf("rank 2 = %+v, want ada", st[1]) + } +} + +// testGame is a minimal sdk.Game for registry-backed Reader tests. It embeds +// sdk.GameBase (sealed marker) and sdk.Base (Handler marker) so it satisfies +// both interfaces without re-declaring their growable surface. +type testGame struct { + sdk.GameBase + slug string +} + +func (g testGame) Meta() sdk.GameMeta { return sdk.GameMeta{Slug: g.slug, Name: g.slug} } +func (g testGame) NewRoom(cfg sdk.RoomConfig, svc sdk.Services) sdk.Handler { + return sdk.Base{} // the Reader path never builds a room; identity only +} From 807b9f291eb707f1bc2cc1983b61ab6666a5036c Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Tue, 16 Jun 2026 07:58:54 +1000 Subject: [PATCH 04/10] host: extract the gameabi wasm host into the kit module Co-Authored-By: Claude Opus 4.8 --- go.mod | 13 +- go.sum | 28 + host/gameabi/abi.go | 29 + host/gameabi/character_ctx_test.go | 107 ++ host/gameabi/checkpoint_cadence.go | 228 ++++ host/gameabi/checkpoint_cadence_test.go | 415 ++++++ host/gameabi/checkpoint_capture_test.go | 104 ++ host/gameabi/checkpoint_drainbudget_test.go | 115 ++ host/gameabi/checkpoint_store.go | 215 +++ host/gameabi/checkpoint_store_test.go | 220 +++ host/gameabi/codec.go | 219 +++ host/gameabi/codec_test.go | 176 +++ host/gameabi/controls_test.go | 56 + host/gameabi/delta.go | 189 +++ host/gameabi/export.go | 221 +++ host/gameabi/hibernate.go | 382 ++++++ host/gameabi/hibernate_e2e_test.go | 231 ++++ host/gameabi/hibernate_test.go | 385 ++++++ host/gameabi/host.go | 1203 +++++++++++++++++ host/gameabi/host_test.go | 291 ++++ host/gameabi/largeroom_test.go | 205 +++ host/gameabi/lifecycle_test.go | 42 + host/gameabi/loadspike_test.go | 246 ++++ host/gameabi/metrics_test.go | 238 ++++ host/gameabi/quarantine.go | 126 ++ host/gameabi/quarantine_test.go | 133 ++ host/gameabi/slug.go | 9 + host/gameabi/slug_test.go | 108 ++ host/gameabi/snapshot.go | 361 +++++ host/gameabi/snapshot_cfg_test.go | 88 ++ host/gameabi/snapshot_test.go | 330 +++++ host/gameabi/storefault_test.go | 296 ++++ .../testdata/fixture-rs-kit/.gitignore | 4 + .../testdata/fixture-rs-kit/Cargo.toml | 31 + .../testdata/fixture-rs-kit/src/lib.rs | 110 ++ host/gameabi/testdata/fixture-rs/.gitignore | 4 + host/gameabi/testdata/fixture-rs/Cargo.toml | 35 + host/gameabi/testdata/fixture-rs/src/lib.rs | 436 ++++++ host/gameabi/testdata/fixture/exports.go | 33 + host/gameabi/testdata/fixture/go.mod | 11 + host/gameabi/testdata/fixture/go.sum | 8 + host/gameabi/testdata/fixture/main.go | 199 +++ host/gameabi/testdata/loadspike/exports.go | 33 + host/gameabi/testdata/loadspike/go.mod | 16 + host/gameabi/testdata/loadspike/go.sum | 12 + host/gameabi/testdata/loadspike/main.go | 429 ++++++ 46 files changed, 8369 insertions(+), 1 deletion(-) create mode 100644 host/gameabi/abi.go create mode 100644 host/gameabi/character_ctx_test.go create mode 100644 host/gameabi/checkpoint_cadence.go create mode 100644 host/gameabi/checkpoint_cadence_test.go create mode 100644 host/gameabi/checkpoint_capture_test.go create mode 100644 host/gameabi/checkpoint_drainbudget_test.go create mode 100644 host/gameabi/checkpoint_store.go create mode 100644 host/gameabi/checkpoint_store_test.go create mode 100644 host/gameabi/codec.go create mode 100644 host/gameabi/codec_test.go create mode 100644 host/gameabi/controls_test.go create mode 100644 host/gameabi/delta.go create mode 100644 host/gameabi/export.go create mode 100644 host/gameabi/hibernate.go create mode 100644 host/gameabi/hibernate_e2e_test.go create mode 100644 host/gameabi/hibernate_test.go create mode 100644 host/gameabi/host.go create mode 100644 host/gameabi/host_test.go create mode 100644 host/gameabi/largeroom_test.go create mode 100644 host/gameabi/lifecycle_test.go create mode 100644 host/gameabi/loadspike_test.go create mode 100644 host/gameabi/metrics_test.go create mode 100644 host/gameabi/quarantine.go create mode 100644 host/gameabi/quarantine_test.go create mode 100644 host/gameabi/slug.go create mode 100644 host/gameabi/slug_test.go create mode 100644 host/gameabi/snapshot.go create mode 100644 host/gameabi/snapshot_cfg_test.go create mode 100644 host/gameabi/snapshot_test.go create mode 100644 host/gameabi/storefault_test.go create mode 100644 host/gameabi/testdata/fixture-rs-kit/.gitignore create mode 100644 host/gameabi/testdata/fixture-rs-kit/Cargo.toml create mode 100644 host/gameabi/testdata/fixture-rs-kit/src/lib.rs create mode 100644 host/gameabi/testdata/fixture-rs/.gitignore create mode 100644 host/gameabi/testdata/fixture-rs/Cargo.toml create mode 100644 host/gameabi/testdata/fixture-rs/src/lib.rs create mode 100644 host/gameabi/testdata/fixture/exports.go create mode 100644 host/gameabi/testdata/fixture/go.mod create mode 100644 host/gameabi/testdata/fixture/go.sum create mode 100644 host/gameabi/testdata/fixture/main.go create mode 100644 host/gameabi/testdata/loadspike/exports.go create mode 100644 host/gameabi/testdata/loadspike/go.mod create mode 100644 host/gameabi/testdata/loadspike/go.sum create mode 100644 host/gameabi/testdata/loadspike/main.go diff --git a/go.mod b/go.mod index c4207f4..fdec727 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,20 @@ go 1.25.0 require ( github.com/extism/go-pdk v1.1.3 + github.com/extism/go-sdk v1.7.1 github.com/google/uuid v1.6.0 + github.com/klauspost/compress v1.18.6 + github.com/tetratelabs/wazero v1.12.0 golang.org/x/term v0.43.0 gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/sys v0.44.0 // indirect +require ( + github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect + github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + golang.org/x/sys v0.44.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/go.sum b/go.sum index beca894..931b386 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,39 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= 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/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= +github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw= +github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= 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= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/host/gameabi/abi.go b/host/gameabi/abi.go new file mode 100644 index 0000000..4410317 --- /dev/null +++ b/host/gameabi/abi.go @@ -0,0 +1,29 @@ +// Package gameabi is the host side of the shellcade wasm game ABI: the Extism +// (wazero) host adapter that makes a .wasm artifact satisfy sdk.Game/sdk.Handler, +// and the host functions exposing the Room effect/read surface to the guest. +// +// The ABI itself — version, names, packed payload encodings — is owned by the +// PUBLIC gamekit module (github.com/shellcade/kit/v2/wire); this package maps +// wire types onto the engine's sdk/canvas types. +package gameabi + +import ( + "time" + + "github.com/shellcade/kit/v2/wire" +) + +// Version is the ABI major version this host implements. +const Version = wire.Version + +// WireRevision is the kit wire revision this host was compiled against +// (wire.Revision): the monotonic counter of wire-visible minor additions +// within the ABI major. Re-exported here because wire imports are confined +// to this package (the anti-corruption layer) — the catalog compares an +// artifact's declared meta revision (sdk.GameMeta.WireRevision) against it +// and warns when an artifact is ahead of the host (deploy-order skew). +const WireRevision = wire.Revision + +// Heartbeat is the default host-owned wake cadence (admin-tunable per game in +// production; a flag in the devkit). +const Heartbeat = 50 * time.Millisecond diff --git a/host/gameabi/character_ctx_test.go b/host/gameabi/character_ctx_test.go new file mode 100644 index 0000000..44abdd8 --- /dev/null +++ b/host/gameabi/character_ctx_test.go @@ -0,0 +1,107 @@ +package gameabi + +// Host-side conformance for the add-character-builder change: the per-guest +// CtxFeatCharacter declaration (meta.CtxFeatures) selects whether encodeCtx / +// encodeCtxEpoch emit per-member character sections, and a non-declaring +// guest's bytes are identical to the pre-feature encoding (the v2.8 guarantee +// at host level). + +import ( + "bytes" + "testing" + + "github.com/shellcade/kit/v2/wire" + + "github.com/shellcade/kit/v2/host/sdk" +) + +func charRoster() []sdk.Player { + return []sdk.Player{ + {AccountID: "a1", Handle: "ada", Kind: sdk.KindMember, Conn: "c1", + Character: sdk.Character{Glyph: "@", InkR: 1, InkG: 2, InkB: 3, BgR: 4, BgG: 5, BgB: 6, Fallback: '@'}}, + {AccountID: "", Handle: "guest", Kind: sdk.KindGuest, Conn: "c2", + Character: sdk.Character{Glyph: "ż", InkR: 250, InkG: 0, InkB: 128, BgR: 9, BgG: 10, BgB: 11, Fallback: 'z'}}, + } +} + +func charCfg() sdk.RoomConfig { + return sdk.RoomConfig{Mode: sdk.ModeQuick, Capacity: 4, MinPlayers: 2, Seed: 7, SeedSet: true} +} + +// wantWireChar is the wire shape of charRoster()[i].Character. +func wantWireChar(p sdk.Player) wire.Character { + return wire.Character{ + Glyph: p.Character.Glyph, + InkR: p.Character.InkR, InkG: p.Character.InkG, InkB: p.Character.InkB, + BgR: p.Character.BgR, BgG: p.Character.BgG, BgB: p.Character.BgB, + Fallback: p.Character.Fallback, + } +} + +// A guest whose meta declares CtxFeatCharacter receives a character section +// for every roster member, intact through a wire decode, in BOTH the legacy +// form and the roster-epoch full form. +func TestEncodeCtxCharacterSections(t *testing.T) { + roster := charRoster() + + // Legacy form (meta declares CtxFeatCharacter only). + w := encodeCtx(99, charCfg(), roster, false, wire.CtxFeatCharacter) + got := wire.DecodeCtxFeat(&wire.Rd{B: w.B}, wire.CtxFeatCharacter) + if len(got.Members) != 2 { + t.Fatalf("legacy form: decoded %d members, want 2", len(got.Members)) + } + for i, p := range roster { + if got.Members[i].Character != wantWireChar(p) { + t.Fatalf("legacy form member %d character:\n got=%+v\nwant=%+v", i, got.Members[i].Character, wantWireChar(p)) + } + } + + // Roster-epoch full form (meta declares both features). + feats := wire.CtxFeatCharacter | wire.CtxFeatRosterEpoch + w = encodeCtxEpoch(99, charCfg(), roster, false, 3, true, feats) + got = wire.DecodeCtxFeat(&wire.Rd{B: w.B}, feats) + if !got.RosterEpochSet || got.RosterEpoch != 3 || got.RosterUnchanged { + t.Fatalf("epoch-full form: epoch state %+v", got) + } + if len(got.Members) != 2 { + t.Fatalf("epoch-full form: decoded %d members, want 2", len(got.Members)) + } + for i, p := range roster { + if got.Members[i].Character != wantWireChar(p) { + t.Fatalf("epoch-full member %d character:\n got=%+v\nwant=%+v", i, got.Members[i].Character, wantWireChar(p)) + } + } +} + +// A non-declaring guest (meta features 0) gets byte-identical encodings to the +// same roster with zero-value characters — populating sdk.Player.Character on +// the host never changes what a pre-character guest receives. +func TestEncodeCtxNonDeclaringBytesUnchanged(t *testing.T) { + withChars := charRoster() + zeroed := charRoster() + for i := range zeroed { + zeroed[i].Character = sdk.Character{} + } + + a := encodeCtx(99, charCfg(), withChars, true, 0) + b := encodeCtx(99, charCfg(), zeroed, true, 0) + if !bytes.Equal(a.B, b.B) { + t.Fatalf("legacy form: features=0 encoding depends on character values:\n with=%x\n zero=%x", a.B, b.B) + } + + // Epoch full form, roster-epoch declared but NOT the character bit. + a = encodeCtxEpoch(99, charCfg(), withChars, true, 5, true, wire.CtxFeatRosterEpoch) + b = encodeCtxEpoch(99, charCfg(), zeroed, true, 5, true, wire.CtxFeatRosterEpoch) + if !bytes.Equal(a.B, b.B) { + t.Fatalf("epoch-full form: roster-epoch-only encoding depends on character values:\n with=%x\n zero=%x", a.B, b.B) + } + + // And the unknown-bit mask: a meta declaring bits this host's wire revision + // does not define encodes as if only the known bits were set (decodeMeta's + // tolerance posture, applied at encode time). + a = encodeCtx(99, charCfg(), withChars, true, uint32(1<<30)) + b = encodeCtx(99, charCfg(), withChars, true, 0) + if !bytes.Equal(a.B, b.B) { + t.Fatal("unknown feature bits leaked into the encoding") + } +} diff --git a/host/gameabi/checkpoint_cadence.go b/host/gameabi/checkpoint_cadence.go new file mode 100644 index 0000000..34cc4d0 --- /dev/null +++ b/host/gameabi/checkpoint_cadence.go @@ -0,0 +1,228 @@ +package gameabi + +import ( + "context" + "math/rand" + "sync" + "sync/atomic" + "time" +) + +// CheckpointScheduler drives ONE room's periodic, non-destructive checkpoint +// cadence (room-hosting spec "Periodic Room Checkpoints", design D5): a jittered +// ticker (default ~30s ± jitter) that fires a checkpoint callback with a +// monotonic epoch starting at 0, suppressed while the room is lobby-idle. +// +// The scheduler is deliberately decoupled from room internals: it takes an Idle +// probe func and a Checkpoint callback rather than a Room/Handler, so it lives +// entirely in this package's durability seam and the caller wires it to whatever +// idle signal and capture path it owns (e.g. CheckpointHandler on the actor). +// The epoch is owned here (monotonic per room from 0); the caller passes it +// straight to CheckpointStore.Write. +type CheckpointScheduler struct { + base time.Duration + jitter time.Duration + clock Clock + idle func() bool + fire func(ctx context.Context, epoch int64) error + rng *rand.Rand + + epoch atomic.Int64 // next epoch to fire (atomic: the drain reads it via NextEpoch) + lastIntv atomicDuration + + // fireMu guards the start of a fire against Close: a fire may begin only while + // closed is false, and it registers on fireWG (under the lock) before it runs. + // Close sets closed (under the lock) then Waits on fireWG, so it returns only + // after any in-flight fire has completed and advanced the epoch — the drain's + // close-then-NextEpoch sequence is then a true fence (the drain epoch is + // strictly above every committed periodic write). + fireMu sync.Mutex + closed bool + fireWG sync.WaitGroup + + closeOnce sync.Once + done chan struct{} +} + +// Clock is the scheduler's view of time, injectable for tests. The production +// implementation (SystemClock) delegates to the stdlib; tests use a controllable +// fake that fires After channels on demand. +type Clock interface { + Now() time.Time + After(d time.Duration) <-chan time.Time +} + +// SystemClock is the real-time Clock backed by the stdlib. +type SystemClock struct{} + +func (SystemClock) Now() time.Time { return time.Now() } +func (SystemClock) After(d time.Duration) <-chan time.Time { return time.After(d) } + +// CadenceConfig configures one room's checkpoint scheduler. Base and Jitter are +// the per-game override hook: the caller picks them per game (default ~30s base +// when zero). Idle suppresses checkpoints for a lobby-idle room. Checkpoint +// captures+writes the snapshot for the given epoch (typically a closure over +// CheckpointHandler, the room's CheckpointStore, and its UUID). +type CadenceConfig struct { + Base time.Duration // default cadence; <=0 falls back to DefaultCheckpointInterval + Jitter time.Duration // +/- spread around Base; <0 treated as 0, clamped to <= Base + Clock Clock // injectable; nil = SystemClock + Idle func() bool // true => suppress this interval's checkpoint; nil = never idle + Checkpoint func(ctx context.Context, epoch int64) error + Rand *rand.Rand // injectable jitter source; nil = a time-seeded source + + // StartEpoch is the FIRST epoch the scheduler fires (default 0). It exists for + // the post-reclaim path: a room restored from a checkpoint at epoch N must seed + // its new scheduler at N+1 so the next periodic write SUPERSEDES the restore + // blob and advances the latest pointer — the pointer-epoch is strictly + // monotonic across drain/reclaim cycles, forever (a fresh placement uses 0). + StartEpoch int64 +} + +// DefaultCheckpointInterval is the spec's ~30s default cadence. +const DefaultCheckpointInterval = 30 * time.Second + +// minInterval is the positive floor a jittered interval is clamped to (only +// reachable at the jitter==base extreme); it keeps clock.After from firing in a +// tight zero-delay loop. +const minInterval = 1 * time.Nanosecond + +// NewCheckpointScheduler builds a scheduler from cfg. It does not start ticking +// until Run is called. +func NewCheckpointScheduler(cfg CadenceConfig) *CheckpointScheduler { + base := cfg.Base + if base <= 0 { + base = DefaultCheckpointInterval + } + jitter := cfg.Jitter + if jitter < 0 { + jitter = 0 + } + if jitter > base { + jitter = base // never let an interval go negative + } + clock := cfg.Clock + if clock == nil { + clock = SystemClock{} + } + idle := cfg.Idle + if idle == nil { + idle = func() bool { return false } + } + rng := cfg.Rand + if rng == nil { + rng = rand.New(rand.NewSource(time.Now().UnixNano())) + } + s := &CheckpointScheduler{ + base: base, + jitter: jitter, + clock: clock, + idle: idle, + fire: cfg.Checkpoint, + rng: rng, + done: make(chan struct{}), + } + s.epoch.Store(cfg.StartEpoch) + return s +} + +// NextEpoch reports the epoch the scheduler would fire next — the value the drain +// reads (after Close, so no periodic tick can advance it concurrently) to pick a +// drain epoch strictly above every committed periodic checkpoint. Safe to call +// from another goroutine. +func (s *CheckpointScheduler) NextEpoch() int64 { return s.epoch.Load() } + +// nextInterval returns base ± a uniform draw in [-jitter, +jitter]. +func (s *CheckpointScheduler) nextInterval() time.Duration { + if s.jitter == 0 { + return s.base + } + // Uniform in [-jitter, +jitter]. + delta := time.Duration(s.rng.Int63n(int64(2*s.jitter)+1)) - s.jitter + d := s.base + delta + if d <= 0 { + // Floor at 1ns. Only reachable at the jitter==base extreme with the + // minimum draw (delta == -base => d == 0); a positive interval keeps + // clock.After from firing immediately in a tight loop. + d = minInterval + } + return d +} + +// lastInterval reports the most recently armed interval (test introspection for +// jitter bounds). +func (s *CheckpointScheduler) lastInterval() time.Duration { return s.lastIntv.load() } + +// Run drives the cadence until ctx is cancelled or Close is called. On each +// jittered tick it skips the checkpoint when Idle reports true (no epoch +// advance), otherwise it fires the Checkpoint callback with the next monotonic +// epoch (0, 1, 2, …) and advances only on a successful capture+write. A +// Checkpoint error is left to the callback to log; the epoch does not advance so +// the same epoch is retried next interval (a failed write must not burn an epoch +// the latest pointer never reached). +func (s *CheckpointScheduler) Run(ctx context.Context) { + for { + d := s.nextInterval() + s.lastIntv.store(d) + select { + case <-ctx.Done(): + return + case <-s.done: + return + case <-s.clock.After(d): + } + if s.idle() { + continue // lobby-idle: suppress this interval, do not advance the epoch + } + if !s.runFire(ctx) { + return // Close fenced us before this fire could start + } + } +} + +// runFire performs one checkpoint fire under the close fence: it registers on +// fireWG (so Close waits for it) only if the scheduler is not already closing, +// then fires and, on success, advances the epoch. It returns false when Close +// has fenced the cadence (the loop then exits without firing). A fire already +// past the gate runs to completion even as Close arrives — and Close blocks until +// it does (see the type doc). +func (s *CheckpointScheduler) runFire(ctx context.Context) bool { + s.fireMu.Lock() + if s.closed { + s.fireMu.Unlock() + return false + } + s.fireWG.Add(1) + s.fireMu.Unlock() + defer s.fireWG.Done() + + ep := s.epoch.Load() + if err := s.fire(ctx, ep); err != nil { + return true // retry the same epoch next interval (no advance) + } + s.epoch.Add(1) + return true +} + +// Close stops the scheduler and BLOCKS until any in-flight fire has completed (so +// the epoch it advanced is visible to a subsequent NextEpoch). Run returns +// promptly once no fire is running. Idempotent. +func (s *CheckpointScheduler) Close() { + s.closeOnce.Do(func() { + s.fireMu.Lock() + s.closed = true + s.fireMu.Unlock() + close(s.done) + }) + // Wait OUTSIDE closeOnce so a concurrent second Close also blocks until the + // in-flight fire finishes (closeOnce's body runs only once, but every caller + // must observe the fence). + s.fireWG.Wait() +} + +// atomicDuration is a tiny atomic wrapper so lastInterval can be read from a +// test goroutine while Run writes it. +type atomicDuration struct{ v atomic.Int64 } + +func (a *atomicDuration) store(d time.Duration) { a.v.Store(int64(d)) } +func (a *atomicDuration) load() time.Duration { return time.Duration(a.v.Load()) } diff --git a/host/gameabi/checkpoint_cadence_test.go b/host/gameabi/checkpoint_cadence_test.go new file mode 100644 index 0000000..b3ff972 --- /dev/null +++ b/host/gameabi/checkpoint_cadence_test.go @@ -0,0 +1,415 @@ +package gameabi + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/blobstore" +) + +// fakeClock is a controllable Clock for the cadence tests: Now is settable and +// After hands back a channel the test fires by advancing time. Only one pending +// timer at a time is needed (the scheduler arms one interval, waits, re-arms). +type fakeClock struct { + mu sync.Mutex + now time.Time + pending []*fakeTimer +} + +type fakeTimer struct { + at time.Time + ch chan time.Time +} + +func newFakeClock() *fakeClock { return &fakeClock{now: time.Unix(1_700_000_000, 0)} } + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *fakeClock) After(d time.Duration) <-chan time.Time { + c.mu.Lock() + defer c.mu.Unlock() + t := &fakeTimer{at: c.now.Add(d), ch: make(chan time.Time, 1)} + c.pending = append(c.pending, t) + return t.ch +} + +// advance moves time forward, firing every timer due at or before the new now. +func (c *fakeClock) advance(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + now := c.now + var stay []*fakeTimer + var fire []*fakeTimer + for _, t := range c.pending { + if !t.at.After(now) { + fire = append(fire, t) + } else { + stay = append(stay, t) + } + } + c.pending = stay + c.mu.Unlock() + for _, t := range fire { + t.ch <- now + } +} + +// armed reports how many timers are currently waiting (the scheduler arms one +// per interval; the test waits for it before advancing to avoid a race). +func (c *fakeClock) armed() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.pending) +} + +func waitArmed(t *testing.T, c *fakeClock) { + t.Helper() + for i := 0; i < 1000; i++ { + if c.armed() > 0 { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("scheduler never armed a timer") +} + +// The scheduler fires checkpoints at base ± jitter, hands each a monotonic epoch +// starting at 0, and stops cleanly on Close. +func TestCadenceFiresWithMonotonicEpochs(t *testing.T) { + clk := newFakeClock() + var mu sync.Mutex + var epochs []int64 + fired := make(chan struct{}, 16) + + sch := NewCheckpointScheduler(CadenceConfig{ + Base: 30 * time.Second, + Jitter: 5 * time.Second, + Clock: clk, + Idle: func() bool { return false }, + Checkpoint: func(ctx context.Context, epoch int64) error { + mu.Lock() + epochs = append(epochs, epoch) + mu.Unlock() + fired <- struct{}{} + return nil + }, + }) + go sch.Run(context.Background()) + + for i := 0; i < 3; i++ { + waitArmed(t, clk) + clk.advance(35 * time.Second) // past base+jitter so the timer is due + <-fired + } + sch.Close() + + mu.Lock() + got := append([]int64(nil), epochs...) + mu.Unlock() + if len(got) != 3 { + t.Fatalf("fired %d times, want 3: %v", len(got), got) + } + for i, e := range got { + if e != int64(i) { + t.Fatalf("epoch[%d] = %d, want %d (monotonic from 0)", i, e, i) + } + } +} + +// A scheduler seeded with StartEpoch fires its first checkpoint at that epoch and +// advances from there — the post-reclaim path where periodic checkpoints must +// supersede the drain blob (a strictly-monotonic pointer-epoch across restarts). +// NextEpoch reports the epoch the scheduler would use next (drain reads it). +func TestCadenceStartEpochSeeds(t *testing.T) { + clk := newFakeClock() + var mu sync.Mutex + var epochs []int64 + fired := make(chan struct{}, 16) + + sch := NewCheckpointScheduler(CadenceConfig{ + Base: 30 * time.Second, + Clock: clk, + StartEpoch: 100, + Checkpoint: func(ctx context.Context, epoch int64) error { + mu.Lock() + epochs = append(epochs, epoch) + mu.Unlock() + fired <- struct{}{} + return nil + }, + }) + if got := sch.NextEpoch(); got != 100 { + t.Fatalf("NextEpoch before any tick = %d, want the seed 100", got) + } + go sch.Run(context.Background()) + for i := 0; i < 2; i++ { + waitArmed(t, clk) + clk.advance(31 * time.Second) + <-fired + } + sch.Close() + + mu.Lock() + got := append([]int64(nil), epochs...) + mu.Unlock() + if len(got) != 2 || got[0] != 100 || got[1] != 101 { + t.Fatalf("seeded epochs = %v, want [100 101]", got) + } + if ne := sch.NextEpoch(); ne != 102 { + t.Fatalf("NextEpoch after 2 ticks = %d, want 102", ne) + } +} + +// Close BLOCKS until an in-flight fire completes (and the epoch it advanced is +// visible): the drain's close-then-NextEpoch sequence then truly fences the +// cadence, so the drain epoch is strictly above every committed periodic write. +func TestCadenceCloseWaitsForInFlightFire(t *testing.T) { + clk := newFakeClock() + enter := make(chan struct{}) // closed when fire() has started + release := make(chan struct{}) + var enterOnce sync.Once + + sch := NewCheckpointScheduler(CadenceConfig{ + Base: 30 * time.Second, + Clock: clk, + Checkpoint: func(ctx context.Context, epoch int64) error { + enterOnce.Do(func() { close(enter) }) + <-release // hold the fire open until the test releases it + return nil + }, + }) + go sch.Run(context.Background()) + + waitArmed(t, clk) + clk.advance(31 * time.Second) + <-enter // a fire is now in flight, blocked on release + + // Close concurrently: it must NOT return while the fire is held. + closed := make(chan struct{}) + go func() { sch.Close(); close(closed) }() + select { + case <-closed: + t.Fatal("Close returned while a fire was still in flight") + case <-time.After(100 * time.Millisecond): + // good: Close is blocked waiting for the in-flight fire + } + if ne := sch.NextEpoch(); ne != 0 { + t.Fatalf("NextEpoch mid-fire = %d, want 0 (not yet advanced)", ne) + } + + close(release) // let the fire complete + select { + case <-closed: + case <-time.After(2 * time.Second): + t.Fatal("Close did not return after the in-flight fire completed") + } + if ne := sch.NextEpoch(); ne != 1 { + t.Fatalf("NextEpoch after Close = %d, want 1 (the completed fire's advance is visible)", ne) + } +} + +// A drain (close-then-NextEpoch-then-Write) NEVER collides with the scheduler's +// periodic writes against a real CheckpointStore: after Close fences the cadence, +// NextEpoch is strictly above every committed periodic epoch, so the drain Write +// succeeds (no never-overwrite error) and advances the latest pointer. This is the +// peer drain sequence in miniature. +func TestCadenceDrainNeverCollides(t *testing.T) { + clk := newFakeClock() + cs := NewCheckpointStore(blobstore.NewMemory(), blobstore.NewHMACSealer([]byte("k"))) + const room = "0190b8a0-dead-7abc-8def-0123456789ab" + payload := []byte("snapshot-bytes") + fired := make(chan struct{}, 16) + + sch := NewCheckpointScheduler(CadenceConfig{ + Base: 30 * time.Second, + Clock: clk, + Checkpoint: func(ctx context.Context, epoch int64) error { + err := cs.Write(ctx, room, epoch, payload) + fired <- struct{}{} + return err + }, + }) + go sch.Run(context.Background()) + + // Commit two periodic checkpoints (epochs 0, 1). + for i := 0; i < 2; i++ { + waitArmed(t, clk) + clk.advance(31 * time.Second) + <-fired + } + + // Drain: fence the cadence, then write at NextEpoch. + sch.Close() + drainEpoch := sch.NextEpoch() + if drainEpoch != 2 { + t.Fatalf("drain epoch = %d, want 2 (strictly above committed periodic 0,1)", drainEpoch) + } + if err := cs.Write(context.Background(), room, drainEpoch, payload); err != nil { + t.Fatalf("drain Write at epoch %d collided/failed: %v", drainEpoch, err) + } + if _, ep, err := cs.ReadLatest(context.Background(), room); err != nil || ep != drainEpoch { + t.Fatalf("latest pointer after drain = (epoch %d, err %v), want epoch %d", ep, err, drainEpoch) + } +} + +// Intervals stay within [base-jitter, base+jitter]. +func TestCadenceJitterBounds(t *testing.T) { + clk := newFakeClock() + const base = 30 * time.Second + const jitter = 5 * time.Second + var mu sync.Mutex + var intervals []time.Duration + + var sch *CheckpointScheduler + sch = NewCheckpointScheduler(CadenceConfig{ + Base: base, + Jitter: jitter, + Clock: clk, + Idle: func() bool { return false }, + Checkpoint: func(ctx context.Context, epoch int64) error { + mu.Lock() + intervals = append(intervals, sch.lastInterval()) + mu.Unlock() + return nil + }, + }) + go sch.Run(context.Background()) + + for i := 0; i < 50; i++ { + waitArmed(t, clk) + clk.advance(base + jitter) // always enough to fire + // brief settle so the callback records before the next arm + for sch.lastInterval() == 0 && i == 0 { + time.Sleep(time.Millisecond) + } + time.Sleep(time.Millisecond) + } + sch.Close() + + mu.Lock() + defer mu.Unlock() + if len(intervals) < 10 { + t.Fatalf("only %d intervals recorded", len(intervals)) + } + sawBelow, sawAbove := false, false + for _, d := range intervals { + if d < base-jitter || d > base+jitter { + t.Fatalf("interval %v out of [%v, %v]", d, base-jitter, base+jitter) + } + if d < base { + sawBelow = true + } + if d > base { + sawAbove = true + } + } + if !sawBelow || !sawAbove { + t.Errorf("jitter not exercised both directions (below=%v above=%v)", sawBelow, sawAbove) + } +} + +// While the idle probe reports true, the scheduler arms its timer but does NOT +// fire a checkpoint (lobby-idle rooms are suppressed); epochs do not advance. +func TestCadenceSkipsWhileIdle(t *testing.T) { + clk := newFakeClock() + var idle struct { + mu sync.Mutex + v bool + } + idle.v = true + fired := make(chan int64, 16) + + sch := NewCheckpointScheduler(CadenceConfig{ + Base: 30 * time.Second, + Jitter: 0, + Clock: clk, + Idle: func() bool { + idle.mu.Lock() + defer idle.mu.Unlock() + return idle.v + }, + Checkpoint: func(ctx context.Context, epoch int64) error { + fired <- epoch + return nil + }, + }) + go sch.Run(context.Background()) + + // Two idle intervals: timer arms and elapses, but no checkpoint fires. + for i := 0; i < 2; i++ { + waitArmed(t, clk) + clk.advance(30 * time.Second) + time.Sleep(2 * time.Millisecond) + } + select { + case e := <-fired: + t.Fatalf("idle room checkpointed at epoch %d", e) + default: + } + + // Become active; the next interval fires at epoch 0 (epochs never advanced + // while idle). + idle.mu.Lock() + idle.v = false + idle.mu.Unlock() + waitArmed(t, clk) + clk.advance(30 * time.Second) + select { + case e := <-fired: + if e != 0 { + t.Fatalf("first active checkpoint epoch = %d, want 0", e) + } + case <-time.After(time.Second): + t.Fatal("active room never checkpointed") + } + sch.Close() +} + +// Close stops the scheduler: Run returns and no further timers arm. +func TestCadenceStopsOnClose(t *testing.T) { + clk := newFakeClock() + done := make(chan struct{}) + sch := NewCheckpointScheduler(CadenceConfig{ + Base: 30 * time.Second, + Clock: clk, + Idle: func() bool { return false }, + Checkpoint: func(context.Context, int64) error { return nil }, + }) + go func() { sch.Run(context.Background()); close(done) }() + waitArmed(t, clk) + sch.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Run did not return after Close") + } + // Close is idempotent. + sch.Close() +} + +// A cancelled context stops the scheduler just like Close. +func TestCadenceStopsOnContextCancel(t *testing.T) { + clk := newFakeClock() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + sch := NewCheckpointScheduler(CadenceConfig{ + Base: 30 * time.Second, + Clock: clk, + Idle: func() bool { return false }, + Checkpoint: func(context.Context, int64) error { return nil }, + }) + go func() { sch.Run(ctx); close(done) }() + waitArmed(t, clk) + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Run did not return after context cancel") + } +} diff --git a/host/gameabi/checkpoint_capture_test.go b/host/gameabi/checkpoint_capture_test.go new file mode 100644 index 0000000..52f8f56 --- /dev/null +++ b/host/gameabi/checkpoint_capture_test.go @@ -0,0 +1,104 @@ +package gameabi + +import ( + "context" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/blobstore" + "github.com/shellcade/kit/v2/host/sdk" +) + +// CheckpointHandler captures the SAME deterministic snapshot the hibernation +// codec produces (no new payload format) and writes it through CheckpointStore +// WITHOUT ending the room: the handler keeps running afterward, and a Restore +// from the written checkpoint reproduces state exactly. This is the +// non-destructive periodic-durability path (distinct from the disposing +// Hibernate, which lives in internal/sdk and tears the room down). +func TestCheckpointHandlerNonDestructive(t *testing.T) { + g := loadFixture(t, Options{}).(*wasmGame) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 12345, SeedSet: true} + roster := []sdk.Player{p1} + start := time.Unix(1_700_000_000, 0) + ctx := context.Background() + + cs := NewCheckpointStore(blobstore.NewMemory(), blobstore.NewHMACSealer([]byte("server-key"))) + const room = "0190b8a0-1234-7abc-8def-0123456789ab" + + // Drive a handler through a prefix, then CHECKPOINT it mid-life. + prefix := func(h *wasmHandler) *replayRoom { + r := newReplayRoom(roster, cfg, start) + h.OnStart(r) + h.OnJoin(r, p1) + for i := 0; i < 4; i++ { + r.clock = r.clock.Add(50 * time.Millisecond) + h.OnTick(r, r.clock) + } + h.OnInput(r, p1, runeIn('r')) + h.OnInput(r, p1, runeIn('i')) + return r + } + + // Control: prefix THEN continuation, never checkpointed. + hCtl := g.NewRoom(cfg, sdk.Services{Log: quietLog()}).(*wasmHandler) + rCtl := prefix(hCtl) + checkpointClock := rCtl.clock + rCtl.frames = nil + snapScript(hCtl, rCtl, p1) + wantFrames := rCtl.frames + + // Live handler: prefix, checkpoint, THEN keep driving the SAME handler. + hLive := g.NewRoom(cfg, sdk.Services{Log: quietLog()}).(*wasmHandler) + rLive := prefix(hLive) + if err := CheckpointHandler(ctx, cs, room, 0, hLive); err != nil { + t.Fatalf("CheckpointHandler: %v", err) + } + // Non-destructive: the room must NOT have ended, and must keep producing + // the same continuation as the uninterrupted control. + if HandlerEnded(hLive) { + t.Fatal("checkpoint ended the room (must be non-destructive)") + } + rLive.frames = nil + snapScript(hLive, rLive, p1) + if len(rLive.frames) != len(wantFrames) { + t.Fatalf("post-checkpoint continuation frame count %d, want %d", len(rLive.frames), len(wantFrames)) + } + for i := range wantFrames { + if !framesEqual(wantFrames[i], rLive.frames[i]) { + t.Fatalf("post-checkpoint continuation frame %d diverged: room was disturbed by the checkpoint", i) + } + } + + // The written checkpoint restores to the SAME state, and a continuation from + // the restored handler matches the control too (reuse of the codec, sealed). + payload, epoch, err := cs.ReadLatest(ctx, room) + if err != nil { + t.Fatalf("ReadLatest: %v", err) + } + if epoch != 0 { + t.Errorf("epoch = %d, want 0", epoch) + } + hRestored, err := g.Restore(payload) + if err != nil { + t.Fatalf("Restore from checkpoint: %v", err) + } + rRestored := newReplayRoom(roster, cfg, checkpointClock) + hRestored.OnResume(rRestored) + snapScript(hRestored, rRestored, p1) + if len(rRestored.frames) != len(wantFrames) { + t.Fatalf("restored continuation frame count %d, want %d", len(rRestored.frames), len(wantFrames)) + } + for i := range wantFrames { + if !framesEqual(wantFrames[i], rRestored.frames[i]) { + t.Fatalf("restored continuation frame %d diverged from control", i) + } + } +} + +// CheckpointHandler refuses a handler that is not a wasm room. +func TestCheckpointHandlerNonWasm(t *testing.T) { + cs := NewCheckpointStore(blobstore.NewMemory(), blobstore.NewHMACSealer([]byte("k"))) + if err := CheckpointHandler(context.Background(), cs, "room", 0, nil); err == nil { + t.Fatal("CheckpointHandler(nil handler) should error") + } +} diff --git a/host/gameabi/checkpoint_drainbudget_test.go b/host/gameabi/checkpoint_drainbudget_test.go new file mode 100644 index 0000000..3c5d6d6 --- /dev/null +++ b/host/gameabi/checkpoint_drainbudget_test.go @@ -0,0 +1,115 @@ +package gameabi + +import ( + "context" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/blobstore" + "github.com/shellcade/kit/v2/host/sdk" +) + +// TestD4CheckpointCost measures the worst-case per-room checkpoint cost +// (serialize + MAC + write) against the in-memory blobstore double, so the +// rooms-per-peer cap can be derived for the 60s drain budget (spike D.4, design +// D5). It is the "current checkpoint path as proxy" measurement: SnapshotHandler +// (the deterministic codec) + Sealer.Seal (HMAC) + Store.Put. The in-memory +// store has zero network RTT, so the reported time is the CPU floor; production +// adds one Tigris PUT RTT per room (the cap must budget that separately). +// +// Run with: go test ./internal/gameabi/ -run TestD4CheckpointCost -v +func TestD4CheckpointCost(t *testing.T) { + g := loadFixture(t, Options{}).(*wasmGame) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 12345, SeedSet: true} + roster := []sdk.Player{p1} + start := time.Unix(1_700_000_000, 0) + ctx := context.Background() + + // Drive the fixture to a realistic mid-game state (instantiate, join, wakes, + // entropy draws, input context) so the captured memory is representative. + h := g.NewRoom(cfg, sdk.Services{Log: quietLog()}).(*wasmHandler) + r := newReplayRoom(roster, cfg, start) + h.OnStart(r) + h.OnJoin(r, p1) + for i := 0; i < 8; i++ { + r.clock = r.clock.Add(50 * time.Millisecond) + h.OnTick(r, r.clock) + } + h.OnInput(r, p1, runeIn('r')) + h.OnInput(r, p1, runeIn('i')) + + cs := NewCheckpointStore(blobstore.NewMemory(), blobstore.NewHMACSealer([]byte("server-side-key"))) + + // Measure the serialized blob size once. + blob, err := SnapshotHandler(h) + if err != nil { + t.Fatalf("SnapshotHandler: %v", err) + } + + // Time N sequential checkpoints (each at a fresh epoch) and report the worst + // and mean per-room cost. Sequential because a single room is serialized per + // actor; the drain runs many rooms in parallel, so the per-room cost is what + // multiplies against the rooms-per-peer cap (÷ parallelism). + const n = 200 + var worst time.Duration + var total time.Duration + for epoch := int64(0); epoch < n; epoch++ { + t0 := time.Now() + if err := CheckpointHandler(ctx, cs, "0190b8a0-1234-7abc-8def-0123456789ab", epoch, h); err != nil { + t.Fatalf("CheckpointHandler epoch %d: %v", epoch, err) + } + d := time.Since(t0) + total += d + if d > worst { + worst = d + } + } + mean := total / n + + t.Logf("D.4 per-room checkpoint cost (in-memory blobstore, fixture mid-game):") + t.Logf(" snapshot blob size = %d bytes (compressed)", len(blob)) + t.Logf(" iterations = %d", n) + t.Logf(" mean per room = %s", mean) + t.Logf(" worst per room = %s", worst) + t.Logf(" (CPU floor only; production adds ~1 Tigris PUT RTT per room)") + + // The measured cost above is INFORMATIONAL — the headline (mean/worst) is + // reported via t.Log and recorded in the change docs (design D.4); it is NOT a + // CI gate. A wall-clock CPU-floor assert is environment-sensitive: a shared CI + // runner measured 84ms against a former 50ms bound and flaked the suite. + // Measurement tests must not gate CI on wall-clock, so this is only a wildly + // generous sanity bound that fires solely on a genuine hang/pathology, never on + // a loaded box. + if worst > 5*time.Second { + t.Fatalf("worst per-room checkpoint %s is pathological (sanity bound only; the number is informational)", worst) + } +} + +// BenchmarkD4Checkpoint is the same serialize+MAC+write path as a Go benchmark, +// for `go test -bench BenchmarkD4Checkpoint -benchmem`. +func BenchmarkD4Checkpoint(b *testing.B) { + g, err := LoadGame(fixturePath, Options{}) + if err != nil { + b.Fatalf("LoadGame: %v", err) + } + wg := g.(*wasmGame) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 12345, SeedSet: true} + roster := []sdk.Player{p1} + start := time.Unix(1_700_000_000, 0) + h := wg.NewRoom(cfg, sdk.Services{Log: quietLog()}).(*wasmHandler) + r := newReplayRoom(roster, cfg, start) + h.OnStart(r) + h.OnJoin(r, p1) + for i := 0; i < 8; i++ { + r.clock = r.clock.Add(50 * time.Millisecond) + h.OnTick(r, r.clock) + } + cs := NewCheckpointStore(blobstore.NewMemory(), blobstore.NewHMACSealer([]byte("k"))) + ctx := context.Background() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := CheckpointHandler(ctx, cs, "bench-room", int64(i), h); err != nil { + b.Fatalf("CheckpointHandler: %v", err) + } + } +} diff --git a/host/gameabi/checkpoint_store.go b/host/gameabi/checkpoint_store.go new file mode 100644 index 0000000..efc2513 --- /dev/null +++ b/host/gameabi/checkpoint_store.go @@ -0,0 +1,215 @@ +package gameabi + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/shellcade/kit/v2/host/blobstore" +) + +// CheckpointStore is the NON-DESTRUCTIVE, versioned room-durability path (design +// D5, room-hosting spec "Periodic Room Checkpoints") — distinct from the +// disposing HibernationStore in this package. Where HibernationStore writes a +// single flat snapshots/ object and deletes it on restore, the +// checkpoint store writes monotonic, MAC'd snapshots// objects +// behind a monotonically-advanced latest pointer, so a room can be checkpointed +// repeatedly while it keeps running and a slow lower-epoch write can never +// regress the pointer off a later (higher-epoch) drain write. +// +// The payload is opaque to the store: it is the same deterministic snapshot blob +// the hibernation codec already produces (SnapshotHandler) — the checkpoint path +// does not invent a new format, it only adds versioned keying + a server-side +// MAC over whatever bytes it is handed. +// +// PRECONDITION — single writer per room: Write MUST be serialized per roomID +// (each room is driven by its own actor/scheduler, which is the only thing that +// checkpoints that room). The store takes no lock and makes no assertion of this +// — coordination deliberately lives in the actor, not here. The monotonic +// latest-pointer advance and the never-overwrite-an-epoch check are correct only +// under this precondition; concurrent writers to one room would race both. Across +// DIFFERENT rooms Write is safe to call concurrently (the backing Store is). +type CheckpointStore struct { + store blobstore.Store + sealer blobstore.Sealer +} + +// NewCheckpointStore wraps a blobstore.Store with a Sealer that MACs every blob +// with a server-side key held outside the wasm sandbox. nil store/sealer is a +// programmer error guarded per-method (matching HibernationStore), never a +// panic. +func NewCheckpointStore(store blobstore.Store, sealer blobstore.Sealer) *CheckpointStore { + return &CheckpointStore{store: store, sealer: sealer} +} + +// ErrNoCheckpoint reports that a room has no checkpoint yet (the latest pointer +// is absent). Distinct from ErrCheckpointCorrupt: missing is "nothing to +// restore", corrupt is "quarantine this room". +var ErrNoCheckpoint = errors.New("gameabi: no checkpoint for room") + +// ErrCheckpointCorrupt reports a checkpoint that failed integrity verification +// (the MAC did not verify, or the blob was truncated/tampered in storage). It +// wraps blobstore.ErrSealVerify. The re-hydration path maps this to PER-ROOM +// quarantine (room-hosting spec "Re-Hydration") — never to peer death. By +// contract NO payload byte escapes ReadLatest when this is returned, so the +// restore path can rely on verify-before-write. +var ErrCheckpointCorrupt = errors.New("gameabi: checkpoint failed integrity verification") + +func (s *CheckpointStore) configured() error { + if s == nil || s.store == nil || s.sealer == nil { + return fmt.Errorf("gameabi: checkpoint store not configured") + } + return nil +} + +// Write seals payload, PUTs it at the never-overwritten epoch key +// snapshots//, then advances the latest pointer to that epoch — +// but only if epoch is higher than the epoch the pointer currently names. That +// advance is a read-check-then-PUT: readPointerEpoch reads the current pointer, +// Write compares, and PUTs the new pointer only when this epoch is strictly +// higher. The single PUT of the pointer object is atomic (S3/Tigris PUT of one +// object is atomic; the in-memory double matches), so a concurrent READER sees +// the old pointer or the new one, never a torn value — but that PUT atomicity +// buys only torn-read safety, NOT the monotonic advance. The monotonic guarantee +// rests on the single-writer-per-room precondition (see the type doc): with no +// concurrent writer for this room, the read-compare-PUT sequence cannot race, so +// a slow lower-epoch Write that completes AFTER a higher-epoch Write reads the +// higher pointer, fails the compare, and leaves the pointer untouched. This is +// the "late periodic PUT cannot clobber the drain snapshot" guarantee: the slow +// write still lands its own (distinct) epoch object, but never regresses the +// pointer off the higher drain epoch. +// +// An epoch key that already exists is never overwritten in place — but it is +// not an error either: under the single-writer-per-room precondition the only +// way the object can exist is OUR OWN earlier attempt at this epoch that was +// interrupted between the epoch PUT and the pointer advance (e.g. a per-fire +// timeout). The blob PUT is atomic, so the stored object is a complete, sealed +// snapshot from that attempt; Write resumes by skipping the payload PUT and +// finishing the pointer advance, so a retry at the same epoch CONVERGES instead +// of failing "already written" forever (which would brick the room's periodic +// cadence AND its drain, both of which retry the unadvanced epoch). The probe +// is a read-then-PUT; like the pointer advance it is race-free only under the +// single-writer precondition (it is not an independent guard against a +// concurrent writer). +func (s *CheckpointStore) Write(ctx context.Context, roomID string, epoch int64, payload []byte) error { + if err := s.configured(); err != nil { + return err + } + if roomID == "" { + return fmt.Errorf("gameabi: checkpoint: empty room id") + } + key := blobstore.CheckpointKey(roomID, epoch) + _, exists, err := s.store.Get(ctx, key) + if err != nil { + return fmt.Errorf("gameabi: checkpoint: probe epoch key: %w", err) + } + if !exists { + if err := s.store.Put(ctx, key, s.sealer.Seal(payload)); err != nil { + return fmt.Errorf("gameabi: checkpoint: put epoch object: %w", err) + } + } + // exists: resume an interrupted earlier attempt at this epoch — keep its + // (complete, sealed) object and just finish the pointer advance below. + // Advance the latest pointer only if this epoch is newer than what it names, + // so an out-of-order (slow, lower-epoch) write cannot regress the pointer. + // Read-check-then-PUT; race-free under the single-writer-per-room precondition. + cur, ok, err := s.readPointerEpoch(ctx, roomID) + if err != nil { + return err + } + if ok && cur >= epoch { + return nil // a newer (or equal) checkpoint already owns the pointer + } + if err := s.store.Put(ctx, blobstore.LatestPointerKey(roomID), []byte(key)); err != nil { + return fmt.Errorf("gameabi: checkpoint: swap latest pointer: %w", err) + } + return nil +} + +// ReadLatest resolves the latest pointer, GETs the checkpoint it names, and +// VERIFIES the MAC before returning any payload byte (verify-before-write: the +// restore path writes these bytes into guest memory). A verification failure +// returns ErrCheckpointCorrupt (wrapping blobstore.ErrSealVerify) and a nil +// payload — no partial payload escapes. An absent pointer returns +// ErrNoCheckpoint. +func (s *CheckpointStore) ReadLatest(ctx context.Context, roomID string) (payload []byte, epoch int64, err error) { + if err := s.configured(); err != nil { + return nil, 0, err + } + if roomID == "" { + return nil, 0, fmt.Errorf("gameabi: checkpoint: empty room id") + } + ptr, ok, err := s.store.Get(ctx, blobstore.LatestPointerKey(roomID)) + if err != nil { + return nil, 0, fmt.Errorf("gameabi: checkpoint: read latest pointer: %w", err) + } + if !ok { + return nil, 0, ErrNoCheckpoint + } + key := string(ptr) + ep, err := epochFromKey(roomID, key) + if err != nil { + // A pointer that does not name a well-formed epoch key is corruption of + // the durability state, not a missing checkpoint. + return nil, 0, fmt.Errorf("%w: malformed latest pointer %q: %v", ErrCheckpointCorrupt, key, err) + } + sealed, ok, err := s.store.Get(ctx, key) + if err != nil { + return nil, 0, fmt.Errorf("gameabi: checkpoint: get %q: %w", key, err) + } + if !ok { + // The pointer names an object that is gone — treat as corrupt durability + // state so the room is quarantined rather than silently losing the body. + return nil, 0, fmt.Errorf("%w: latest pointer names absent object %q", ErrCheckpointCorrupt, key) + } + open, err := s.sealer.Open(sealed) + if err != nil { + // Verify BEFORE returning: no payload byte escapes a failed MAC. + return nil, 0, fmt.Errorf("%w: %w", ErrCheckpointCorrupt, err) + } + return open, ep, nil +} + +// readPointerEpoch returns the epoch the latest pointer currently names, or +// ok=false when there is no pointer yet. A malformed pointer is a hard error +// (durability-state corruption) rather than a silent reset. +func (s *CheckpointStore) readPointerEpoch(ctx context.Context, roomID string) (int64, bool, error) { + ptr, ok, err := s.store.Get(ctx, blobstore.LatestPointerKey(roomID)) + if err != nil { + return 0, false, fmt.Errorf("gameabi: checkpoint: read latest pointer: %w", err) + } + if !ok { + return 0, false, nil + } + ep, err := epochFromKey(roomID, string(ptr)) + if err != nil { + return 0, false, fmt.Errorf("%w: malformed latest pointer %q: %v", ErrCheckpointCorrupt, string(ptr), err) + } + return ep, true, nil +} + +// epochFromKey parses the trailing / segment of a checkpoint key for the +// given room, validating that the key is exactly the room's epoch key. +func epochFromKey(roomID, key string) (int64, error) { + prefix := blobstore.CheckpointKey(roomID, 0) + // CheckpointKey(roomID, 0) ends in the zero-padded epoch; strip to the room's + // "snapshots//" prefix and parse what follows. + base := strings.TrimSuffix(prefix, "00000000000000000000") + if !strings.HasPrefix(key, base) { + return 0, fmt.Errorf("key %q is not under room prefix %q", key, base) + } + rest := strings.TrimPrefix(key, base) + if strings.Contains(rest, "/") { + return 0, fmt.Errorf("key %q has extra path segments", key) + } + ep, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + return 0, fmt.Errorf("epoch segment %q: %w", rest, err) + } + if ep < 0 { + return 0, fmt.Errorf("negative epoch %d", ep) + } + return ep, nil +} diff --git a/host/gameabi/checkpoint_store_test.go b/host/gameabi/checkpoint_store_test.go new file mode 100644 index 0000000..9883b10 --- /dev/null +++ b/host/gameabi/checkpoint_store_test.go @@ -0,0 +1,220 @@ +package gameabi + +import ( + "context" + "errors" + "testing" + + "github.com/shellcade/kit/v2/host/blobstore" +) + +const ckRoom = "0190b8a0-1234-7abc-8def-0123456789ab" + +func newCkStore() (*CheckpointStore, *blobstore.Memory) { + mem := blobstore.NewMemory() + cs := NewCheckpointStore(mem, blobstore.NewHMACSealer([]byte("server-side-key"))) + return cs, mem +} + +// Write then ReadLatest round-trips the payload and reports the epoch the +// latest pointer names. +func TestCheckpointWriteReadLatest(t *testing.T) { + cs, _ := newCkStore() + ctx := context.Background() + payload := []byte("room checkpoint bytes") + if err := cs.Write(ctx, ckRoom, 7, payload); err != nil { + t.Fatalf("Write: %v", err) + } + got, epoch, err := cs.ReadLatest(ctx, ckRoom) + if err != nil { + t.Fatalf("ReadLatest: %v", err) + } + if epoch != 7 { + t.Errorf("ReadLatest epoch = %d, want 7", epoch) + } + if string(got) != string(payload) { + t.Errorf("ReadLatest payload = %q, want %q", got, payload) + } +} + +// ReadLatest with no checkpoint reports not-found (ok=false-style: a sentinel +// error the caller can distinguish from corruption). +func TestCheckpointReadLatestMissing(t *testing.T) { + cs, _ := newCkStore() + _, _, err := cs.ReadLatest(context.Background(), ckRoom) + if !errors.Is(err, ErrNoCheckpoint) { + t.Fatalf("ReadLatest(missing) err = %v, want ErrNoCheckpoint", err) + } +} + +// A checkpoint key is never overwritten in place: a second Write at the same +// epoch keeps the FIRST object (under the single-writer-per-room precondition +// it can only be our own interrupted earlier attempt) and converges — it +// succeeds and finishes the pointer advance rather than failing forever. +func TestCheckpointNeverOverwriteEpoch(t *testing.T) { + cs, _ := newCkStore() + ctx := context.Background() + if err := cs.Write(ctx, ckRoom, 3, []byte("first")); err != nil { + t.Fatalf("Write 1: %v", err) + } + if err := cs.Write(ctx, ckRoom, 3, []byte("second")); err != nil { + t.Fatalf("Write 2 (same epoch) must resume, not fail: %v", err) + } + // The original payload survives: the retry must not overwrite in place. + got, epoch, err := cs.ReadLatest(ctx, ckRoom) + if err != nil { + t.Fatalf("ReadLatest: %v", err) + } + if epoch != 3 { + t.Errorf("latest epoch = %d, want 3", epoch) + } + if string(got) != "first" { + t.Errorf("payload = %q, want %q (overwrite must not land)", got, "first") + } +} + +// pointerFailOnce wraps the in-memory store to fail the FIRST latest-pointer +// PUT — the interrupted-fire shape: the epoch object lands, the pointer advance +// does not (a per-fire timeout or a store blip between the two PUTs). +type pointerFailOnce struct { + *blobstore.Memory + failed bool +} + +func (p *pointerFailOnce) Put(ctx context.Context, key string, data []byte) error { + if !p.failed && key == blobstore.LatestPointerKey(ckRoom) { + p.failed = true + return errors.New("injected: pointer put failed") + } + return p.Memory.Put(ctx, key, data) +} + +// The retry-same-epoch contract the cadence depends on (CheckpointScheduler +// retries an unadvanced epoch next interval; the drain fires at NextEpoch == +// that same epoch): a Write interrupted AFTER its epoch object landed but +// BEFORE the pointer advanced must converge on retry — resume from the stored +// object and finish the pointer advance — not fail "already written" forever. +func TestCheckpointInterruptedWriteRetryConverges(t *testing.T) { + mem := &pointerFailOnce{Memory: blobstore.NewMemory()} + cs := NewCheckpointStore(mem, blobstore.NewHMACSealer([]byte("server-side-key"))) + ctx := context.Background() + + if err := cs.Write(ctx, ckRoom, 5, []byte("captured")); err == nil { + t.Fatal("first Write should fail (injected pointer-put failure)") + } + // The epoch object landed; the pointer did not. + if _, _, err := cs.ReadLatest(ctx, ckRoom); !errors.Is(err, ErrNoCheckpoint) { + t.Fatalf("pointer must not have advanced, ReadLatest err = %v", err) + } + + // The scheduler retries the SAME epoch (it advances only on success). + if err := cs.Write(ctx, ckRoom, 5, []byte("recaptured")); err != nil { + t.Fatalf("retry at the same epoch must converge: %v", err) + } + got, epoch, err := cs.ReadLatest(ctx, ckRoom) + if err != nil { + t.Fatalf("ReadLatest after retry: %v", err) + } + if epoch != 5 { + t.Errorf("latest epoch = %d, want 5", epoch) + } + // The FIRST attempt's complete object is what the pointer names (never + // overwritten in place); it is a valid sealed snapshot, one capture older. + if string(got) != "captured" { + t.Errorf("payload = %q, want %q (the interrupted attempt's object)", got, "captured") + } +} + +// The drain scenario, single-writer (the realistic case): a higher-epoch Write +// completes and swaps the pointer, THEN a lower-epoch Write completes. This +// proves the contract — a lower-epoch Write that completes AFTER a higher-epoch +// Write cannot regress the pointer — at the Write granularity the per-room actor +// guarantees. It deliberately does NOT interleave the two Writes' internal +// read-check-PUT steps: concurrent writers to one room are out of contract (see +// the single-writer-per-room precondition on CheckpointStore), so there is +// nothing finer to assert here. +func TestCheckpointLatePutCannotClobber(t *testing.T) { + cs, _ := newCkStore() + ctx := context.Background() + // Newer (drain) epoch lands first and swaps the pointer. + if err := cs.Write(ctx, ckRoom, 10, []byte("drain")); err != nil { + t.Fatalf("Write drain: %v", err) + } + // Slow periodic PUT at a LOWER epoch completes afterward. + if err := cs.Write(ctx, ckRoom, 9, []byte("periodic")); err != nil { + t.Fatalf("Write periodic: %v", err) + } + got, epoch, err := cs.ReadLatest(ctx, ckRoom) + if err != nil { + t.Fatalf("ReadLatest: %v", err) + } + if epoch != 10 { + t.Errorf("latest epoch = %d, want 10 (drain must remain latest)", epoch) + } + if string(got) != "drain" { + t.Errorf("latest payload = %q, want %q", got, "drain") + } +} + +// A blob tampered IN STORAGE is refused before any payload byte reaches the +// caller, mapped to the quarantine-able ErrCheckpointCorrupt (wrapping +// ErrSealVerify). +func TestCheckpointTamperedInStorageRefused(t *testing.T) { + cs, mem := newCkStore() + ctx := context.Background() + if err := cs.Write(ctx, ckRoom, 1, []byte("good payload")); err != nil { + t.Fatalf("Write: %v", err) + } + // Mutate the stored object directly via the in-memory double. + key := blobstore.CheckpointKey(ckRoom, 1) + stored, ok, _ := mem.Get(ctx, key) + if !ok { + t.Fatal("checkpoint object not in store") + } + stored[0] ^= 0xff + if err := mem.Put(ctx, key, stored); err != nil { + t.Fatalf("Put tampered: %v", err) + } + + payload, _, err := cs.ReadLatest(ctx, ckRoom) + if !errors.Is(err, ErrCheckpointCorrupt) { + t.Fatalf("ReadLatest(tampered) err = %v, want ErrCheckpointCorrupt", err) + } + if !errors.Is(err, blobstore.ErrSealVerify) { + t.Fatalf("ErrCheckpointCorrupt must wrap ErrSealVerify, got %v", err) + } + if payload != nil { + t.Fatalf("no payload may escape a corrupt checkpoint, got %q", payload) + } +} + +// A checkpoint sealed under a different server-side key is refused (artifact +// equality is not blob integrity; the key is). +func TestCheckpointWrongKeyRefused(t *testing.T) { + mem := blobstore.NewMemory() + writer := NewCheckpointStore(mem, blobstore.NewHMACSealer([]byte("key-a"))) + reader := NewCheckpointStore(mem, blobstore.NewHMACSealer([]byte("key-b"))) + ctx := context.Background() + if err := writer.Write(ctx, ckRoom, 1, []byte("payload")); err != nil { + t.Fatalf("Write: %v", err) + } + payload, _, err := reader.ReadLatest(ctx, ckRoom) + if !errors.Is(err, ErrCheckpointCorrupt) { + t.Fatalf("ReadLatest(wrong key) err = %v, want ErrCheckpointCorrupt", err) + } + if payload != nil { + t.Fatalf("no payload may escape, got %q", payload) + } +} + +// nil store / sealer are programmer errors guarded with an error, matching +// HibernationStore's not-configured guard. +func TestCheckpointStoreNotConfigured(t *testing.T) { + var cs *CheckpointStore + if err := cs.Write(context.Background(), ckRoom, 0, nil); err == nil { + t.Fatal("Write on nil store should error") + } + if _, _, err := cs.ReadLatest(context.Background(), ckRoom); err == nil { + t.Fatal("ReadLatest on nil store should error") + } +} diff --git a/host/gameabi/codec.go b/host/gameabi/codec.go new file mode 100644 index 0000000..1a0bc7c --- /dev/null +++ b/host/gameabi/codec.go @@ -0,0 +1,219 @@ +package gameabi + +import ( + "fmt" + + "github.com/shellcade/kit/v2/wire" + + "github.com/shellcade/kit/v2/host/canvas" + "github.com/shellcade/kit/v2/host/sdk" +) + +// The host side of the ABI codecs: thin mappings between wire types (the +// canonical encodings owned by the public gamekit module) and engine types. + +var modeCode = map[sdk.Mode]uint8{sdk.ModeQuick: wire.ModeQuick, sdk.ModePrivate: wire.ModePrivate, sdk.ModeSolo: wire.ModeSolo} + +// wirePlayer maps one roster member onto its wire shape. The character is +// mapped unconditionally — the kit encoder emits the per-member character +// section iff the guest's declared features carry wire.CtxFeatCharacter, so +// non-declaring guests still get byte-identical encodings. +func wirePlayer(p sdk.Player) wire.Player { + k := wire.KindGuest + if p.Kind == sdk.KindMember { + k = wire.KindMember + } + return wire.Player{ + Handle: p.Handle, AccountID: p.AccountID, Conn: p.Conn, Kind: k, + Character: wire.Character{ + Glyph: p.Character.Glyph, + InkR: p.Character.InkR, InkG: p.Character.InkG, InkB: p.Character.InkB, + BgR: p.Character.BgR, BgG: p.Character.BgG, BgB: p.Character.BgB, + Fallback: p.Character.Fallback, + }, + } +} + +// encodeCtx packs the CallContext for one callback. roster is the roster the +// host will resolve player indices against for the duration of the callback. +// features is the guest's meta-declared Ctx feature bitset (it selects the +// per-member encoding; masked against wire.KnownCtxFeatures defensively, +// matching decodeMeta's posture). +func encodeCtx(nowUnixNanos int64, cfg sdk.RoomConfig, roster []sdk.Player, settled bool, features uint32) *wire.Buf { + c := wire.Ctx{ + NowUnixNanos: nowUnixNanos, + Seed: cfg.Seed, + SeedSet: cfg.SeedSet, + Mode: modeCode[cfg.Mode], + Capacity: uint16(cfg.Capacity), + MinPlayers: uint16(cfg.MinPlayers), + Settled: settled, + } + for _, p := range roster { + c.Members = append(c.Members, wirePlayer(p)) + } + var w wire.Buf + wire.EncodeCtxFeat(&w, c, features&wire.KnownCtxFeatures) + return &w +} + +// decodeMeta maps a packed wire.Meta onto sdk.GameMeta. +func decodeMeta(b []byte) (sdk.GameMeta, error) { + wm, err := wire.DecodeMeta(b) + if err != nil { + return sdk.GameMeta{}, err + } + m := sdk.GameMeta{ + Slug: wm.Slug, + Name: wm.Name, + ShortDescription: wm.ShortDescription, + MinPlayers: int(wm.MinPlayers), + MaxPlayers: int(wm.MaxPlayers), + Tags: wm.Tags, + QuickModeLabel: wm.QuickModeLabel, + SoloModeLabel: wm.SoloModeLabel, + PrivateInviteLine: wm.PrivateInviteLine, + } + if wm.HasLeaderboard { + m.Leaderboard = &sdk.LeaderboardSpec{ + MetricLabel: wm.MetricLabel, + Direction: sdk.Direction(wm.Direction), + Aggregation: sdk.Aggregation(wm.Aggregation), + Format: sdk.MetricFormat(wm.Format), + } + } + // Declared config specs must satisfy the ABI authoring rules; a violation + // is a malformed artifact (kit SDKs can't produce one — they fail at + // encode time), refused at load like a bad slug. + if err := wire.ValidateConfigSpecs(wm.ConfigSpecs); err != nil { + return sdk.GameMeta{}, fmt.Errorf("gameabi: meta config specs: %w", err) + } + for _, cs := range wm.ConfigSpecs { + m.Config = append(m.Config, sdk.ConfigKeySpec{ + Key: cs.Key, + Title: cs.Title, + Description: cs.Description, + Type: sdk.ConfigType(cs.Type), + Default: cs.Default, + Schema: cs.Schema, + }) + } + // Large-room trailer (minor addition): the features bitset is carried + // verbatim (the host honors bits it implements and ignores the rest — + // tolerant in both directions); a declared heartbeat outside the + // envelope is a malformed artifact (kit SDKs fail at encode time). + if err := wire.ValidateMetaTrailer(wm.CtxFeatures&wire.KnownCtxFeatures, wm.HeartbeatMS); err != nil { + return sdk.GameMeta{}, fmt.Errorf("gameabi: meta trailer: %w", err) + } + m.CtxFeatures = wm.CtxFeatures + m.HeartbeatMS = int(wm.HeartbeatMS) + // Lifecycle is tolerant in the host direction (game-abi: values this + // host does not implement read as resumable) — forward compatibility + // with future lifecycles; kit SDKs reject undefined values at encode. + if wm.Lifecycle <= wire.LifecycleResident { + m.Lifecycle = sdk.Lifecycle(wm.Lifecycle) + } + // Wire-revision field (minor addition): carried verbatim — a trailing + // presence-guarded u16 the SDK encoders stamp with the kit's + // wire.Revision (absent = 0 = unknown, kit ≤ v2.7.x). The catalog + // compares it against this host's wire.Revision and warns on artifacts + // ahead of the host (the deploy-order rule's mechanical anchor). + m.WireRevision = wm.WireRevision + // Declared controls (minor addition): validated like config specs — kit + // SDKs can't produce a violation (they fail at meta() encode time), so + // one here is a malformed artifact, refused at load like a bad slug. + if err := wire.ValidateControls(wm.Controls); err != nil { + return sdk.GameMeta{}, fmt.Errorf("gameabi: meta controls: %w", err) + } + for _, cd := range wm.Controls { + m.Controls = append(m.Controls, sdk.ControlDecl{ + Kind: sdk.InputKind(cd.Kind), + Rune: cd.Rune, + Key: sdk.Key(cd.Key), + Label: cd.Label, + }) + } + return m, nil +} + +// encodeCtxEpoch packs the CallContext in roster-epoch mode (guests that +// declare wire.CtxFeatRosterEpoch). When full is false the member list is +// not built at all — the member section is 6 bytes regardless of roster +// size, which is the entire point. features is the guest's meta-declared Ctx +// feature bitset (masked like encodeCtx). +func encodeCtxEpoch(nowUnixNanos int64, cfg sdk.RoomConfig, roster []sdk.Player, settled bool, epoch uint32, full bool, features uint32) *wire.Buf { + c := wire.Ctx{ + NowUnixNanos: nowUnixNanos, + Seed: cfg.Seed, + SeedSet: cfg.SeedSet, + Mode: modeCode[cfg.Mode], + Capacity: uint16(cfg.Capacity), + MinPlayers: uint16(cfg.MinPlayers), + Settled: settled, + } + if full { + for _, p := range roster { + c.Members = append(c.Members, wirePlayer(p)) + } + } + var w wire.Buf + wire.EncodeCtxEpochFeat(&w, c, epoch, full, features&wire.KnownCtxFeatures) + return &w +} + +// decodeFrame decodes a packed cell array into a canvas.Grid. A wrong-length +// payload is an error (the caller drops the frame with a log, never panics). +func decodeFrame(b []byte) (canvas.Grid, error) { + g := canvas.New() + if err := wire.CheckFrame(b); err != nil { + return g, err + } + i := 0 + for row := 0; row < wire.Rows; row++ { + for col := 0; col < wire.Cols; col++ { + wc := wire.GetCell(b, i) + var c canvas.Cell + c.Rune = wc.Rune + c.Cp2 = wc.Cp2 + c.Cp3 = wc.Cp3 + if wc.FGSet { + c.FG = canvas.RGB(wc.FGR, wc.FGG, wc.FGB) + } + if wc.BGSet { + c.BG = canvas.RGB(wc.BGR, wc.BGG, wc.BGB) + } + c.Attr = canvas.Attr(wc.Attr) + c.Cont = wc.Cont + g.Set(row, col, c) + i++ + } + } + return g, nil +} + +var statusByCode = map[uint8]sdk.Status{ + wire.StatusFinished: sdk.StatusFinished, + wire.StatusDNF: sdk.StatusDNF, + wire.StatusFlagged: sdk.StatusFlagged, +} + +// decodeResult decodes a guest result payload against the callback roster. +func decodeResult(b []byte, roster []sdk.Player, mode sdk.Mode) (sdk.Result, error) { + wr, err := wire.DecodeResult(b) + if err != nil { + return sdk.Result{}, err + } + res := sdk.Result{Mode: mode} + for _, rk := range wr.Rankings { + if int(rk.PlayerIdx) >= len(roster) { + return sdk.Result{}, fmt.Errorf("gameabi: result player index %d out of roster %d", rk.PlayerIdx, len(roster)) + } + res.Rankings = append(res.Rankings, sdk.PlayerResult{ + Player: roster[rk.PlayerIdx], + Metric: int(rk.Metric), + Rank: int(rk.Rank), + Status: statusByCode[rk.Status], + }) + } + return res, nil +} diff --git a/host/gameabi/codec_test.go b/host/gameabi/codec_test.go new file mode 100644 index 0000000..65820de --- /dev/null +++ b/host/gameabi/codec_test.go @@ -0,0 +1,176 @@ +package gameabi + +import ( + "testing" + + "github.com/shellcade/kit/v2/wire" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// The host-side decoders sit on the trust boundary: every byte comes from an +// untrusted guest, so they must return errors — never panic — on arbitrary +// input. (The wire package itself is fuzzed in the kit repo; these targets +// cover the host mappings on top of it.) + +func fuzzRoster() []sdk.Player { + return []sdk.Player{ + {AccountID: "a1", Handle: "ada", Kind: sdk.KindMember, Conn: "c1"}, + {AccountID: "", Handle: "guest", Kind: sdk.KindGuest, Conn: "c2"}, + } +} + +func FuzzDecodeMeta(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte{0xff, 0xff, 0xff, 0xff}) + valid := wire.EncodeMeta(wire.Meta{Slug: "fixture", Name: "Fixture", MinPlayers: 1, MaxPlayers: 2}) + f.Add(valid) + f.Add(valid[:len(valid)/2]) // truncated valid prefix + f.Fuzz(func(t *testing.T, b []byte) { + _, _ = decodeMeta(b) // must not panic + }) +} + +// TestDecodeMetaConfigSpecs pins the wire → sdk mapping of declared config +// key specs, and that a pre-config payload (no trailing section) decodes with +// a nil Config. +func TestDecodeMetaConfigSpecs(t *testing.T) { + b := wire.EncodeMeta(wire.Meta{ + Slug: "pokies", Name: "Pokies", MinPlayers: 1, MaxPlayers: 5, + ConfigSpecs: []wire.ConfigSpec{ + {Key: "odds-variant", Title: "Odds variant", Description: "PAR sheet.", + Type: wire.ConfigJSON, Default: `{"name":"Default"}`, Schema: `{"type":"object"}`}, + {Key: "motd", Title: "Banner", Type: wire.ConfigText}, + }, + }) + m, err := decodeMeta(b) + if err != nil { + t.Fatal(err) + } + if len(m.Config) != 2 { + t.Fatalf("want 2 specs, got %+v", m.Config) + } + want := sdk.ConfigKeySpec{Key: "odds-variant", Title: "Odds variant", Description: "PAR sheet.", + Type: sdk.ConfigJSON, Default: `{"name":"Default"}`, Schema: `{"type":"object"}`} + if m.Config[0] != want { + t.Fatalf("spec mismatch:\n got=%+v\nwant=%+v", m.Config[0], want) + } + if m.Config[1].Type != sdk.ConfigText { + t.Fatalf("second spec type: %v", m.Config[1].Type) + } + + // Hand-built pre-config payload: ends after the leaderboard byte. + var w wire.Buf + w.Str("old") + w.Str("Old") + w.Str("") + w.U16(1) + w.U16(1) + w.U16(0) // tags + w.Str("") + w.Str("") + w.Str("") + w.Bool(false) // no leaderboard; payload ends here + old, err := decodeMeta(w.B) + if err != nil { + t.Fatal(err) + } + if old.Config != nil { + t.Fatalf("pre-config payload decoded specs: %+v", old.Config) + } +} + +// TestDecodeMetaRefusesInvalidConfigSpecs pins the load-time refusal: a +// hand-rolled artifact whose specs break the ABI authoring rules is malformed +// (kit SDKs cannot produce one — they fail at encode time). +func TestDecodeMetaRefusesInvalidConfigSpecs(t *testing.T) { + for name, specs := range map[string][]wire.ConfigSpec{ + "reserved host. prefix": {{Key: "host.heartbeat_ms", Type: wire.ConfigNumber}}, + "duplicate keys": {{Key: "k", Type: wire.ConfigText}, {Key: "k", Type: wire.ConfigText}}, + "schema on non-json": {{Key: "k", Type: wire.ConfigNumber, Schema: "{}"}}, + } { + b := wire.EncodeMeta(wire.Meta{Slug: "x", Name: "X", MinPlayers: 1, MaxPlayers: 1, ConfigSpecs: specs}) + if _, err := decodeMeta(b); err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +// TestDecodeMetaWireRevision (adopt kit v2.8.0) pins the wire → sdk mapping of +// the trailing wire-revision field: the declared value rides through verbatim +// (including one ahead of this host's wire.Revision — the catalog, not the +// codec, decides what to do with skew), and a pre-field payload decodes as +// the legacy 0 = unknown. +func TestDecodeMetaWireRevision(t *testing.T) { + for _, rev := range []uint16{0, wire.Revision, wire.Revision + 1} { + b := wire.EncodeMeta(wire.Meta{Slug: "x", Name: "X", MinPlayers: 1, MaxPlayers: 1, WireRevision: rev}) + m, err := decodeMeta(b) + if err != nil { + t.Fatalf("revision %d: %v", rev, err) + } + if m.WireRevision != rev { + t.Fatalf("decoded WireRevision = %d, want %d", m.WireRevision, rev) + } + } + + // Pre-revision payload: a stamped meta truncated before the trailing u16 + // (the kit ≤ v2.7.x shape) decodes as revision 0. The chop also takes the + // controls u16 count that now trails the revision (wire revision 6). + b := wire.EncodeMeta(wire.Meta{Slug: "x", Name: "X", MinPlayers: 1, MaxPlayers: 1, WireRevision: wire.Revision}) + old, err := decodeMeta(b[:len(b)-4]) + if err != nil { + t.Fatal(err) + } + if old.WireRevision != 0 { + t.Fatalf("pre-field payload decoded WireRevision = %d, want 0", old.WireRevision) + } +} + +func FuzzDecodeFrame(f *testing.F) { + f.Add([]byte{}) + f.Add(make([]byte, wire.FrameBytes)) + f.Add(make([]byte, wire.FrameBytes-1)) + f.Add(make([]byte, wire.FrameBytes+1)) + f.Fuzz(func(t *testing.T, b []byte) { + g, err := decodeFrame(b) + if err == nil && len(b) != wire.FrameBytes { + t.Fatalf("accepted %d-byte frame, want exactly %d", len(b), wire.FrameBytes) + } + _ = g + }) +} + +func FuzzDecodeResult(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte{0xff}) + f.Add(wire.EncodeResult(wire.Result{Rankings: []wire.Ranking{{PlayerIdx: 0, Metric: 42, Rank: 1}}})) + f.Add(wire.EncodeResult(wire.Result{Rankings: []wire.Ranking{{PlayerIdx: 200, Metric: 1, Rank: 1}}})) + roster := fuzzRoster() + f.Fuzz(func(t *testing.T, b []byte) { + res, err := decodeResult(b, roster, sdk.ModeQuick) + if err != nil { + return + } + for _, pr := range res.Rankings { // an accepted result only names roster players + ok := false + for _, p := range roster { + if pr.Player == p { + ok = true + } + } + if !ok { + t.Fatalf("decoded ranking for non-roster player %+v", pr.Player) + } + } + }) +} + +// TestDecodeResultRejectsOutOfRoster pins the trust-boundary check: a guest +// naming an index past the callback roster is an error, not a panic or a +// stray account write. +func TestDecodeResultRejectsOutOfRoster(t *testing.T) { + b := wire.EncodeResult(wire.Result{Rankings: []wire.Ranking{{PlayerIdx: 2, Metric: 1, Rank: 1}}}) + if _, err := decodeResult(b, fuzzRoster(), sdk.ModeQuick); err == nil { + t.Fatal("out-of-roster player index accepted") + } +} diff --git a/host/gameabi/controls_test.go b/host/gameabi/controls_test.go new file mode 100644 index 0000000..c874df8 --- /dev/null +++ b/host/gameabi/controls_test.go @@ -0,0 +1,56 @@ +package gameabi + +import ( + "reflect" + "testing" + + "github.com/shellcade/kit/v2/wire" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// TestDecodeMetaControls pins the wire → sdk mapping of declared controls, +// and that a pre-controls payload decodes with nil Controls. +func TestDecodeMetaControls(t *testing.T) { + b := wire.EncodeMeta(wire.Meta{ + Slug: "chess", Name: "Chess", MinPlayers: 2, MaxPlayers: 2, + Controls: []wire.ControlDecl{ + {Kind: wire.InputRune, Rune: 'r', Label: "RESIGN"}, + {Kind: wire.InputKey, Key: wire.KeyCodeBackspace, Label: "UNDO"}, + }, + }) + m, err := decodeMeta(b) + if err != nil { + t.Fatal(err) + } + want := []sdk.ControlDecl{ + {Kind: sdk.InputRune, Rune: 'r', Label: "RESIGN"}, + {Kind: sdk.InputKey, Key: sdk.KeyBackspace, Label: "UNDO"}, + } + if !reflect.DeepEqual(m.Controls, want) { + t.Fatalf("controls mismatch:\n got=%+v\nwant=%+v", m.Controls, want) + } + + // Pre-controls payload (no trailing section): nil Controls, no error. + pre := wire.EncodeMeta(wire.Meta{Slug: "old", Name: "Old", MinPlayers: 1, MaxPlayers: 2}) + pre = pre[:len(pre)-2] // strip the zero-count controls section + m, err = decodeMeta(pre) + if err != nil { + t.Fatal(err) + } + if m.Controls != nil { + t.Fatalf("pre-controls payload decoded controls: %+v", m.Controls) + } +} + +// TestDecodeMetaRefusesInvalidControls pins the malformed-artifact posture: +// declarations a kit SDK could never encode are refused at load. +func TestDecodeMetaRefusesInvalidControls(t *testing.T) { + b := wire.EncodeMeta(wire.Meta{ + Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 1, + Controls: []wire.ControlDecl{{Kind: wire.InputRune, Rune: 'r', Label: ""}}, + }) + if _, err := decodeMeta(b); err == nil { + t.Fatal("empty-label control decl decoded without error") + } +} diff --git a/host/gameabi/delta.go b/host/gameabi/delta.go new file mode 100644 index 0000000..d543fb2 --- /dev/null +++ b/host/gameabi/delta.go @@ -0,0 +1,189 @@ +package gameabi + +import ( + "hash/fnv" + + "github.com/shellcade/kit/v2/wire" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// rosterFingerprint hashes the membership shape that determines baseline slot +// assignment: the count and each member's (AccountID, Kind) in roster order. A +// join, leave, or index shift changes it; Conn is intentionally EXCLUDED because +// it changes across hibernation (a resume must not be mistaken for a roster +// mutation — the epoch re-seed already handles resume). It is a backstop under +// the per-send epoch authority, not the primary resync. +func rosterFingerprint(roster []sdk.Player) uint64 { + h := fnv.New64a() + var lenb [2]byte + lenb[0] = byte(len(roster)) + lenb[1] = byte(len(roster) >> 8) + _, _ = h.Write(lenb[:]) + for _, p := range roster { + _, _ = h.Write([]byte(p.AccountID)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(p.Kind)) + _, _ = h.Write([]byte{0}) + } + return h.Sum64() +} + +// Host-side frame-delta ingestion (D4/D5/D7/D9). In ABI v2 the guest→host frame +// payload is the variable-length delta container (wire §4.5), not a bare packed +// grid. The host is the sole BASELINE AUTHORITY: per consumer slot it holds a +// previous packed grid, an epoch, and a present flag, and it returns the epoch +// the guest must stamp its baseline with. A non-keyframe delta applies iff its +// header epoch equals the slot epoch AND the slot has a baseline; otherwise the +// host drops it, bumps the slot epoch, and returns the new epoch (forcing the +// guest's next send to that slot to a keyframe). A keyframe is accepted +// regardless of epoch (self-contained), sets the baseline, and adopts the header +// epoch. +// +// The cache is host memory (one [FrameBytes]byte per consumer), actor-goroutine +// owned like h.cur/h.roster — no locking. It is NOT snapshotted (it is +// ephemeral host memory); on resume the epoch counter is re-seeded above any +// pre-snapshot high-water and every slot is marked not-present (D6/4.8). + +// rosterCap is the fixed per-index baseline ceiling. The contract lives in +// kit/wire (wire.RosterCap): a shared protocol invariant the SDKs and the +// host all size against — changing it is ABI-affecting and lands in wire, +// every guest SDK, and this host in lockstep. Slots 0..rosterCap-1 are +// per-roster-index consumers; slot rosterCap is the broadcast (Identical) +// slot. The guest SDK drops sends for an index >= rosterCap, and the host's +// own bounds check (host.go) — not guest discipline — is what protects the +// slot table (D8). prev slots are lazily allocated so host memory tracks the +// ACTIVE roster, not the cap (~45 KiB per actively-sent-to consumer). +const rosterCap = wire.RosterCap + +// broadcastSlot is the Identical baseline slot index within the cache. +const broadcastSlot = rosterCap + +// numSlots is the cache size: rosterCap per-index slots + 1 broadcast slot. +const numSlots = rosterCap + 1 + +// baselineCache is the per-consumer baseline+epoch authority for one wasm room. +// epochSeq is the monotonic epoch the host stamps on a bump; it is re-seeded +// strictly above any pre-snapshot value on resume (D6). +type baselineCache struct { + prev [numSlots][]byte // wire.FrameBytes each, lazily allocated on first use + epoch [numSlots]uint32 + has [numSlots]bool + epochSeq uint32 // last issued epoch (highWater); next bump uses ++epochSeq +} + +// buf returns slot's baseline buffer, allocating it on first use (lazy: host +// memory tracks the active roster, not the rosterCap ceiling). +func (c *baselineCache) buf(slot int) []byte { + if c.prev[slot] == nil { + c.prev[slot] = make([]byte, wire.FrameBytes) + } + return c.prev[slot] +} + +// bump advances the epoch counter and assigns the new value to slot, marking it +// not-present so the next send to it is forced to a keyframe. Returns the new +// epoch the host hands back to the guest. +func (c *baselineCache) bump(slot int) uint32 { + c.epochSeq++ + c.epoch[slot] = c.epochSeq + c.has[slot] = false + return c.epochSeq +} + +// invalidateAll bumps the epoch counter once and marks every slot not-present, +// re-stamping each slot's epoch to the new value. Used on any roster mutation +// (join/leave/index shift) and on resume so the next send to each slot is +// epoch-rejected into a keyframe (D7/4.6/4.8). One bump for the whole sweep so +// the counter stays a tight high-water. +func (c *baselineCache) invalidateAll() { + c.epochSeq++ + for i := 0; i < numSlots; i++ { + c.epoch[i] = c.epochSeq + c.has[i] = false + } +} + +// reseed sets the epoch counter strictly above highWater and marks every slot +// not-present (D6 hibernation resume). The baseline bytes are irrelevant once +// has[i] is false, so they are not cleared. +func (c *baselineCache) reseed(highWater uint32) { + c.epochSeq = highWater + for i := 0; i < numSlots; i++ { + c.has[i] = false + } + // invalidateAll advances to highWater+1 and stamps every slot, giving the + // "strictly greater than any pre-snapshot epoch" guarantee unconditionally. + c.invalidateAll() +} + +// applyResult reports the outcome of ingesting one delta container for a slot. +type applyResult struct { + epoch uint32 // the epoch to return to the guest + applied bool // true if the slot baseline advanced (a frame should be rendered) +} + +// apply ingests a delta container b for the given slot, enforcing the epoch +// authority (D4) and the absent-baseline guard (D5). On a successful apply it +// advances prev[slot] in place and returns applied=true with the slot epoch; on +// a malformed/short container, an epoch mismatch, or a non-keyframe delta to a +// slot with no baseline, it bumps the slot epoch, drops the delta, and returns +// applied=false. It never panics and never reads out of bounds (CheckFrameDelta +// /ApplyFrameDelta enforce that). On a malformed container logFn is invoked once +// with the dropped-delta reason. +func (c *baselineCache) apply(slot int, b []byte, logFn func(reason string)) applyResult { + if err := wire.CheckFrameDelta(b); err != nil { + if logFn != nil { + logFn(err.Error()) + } + return applyResult{epoch: c.bump(slot), applied: false} + } + if wire.IsKeyframe(b) { + // A keyframe is self-contained: accept regardless of epoch, overwrite the + // whole baseline, adopt the header epoch. + hdr := wire.DeltaEpoch(b) + if err := wire.ApplyFrameDelta(c.buf(slot), b); err != nil { + // Should not happen (CheckFrameDelta passed), but degrade-to-drop. + if logFn != nil { + logFn(err.Error()) + } + return applyResult{epoch: c.bump(slot), applied: false} + } + c.epoch[slot] = hdr + c.has[slot] = true + return applyResult{epoch: hdr, applied: true} + } + // Non-keyframe delta: require a present baseline AND a matching epoch. + // (has[slot] implies the slot buffer was allocated by a prior keyframe.) + if !c.has[slot] || wire.DeltaEpoch(b) != c.epoch[slot] { + return applyResult{epoch: c.bump(slot), applied: false} + } + if err := wire.ApplyFrameDelta(c.prev[slot], b); err != nil { + if logFn != nil { + logFn(err.Error()) + } + return applyResult{epoch: c.bump(slot), applied: false} + } + return applyResult{epoch: c.epoch[slot], applied: true} +} + +// reconcileBroadcast copies the broadcast slot's reconstructed grid into every +// ALLOCATED per-index slot and stamps it with the broadcast epoch (D7). Called +// after a successful Identical apply so a later per-player Send diffs against +// the baseline the broadcast left. Unallocated (never sent-to) slots are +// skipped instead of materialized — reconciling all rosterCap slots would copy +// ~45 MiB per broadcast at cap 1024. A skipped slot stays not-present, so the +// guest's next per-player Send to it is epoch-rejected into a keyframe (the +// standard recovery path); this mirrors the guest-side lazy reconcile exactly. +func (c *baselineCache) reconcileBroadcast(bcastEpoch uint32) { + src := c.prev[broadcastSlot] + for i := 0; i < rosterCap; i++ { + if c.prev[i] == nil { + c.has[i] = false + continue + } + copy(c.prev[i], src) + c.epoch[i] = bcastEpoch + c.has[i] = true + } +} diff --git a/host/gameabi/export.go b/host/gameabi/export.go new file mode 100644 index 0000000..e884067 --- /dev/null +++ b/host/gameabi/export.go @@ -0,0 +1,221 @@ +package gameabi + +import ( + "context" + "fmt" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// Exported entry points for out-of-package drivers (the conformance harness, and +// later the engine's hibernation path). They keep wasmGame/wasmHandler +// unexported while giving callers the snapshot/restore + memory-probe surface +// they need to instrument a real wasm room. + +// SnapshotHandler freezes a wasm room handler into a portable blob. h must be a +// handler returned by a wasm game's NewRoom, taken at a quiescent point (no +// guest call on the stack). +func SnapshotHandler(h sdk.Handler) ([]byte, error) { + wh, ok := h.(*wasmHandler) + if !ok { + return nil, fmt.Errorf("gameabi: SnapshotHandler: handler is not a wasm room") + } + return wh.Snapshot() +} + +// RestoreHandler rehydrates a blob into a fresh handler bound to game g. g must +// be the same artifact the blob was taken from (the embedded sha256 + ABI +// version are verified). +// +// The restored handler resumes the guest's linear memory, clock, roster, input +// context, RoomConfig, and entropy position — everything the snapshot owns. It +// does NOT resume the host SERVICES (leaderboard, per-user KV, config): those +// are live host resources, not part of the portable blob, so the caller must +// rebind them with BindServices before driving the first callback, exactly as +// the engine wires services into a fresh NewRoom. A restored room with no +// services no-ops kv/config/leaderboard host calls and will diverge from a live +// room that has them. +func RestoreHandler(g sdk.Game, blob []byte) (sdk.Handler, error) { + wg, ok := g.(*wasmGame) + if !ok { + return nil, fmt.Errorf("gameabi: RestoreHandler: game is not a wasm game") + } + return wg.Restore(blob) +} + +// CloseHandler releases a handler's live plugin instance WITHOUT driving the +// room — the disposal path for a restored handler that was never adopted by a +// runtime. RestoreHandler returns a handler holding a live instance with +// grown, written linear memory (up to the game's 32MiB cap); the instance is +// otherwise closed only via OnClose through a running room, so dropping an +// unadopted handler pins that memory in the compiled plugin's shared wazero +// runtime until process restart. Every Restore call site guards its error and +// lost-race returns with the adopted-flag pattern: +// +// adopted := false +// defer func() { +// if !adopted { +// gameabi.CloseHandler(h) +// } +// }() +// ... +// ctl := sdk.NewRoomRuntime(roomID, h, ...) // the runtime owns h from here +// adopted = true +// +// Safe on a never-driven handler (no guest call is on the stack, so the +// instance closes immediately) and idempotent. No-op (reports false) if h is +// not a wasm room. +func CloseHandler(h sdk.Handler) bool { + wh, ok := h.(*wasmHandler) + if !ok { + return false + } + wh.closeInstance() + return true +} + +// CheckpointHandler captures a NON-DESTRUCTIVE snapshot of a live wasm room and +// writes it through cs at the given epoch — the periodic-durability path +// (room-hosting spec "Periodic Room Checkpoints", design D5). It reuses the +// hibernation codec's deterministic snapshot byte-for-byte (SnapshotHandler); +// unlike sdk.Room.Hibernate it does NOT end or dispose the room, so the same +// handler keeps running and is checkpointed again at the next epoch. The capture +// MUST be taken at a quiescent point (no guest callback on the stack), exactly +// like SnapshotHandler — the actor schedules it on the room goroutine. +// +// NOTE this convenience runs capture AND store write on the calling goroutine; +// the production peer instead splits them (SnapshotHandler on the actor, Write +// from the scheduler/drain goroutine — peer.fireCheckpoint) so a slow store +// never stalls the room actor. Prefer the split anywhere a live room serves +// players; this composite remains for the conformance harness and tests. +func CheckpointHandler(ctx context.Context, cs *CheckpointStore, roomID string, epoch int64, h sdk.Handler) error { + payload, err := SnapshotHandler(h) + if err != nil { + return fmt.Errorf("gameabi: checkpoint: capture: %w", err) + } + return cs.Write(ctx, roomID, epoch, payload) +} + +// BindServices attaches live host services to a restored handler before it is +// driven. Services (leaderboard, per-user KV, config) are host resources that a +// snapshot deliberately does not carry; a resumed room must be rebound to the +// running instance's services so kv/config/leaderboard host calls behave as they +// did before hibernation. No-op (and reports false) if h is not a wasm room. +func BindServices(h sdk.Handler, svc sdk.Services) bool { + wh, ok := h.(*wasmHandler) + if !ok { + return false + } + wh.svc = svc + // Re-apply the per-room host.* config overrides exactly as NewRoom does, so + // a resumed room runs with the same admin-tuned cadence/deadline as a fresh + // one (the snapshot deliberately carries neither services nor host config). + if svc.Config != nil { + if d, ok := readConfigDuration(svc.Config, cfgHeartbeatMS); ok { + wh.heartbeat = clampDur(d, minHeartbeat, maxHeartbeat) + } + if d, ok := readConfigDuration(svc.Config, cfgDeadlineMS); ok { + wh.deadline = clampDur(d, minDeadline, maxDeadline) + } + } + return true +} + +// HandlerConfig returns the wasm room's RoomConfig — for a restored handler +// that is the ORIGINAL room's config carried by the snapshot (mode, capacity, +// min players, seed), so a resume can rebuild the runtime with the room's real +// identity instead of synthesizing a fresh one. +func HandlerConfig(h sdk.Handler) (sdk.RoomConfig, bool) { + wh, ok := h.(*wasmHandler) + if !ok { + return sdk.RoomConfig{}, false + } + return wh.cfg, true +} + +// GuestMemorySize reports the wasm room's current GUEST linear-memory size in +// bytes (the program's own TinyGo heap, not the extism runtime's). Returns 0 if +// h is not a wasm room or has no live instance. Sample it after a callback to +// track peak memory under a limit. +func GuestMemorySize(h sdk.Handler) uint32 { + wh, ok := h.(*wasmHandler) + if !ok || wh.inst == nil { + return 0 + } + mem := guestMemory(wh.inst) + if mem == nil { + return 0 + } + return mem.Size() +} + +// HandlerRoster returns the wasm room's last-seen roster — the same membership +// the snapshot codec records — so the hibernation header can carry it WITHOUT +// decompressing the blob. At an abandonment quiesce point the live room is +// empty, but the handler still holds the roster of the player(s) who were in it +// (the codec's roster of record), which is exactly who may resume the room. +// Returns nil if h is not a wasm room. +func HandlerRoster(h sdk.Handler) []sdk.Player { + wh, ok := h.(*wasmHandler) + if !ok { + return nil + } + return append([]sdk.Player(nil), wh.roster...) +} + +// HandlerEnded reports whether the wasm room has settled (the guest called end, +// or a fault settled it). +func HandlerEnded(h sdk.Handler) bool { + wh, ok := h.(*wasmHandler) + if !ok { + return false + } + return wh.ended || wh.dead +} + +// LastCallback reports the most recent callback's wasm exit code, any trap or +// deadline error, and whether that callback faulted the room (a non-zero exit or +// a kill-switch error settled it). A timed-out callback surfaces as faulted with +// a non-nil err — the harness names it against the per-callback deadline. +func LastCallback(h sdk.Handler) (exit uint32, err error, faulted bool) { + wh, ok := h.(*wasmHandler) + if !ok { + return 0, nil, false + } + return wh.lastExit, wh.lastErr, wh.lastErr != nil || wh.lastExit != 0 +} + +// HandlerDeadline returns the per-room callback deadline the handler enforces +// (after any host.* config override) — the harness names this as the limit when +// a callback breaches it. +func HandlerDeadline(h sdk.Handler) (d time.Duration, ok bool) { + wh, k := h.(*wasmHandler) + if !k { + return 0, false + } + return wh.deadline, true +} + +// CallbackSplit reports the cumulative wall time spent inside guest +// callbacks for h, and the portion of it spent in the send/identical HOST +// functions (delta apply + frame decode + fan-out) the guest invoked +// mid-callback. Guest-pure compute = total - host. Diagnostic surface for +// load benchmarks; actor-goroutine accuracy (read between callbacks). +func CallbackSplit(h sdk.Handler) (total, host time.Duration) { + wh, ok := h.(*wasmHandler) + if !ok { + return 0, 0 + } + return time.Duration(wh.cbTotalNanos), time.Duration(wh.cbHostNanos) +} + +// MemoryCapBytes returns the wasm game's linear-memory cap in bytes (the +// load-time manifest MaxPages) — the harness names this as the memory limit. +func MemoryCapBytes(g sdk.Game) (uint64, bool) { + wg, ok := g.(*wasmGame) + if !ok { + return 0, false + } + return uint64(wg.opts.MemoryPages) * 64 * 1024, true +} diff --git a/host/gameabi/hibernate.go b/host/gameabi/hibernate.go new file mode 100644 index 0000000..8e34339 --- /dev/null +++ b/host/gameabi/hibernate.go @@ -0,0 +1,382 @@ +package gameabi + +import ( + "context" + "encoding/binary" + "fmt" + "sort" + "strings" + "time" + + "github.com/shellcade/kit/v2/host/blobstore" + "github.com/shellcade/kit/v2/host/sdk" +) + +// D9 hibernation, ABI tasks 6.2–6.5: the snapshot CODEC lives in snapshot.go; +// this file is the STORE (where a frozen room is parked + how it is listed for +// resume) and the capability glue (which handlers may be frozen). The codec is +// consumed only through export.go's SnapshotHandler/RestoreHandler — this file +// never reaches into wasmHandler/wasmGame internals beyond the capability check. + +// CanHibernate implements sdk.HibernationCapable: a wasm room can be frozen when +// it holds a live instance that has not faulted, ended, or closed. The actor +// guarantees no callback is on the stack at the quiescent point Hibernate runs, +// so this is a pure state check. +func (h *wasmHandler) CanHibernate() bool { + return h.inst != nil && !h.dead && !h.ended +} + +// OnResume implements sdk.Resumed: a restored handler already holds a live, +// memory-restored instance (RestoreHandler built it), so resuming must NOT +// re-instantiate the way OnStart does — it only re-establishes the engine-owned +// heartbeat (the sim rate OnStart would normally set). The guest is not called; +// the next OnTick/OnInput continues exactly where the snapshot left off. A +// restored-but-dead handler (shouldn't happen — Restore returns an error +// instead) settles the room rather than running headless. +func (h *wasmHandler) OnResume(r sdk.Room) { + if h.inst == nil || h.dead { + r.End(sdk.Result{Mode: h.cfg.Mode}) + h.dead = true + return + } + // D6 frame-delta resync: Restore already seeded the baseline cache's epoch + // counter strictly above the snapshot's high-water and marked every slot + // not-present. Re-assert not-present here (idempotent) so a reconnecting + // viewer / new connection token also starts from a keyframe — the engine's + // resume entry point owns this guarantee even if a future Restore path + // changes. The cache is host memory and was never snapshotted. + h.baselines.invalidateAll() + r.SetSimRate(h.heartbeat) +} + +// ---- hibernation store ------------------------------------------------------- + +// snapshotPrefix is the blobstore key namespace for parked rooms; the bucket's +// lifecycle rule (blobstore.EnsureSnapshotTTL) expires anything under it, so the +// store itself never has to age blobs out. +const snapshotPrefix = "snapshots/" + +// Header is the small, UNCOMPRESSED descriptor prepended to a stored snapshot +// so the resume metadata (game, age, roster) is readable WITHOUT decompressing +// the zstd body or touching the wasm runtime. It is also the row shape of the +// Postgres parked-room directory (add-parked-room-directory) the production +// resume listing derives from. It deliberately duplicates a few fields the +// codec also carries (slug, roster) because the codec body is opaque to the +// store and only the host that owns the matching artifact can decode it — the +// lobby must list a member's parked rooms without loading every game. +// +// On-wire layout is fixed and self-describing (magic + format), length-prefixed, +// little-endian — decodable standalone, never via zstd: +// +// u32 magic ("SCH1") +// u32 format version +// str game slug +// str room id +// i64 hibernated-at unix nanos +// u16 roster length, then that many { str accountID; str handle } +// ... snapshot body (the opaque zstd codec blob) follows the header. +type Header struct { + Slug string // game slug the snapshot belongs to (resume listing + game lookup) + RoomID string // the parked room's id (== the storage key suffix) + At time.Time // when the room was hibernated (resume listing "age") + Roster []RosterMember // who was in the room (resume listing "players" + membership filter) +} + +// RosterMember is one parked player, enough to filter the resume list to the +// requesting member and to show who else was in the room. +type RosterMember struct { + AccountID string + Handle string +} + +const ( + headerMagic = 0x53434831 // "SCH1" + headerFormat = 1 +) + +// RosterFrom projects a roster of sdk.Players to header roster members. +func RosterFrom(roster []sdk.Player) []RosterMember { + out := make([]RosterMember, 0, len(roster)) + for _, p := range roster { + out = append(out, RosterMember{AccountID: p.AccountID, Handle: p.Handle}) + } + return out +} + +// Has reports whether accountID is in the parked roster. +func (h Header) Has(accountID string) bool { + for _, m := range h.Roster { + if m.AccountID == accountID { + return true + } + } + return false +} + +// encodeHeader writes the fixed, uncompressed header. It is a hand-rolled +// little-endian framing (no dependency on the wasm wire codec) so the store +// stays independent of the ABI. +func encodeHeader(h Header) []byte { + var b []byte + b = binary.LittleEndian.AppendUint32(b, headerMagic) + b = binary.LittleEndian.AppendUint32(b, headerFormat) + b = appendStr(b, h.Slug) + b = appendStr(b, h.RoomID) + b = binary.LittleEndian.AppendUint64(b, uint64(h.At.UnixNano())) + b = binary.LittleEndian.AppendUint16(b, uint16(len(h.Roster))) + for _, m := range h.Roster { + b = appendStr(b, m.AccountID) + b = appendStr(b, m.Handle) + } + return b +} + +// decodeHeader reads a header and returns it plus the length of the header +// region (so the caller can slice off the snapshot body that follows). +func decodeHeader(blob []byte) (Header, int, error) { + r := reader{b: blob} + if r.u32() != headerMagic { + return Header{}, 0, fmt.Errorf("blobstore: snapshot header: bad magic") + } + if f := r.u32(); f != headerFormat { + return Header{}, 0, fmt.Errorf("blobstore: snapshot header: format v%d, want v%d", f, headerFormat) + } + var h Header + h.Slug = r.str() + h.RoomID = r.str() + h.At = time.Unix(0, r.i64()) + n := int(r.u16()) + for i := 0; i < n; i++ { + m := RosterMember{AccountID: r.str(), Handle: r.str()} + h.Roster = append(h.Roster, m) + } + if r.err != nil { + return Header{}, 0, fmt.Errorf("blobstore: snapshot header: %w", r.err) + } + return h, r.off, nil +} + +// HibernationStore parks frozen rooms in a blobstore.Store under snapshots/. A +// stored blob is the uncompressed Header followed by the opaque snapshot body; +// Get returns the body for restore, and a successful restore Deletes the blob +// (TTL is the bucket's backstop). List exists ONLY for the directory-less +// legacy path (see its doc): the production resume listing derives from the +// Postgres parked-room directory (add-parked-room-directory), never from blob +// enumeration. All ops are context-bound and concurrency-safe (the backing +// Store is). +// +// INTEGRITY: when constructed with a Sealer, Put MACs the whole header+body +// blob and Get/List verify it BEFORE decoding the header — a restored snapshot +// writes the roster and raw guest linear memory into the host, so an +// unauthenticated blob is a direct write-primitive for anyone who can write +// the bucket, and the header roster alone gates whose Resume menu a parked +// room appears in. This is the same HMAC Sealer (same server-side key +// convention) the versioned checkpoint scheme uses. A nil Sealer keeps the +// legacy unsealed layout for keyless dev/test stores. +type HibernationStore struct { + store blobstore.Store + sealer blobstore.Sealer +} + +// NewHibernationStore wraps a blobstore.Store. A nil store yields a no-op-safe +// zero value? No — callers must pass a real store; nil is a programmer error and +// every method guards it by returning an error rather than panicking. sealer +// authenticates parked blobs (see the type doc); nil means unsealed — only +// acceptable when no server-side MAC key exists (keyless dev mode, in-process +// test rigs). +// +// MIGRATION (clean cutover): a sealed store REJECTS blobs parked unsealed by an +// older binary (their trailing bytes are not a MAC), discarding them through +// the corrupt-blob path. Grandfathering unsealed blobs was deliberately NOT +// implemented — it would let a bucket writer bypass the MAC by stripping it, +// and the bucket's 14-day snapshot TTL bounds the loss to rooms parked at +// deploy time, the same loss policy as prior snapshot format bumps (v2→v3, +// v3→v4 hard-reject). +func NewHibernationStore(store blobstore.Store, sealer blobstore.Sealer) *HibernationStore { + return &HibernationStore{store: store, sealer: sealer} +} + +// key is the storage key for a room id: the FLAT hibernation key +// snapshots/. Namespace note: this shares the "snapshots/" prefix with +// the versioned room-checkpoint scheme snapshots// +// (blobstore.CheckpointKey). The two coexist deliberately in Phase 0; Track C / +// task G.5 unifies durability onto the versioned scheme and retires this flat +// key. A roomID is a UUIDv7, so the flat key never collides with a versioned +// one (the latter has a trailing "/epoch" segment). +func (s *HibernationStore) key(roomID string) string { return snapshotPrefix + roomID } + +// Put parks a snapshot: it prepends the header to the body, seals the whole +// blob when a Sealer is wired, and writes it at snapshots/. The +// header's RoomID is authoritative for the key. +func (s *HibernationStore) Put(ctx context.Context, h Header, body []byte) error { + if s == nil || s.store == nil { + return fmt.Errorf("blobstore: hibernation store not configured") + } + if h.RoomID == "" { + return fmt.Errorf("blobstore: hibernate: empty room id") + } + blob := append(encodeHeader(h), body...) + if s.sealer != nil { + blob = s.sealer.Seal(blob) + } + return s.store.Put(ctx, s.key(h.RoomID), blob) +} + +// open verifies and strips the seal when a Sealer is wired; with no Sealer the +// blob passes through unchanged (legacy unsealed layout). The error wraps +// blobstore.ErrSealVerify so callers can route it to the discard path. +func (s *HibernationStore) open(blob []byte) ([]byte, error) { + if s.sealer == nil { + return blob, nil + } + payload, err := s.sealer.Open(blob) + if err != nil { + return nil, fmt.Errorf("blobstore: hibernation snapshot: %w", err) + } + return payload, nil +} + +// List returns the header of every parked room, newest first. +// +// COST: this is NOT cheap. blobstore.Store.Get returns the WHOLE object, so +// List downloads every blob under snapshots/ — including every versioned room +// checkpoint sharing the prefix (snapshots//, headerless, fully +// fetched only to fail the magic check below) — just to decode the small +// headers. It is retained ONLY for hibernators constructed without a Postgres +// parked-room directory (in-process test rigs over blobstore.Memory); the +// production resume listing is a per-account directory query +// (add-parked-room-directory) and never calls this. +// +// Corrupt or foreign blobs under snapshots/ are skipped (logged by the caller +// if it cares), never fatal — a single bad object must not hide the rest. With +// a Sealer wired, a blob that fails seal verification is skipped the same way +// BEFORE its header is decoded: the header roster gates Resume-list visibility +// (Header.Has), so an unverified header must never reach a listing. +func (s *HibernationStore) List(ctx context.Context) ([]Header, error) { + if s == nil || s.store == nil { + return nil, fmt.Errorf("blobstore: hibernation store not configured") + } + keys, err := s.store.List(ctx, snapshotPrefix) + if err != nil { + return nil, err + } + out := make([]Header, 0, len(keys)) + for _, k := range keys { + if !strings.HasPrefix(k, snapshotPrefix) { + continue + } + blob, ok, err := s.store.Get(ctx, k) + if err != nil || !ok { + continue + } + payload, err := s.open(blob) + if err != nil { + continue // unverifiable (tampered/unsealed/foreign) — never list it + } + h, _, err := decodeHeader(payload) + if err != nil { + continue // skip a corrupt/foreign object rather than failing the list + } + out = append(out, h) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].At.After(out[j].At) }) + return out, nil +} + +// Get returns the parked room's header and snapshot body (the opaque codec blob +// to feed RestoreHandler). ok=false when no snapshot exists for roomID. With a +// Sealer wired, the seal is verified BEFORE the header is decoded; a failure +// returns an error wrapping blobstore.ErrSealVerify and the caller MUST refuse +// the restore (no guest memory is written) and discard the blob like any other +// corrupt snapshot. +func (s *HibernationStore) Get(ctx context.Context, roomID string) (Header, []byte, bool, error) { + if s == nil || s.store == nil { + return Header{}, nil, false, fmt.Errorf("blobstore: hibernation store not configured") + } + blob, ok, err := s.store.Get(ctx, s.key(roomID)) + if err != nil || !ok { + return Header{}, nil, false, err + } + payload, err := s.open(blob) + if err != nil { + return Header{}, nil, false, err + } + h, n, err := decodeHeader(payload) + if err != nil { + return Header{}, nil, false, err + } + body := make([]byte, len(payload)-n) + copy(body, payload[n:]) + return h, body, true, nil +} + +// Delete removes a parked room (called on a successful restore, and to discard a +// failed/corrupt snapshot). Deleting a missing room is not an error. +func (s *HibernationStore) Delete(ctx context.Context, roomID string) error { + if s == nil || s.store == nil { + return fmt.Errorf("blobstore: hibernation store not configured") + } + return s.store.Delete(ctx, s.key(roomID)) +} + +// ---- little-endian framing helpers (header only) ----------------------------- + +func appendStr(b []byte, s string) []byte { + b = binary.LittleEndian.AppendUint16(b, uint16(len(s))) + return append(b, s...) +} + +// reader is a bounds-checked little-endian reader; on overrun it latches err and +// every further read yields a zero value, so decodeHeader can check err once. +type reader struct { + b []byte + off int + err error +} + +func (r *reader) ok(n int) bool { + if r.err != nil || r.off+n > len(r.b) { + if r.err == nil { + r.err = fmt.Errorf("unexpected end of header") + } + return false + } + return true +} + +func (r *reader) u16() uint16 { + if !r.ok(2) { + return 0 + } + v := binary.LittleEndian.Uint16(r.b[r.off:]) + r.off += 2 + return v +} + +func (r *reader) u32() uint32 { + if !r.ok(4) { + return 0 + } + v := binary.LittleEndian.Uint32(r.b[r.off:]) + r.off += 4 + return v +} + +func (r *reader) i64() int64 { + if !r.ok(8) { + return 0 + } + v := binary.LittleEndian.Uint64(r.b[r.off:]) + r.off += 8 + return int64(v) +} + +func (r *reader) str() string { + n := int(r.u16()) + if !r.ok(n) { + return "" + } + s := string(r.b[r.off : r.off+n]) + r.off += n + return s +} diff --git a/host/gameabi/hibernate_e2e_test.go b/host/gameabi/hibernate_e2e_test.go new file mode 100644 index 0000000..2c0d3af --- /dev/null +++ b/host/gameabi/hibernate_e2e_test.go @@ -0,0 +1,231 @@ +package gameabi + +import ( + "context" + "strconv" + "strings" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/blobstore" + "github.com/shellcade/kit/v2/host/sdk" +) + +// wakesOf parses the fixture's "wakes=N" status row (row 2) from a frame. +func wakesOf(t *testing.T, f sdk.Frame) int { + t.Helper() + row := rowText(f, 2) + if !strings.HasPrefix(row, "wakes=") { + t.Fatalf("frame row 2 = %q, want wakes=N", row) + } + n, err := strconv.Atoi(strings.TrimPrefix(row, "wakes=")) + if err != nil { + t.Fatalf("bad wakes row %q: %v", row, err) + } + return n +} + +// TestHibernateE2EContinuity is the full hibernation round trip across a process +// restart boundary, exercised through the real engine + the hibernation store: +// +// 1. A live wasm room (the matchmaker's runtime) plays — a member joins and the +// heartbeat drives wakes, advancing the guest's persistent wake counter. +// 2. The room is hibernated via the drain path (RoomCtl.Hibernate), which +// snapshots the live handler and parks it in the store over a Memory +// blobstore. The room disposes WITHOUT a result. +// 3. We simulate a restart: a FRESH LoadGame (new CompiledPlugin) and a FRESH +// HibernationStore handle over the SAME Memory blobstore — nothing carried in +// process memory but the blob bytes. +// 4. We restore and continue: the restored handler's wake counter picks up from +// where it left off (wakes=N -> N+1), proving guest memory survived; and the +// snapshot is deleted from the store on a successful restore. +func TestHibernateE2EContinuity(t *testing.T) { + mem := blobstore.NewMemory() // the durable bucket that survives the "restart" + + // --- phase 1+2: play a live room, then hibernate it via the drain path --- + gLive := loadFixture(t, Options{Heartbeat: 25 * time.Millisecond}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 99, SeedSet: true} + storeA := NewHibernationStore(mem, nil) + roomID := "e2e-room" + ctl := sdk.NewRoomRuntime(roomID, gLive.NewRoom(cfg, sdk.Services{Log: quietLog()}), cfg, sdk.Services{Log: quietLog()}, + sdk.WithAbandonHibernate(hibernateHook(t, storeA, "fixture", roomID), time.Hour)) + t.Cleanup(func() { _ = ctl.Close() }) + + if err := ctl.Join(p1); err != nil { + t.Fatalf("join: %v", err) + } + frames := ctl.Frames(p1) + + // Let the heartbeat advance the wake counter past zero, then capture the last + // observed wakes value from a broadcast frame. + var lastWakes int + waitFor(t, "some wakes", func() bool { + f, ok := drainLatest(frames) + if !ok { + return false + } + lastWakes = wakesOf(t, f) + return lastWakes >= 2 + }) + + // Hibernate via the drain path. The snapshot is taken at a quiescent point on + // the actor, so it captures a coherent wake counter >= the last we saw. + if err := ctl.Hibernate(hibernateHook(t, storeA, "fixture", roomID)); err != nil { + t.Fatalf("hibernate: %v", err) + } + select { + case <-ctl.Done(): + case <-time.After(5 * time.Second): + t.Fatal("room did not dispose after drain hibernate") + } + if _, ok := ctl.Result(); ok { + t.Fatal("hibernated room published a Result (should be paused, not finished)") + } + + // --- phase 3: simulate a restart — fresh game + fresh store, same bucket --- + gFresh := loadFixture(t, Options{Heartbeat: 25 * time.Millisecond}) + if gFresh.(*wasmGame) == gLive.(*wasmGame) { + t.Fatal("expected a distinct CompiledPlugin after reload") + } + storeB := NewHibernationStore(mem, nil) + + hdrs, err := storeB.List(context.Background()) + if err != nil { + t.Fatalf("list after restart: %v", err) + } + if len(hdrs) != 1 || hdrs[0].RoomID != roomID { + t.Fatalf("parked headers after restart = %+v, want one for %s", hdrs, roomID) + } + + hdr, body, ok, err := storeB.Get(context.Background(), roomID) + if err != nil || !ok { + t.Fatalf("get parked room: ok=%v err=%v", ok, err) + } + if hdr.Slug != "fixture" { + t.Fatalf("header slug = %q, want fixture", hdr.Slug) + } + + // --- phase 4: restore + continue, assert wake continuity + snapshot gone --- + h, err := RestoreHandler(gFresh, body) + if err != nil { + t.Fatalf("restore: %v", err) + } + wh := h.(*wasmHandler) + snapWakes := guestWakes(t, wh) + if snapWakes < lastWakes { + t.Fatalf("restored wakes=%d < last observed %d (state regressed)", snapWakes, lastWakes) + } + + // One more wake on the restored handler must advance from the restored value: + // continuity across the hibernate boundary. + r := newReplayRoom([]sdk.Player{p1}, cfg, time.Unix(1_700_000_000, 0)) + wh.OnTick(r, r.clock) + got := wakesOf(t, r.frames[len(r.frames)-1]) + if got != snapWakes+1 { + t.Fatalf("post-restore wake produced wakes=%d, want %d (continuity broken)", got, snapWakes+1) + } + + // Snapshot deleted on successful restore (delete-on-restore), so a resume can + // never double-spawn the same room. + if err := storeB.Delete(context.Background(), roomID); err != nil { + t.Fatalf("delete on restore: %v", err) + } + hdrs, err = storeB.List(context.Background()) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if len(hdrs) != 0 { + t.Fatalf("snapshot still present after restore+delete: %+v", hdrs) + } +} + +// guestWakes reads the restored handler's current wake counter via a wake-free +// render ('x' is unhandled -> render path; no wake increment). In ABI v2 the +// FIRST post-restore send hits the resync: the host's baseline cache is +// ephemeral (not snapshotted) so it re-seeds the epoch and rejects the restored +// guest's delta — and the SDK retries the same frame as a keyframe within the +// same call (kit >= v2.0.1), so one render suffices and no frame is dropped. +func guestWakes(t *testing.T, wh *wasmHandler) int { + t.Helper() + r := newReplayRoom([]sdk.Player{p1}, wh.cfg, time.Unix(1_700_000_000, 0)) + wh.OnResume(r) // engine resume path: re-seed epoch, mark slots not-present + wh.OnInput(r, p1, runeIn('x')) // post-restore render: delta rejected, keyframe retried in-call + if len(r.frames) == 0 { + t.Fatal("restored handler produced no frame after the resync render") + } + return wakesOf(t, r.frames[len(r.frames)-1]) +} + +// drainLatest returns the most recent buffered frame without blocking (ok=false +// if none is buffered). +func drainLatest(ch <-chan sdk.Frame) (sdk.Frame, bool) { + var last sdk.Frame + got := false + for { + select { + case f, ok := <-ch: + if !ok { + return last, got + } + last, got = f, true + default: + return last, got + } + } +} + +// TestHibernateFailedRestoreDiscards: a corrupt / artifact-mismatched snapshot +// cannot be resumed, and the safe-degrade path discards it so a member never +// gets stuck staring at a dead resume entry. Here we restore against the WRONG +// game (a freshly loaded fixture is fine, but a tampered body fails the codec's +// artifact-hash check); on failure the caller discards via Delete. +func TestHibernateFailedRestoreDiscards(t *testing.T) { + mem := blobstore.NewMemory() + store := NewHibernationStore(mem, nil) + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + roomID := "corrupt-room" + + // Park a valid snapshot, then corrupt its body in the bucket so restore fails. + h := g.NewRoom(cfg, sdk.Services{Log: quietLog()}).(*wasmHandler) + rr := newReplayRoom([]sdk.Player{p1}, cfg, time.Unix(1_700_000_000, 0)) + h.OnStart(rr) + h.OnJoin(rr, p1) + blob, err := SnapshotHandler(h) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if err := store.Put(context.Background(), Header{Slug: "fixture", RoomID: roomID, At: time.Now(), Roster: RosterFrom([]sdk.Player{p1})}, blob); err != nil { + t.Fatalf("put: %v", err) + } + + // Corrupt the stored body (flip a byte deep in the zstd payload). + _, body, ok, err := store.Get(context.Background(), roomID) + if err != nil || !ok { + t.Fatalf("get: ok=%v err=%v", ok, err) + } + body[len(body)/2] ^= 0xff + if err := store.Put(context.Background(), Header{Slug: "fixture", RoomID: roomID, At: time.Now(), Roster: RosterFrom([]sdk.Player{p1})}, body); err != nil { + t.Fatalf("re-put corrupt: %v", err) + } + + // Restore must fail (corrupt body), and the resume flow discards the snapshot. + _, badBody, ok, err := store.Get(context.Background(), roomID) + if err != nil || !ok { + t.Fatalf("re-get: ok=%v err=%v", ok, err) + } + if _, err := RestoreHandler(g, badBody); err == nil { + t.Fatal("RestoreHandler accepted a corrupt snapshot body") + } + // Safe-degrade: discard the un-restorable snapshot. + if err := store.Delete(context.Background(), roomID); err != nil { + t.Fatalf("discard: %v", err) + } + hdrs, err := store.List(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(hdrs) != 0 { + t.Fatalf("corrupt snapshot not discarded: %+v", hdrs) + } +} diff --git a/host/gameabi/hibernate_test.go b/host/gameabi/hibernate_test.go new file mode 100644 index 0000000..f770dfc --- /dev/null +++ b/host/gameabi/hibernate_test.go @@ -0,0 +1,385 @@ +package gameabi + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/blobstore" + "github.com/shellcade/kit/v2/host/sdk" +) + +// hibernateHook returns the WithAbandonHibernate fn that the matchmaker wires in +// production: snapshot the live handler and park it in the store under roomID. +// It records the parked roster header so the test can assert membership. +func hibernateHook(t *testing.T, store *HibernationStore, slug, roomID string) func(sdk.Handler) error { + t.Helper() + return func(h sdk.Handler) error { + blob, err := SnapshotHandler(h) + if err != nil { + return err + } + hdr := Header{Slug: slug, RoomID: roomID, At: time.Now()} + if wh, ok := h.(*wasmHandler); ok { + hdr.Roster = RosterFrom(wh.roster) + } + return store.Put(context.Background(), hdr, blob) + } +} + +// TestHeaderRoundTrip: the uncompressed header encodes and decodes without zstd, +// preserving every field, and reports the body offset so the snapshot body can be +// sliced off. +func TestHeaderRoundTrip(t *testing.T) { + at := time.Unix(1_700_000_000, 123_456_789) + in := Header{ + Slug: "fixture", + RoomID: "room-7", + At: at, + Roster: []RosterMember{{AccountID: "a1", Handle: "ada"}, {AccountID: "a2", Handle: "bob"}}, + } + body := []byte{0xde, 0xad, 0xbe, 0xef} + blob := append(encodeHeader(in), body...) + + got, n, err := decodeHeader(blob) + if err != nil { + t.Fatalf("decodeHeader: %v", err) + } + if got.Slug != in.Slug || got.RoomID != in.RoomID || !got.At.Equal(in.At) { + t.Fatalf("scalar fields differ: got %+v want %+v", got, in) + } + if len(got.Roster) != 2 || got.Roster[0] != in.Roster[0] || got.Roster[1] != in.Roster[1] { + t.Fatalf("roster differs: %+v", got.Roster) + } + if !got.Has("a1") || got.Has("nope") { + t.Fatalf("Has() membership wrong on %+v", got.Roster) + } + if string(blob[n:]) != string(body) { + t.Fatalf("body offset wrong: blob[%d:]=%x want %x", n, blob[n:], body) + } +} + +// TestHeaderRejectsGarbage: decodeHeader must error (never panic) on truncated or +// bad-magic input — the store relies on this to skip foreign/corrupt objects. +func TestHeaderRejectsGarbage(t *testing.T) { + for _, b := range [][]byte{nil, {0x00}, {0xff, 0xff, 0xff, 0xff}, make([]byte, 8)} { + if _, _, err := decodeHeader(b); err == nil { + t.Fatalf("decodeHeader(%x) accepted garbage", b) + } + } +} + +// TestStoreListSkipsForeign: a non-header object under snapshots/ is skipped by +// List, not fatal — one bad blob must not hide valid parked rooms. +func TestStoreListSkipsForeign(t *testing.T) { + mem := blobstore.NewMemory() + store := NewHibernationStore(mem, nil) + if err := store.Put(context.Background(), Header{Slug: "fixture", RoomID: "good", At: time.Now()}, []byte("body")); err != nil { + t.Fatalf("put: %v", err) + } + // A foreign object directly in the bucket under the prefix. + if err := mem.Put(context.Background(), snapshotPrefix+"junk", []byte("not a header")); err != nil { + t.Fatalf("put junk: %v", err) + } + hdrs, err := store.List(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(hdrs) != 1 || hdrs[0].RoomID != "good" { + t.Fatalf("list = %+v, want just the good header", hdrs) + } +} + +// TestSealedStoreRoundTrip: a store built with an HMAC Sealer parks a sealed +// blob and Get verifies + strips the seal, returning the exact header and body. +func TestSealedStoreRoundTrip(t *testing.T) { + mem := blobstore.NewMemory() + store := NewHibernationStore(mem, blobstore.NewHMACSealer([]byte("test-mac-key"))) + hdr := Header{Slug: "fixture", RoomID: "sealed-room", At: time.Unix(1_700_000_000, 0), + Roster: []RosterMember{{AccountID: "a1", Handle: "ada"}}} + body := []byte("opaque snapshot body") + ctx := context.Background() + if err := store.Put(ctx, hdr, body); err != nil { + t.Fatalf("put: %v", err) + } + // The stored object must NOT be the raw header+body (the seal is real). + raw, ok, err := mem.Get(ctx, snapshotPrefix+"sealed-room") + if err != nil || !ok { + t.Fatalf("raw get: ok=%v err=%v", ok, err) + } + if len(raw) == len(encodeHeader(hdr))+len(body) { + t.Fatal("stored blob is exactly header+body — no MAC was appended") + } + got, gotBody, ok, err := store.Get(ctx, "sealed-room") + if err != nil || !ok { + t.Fatalf("get: ok=%v err=%v", ok, err) + } + if got.Slug != hdr.Slug || got.RoomID != hdr.RoomID || !got.Has("a1") { + t.Fatalf("header round trip: %+v", got) + } + if string(gotBody) != string(body) { + t.Fatalf("body round trip: %q", gotBody) + } +} + +// TestSealedStoreRejectsTamperAndUnsealed: a sealed store's Get must refuse — +// with blobstore.ErrSealVerify, before the header is even decoded — both a +// tampered blob and a legacy blob parked UNSEALED by a pre-sealing binary (the +// clean-cutover migration behavior). This is the write-primitive regression: +// nothing read from an unverified blob may reach the roster or guest memory. +func TestSealedStoreRejectsTamperAndUnsealed(t *testing.T) { + mem := blobstore.NewMemory() + sealer := blobstore.NewHMACSealer([]byte("test-mac-key")) + sealed := NewHibernationStore(mem, sealer) + ctx := context.Background() + + // Tampered: park sealed, then flip one byte of the stored object. + hdr := Header{Slug: "fixture", RoomID: "tampered", At: time.Now()} + if err := sealed.Put(ctx, hdr, []byte("body")); err != nil { + t.Fatalf("put: %v", err) + } + raw, _, _ := mem.Get(ctx, snapshotPrefix+"tampered") + raw[len(raw)/2] ^= 0x01 + if err := mem.Put(ctx, snapshotPrefix+"tampered", raw); err != nil { + t.Fatalf("re-put tampered: %v", err) + } + if _, _, _, err := sealed.Get(ctx, "tampered"); !errors.Is(err, blobstore.ErrSealVerify) { + t.Fatalf("tampered get err = %v, want ErrSealVerify", err) + } + + // Unsealed legacy: parked by a store with no sealer (an older binary). + legacy := NewHibernationStore(mem, nil) + if err := legacy.Put(ctx, Header{Slug: "fixture", RoomID: "legacy", At: time.Now()}, []byte("body")); err != nil { + t.Fatalf("legacy put: %v", err) + } + if _, _, _, err := sealed.Get(ctx, "legacy"); !errors.Is(err, blobstore.ErrSealVerify) { + t.Fatalf("legacy get err = %v, want ErrSealVerify", err) + } +} + +// TestSealedListSkipsUnverified: the legacy List path (directory-less rigs) +// must verify each blob BEFORE decoding its header — the header roster gates +// Resume-list visibility, so tampered/unsealed/foreign objects are skipped. +func TestSealedListSkipsUnverified(t *testing.T) { + mem := blobstore.NewMemory() + sealer := blobstore.NewHMACSealer([]byte("test-mac-key")) + sealed := NewHibernationStore(mem, sealer) + ctx := context.Background() + + if err := sealed.Put(ctx, Header{Slug: "fixture", RoomID: "good", At: time.Now()}, []byte("body")); err != nil { + t.Fatalf("put: %v", err) + } + // Unsealed legacy blob with a VALID header and a forged roster: without + // List-side verification this would surface in the forged member's menu. + forged := append(encodeHeader(Header{Slug: "fixture", RoomID: "forged", At: time.Now(), + Roster: []RosterMember{{AccountID: "victim", Handle: "v"}}}), "body"...) + if err := mem.Put(ctx, snapshotPrefix+"forged", forged); err != nil { + t.Fatalf("put forged: %v", err) + } + // Sealed-but-headerless object (a versioned checkpoint under the shared + // prefix is sealed with the same key but carries no hibernation header). + if err := mem.Put(ctx, snapshotPrefix+"room/00000000000000000001", sealer.Seal([]byte("checkpoint payload"))); err != nil { + t.Fatalf("put checkpoint: %v", err) + } + + hdrs, err := sealed.List(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(hdrs) != 1 || hdrs[0].RoomID != "good" { + t.Fatalf("list = %+v, want just the sealed good header", hdrs) + } +} + +// TestHibernateCapability: a wasm room with a wired hook reports Hibernatable; +// the same game without the hook does not (the room ends normally on +// abandonment, the legacy behavior). +func TestHibernateCapability(t *testing.T) { + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + store := NewHibernationStore(blobstore.NewMemory(), nil) + + withHook := sdk.NewRoomRuntime("cap-hook", g.NewRoom(cfg, sdk.Services{Log: quietLog()}), cfg, sdk.Services{Log: quietLog()}, + sdk.WithAbandonHibernate(hibernateHook(t, store, "fixture", "cap-hook"), time.Hour)) + t.Cleanup(func() { _ = withHook.Close() }) + if err := withHook.Join(p1); err != nil { + t.Fatalf("join: %v", err) + } + waitFor(t, "hooked room hibernatable", func() bool { return withHook.Hibernatable() }) + + noHook := newLiveRoom(t, g, cfg) + if err := noHook.Join(p1); err != nil { + t.Fatalf("join: %v", err) + } + // Give it a beat to start; it must never report hibernatable without a hook. + time.Sleep(20 * time.Millisecond) + if noHook.Hibernatable() { + t.Fatal("room without a hibernate hook reported Hibernatable") + } +} + +// TestHibernateDrainTrigger drives the deploy-drain path: a live room with a +// member is frozen via RoomCtl.Hibernate. The snapshot lands in the store with +// the room's roster, the player's frame stream closes (paused, not finished), +// the room reports Done, and crucially NO Result is published (a hibernated room +// is not a finished room — no DNF backfill, no leaderboard post). +func TestHibernateDrainTrigger(t *testing.T) { + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + store := NewHibernationStore(blobstore.NewMemory(), nil) + roomID := "drain-1" + ctl := sdk.NewRoomRuntime(roomID, g.NewRoom(cfg, sdk.Services{Log: quietLog()}), cfg, sdk.Services{Log: quietLog()}, + sdk.WithAbandonHibernate(hibernateHook(t, store, "fixture", roomID), time.Hour)) + t.Cleanup(func() { _ = ctl.Close() }) + + if err := ctl.Join(p1); err != nil { + t.Fatalf("join: %v", err) + } + frames := ctl.Frames(p1) + waitFor(t, "hibernatable", func() bool { return ctl.Hibernatable() }) + + if err := ctl.Hibernate(func(h sdk.Handler) error { + return hibernateHook(t, store, "fixture", roomID)(h) + }); err != nil { + t.Fatalf("hibernate: %v", err) + } + + // The room is disposed: Done closes, the player stream closes. + select { + case <-ctl.Done(): + case <-time.After(5 * time.Second): + t.Fatal("room did not dispose after hibernate") + } + select { + case _, ok := <-frames: + if ok { + // drain any buffered frame, then confirm the stream is now closed + select { + case _, ok2 := <-frames: + if ok2 { + t.Fatal("player frame stream still open after hibernate") + } + case <-time.After(time.Second): + t.Fatal("player frame stream did not close after hibernate") + } + } + case <-time.After(time.Second): + t.Fatal("player frame stream did not close after hibernate") + } + + // No Result was published: hibernation is a pause, not an end. + if res, ok := ctl.Result(); ok { + t.Fatalf("hibernated room published a Result: %+v", res) + } + + // The snapshot is parked, tagged with the room's member. + hdrs, err := store.List(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(hdrs) != 1 { + t.Fatalf("parked %d snapshots, want 1", len(hdrs)) + } + if hdrs[0].RoomID != roomID || hdrs[0].Slug != "fixture" || !hdrs[0].Has(p1.AccountID) { + t.Fatalf("header = %+v, want room=%s slug=fixture with p1", hdrs[0], roomID) + } + + // The body restores into a fresh handler with the right roster. + _, body, ok, err := store.Get(context.Background(), roomID) + if err != nil || !ok { + t.Fatalf("get body: ok=%v err=%v", ok, err) + } + h, err := RestoreHandler(g, body) + if err != nil { + t.Fatalf("restore: %v", err) + } + wh := h.(*wasmHandler) + if len(wh.roster) != 1 || wh.roster[0] != p1 { + t.Fatalf("restored roster = %+v, want [p1]", wh.roster) + } +} + +// TestHibernateAbandonTrigger drives the abandonment path: an empty +// hibernate-capable room is NOT ended immediately; after the grace window it +// auto-hibernates instead. (A non-capable room ends right away — covered by the +// existing room tests.) +func TestHibernateAbandonTrigger(t *testing.T) { + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + store := NewHibernationStore(blobstore.NewMemory(), nil) + roomID := "abandon-1" + ctl := sdk.NewRoomRuntime(roomID, g.NewRoom(cfg, sdk.Services{Log: quietLog()}), cfg, sdk.Services{Log: quietLog()}, + sdk.WithAbandonHibernate(hibernateHook(t, store, "fixture", roomID), 60*time.Millisecond)) + t.Cleanup(func() { _ = ctl.Close() }) + + if err := ctl.Join(p1); err != nil { + t.Fatalf("join: %v", err) + } + waitFor(t, "hibernatable", func() bool { return ctl.Hibernatable() }) + + // Member leaves: the room must NOT settle immediately (grace reprieve). + ctl.Leave(p1) + time.Sleep(20 * time.Millisecond) + select { + case <-ctl.Done(): + t.Fatal("room disposed before the grace window elapsed") + default: + } + + // After the grace window the room auto-hibernates: Done, snapshot parked. + select { + case <-ctl.Done(): + case <-time.After(5 * time.Second): + t.Fatal("room did not auto-hibernate after the grace window") + } + if res, ok := ctl.Result(); ok { + t.Fatalf("abandoned-then-hibernated room published a Result: %+v", res) + } + hdrs, err := store.List(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(hdrs) != 1 || hdrs[0].RoomID != roomID { + t.Fatalf("parked headers = %+v, want one for %s", hdrs, roomID) + } +} + +// TestHibernateGraceCancelledByRejoin: a rejoin inside the grace window cancels +// the pending abandonment hibernation — the room stays live and nothing is +// parked. +func TestHibernateGraceCancelledByRejoin(t *testing.T) { + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeQuick, Capacity: 2, MinPlayers: 1, Seed: 7, SeedSet: true} + store := NewHibernationStore(blobstore.NewMemory(), nil) + roomID := "rejoin-1" + ctl := sdk.NewRoomRuntime(roomID, g.NewRoom(cfg, sdk.Services{Log: quietLog()}), cfg, sdk.Services{Log: quietLog()}, + sdk.WithAbandonHibernate(hibernateHook(t, store, "fixture", roomID), 150*time.Millisecond)) + t.Cleanup(func() { _ = ctl.Close() }) + + if err := ctl.Join(p1); err != nil { + t.Fatalf("join: %v", err) + } + waitFor(t, "hibernatable", func() bool { return ctl.Hibernatable() }) + + ctl.Leave(p1) // empties the room, arms the grace timer + time.Sleep(30 * time.Millisecond) + if err := ctl.Join(p2); err != nil { // rejoin before grace fires + t.Fatalf("rejoin: %v", err) + } + + // Wait past the original grace window; the room must still be live. + time.Sleep(250 * time.Millisecond) + select { + case <-ctl.Done(): + t.Fatal("room hibernated despite a rejoin inside the grace window") + default: + } + hdrs, err := store.List(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(hdrs) != 0 { + t.Fatalf("parked %d snapshots after a cancelling rejoin, want 0", len(hdrs)) + } +} diff --git a/host/gameabi/host.go b/host/gameabi/host.go new file mode 100644 index 0000000..0a49c89 --- /dev/null +++ b/host/gameabi/host.go @@ -0,0 +1,1203 @@ +package gameabi + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "log/slog" + "math/rand" + "os" + "regexp" + "strconv" + "strings" + "time" + + extism "github.com/extism/go-sdk" + "github.com/tetratelabs/wazero" + wzsys "github.com/tetratelabs/wazero/sys" + + "github.com/shellcade/kit/v2/wire" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// kvTimeout caps a kv/config host call's store context. The context is DERIVED +// from the callback context (see the kv host functions), so the effective bound +// is min(remaining callback deadline, kvTimeout) — a slow Postgres can never +// hold the room actor past the per-callback kill switch. +const kvTimeout = 2 * time.Second + +// Guest log caps: log volume is the one resource the wasm sandbox (memory cap, +// CPU deadline, virtualized WASI) does not otherwise meter, so a printf-spamming +// guest could inflate log costs and drown the audit/quarantine events operators +// need. Both guest log paths — stdout/stderr (logWriter) and the `log` host +// function — share one per-room byte budget per real-time window, with each +// write truncated. One Warn marker per window records that limiting happened. +const ( + guestLogMaxWrite = 4 << 10 // bytes per write/log call; longer payloads are truncated + guestLogBudget = 32 << 10 // bytes per room per window, across both paths + guestLogWindow = time.Second +) + +// DefaultCallbackDeadline is the per-callback wall-clock kill switch when +// Options.CallbackDeadline is unset (admin-tunable per game via +// host.deadline_ms — see HostConfigSpecs). +const DefaultCallbackDeadline = 100 * time.Millisecond + +// Options bound a loaded game's runtime behavior. Zero values take defaults. +type Options struct { + Heartbeat time.Duration // wake cadence (default Heartbeat) + MemoryPages uint32 // linear-memory cap in 64KiB pages (default 512 = 32MiB) + CallbackDeadline time.Duration // per-callback wall-clock kill switch (default 100ms) + + // OnFault, when set, is told the game's slug each time a guest faults + // (failed instantiation, trap, callback deadline, memory cap). Wire it to + // Quarantine.RecordFault to remove repeat offenders from the live roster. + // Called from room actor goroutines; must be safe for concurrent use. + OnFault func(slug string) + + // Metrics, when set, receives host-measured per-game counters (add-metrics). + // Every value is measured by the HOST from bytes it moved across the module + // boundary — never a module-reported figure, so a guest cannot inflate or + // fabricate a count. Called from room actor goroutines; implementations must + // be safe for concurrent use. nil ⇒ no recording. + Metrics Metrics +} + +// Metrics is the host-side per-game instrumentation surface (implemented by +// *metrics.Metrics; defined here so gameabi does not import the metrics +// package). Byte counts are logical (pre-terminal-encoding) frame/input sizes +// — the per-game attribution numbers, not wire bytes. +type Metrics interface { + GameFrameBytesOut(slug string, n int) + GameInputBytesIn(slug string, n int) + GameFault(slug string) + // GameCallback records the host-measured wall-clock duration of one guest + // callback (the CPU-attribution surface: a spinning game piles into the top + // bucket right before its deadline kill). + GameCallback(slug, callback string, seconds float64) + // GameCallbackDeadline records one callback the kill switch fired on — a + // spin-to-deadline, distinct from other faults. + GameCallbackDeadline(slug, callback string) + // GameHostIODeadline records one callback the kill switch fired on while + // the guest was blocked in the host's OWN store/config call — a host-I/O + // incident (slow shared Postgres), deliberately excluded from the fault + // path so DB slowness never feeds quarantine. + GameHostIODeadline(slug, callback string) + // GameKVError records one failed kv/config host call (op: kv_get | kv_set | + // kv_delete | config_get) — silent dropped writes made visible. + GameKVError(slug, op string) + // GameLinearMemoryDelta adjusts the per-game linear-memory gauge by delta + // bytes. The host samples each room's ACTUAL guest memory size (wazero + // Memory.Size — never a module-reported figure) on the room heartbeat and + // reports the change since the room's previous sample, retiring the room's + // whole contribution when its instance closes, so the gauge is the sum + // across the game's live rooms with no per-room series. + GameLinearMemoryDelta(slug string, delta int64) +} + +func (o Options) withDefaults() Options { + if o.Heartbeat <= 0 { + o.Heartbeat = Heartbeat + } + if o.MemoryPages == 0 { + o.MemoryPages = 512 // 32 MiB + } + if o.CallbackDeadline <= 0 { + o.CallbackDeadline = DefaultCallbackDeadline + } + return o +} + +// handlerKey carries the current room's wasmHandler through the Call context so +// host functions resolve against the callback's roster (mid-callback-only rule). +type handlerKey struct{} + +// LoadGame compiles a wasm artifact from a file, validates the ABI handshake and +// meta on a throwaway instance, and returns a sdk.Game whose rooms run the guest. +func LoadGame(path string, opts Options) (sdk.Game, error) { + wasm, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return LoadGameBytes(wasm, opts) +} + +// LoadGameBytes is LoadGame over an in-memory artifact (the catalog loads a +// verified blob from the object store, never a path). The two share all +// compile/handshake/meta logic; LoadGame is the file-backed convenience. +func LoadGameBytes(wasm []byte, opts Options) (sdk.Game, error) { + opts = opts.withDefaults() + g := &wasmGame{wasm: wasm, wasmSHA: sha256.Sum256(wasm), opts: opts} + + // Limits (D6): linear-memory cap via the manifest; the per-callback + // deadline needs the runtime to honor context cancellation. + manifest := extism.Manifest{ + Wasm: []extism.Wasm{extism.WasmData{Data: wasm}}, + Memory: &extism.ManifestMemory{MaxPages: opts.MemoryPages}, + } + cfg := extism.PluginConfig{ + EnableWasi: true, + RuntimeConfig: wazero.NewRuntimeConfig().WithCloseOnContextDone(true), + } + compiled, err := extism.NewCompiledPlugin(context.Background(), manifest, cfg, hostFunctions()) + if err != nil { + return nil, fmt.Errorf("gameabi: compile: %w", err) + } + g.compiled = compiled + + // Throwaway instance: handshake + meta, then discard. + probe, err := compiled.Instance(context.Background(), extism.PluginInstanceConfig{}) + if err != nil { + return nil, fmt.Errorf("gameabi: instantiate probe: %w", err) + } + defer probe.Close(context.Background()) + exit, out, err := probe.Call(wire.ExpABI, nil) + if err != nil || exit != 0 { + return nil, fmt.Errorf("gameabi: %s failed (exit %d): %v", wire.ExpABI, exit, err) + } + if len(out) < 4 { + return nil, fmt.Errorf("gameabi: %s returned %d bytes, want 4", wire.ExpABI, len(out)) + } + v := uint32(out[0]) | uint32(out[1])<<8 | uint32(out[2])<<16 | uint32(out[3])<<24 + // v2 is a hard major cutover (D-transition): the host requires major 2 and + // refuses any other major — there is no dual loader and no v1 ingestion path. + // A major-1 artifact (the pre-diffing 16-byte-cell encoding) is unsupported. + if v != Version { + return nil, fmt.Errorf("gameabi: unsupported ABI major v%d (host requires v%d); refusing to instantiate", v, Version) + } + g.abiMajor = v + exit, out, err = probe.Call(wire.ExpMeta, nil) + if err != nil || exit != 0 { + return nil, fmt.Errorf("gameabi: %s failed (exit %d): %v", wire.ExpMeta, exit, err) + } + meta, err := decodeMeta(out) + if err != nil { + return nil, err + } + if err := validateBareName(meta.Slug); err != nil { + return nil, err + } + g.meta = meta + return g, nil +} + +// bareName matches the slug a guest is allowed to declare: a lower-case +// kebab-case identifier with NO namespace separator. A game's full platform +// slug is /; the namespace prefix is composed +// host-side from the verified submitter, so a binary can never claim one — it +// names ONLY the bare game name here. +var bareName = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) + +// validateBareName rejects a guest meta whose declared slug is not a bare name +// (e.g. carries a "/" namespace, an upper-case letter, or is over-long). The +// host owns namespacing, so a binary that tries to ship a namespaced slug is a +// malformed artifact, not a loadable game. +func validateBareName(slug string) error { + if !bareName.MatchString(slug) { + return fmt.Errorf("gameabi: guest declared slug %q; meta.slug must be a bare name matching %s (the host composes the / namespace)", slug, bareName.String()) + } + return nil +} + +// wasmGame implements sdk.Game over a compiled Extism plugin. +type wasmGame struct { + sdk.GameBase + wasm []byte + wasmSHA [sha256.Size]byte // artifact hash, bound into snapshots (restore must match) + compiled *extism.CompiledPlugin + meta sdk.GameMeta + opts Options + abiMajor uint32 // the major the guest declared at the handshake probe +} + +// OverrideSlug renames a loaded wasm game (dev sideloading: avoid colliding +// with a compiled-in slug). Returns false if g is not a wasm game. +func OverrideSlug(g sdk.Game, slug string) bool { + wg, ok := g.(*wasmGame) + if !ok { + return false + } + wg.meta.Slug = slug + return true +} + +// SetHidden marks a loaded wasm game live-but-unlisted (sdk.GameMeta.Hidden): it +// stays reachable by exact slug (quick-match, direct entry, admin) but the +// lobby's player-facing menu omits it. Used for the built-in load-test game +// (add-loadtest-harness). ok is false if g is not a wasm game. +func SetHidden(g sdk.Game, hidden bool) bool { + wg, ok := g.(*wasmGame) + if !ok { + return false + } + wg.meta.Hidden = hidden + return true +} + +// SetMaxPlayers overrides the room capacity a loaded wasm game declares. The +// built-in load-test game uses this to cap lobby size (a guest may declare a huge +// MaxPlayers as a stress testbed; the host bounds it). ok is false if g is not a +// wasm game. +func SetMaxPlayers(g sdk.Game, n int) bool { + wg, ok := g.(*wasmGame) + if !ok { + return false + } + wg.meta.MaxPlayers = n + return true +} + +// SetLifecycle overrides the room end-of-life mode a loaded wasm game declares. +// The built-in load-test game forces Ephemeral so its rooms run while players are +// connected and dispose after the abandon grace — never hibernated (no parked-room +// snapshots) and never resident (no always-on idle tick). ok is false if g is not +// a wasm game. +func SetLifecycle(g sdk.Game, lc sdk.Lifecycle) bool { + wg, ok := g.(*wasmGame) + if !ok { + return false + } + wg.meta.Lifecycle = lc + return true +} + +// CallbackDeadlineOf reports the per-callback wall-clock deadline a loaded wasm +// game runs under. ok is false if g is not a wasm game. Inspection helper (the +// load-test game runs a generous deadline so heavy ticks degrade, not trap). +func CallbackDeadlineOf(g sdk.Game) (time.Duration, bool) { + wg, ok := g.(*wasmGame) + if !ok { + return 0, false + } + return wg.opts.CallbackDeadline, true +} + +// QuarantineExempt reports whether a loaded wasm game has NO fault hook wired, so +// the fault watchdog can never quarantine it — as the built-in load-test game is +// (it must survive the overload it exists to measure). ok is false if g is not a +// wasm game. +func QuarantineExempt(g sdk.Game) (exempt, ok bool) { + wg, isWasm := g.(*wasmGame) + if !isWasm { + return false, false + } + return wg.opts.OnFault == nil, true +} + +// ABIMajor reports the ABI major a loaded wasm game declared at the handshake +// probe (record-abi-major); ok is false if g is not a wasm game. Today the +// value always equals Version — LoadGameBytes refuses any other major — but +// the catalog persists the PROBED value per verified version so the column +// stays truthful the day the host accepts a set of majors. +func ABIMajor(g sdk.Game) (major uint32, ok bool) { + wg, ok := g.(*wasmGame) + if !ok { + return 0, false + } + return wg.abiMajor, true +} + +func (g *wasmGame) Meta() sdk.GameMeta { return g.meta } + +// Reserved host config keys (D6): an admin tunes a deployed game's runtime +// cadence and per-callback kill switch per slug without a rebuild. Values are +// base-10 milliseconds; invalid or out-of-range values fall back to the loaded +// Options. MemoryPages is NOT here — the linear-memory cap is fixed in the +// compiled manifest (LoadGame), so it cannot change per room; admins must +// reload the game to change it. +const ( + cfgHeartbeatMS = "host.heartbeat_ms" + cfgDeadlineMS = "host.deadline_ms" +) + +// Sane bounds for the config-driven knobs (a hostile or fat-fingered config +// value can't starve the actor or hand a runaway guest an unbounded budget). +const ( + minHeartbeat = 20 * time.Millisecond + maxHeartbeat = 1 * time.Second + minDeadline = 10 * time.Millisecond + maxDeadline = 2 * time.Second +) + +// ResidentConfigKey is the reserved admin config key granting the resident +// lifecycle to a slug (room-lifecycle-modes): a bool, set via the admin Game +// settings UI, read by the matchmaker when resolving a game's lifecycle. +const ResidentConfigKey = "host.resident" + +// HostConfigSpecs declares the reserved host-interpreted config keys as +// platform-side specs (add-config-specs), so the admin Game settings area +// renders them through the same typed machinery as game-declared keys — for +// every wasm game, replacing hand-written help text. Defaults and bounds are +// sourced from the constants above; like any config write, a change applies +// to NEW rooms only (read once at room construction). +func HostConfigSpecs() []sdk.ConfigKeySpec { + return []sdk.ConfigKeySpec{ + { + Key: cfgHeartbeatMS, + Title: "Host heartbeat", + Description: fmt.Sprintf( + "Wake cadence in milliseconds — the host tick budget. Clamped to %d–%d ms; applies to new rooms.", + minHeartbeat.Milliseconds(), maxHeartbeat.Milliseconds()), + Type: sdk.ConfigNumber, + Default: strconv.FormatInt(Heartbeat.Milliseconds(), 10), + }, + { + Key: ResidentConfigKey, + Title: "Resident world", + Description: "Grant the resident lifecycle: one always-on world for this game " + + "(boot-restored, ticks while empty, never in the Resume menu). Takes effect " + + "only for games DECLARING the resident lifecycle in their meta; granting is " + + "an operator decision — applies within ~15s to new joins.", + Type: sdk.ConfigBool, + Default: "false", + }, + { + Key: cfgDeadlineMS, + Title: "Callback deadline", + Description: fmt.Sprintf( + "Per-callback wall-clock kill switch in milliseconds. Clamped to %d–%d ms; applies to new rooms.", + minDeadline.Milliseconds(), maxDeadline.Milliseconds()), + Type: sdk.ConfigNumber, + Default: strconv.FormatInt(DefaultCallbackDeadline.Milliseconds(), 10), + }, + } +} + +func (g *wasmGame) NewRoom(cfg sdk.RoomConfig, svc sdk.Services) sdk.Handler { + h := &wasmHandler{ + game: g, + cfg: cfg, + svc: svc, + heartbeat: g.opts.Heartbeat, + deadline: g.opts.CallbackDeadline, + epochMode: g.meta.CtxFeatures&wire.CtxFeatRosterEpoch != 0, + } + h.forceFullRoster = h.epochMode + // Heartbeat precedence: admin host.heartbeat_ms config > the game's + // declared HeartbeatMS > the loaded default — declaration applied first + // so the admin override below still wins. Always clamped to the envelope. + if g.meta.HeartbeatMS > 0 { + h.heartbeat = clampDur(time.Duration(g.meta.HeartbeatMS)*time.Millisecond, minHeartbeat, maxHeartbeat) + } + // Per-room config overrides (admin-tunable; applies to NEW rooms only, since + // it is read once here at room construction). The ConfigStore is slug-bound, + // so a game can only see its own host.* keys; it may be nil (no config). + if svc.Config != nil { + if d, ok := readConfigDuration(svc.Config, cfgHeartbeatMS); ok { + h.heartbeat = clampDur(d, minHeartbeat, maxHeartbeat) + } + if d, ok := readConfigDuration(svc.Config, cfgDeadlineMS); ok { + h.deadline = clampDur(d, minDeadline, maxDeadline) + } + } + return h +} + +// readConfigDuration reads a reserved host key as base-10 milliseconds. A +// missing, malformed, or non-positive value reads as "no override" so the +// loaded Options stand. +func readConfigDuration(store sdk.ConfigStore, key string) (time.Duration, bool) { + ctx, cancel := context.WithTimeout(context.Background(), kvTimeout) + defer cancel() + v, ok, err := store.Get(ctx, key) + if err != nil || !ok { + return 0, false + } + ms, err := strconv.ParseInt(strings.TrimSpace(string(v)), 10, 64) + if err != nil || ms <= 0 { + return 0, false + } + return time.Duration(ms) * time.Millisecond, true +} + +func clampDur(d, lo, hi time.Duration) time.Duration { + if d < lo { + return lo + } + if d > hi { + return hi + } + return d +} + +// wasmHandler implements sdk.Handler for ONE room by delegating to one plugin +// instance. All callbacks run on the room's actor goroutine, so cur/roster +// need no locking; host functions are honored only while cur != nil. +type wasmHandler struct { + sdk.Base + game *wasmGame + cfg sdk.RoomConfig + svc sdk.Services + + // Per-room runtime knobs: seeded from the loaded Options, then overridden by + // the room's host.* config keys in NewRoom. The memory cap stays load-time + // (manifest-fixed), so it is not mirrored here. + heartbeat time.Duration + deadline time.Duration + + inst *extism.Plugin + + // Virtualized-entropy bookkeeping (snapshot determinism): the seed the room's + // rand source was created from, and a counter wrapping that source so a + // restore can replay the stream to the same position. + seed int64 + entropy *countingReader + + // inputCtx mirrors the latest SetInputContext the guest published, so a + // snapshot can carry the room's current input context. + inputCtx sdk.InputContext + + // postSeq counts the leaderboard posts this room has issued (1-based; the + // value stamped on a Result is the post-increment counter). Persisted in + // the snapshot blob (format 4) so a room reclaimed from a pre-settle + // checkpoint re-issues the SAME sequence when it re-settles the same round + // — the durable leaderboard derives the round id deterministically from + // (roomID, postSeq), making post-restore re-settles (and write retries) + // idempotent. Actor-goroutine owned. + postSeq uint32 + + // Per-consumer frame-delta baseline+epoch authority (D4/D7/D9). Host memory + // (not guest linear memory, not snapshotted); actor-goroutine owned like + // cur/roster, so no locking. ~0.78 MB/room at 24-byte cells. + baselines baselineCache + + // Roster-epoch mode (the guest's meta declared wire.CtxFeatRosterEpoch): + // rosterEpoch bumps on every roster mutation; lastFullEpoch is the epoch + // most recently sent in the 0xFFFE full form to THIS instance (0 = never + // — both zero on construction AND on restore, forcing the first callback + // of any instantiation to carry the full roster). Ephemeral host memory, + // never snapshotted. + epochMode bool + rosterEpoch uint32 + lastFullEpoch uint32 + // forceFullRoster forces the next callback's member section to the full + // form regardless of epoch equality — set on construction AND on restore + // (where rosterFP is deliberately seeded from the snapshot roster, so the + // fingerprint bump does not fire for a same-roster resume). + forceFullRoster bool + + // Callback time accounting (actor-goroutine owned, no locking): total + // wall time inside guest calls, and the portion spent in the send/ + // identical HOST functions the guest invokes mid-callback (delta apply + + // frame decode + fan-out). guest-pure time = total - host. Cumulative; + // read via CallbackSplit for benchmarks/diagnostics. + cbTotalNanos int64 + cbHostNanos int64 + + // hostIOExpired marks that the CURRENT callback's kill-switch context + // expired while the guest was blocked in the host's own kv/config store + // call (set by the kv host functions, reset at the top of invoke). Together + // with the per-callback host-time split it discriminates "the guest spun" + // from "the host's I/O was slow" in the trap path, so a Postgres brownout + // is never booked as a game fault feeding quarantine. Actor-goroutine owned. + hostIOExpired bool + + // Guest log budget bookkeeping (actor-goroutine owned): bytes emitted in + // the current real-time window across stdout/stderr and the log host + // function, plus whether the one-per-window rate-limited marker fired. + logWindowStart time.Time + logBytes int + logLimited bool + + // rosterFP fingerprints the last callback's roster so a roster mutation + // (join/leave/index shift) can invalidate every baseline slot (D7/4.6). + rosterFP uint64 + + // memSampled is the guest linear-memory size (bytes) last folded into the + // per-game memory gauge — sampleMemory reports deltas against it on each + // heartbeat and closeInstance retires it. Actor-goroutine owned. + memSampled uint32 + + // last callback outcome (for out-of-package instrumentation, e.g. the + // conformance harness): the wasm exit code and any trap/deadline error. + lastExit uint32 + lastErr error + + // callback-scoped (actor goroutine only) + cur sdk.Room + roster []sdk.Player // the last callback's roster; survives between calls for Snapshot + dead bool + ended bool // guest called end() — the room is settling + closePending bool // OnClose arrived while a guest call was on the stack + nowNanos int64 // room clock for the virtualized WASI surface (actor-owned) +} + +func (h *wasmHandler) OnStart(r sdk.Room) { + h.nowNanos = r.Now().UnixNano() + h.seed = h.cfg.Seed + if !h.cfg.SeedSet { + h.seed = h.nowNanos + } + + inst, err := h.game.compiled.Instance(context.Background(), + extism.PluginInstanceConfig{ModuleConfig: h.moduleConfig(h.seed)}) + if err != nil { + r.Log().Error("gameabi: instantiate room", "err", err) + h.fault() + r.End(sdk.Result{Mode: h.cfg.Mode}) + h.dead = true + return + } + h.inst = inst + r.SetSimRate(h.heartbeat) // engine OnTick drives the guest heartbeat (per-room) + h.call(r, wire.ExpStart, nil) + h.sampleMemory() // first gauge sample at birth; the heartbeat keeps it fresh +} + +// moduleConfig builds the virtualized WASI surface (D10): the guest's built-in +// clock reads the room clock (== CallContext time), entropy is room-seeded, +// sleep cannot block the actor, and stdio lands in the room log. The system +// clock, system entropy, filesystem, and network are unreachable. +// +// The entropy source is wrapped in a counting reader (snapshot determinism): a +// fresh room starts the seeded stream at byte 0; a restore re-seeds the stream +// to the snapshot's consumed position via resumeEntropy AFTER the runtime is +// primed (so the runtime-init draw is not double-counted). +func (h *wasmHandler) moduleConfig(seed int64) wazero.ModuleConfig { + logw := &logWriter{h: h} + h.entropy = &countingReader{r: rand.New(rand.NewSource(seed))} + return wazero.NewModuleConfig(). + WithWalltime(func() (int64, int32) { + ns := h.nowNanos + return ns / 1e9, int32(ns % 1e9) + }, wzsys.ClockResolution(1)). + WithNanotime(func() int64 { return h.nowNanos }, wzsys.ClockResolution(1)). + WithNanosleep(func(int64) {}). // sleep is a no-op: use wake + CallContext time + WithRandSource(h.entropy). + WithStdout(logw). + WithStderr(logw) +} + +// resumeEntropy re-points the entropy reader at the seeded stream's `consumed` +// position, discarding whatever the runtime-init prime drew. After this the +// guest draws exactly the bytes it would have next drawn at snapshot time. +func (h *wasmHandler) resumeEntropy(seed, consumed int64) { + src := rand.New(rand.NewSource(seed)) + if consumed > 0 { + _, _ = io.CopyN(io.Discard, src, consumed) + } + h.entropy.r = src + h.entropy.n = consumed +} + +// logWriter pipes guest stdout/stderr into the room log. Guest code only runs +// during callbacks, so h.cur is set whenever a write can occur; instantiation +// output (before the first callback) falls back to the services logger. +// stdout/stderr is author-debug data, not operator data, so it lands at Debug — +// truncated per write and metered by the per-room guest log budget. +type logWriter struct{ h *wasmHandler } + +func (w *logWriter) Write(p []byte) (int, error) { + msg := truncateGuestLog(string(p)) + if w.h.guestLogAllow(len(msg)) { + if log := w.h.guestLogger(); log != nil { + log.Debug("guest", "out", msg) + } + } + return len(p), nil +} + +// guestLogger returns the room log mid-callback, else the services logger +// (instantiation output lands before the first callback). May be nil. +func (h *wasmHandler) guestLogger() *slog.Logger { + if h.cur != nil { + return h.cur.Log() + } + return h.svc.Log +} + +// truncateGuestLog caps one guest write/log call at guestLogMaxWrite bytes. +func truncateGuestLog(s string) string { + if len(s) <= guestLogMaxWrite { + return s + } + return s[:guestLogMaxWrite] + "…[truncated]" +} + +// guestLogAllow charges n bytes against the room's guest log budget, reporting +// whether the write may be emitted. The first refusal per window emits one Warn +// marker so operators still see that limiting happened. Real wall clock (not +// the virtualized room clock — the budget meters host log volume per real +// second). Actor-goroutine owned, no locking. +func (h *wasmHandler) guestLogAllow(n int) bool { + now := time.Now() + if now.Sub(h.logWindowStart) >= guestLogWindow { + h.logWindowStart, h.logBytes, h.logLimited = now, 0, false + } + h.logBytes += n + if h.logBytes <= guestLogBudget { + return true + } + if !h.logLimited { + h.logLimited = true + if log := h.guestLogger(); log != nil { + log.Warn("gameabi: guest log output rate-limited", "slug", h.game.meta.Slug) + } + } + return false +} + +func (h *wasmHandler) OnJoin(r sdk.Room, p sdk.Player) { h.callWithPlayer(r, wire.ExpJoin, p) } +func (h *wasmHandler) OnLeave(r sdk.Room, p sdk.Player) { h.callWithPlayer(r, wire.ExpLeave, p) } + +func (h *wasmHandler) OnInput(r sdk.Room, p sdk.Player, in sdk.Input) { + idx, roster := rosterWith(r.Members(), p) + var w wire.Buf + w.U32(uint32(idx)) + if in.Kind == sdk.InputRune { + w.U8(wire.InputRune) + } else { + w.U8(wire.InputKey) + } + w.U32(uint32(in.Rune)) + w.U8(uint8(in.Key)) + // Bytes in (add-metrics): the normalized input payload the host delivers — + // host-measured, the module never reports its own numbers. + if h.game.opts.Metrics != nil { + h.game.opts.Metrics.GameInputBytesIn(h.game.meta.Slug, len(w.B)) + } + h.invoke(r, wire.ExpInput, roster, w.B) +} + +func (h *wasmHandler) OnTick(r sdk.Room, now time.Time) { + // Sample memory each heartbeat, AFTER the wake so growth the beat itself + // caused is captured — and on the empty-room early return too: an idle + // room (no members, not yet frozen) still pins its grown linear memory + // against GOMEMLIMIT, and the gauge must keep saying so. + defer h.sampleMemory() + if len(r.Members()) == 0 && h.cfg.Lifecycle != sdk.LifecycleResident { + return // no heartbeat for an empty room (resident worlds keep ticking) + } + h.call(r, wire.ExpWake, nil) +} + +// sampleMemory folds the room's current guest linear-memory size into the +// per-game memory gauge, as a delta against the last sample, so the exported +// series is the SUM across the game's live rooms (no per-room series — the +// metrics package's low-cardinality rule). Host-measured from the wazero +// instance; heartbeat-sampled on the actor goroutine. +func (h *wasmHandler) sampleMemory() { + m := h.game.opts.Metrics + if m == nil || h.inst == nil { + return + } + mem := guestMemory(h.inst) + if mem == nil { + return + } + if size := mem.Size(); size != h.memSampled { + m.GameLinearMemoryDelta(h.game.meta.Slug, int64(size)-int64(h.memSampled)) + h.memSampled = size + } +} + +func (h *wasmHandler) OnClose(r sdk.Room) { + if h.cur == nil { // never re-enter a guest already on the stack + h.call(r, wire.ExpClose, nil) + } + h.closeInstance() +} + +// closeInstance releases the plugin instance, deferring to the end of the +// in-flight guest call if one is on the stack (a synchronous room driver can +// settle the room from inside the end host function). +func (h *wasmHandler) closeInstance() { + if h.cur != nil { + h.closePending = true + return + } + if h.inst != nil { + _ = h.inst.Close(context.Background()) + h.inst = nil + } + // Retire this room's contribution to the per-game memory gauge — the + // closed instance's linear memory is released back to the Go heap. + if h.memSampled != 0 { + if m := h.game.opts.Metrics; m != nil { + m.GameLinearMemoryDelta(h.game.meta.Slug, -int64(h.memSampled)) + } + h.memSampled = 0 + } +} + +// callWithPlayer encodes join/leave: the player rides as a roster index; for +// leave (already removed from Members) the departed player is appended as the +// final roster entry so kv writes for the leaver still resolve. +func (h *wasmHandler) callWithPlayer(r sdk.Room, name string, p sdk.Player) { + idx, roster := rosterWith(r.Members(), p) + var w wire.Buf + w.U32(uint32(idx)) + h.invoke(r, name, roster, w.B) +} + +func (h *wasmHandler) call(r sdk.Room, name string, extra []byte) { + h.invoke(r, name, r.Members(), extra) +} + +// invoke runs one guest callback: CallContext || extra as the input payload, +// with the handler reachable from host functions via the call context. +func (h *wasmHandler) invoke(r sdk.Room, name string, roster []sdk.Player, extra []byte) { + if h.inst == nil || h.dead || h.cur != nil { + return // dead instance, or a guest call is already on the stack + } + _, settled := r.Result() + h.nowNanos = r.Now().UnixNano() // keep the virtualized clock == CallContext time + + // Roster mutation (join/leave/index shift/mid-join) renumbers slots, so any + // surviving guest baseline would diff against a stale host baseline. Detect a + // roster delta BEFORE encoding (the roster epoch must bump first), and + // invalidate EVERY baseline slot so the next send to each slot is + // epoch-rejected into a keyframe (D7/4.6). This is the host-authority + // backstop layered under the per-send epoch check. + if fp := rosterFingerprint(roster); fp != h.rosterFP { + h.rosterFP = fp + h.baselines.invalidateAll() + h.rosterEpoch++ + } + + var w *wire.Buf + features := h.game.meta.CtxFeatures + if h.epochMode { + // Roster-epoch mode: full roster only when this instance hasn't seen + // the current epoch (mutation, first callback, post-restore); a + // 6-byte unchanged marker otherwise — the member list isn't even + // built on that path (the O(members)-per-callback win). + full := h.forceFullRoster || h.rosterEpoch != h.lastFullEpoch + w = encodeCtxEpoch(h.nowNanos, h.cfg, roster, settled, h.rosterEpoch, full, features) + if full { + h.lastFullEpoch = h.rosterEpoch + h.forceFullRoster = false + } + } else { + w = encodeCtx(h.nowNanos, h.cfg, roster, settled, features) + } + payload := append(w.B, extra...) + + h.cur, h.roster = r, roster + defer func() { + // h.cur is callback-scoped (the Room handle is invalid after the call), + // but h.roster is left set as the LAST-SEEN roster so a quiescent + // Snapshot can record the room's membership between callbacks. + h.cur = nil + if h.closePending { + h.closePending = false + h.closeInstance() + } + }() + + ctx := context.WithValue(context.Background(), handlerKey{}, h) + ctx, cancel := context.WithTimeout(ctx, h.deadline) // kill switch, not a budget (per-room) + defer cancel() + hostStart := h.cbHostNanos // per-callback host-time delta baseline + h.hostIOExpired = false + cbStart := time.Now() + exit, _, err := h.inst.CallWithContext(ctx, name, payload) + dur := time.Since(cbStart) + h.cbTotalNanos += dur.Nanoseconds() + hostDelta := h.cbHostNanos - hostStart + h.lastExit, h.lastErr = exit, err + // The kill switch firing is the deadline signal: the callback errored AND + // the per-callback timeout (not the parent ctx) elapsed. Discriminate WHOSE + // time was burned: a deadline that expired while the guest was blocked in + // the host's own store/config call, with host time dominating the callback + // wall clock, is a host-I/O incident (slow shared Postgres) — the room must + // still end (wazero condemned the instance at the deadline) but it is NOT a + // game fault, so DB slowness never feeds quarantine. The guest cannot game + // this into quarantine evasion: a spin-then-kv guest accrues its time as + // guest-pure, failing the dominance check. + deadlined := err != nil && ctx.Err() == context.DeadlineExceeded + hostIOKill := deadlined && h.hostIOExpired && 2*hostDelta >= dur.Nanoseconds() + if m := h.game.opts.Metrics; m != nil { + m.GameCallback(h.game.meta.Slug, name, dur.Seconds()) + if hostIOKill { + m.GameHostIODeadline(h.game.meta.Slug, name) + } else if deadlined { + m.GameCallbackDeadline(h.game.meta.Slug, name) + } + } + if err != nil || exit != 0 { + h.dead = true + if hostIOKill { + r.Log().Error("gameabi: host call outlived the callback deadline — settling room without fault", + "callback", name, "hostNanos", hostDelta, "callbackNanos", dur.Nanoseconds(), "err", err) + r.End(sdk.Result{Mode: h.cfg.Mode}) + return + } + r.Log().Error("gameabi: guest trap — settling room", "callback", name, "exit", exit, "err", err) + h.fault() + r.End(sdk.Result{Mode: h.cfg.Mode}) + return + } + h.publishPhase(r, settled) +} + +// fault reports a guest fault to the load-time hook (quarantine accounting) +// and the fault counter (add-metrics). +func (h *wasmHandler) fault() { + if h.game.opts.Metrics != nil { + h.game.opts.Metrics.GameFault(h.game.meta.Slug) + } + if h.game.opts.OnFault != nil { + h.game.opts.OnFault(h.game.meta.Slug) + } +} + +// publishPhase derives the lobby-visible phase for a wasm room — the ABI has +// no phase surface, so the host owns joinability: open ⇔ unsettled ∧ below +// capacity. Published after every callback so join/leave/end are reflected +// immediately; the engine's terminal "settled" phase is never overwritten. +func (h *wasmHandler) publishPhase(r sdk.Room, settled bool) { + if settled || h.ended { + return + } + open := h.cfg.Capacity == 0 || len(r.Members()) < h.cfg.Capacity + r.SetPhase("in play", open, time.Time{}) +} + +// rosterWith returns members plus p (appended if absent) and p's index. +func rosterWith(members []sdk.Player, p sdk.Player) (int, []sdk.Player) { + for i, m := range members { + if m == p { + return i, members + } + } + return len(members), append(append([]sdk.Player{}, members...), p) +} + +// ---- host functions --------------------------------------------------------- + +func currentHandler(ctx context.Context) *wasmHandler { + h, _ := ctx.Value(handlerKey{}).(*wasmHandler) + return h +} + +// hf builds a stack-based host function in the shellcade namespace. +func hf(name string, params, returns []extism.ValueType, fn extism.HostFunctionStackCallback) extism.HostFunction { + f := extism.NewHostFunctionWithStack(name, fn, params, returns) + f.SetNamespace(wire.HostNamespace) + return f +} + +var ( + ptr = extism.ValueTypePTR + i64 = extism.ValueTypeI64 +) + +func hostFunctions() []extism.HostFunction { + return []extism.HostFunction{ + // send(i64 playerIdx, ptr deltaContainer) -> i64 epoch. The payload is the + // v2 delta container (wire §4.5), applied to the per-index baseline under + // the epoch authority (D4/D5). The return value's low 32 bits carry the + // epoch the guest must stamp; the upper 32 bits are reserved-zero. + hf("send", []extism.ValueType{i64, ptr}, []extism.ValueType{i64}, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + if h == nil || h.cur == nil { + stack[0] = 0 + return + } + hfStart := time.Now() + defer func() { h.cbHostNanos += time.Since(hfStart).Nanoseconds() }() + idx := int(stack[0]) + b, err := p.ReadBytes(stack[1]) + if err != nil || idx < 0 || idx >= rosterCap || idx >= len(h.roster) { + // Out-of-range index: there is no slot to advance; return its + // current epoch (0 if never issued) without mutating the cache. + stack[0] = 0 + return + } + res := h.baselines.apply(idx, b, func(reason string) { + h.cur.Log().Warn("gameabi: dropped malformed delta", "err", reason) + }) + if res.applied { + if g, derr := decodeFrame(h.baselines.prev[idx]); derr == nil { + h.cur.Send(h.roster[idx], g) + // Bytes out (add-metrics): wire bytes the host accepted, once + // per frame produced. Under ABI v2 this is the delta container, + // so the metric measures real guest wire output (tens of bytes + // steady-state), not a constant full frame. + if h.game.opts.Metrics != nil { + h.game.opts.Metrics.GameFrameBytesOut(h.game.meta.Slug, len(b)) + } + } else { + h.cur.Log().Warn("gameabi: dropped undecodable reconstructed frame", "err", derr) + } + } + stack[0] = uint64(res.epoch) + }), + // identical(ptr deltaContainer) -> i64 epoch. Diffed against the broadcast + // slot; on apply the reconstructed grid is copied into EVERY per-index + // baseline and every slot's epoch is set to the broadcast epoch (D7). + hf("identical", []extism.ValueType{ptr}, []extism.ValueType{i64}, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + if h == nil || h.cur == nil { + stack[0] = 0 + return + } + hfStart := time.Now() + defer func() { h.cbHostNanos += time.Since(hfStart).Nanoseconds() }() + b, err := p.ReadBytes(stack[0]) + if err != nil { + stack[0] = uint64(h.baselines.bump(broadcastSlot)) + return + } + res := h.baselines.apply(broadcastSlot, b, func(reason string) { + h.cur.Log().Warn("gameabi: dropped malformed delta", "err", reason) + }) + if res.applied { + if g, derr := decodeFrame(h.baselines.prev[broadcastSlot]); derr == nil { + h.cur.Identical(g) + // Reconcile every per-index baseline so a later per-player + // Send diffs against the broadcast baseline (D7). + h.baselines.reconcileBroadcast(res.epoch) + // Bytes out (add-metrics): counted ONCE per frame produced here + // too — fan-out to the roster is delivery, not production. Under + // ABI v2 this is the delta container's wire bytes. + if h.game.opts.Metrics != nil { + h.game.opts.Metrics.GameFrameBytesOut(h.game.meta.Slug, len(b)) + } + } else { + h.cur.Log().Warn("gameabi: dropped undecodable reconstructed frame", "err", derr) + } + } + stack[0] = uint64(res.epoch) + }), + hf("set_input_context", []extism.ValueType{i64}, nil, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + if h == nil || h.cur == nil { + return + } + h.inputCtx = sdk.InputContext(stack[0]) // mirrored for snapshots + h.cur.SetInputContext(h.inputCtx) + }), + hf("end", []extism.ValueType{ptr}, nil, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + if h == nil || h.cur == nil { + return + } + b, _ := p.ReadBytes(stack[0]) + res, err := decodeResult(b, h.roster, h.cfg.Mode) + if err != nil { + h.cur.Log().Warn("gameabi: bad end payload", "err", err) + res = sdk.Result{Mode: h.cfg.Mode} + } + h.ended = true + h.cur.End(res) + }), + hf("post", []extism.ValueType{ptr}, nil, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + if h == nil || h.cur == nil || h.svc.Leaderboard == nil { + return + } + b, _ := p.ReadBytes(stack[0]) + res, err := decodeResult(b, h.roster, h.cfg.Mode) + if err != nil { + return + } + // Stamp the room-scoped post sequence (mirrored for snapshots): + // the durable leaderboard derives an idempotent round id from + // (roomID, RoundSeq), so a re-settle after a pre-settle reclaim + // — which replays this exact counter value — dedupes instead of + // double-counting (and a retried write inserts nothing twice). + h.postSeq++ + res.RoundSeq = uint64(h.postSeq) + h.svc.Leaderboard.Post(h.game.meta.Slug, res) + }), + hf("log", []extism.ValueType{i64, ptr}, nil, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + if h == nil || h.cur == nil { + return + } + msg, _ := p.ReadString(stack[1]) + msg = truncateGuestLog(msg) + if !h.guestLogAllow(len(msg)) { + return + } + switch stack[0] { + case 0: + h.cur.Log().Debug(msg) + case 2: + h.cur.Log().Warn(msg) + case 3: + h.cur.Log().Error(msg) + default: + h.cur.Log().Info(msg) + } + }), + // The kv/config host functions all follow one shape: the store context is + // DERIVED from the callback ctx (capped at kvTimeout) so a slow Postgres + // cancels with the kill switch instead of stalling the room actor; the + // blocked time is accounted as HOST time (cbHostNanos, like send/identical) + // and a kill-switch expiry during the store call is flagged so the trap + // path books it as a host-I/O incident, not a game fault; and store errors + // are logged with slug/account/key (+ a metrics counter) instead of + // vanishing — the guest still sees the ABI's silent absent/dropped result. + hf("kv_get", []extism.ValueType{i64, ptr}, []extism.ValueType{ptr}, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + idx := int(stack[0]) + store := h.kvStore(idx) + key, _ := p.ReadString(stack[1]) + stack[0] = 0 + if store == nil || key == "" { + return + } + hfStart := time.Now() + defer func() { h.cbHostNanos += time.Since(hfStart).Nanoseconds() }() + cctx, cancel := context.WithTimeout(ctx, kvTimeout) + defer cancel() + v, ok, err := store.Get(cctx, key) + if ctx.Err() != nil { + h.hostIOExpired = true + } + if err != nil { + h.kvFailed("kv_get", idx, key, err) + return + } + if !ok { + return + } + off, werr := p.WriteBytes(v) + if werr == nil { + stack[0] = off + } + }), + hf("kv_set", []extism.ValueType{i64, ptr, ptr, ptr}, nil, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + idx := int(stack[0]) + store := h.kvStore(idx) + key, _ := p.ReadString(stack[1]) + val, _ := p.ReadBytes(stack[2]) + rule, _ := p.ReadString(stack[3]) + if store == nil || key == "" { + return + } + hfStart := time.Now() + defer func() { h.cbHostNanos += time.Since(hfStart).Nanoseconds() }() + cctx, cancel := context.WithTimeout(ctx, kvTimeout) + defer cancel() + err := store.Set(cctx, key, val, sdk.MergeRule(rule)) + if ctx.Err() != nil { + h.hostIOExpired = true + } + if err != nil { + h.kvFailed("kv_set", idx, key, err) + } + }), + hf("kv_delete", []extism.ValueType{i64, ptr}, nil, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + idx := int(stack[0]) + store := h.kvStore(idx) + key, _ := p.ReadString(stack[1]) + if store == nil || key == "" { + return + } + hfStart := time.Now() + defer func() { h.cbHostNanos += time.Since(hfStart).Nanoseconds() }() + cctx, cancel := context.WithTimeout(ctx, kvTimeout) + defer cancel() + err := store.Delete(cctx, key) + if ctx.Err() != nil { + h.hostIOExpired = true + } + if err != nil { + h.kvFailed("kv_delete", idx, key, err) + } + }), + hf("config_get", []extism.ValueType{ptr}, []extism.ValueType{ptr}, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + key, _ := p.ReadString(stack[0]) + stack[0] = 0 + if h == nil || h.cur == nil || h.svc.Config == nil || key == "" { + return + } + hfStart := time.Now() + defer func() { h.cbHostNanos += time.Since(hfStart).Nanoseconds() }() + cctx, cancel := context.WithTimeout(ctx, kvTimeout) + defer cancel() + v, ok, err := h.svc.Config.Get(cctx, key) + if ctx.Err() != nil { + h.hostIOExpired = true + } + if err != nil { + h.kvFailed("config_get", -1, key, err) + return + } + if !ok { + return + } + off, werr := p.WriteBytes(v) + if werr == nil { + stack[0] = off + } + }), + } +} + +// kvFailed surfaces a kv/config host-call store error: logged with +// slug/account/key plus a metrics counter, so a dropped write or a conflated +// "absent" read is never silent host-side. idx < 0 (config_get) carries no +// account. Called only mid-callback (the host functions verified h.cur). +func (h *wasmHandler) kvFailed(op string, idx int, key string, err error) { + if m := h.game.opts.Metrics; m != nil { + m.GameKVError(h.game.meta.Slug, op) + } + acct := "" + if idx >= 0 && idx < len(h.roster) { + acct = h.roster[idx].AccountID + } + h.cur.Log().Error("gameabi: "+op+" failed", + "slug", h.game.meta.Slug, "account", acct, "key", key, "err", err) +} + +// kvStore resolves the per-user KV for a roster index (host-side scoping: the +// guest names only the index; account + game namespace are derived here). +func (h *wasmHandler) kvStore(idx int) sdk.KVStore { + if h == nil || h.cur == nil || h.svc.Accounts == nil { + return nil + } + if idx < 0 || idx >= len(h.roster) { + return nil + } + acct := h.svc.Accounts.For(h.roster[idx]) + if acct == nil { + return nil + } + return acct.Store() +} + +// countingReader wraps the seeded entropy source and tallies the bytes the guest +// has drawn, so Snapshot can record the stream position and Restore can replay +// the deterministic source to exactly that point. Access is serialized to the +// room actor goroutine (entropy is drawn only inside a guest callback, and a +// snapshot is taken at a quiescent point on the same goroutine), so the counter +// needs no lock. +type countingReader struct { + r io.Reader + n int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.n += int64(n) + return n, err +} + +// consumed reports how many entropy bytes the guest has drawn so far (0 if the +// source was never wired — e.g. before OnStart). +func (h *wasmHandler) consumed() int64 { + if h.entropy == nil { + return 0 + } + return h.entropy.n +} diff --git a/host/gameabi/host_test.go b/host/gameabi/host_test.go new file mode 100644 index 0000000..f21fc7d --- /dev/null +++ b/host/gameabi/host_test.go @@ -0,0 +1,291 @@ +package gameabi + +import ( + "io" + "log/slog" + "os" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// The committed fixture guest (testdata/fixture/): renders a status frame and +// misbehaves on command — 'p' panic, 'l' spin, 'o' allocate, 'e' end. +const fixturePath = "testdata/fixture/fixture.wasm" + +var ( + p1 = sdk.Player{AccountID: "a1", Handle: "ada", Kind: sdk.KindMember, Conn: "c1"} + p2 = sdk.Player{AccountID: "a2", Handle: "bob", Kind: sdk.KindMember, Conn: "c2"} +) + +func loadFixture(t *testing.T, opts Options) sdk.Game { + t.Helper() + g, err := LoadGame(fixturePath, opts) + if err != nil { + t.Fatalf("LoadGame(%s): %v", fixturePath, err) + } + return g +} + +func runeIn(r rune) sdk.Input { return sdk.Input{Kind: sdk.InputRune, Rune: r} } + +func quietLog() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func TestFixtureHandshake(t *testing.T) { + g := loadFixture(t, Options{}) + m := g.Meta() + if m.Slug != "fixture" || m.MaxPlayers != 2 { + t.Fatalf("meta = %+v, want slug=fixture max=2", m) + } +} + +// TestLoadGameBytes proves the in-memory loader (the catalog loads a verified +// blob from the object store, never a path) yields an equivalent game to the +// file-backed LoadGame. +func TestLoadGameBytes(t *testing.T) { + b, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + g, err := LoadGameBytes(b, Options{}) + if err != nil { + t.Fatalf("LoadGameBytes: %v", err) + } + if g.Meta().Slug != "fixture" { + t.Fatalf("LoadGameBytes meta slug = %q, want fixture", g.Meta().Slug) + } + // The host-composed namespaced slug applies to a bytes-loaded game too. + if !OverrideSlug(g, "alice/fixture") || g.Meta().Slug != "alice/fixture" { + t.Fatalf("OverrideSlug on a bytes-loaded game failed: %q", g.Meta().Slug) + } +} + +// TestDerivedJoinability proves the host-owned phase: open ⇔ unsettled ∧ +// below capacity, published after every callback (the matchmaker's Quick and +// PrivateJoin read exactly this). +func TestDerivedJoinability(t *testing.T) { + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeQuick, Capacity: 2, MinPlayers: 1, Seed: 7, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, sdk.Services{Log: quietLog()}) + + assertOpen := func(step string, want bool) { + t.Helper() + ph, ok := tr.LastPhase() + if !ok { + t.Fatalf("%s: no phase published", step) + } + if ph.Open != want || ph.Name != "in play" { + t.Fatalf("%s: phase = %+v, want open=%v name=%q", step, ph, want, "in play") + } + } + + tr.Start() + assertOpen("start (0/2)", true) + tr.Join(p1) + assertOpen("join p1 (1/2)", true) + tr.Join(p2) + assertOpen("join p2 (2/2 full)", false) + tr.Leave(p2) + assertOpen("leave p2 (1/2)", true) + + // Guest ends the room: no phase is published after end (the engine's + // settled phase must win), and the result carries the guest's ranking. + before := len(tr.Phases) + tr.Input(p1, runeIn('e')) + if !tr.Ended { + t.Fatal("input 'e': room did not end") + } + if len(tr.Phases) != before { + t.Fatalf("phase published after guest end: %+v", tr.Phases[before:]) + } + res, ok := tr.Result() + if !ok { + t.Fatal("no result after guest end") + } + found := false + for _, pr := range res.Rankings { + if pr.Player == p1 { + found = pr.Metric == 42 && pr.Rank == 1 && pr.Status == sdk.StatusFinished + } + } + if !found { + t.Fatalf("result = %+v, want p1 finished metric 42 rank 1", res) + } +} + +// TestDerivedJoinabilityUnlimited: capacity 0 means no cap — always open while +// unsettled. +func TestDerivedJoinabilityUnlimited(t *testing.T) { + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModePrivate, Capacity: 0, MinPlayers: 1, Seed: 7, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, sdk.Services{Log: quietLog()}) + tr.Start() + tr.Join(p1) + tr.Join(p2) + ph, ok := tr.LastPhase() + if !ok || !ph.Open { + t.Fatalf("phase = %+v ok=%v, want open with no capacity", ph, ok) + } +} + +// TestRendersFrames: the fixture broadcasts a frame on join. +func TestRendersFrames(t *testing.T) { + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, sdk.Services{Log: quietLog()}) + tr.Start() + tr.Join(p1) + f, ok := tr.LastFrame(p1) + if !ok { + t.Fatal("no frame after join") + } + if got := f.Cells[0][0].Rune; got != 'F' { + t.Fatalf("frame cell(0,0) = %q, want 'F' (FIXTURE banner)", got) + } +} + +// ---- live-runtime tests (the real actor goroutine the matchmaker drives) ---- + +func newLiveRoom(t *testing.T, g sdk.Game, cfg sdk.RoomConfig) sdk.RoomCtl { + t.Helper() + svc := sdk.Services{Log: quietLog()} + ctl := sdk.NewRoomRuntime("test-"+t.Name(), g.NewRoom(cfg, svc), cfg, svc) + t.Cleanup(func() { _ = ctl.Close() }) + return ctl +} + +func waitFor(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +// TestLiveJoinability is the quick-match regression: the matchmaker prunes +// rooms whose Snapshot is not Open, so a live wasm room must publish open +// while unsettled and below capacity, closed when full. +func TestLiveJoinability(t *testing.T) { + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeQuick, Capacity: 2, MinPlayers: 1, Seed: 7, SeedSet: true} + ctl := newLiveRoom(t, g, cfg) + + if err := ctl.Join(p1); err != nil { + t.Fatalf("join p1: %v", err) + } + waitFor(t, "open after first join", func() bool { return ctl.Snapshot().Open }) + + if err := ctl.Join(p2); err != nil { + t.Fatalf("join p2: %v", err) + } + waitFor(t, "closed when full", func() bool { + ph := ctl.Snapshot() + return !ph.Open && !ph.Settled + }) + + ctl.Leave(p2) + waitFor(t, "reopen after leave", func() bool { return ctl.Snapshot().Open }) +} + +// TestContainment proves a faulting guest settles ONLY its own room: a panic +// ('p'), a spin past the callback deadline ('l'), and allocation past the +// memory cap ('o') each kill room A while room B — same compiled game — keeps +// answering. +func TestContainment(t *testing.T) { + cases := []struct { + name string + cmd rune + opts Options + }{ + {"trap", 'p', Options{}}, + {"deadline", 'l', Options{CallbackDeadline: 50 * time.Millisecond}}, + {"oom", 'o', Options{MemoryPages: 80, CallbackDeadline: 5 * time.Second}}, // 5 MiB cap + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + g := loadFixture(t, tc.opts) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + ctlA := newLiveRoom(t, g, cfg) + ctlB := newLiveRoom(t, g, cfg) + if err := ctlA.Join(p1); err != nil { + t.Fatalf("join A: %v", err) + } + if err := ctlB.Join(p2); err != nil { + t.Fatalf("join B: %v", err) + } + framesB := ctlB.Frames(p2) + + ctlA.Input(p1, runeIn(tc.cmd)) + select { + case <-ctlA.Done(): + case <-time.After(10 * time.Second): + t.Fatal("room A did not settle after fault") + } + if ph := ctlA.Snapshot(); !ph.Settled { + t.Fatalf("room A phase = %+v, want settled", ph) + } + + // Room B still answers: an input produces a fresh frame. + drain(framesB) + ctlB.Input(p2, runeIn('x')) + select { + case _, ok := <-framesB: + if !ok { + t.Fatal("room B frame stream closed — fault leaked across rooms") + } + case <-time.After(5 * time.Second): + t.Fatal("room B unresponsive after room A fault") + } + if ph := ctlB.Snapshot(); ph.Settled { + t.Fatal("room B settled — fault leaked across rooms") + } + }) + } +} + +func drain(ch <-chan sdk.Frame) { + for { + select { + case <-ch: + default: + return + } + } +} + +// TestRoomsAreIsolatedInstances: two rooms of one compiled game hold distinct +// guest memories (wake counters advance independently). +func TestRoomsAreIsolatedInstances(t *testing.T) { + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + trA := sdk.NewTestRoom(g, cfg, sdk.Services{Log: quietLog()}) + trB := sdk.NewTestRoom(g, cfg, sdk.Services{Log: quietLog()}) + trA.Start() + trB.Start() + trA.Join(p1) + trB.Join(p2) + trA.Tick() // one wake in A only + fA, _ := trA.LastFrame(p1) + fB, _ := trB.LastFrame(p2) + rowA, rowB := rowText(fA, 2), rowText(fB, 2) + if rowA != "wakes=1" || rowB != "wakes=0" { + t.Fatalf("wake rows = %q / %q, want wakes=1 / wakes=0", rowA, rowB) + } +} + +func rowText(f sdk.Frame, row int) string { + var out []rune + for col := 0; col < 80; col++ { + r := f.Cells[row][col].Rune + if r == 0 || r == ' ' { + break + } + out = append(out, r) + } + return string(out) +} diff --git a/host/gameabi/largeroom_test.go b/host/gameabi/largeroom_test.go new file mode 100644 index 0000000..132178d --- /dev/null +++ b/host/gameabi/largeroom_test.go @@ -0,0 +1,205 @@ +package gameabi + +// Conformance for the large-room-callbacks change: ctx roster-epoch mode +// (sentinel lifecycle: full on first callback / on mutation / on restore; +// unchanged otherwise) and game-declared heartbeat precedence. The loadspike +// guest declares CtxFeatRosterEpoch + HeartbeatMS=100, so it doubles as the +// feature-declaring artifact; the fixture guest declares neither (legacy). + +import ( + "context" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// cfgStub is a one-key sdk.ConfigStore for precedence tests. +type cfgStub map[string]string + +func (c cfgStub) Get(_ context.Context, key string) ([]byte, bool, error) { + v, ok := c[key] + return []byte(v), ok, nil +} + +func lsPlayer(i int) sdk.Player { + ids := []string{"a", "b", "c"} + return sdk.Player{AccountID: ids[i], Handle: "p" + ids[i], Kind: sdk.KindMember, Conn: "conn-" + ids[i]} +} + +func frameHasAt(t *testing.T, tr *sdk.TestRoom, p sdk.Player) { + t.Helper() + fr, ok := tr.LastFrame(p) + if !ok { + t.Fatalf("no frame for %s", p.Handle) + } + for r := 0; r < 22; r++ { + for c := 0; c < 80; c++ { + if fr.Cells[r][c].Rune == '@' { + return + } + } + } + t.Fatalf("%s's frame has no '@' — guest did not resolve the roster", p.Handle) +} + +// The epoch lifecycle: full form on the first callback and on every roster +// mutation; the unchanged form (no member encode) between mutations. Observed +// white-box via the handler's epoch counters plus black-box via the guest +// still rendering members correctly from its cache. +func TestEpochModeLifecycle(t *testing.T) { + // The loadspike guest generates 12 floors of dungeon in OnStart; under a + // loaded test machine that can exceed the production 100ms wall-clock + // deadline, killing the instance and silently no-oping every later + // callback (epoch stuck at 1). Raise the kill switch like the loadspike + // benchmark does — this test measures epoch semantics, not latency. + g, err := LoadGame(loadspikeConsPath, Options{CallbackDeadline: 5 * time.Second}) + if err != nil { + t.Fatalf("LoadGame: %v", err) + } + cfg := sdk.RoomConfig{Mode: sdk.ModeQuick, Capacity: 10, MinPlayers: 1, Seed: 7, SeedSet: true} + h := g.NewRoom(cfg, sdk.Services{Log: quietLog()}) + wh := h.(*wasmHandler) + if !wh.epochMode { + t.Fatal("loadspike declares CtxFeatRosterEpoch; epochMode not set") + } + tr := sdk.NewTestRoomFor(h, cfg, sdk.Services{Log: quietLog()}) + + tr.Start() + if wh.rosterEpoch == 0 || wh.lastFullEpoch != wh.rosterEpoch { + t.Fatalf("start: epoch=%d lastFull=%d — first callback must carry the full form", wh.rosterEpoch, wh.lastFullEpoch) + } + + tr.Join(lsPlayer(0)) + epochAfterJoin := wh.rosterEpoch + if epochAfterJoin == 1 || wh.lastFullEpoch != epochAfterJoin { + t.Fatalf("join: epoch=%d lastFull=%d — mutation must bump and send full", epochAfterJoin, wh.lastFullEpoch) + } + + // Steady state: ticks must NOT advance the epoch (unchanged form). + for i := 0; i < 25; i++ { + tr.Advance(50 * time.Millisecond) + tr.Tick() + } + if wh.rosterEpoch != epochAfterJoin { + t.Fatalf("steady state bumped the epoch: %d -> %d", epochAfterJoin, wh.rosterEpoch) + } + frameHasAt(t, tr, lsPlayer(0)) // guest resolved members from its cache + + // A second join: bump + full again, and BOTH players render. + tr.Join(lsPlayer(1)) + if wh.rosterEpoch == epochAfterJoin || wh.lastFullEpoch != wh.rosterEpoch { + t.Fatalf("second join: epoch=%d lastFull=%d", wh.rosterEpoch, wh.lastFullEpoch) + } + tr.Advance(50 * time.Millisecond) + tr.Tick() + frameHasAt(t, tr, lsPlayer(0)) + frameHasAt(t, tr, lsPlayer(1)) + + // Leave: the leave callback's roster still INCLUDES the departed entry + // (ABI §2), so the epoch bump lands on the NEXT callback, when the + // roster is actually smaller. + beforeLeave := wh.rosterEpoch + tr.Leave(lsPlayer(1)) + tr.Advance(50 * time.Millisecond) + tr.Tick() + if wh.rosterEpoch == beforeLeave { + t.Fatal("post-leave callback did not bump the roster epoch") + } + if wh.lastFullEpoch != wh.rosterEpoch { + t.Fatalf("post-leave full form not sent: epoch=%d lastFull=%d", wh.rosterEpoch, wh.lastFullEpoch) + } +} + +// The legacy guest (fixture: declares no features) keeps legacy encoding — +// epochMode off, counters never engaged beyond the fingerprint bump. +func TestLegacyGuestStaysLegacy(t *testing.T) { + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeQuick, Capacity: 2, MinPlayers: 1, Seed: 7, SeedSet: true} + h := g.NewRoom(cfg, sdk.Services{Log: quietLog()}) + if h.(*wasmHandler).epochMode { + t.Fatal("fixture declares no ctx features; epochMode must be off") + } + tr := sdk.NewTestRoomFor(h, cfg, sdk.Services{Log: quietLog()}) + tr.Start() + tr.Join(p1) + tr.Input(p1, runeIn('f')) // personal frame — proves decode still works + if _, ok := tr.LastFrame(p1); !ok { + t.Fatal("legacy guest stopped rendering") + } +} + +// Restore: epoch state is ephemeral, so the first post-restore callback +// carries the full form and the guest re-renders from the fresh cache. +func TestEpochModeRestoreSendsFull(t *testing.T) { + // 5s deadline for the same reason as TestEpochModeLifecycle: don't let a + // loaded machine kill the heavyweight OnStart. + g, err := LoadGame(loadspikeConsPath, Options{CallbackDeadline: 5 * time.Second}) + if err != nil { + t.Fatalf("LoadGame: %v", err) + } + cfg := sdk.RoomConfig{Mode: sdk.ModeQuick, Capacity: 10, MinPlayers: 1, Seed: 7, SeedSet: true} + h := g.NewRoom(cfg, sdk.Services{Log: quietLog()}) + tr := sdk.NewTestRoomFor(h, cfg, sdk.Services{Log: quietLog()}) + tr.Start() + tr.Join(lsPlayer(0)) + tr.Advance(50 * time.Millisecond) + tr.Tick() + + blob, err := SnapshotHandler(h) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + h2, err := RestoreHandler(g, blob) + if err != nil { + t.Fatalf("restore: %v", err) + } + wh2 := h2.(*wasmHandler) + if !wh2.epochMode { + t.Fatal("restored handler lost epochMode") + } + if wh2.lastFullEpoch != 0 { + t.Fatal("restored handler must start with lastFullEpoch=0 (full on first callback)") + } + if !wh2.forceFullRoster { + t.Fatal("restored handler must force the first callback to the full form") + } + tr2 := sdk.NewTestRoomFor(h2, cfg, sdk.Services{Log: quietLog()}) + tr2.Join(lsPlayer(0)) // re-seat (same account, same seat) + if wh2.forceFullRoster { + t.Fatal("first post-restore callback did not send the full form") + } + tr2.Advance(50 * time.Millisecond) + tr2.Tick() + frameHasAt(t, tr2, lsPlayer(0)) +} + +// Heartbeat precedence: admin config > meta declaration > loaded default, +// clamped to the envelope. +func TestMetaHeartbeatPrecedence(t *testing.T) { + g, err := LoadGame(loadspikeConsPath, Options{}) // meta declares 100ms + if err != nil { + t.Fatalf("LoadGame: %v", err) + } + cfg := sdk.RoomConfig{Mode: sdk.ModeQuick, Capacity: 10, MinPlayers: 1, Seed: 7, SeedSet: true} + + // Declaration wins over the loaded default when no admin config exists. + h := g.NewRoom(cfg, sdk.Services{Log: quietLog()}) + if hb := h.(*wasmHandler).heartbeat; hb != 100*time.Millisecond { + t.Fatalf("declared heartbeat not applied: %v", hb) + } + + // Admin config wins over the declaration. + svc := sdk.Services{Log: quietLog(), Config: cfgStub{"host.heartbeat_ms": "250"}} + h = g.NewRoom(cfg, svc) + if hb := h.(*wasmHandler).heartbeat; hb != 250*time.Millisecond { + t.Fatalf("admin override not applied over declaration: %v", hb) + } + + // The fixture declares nothing: loaded default stands. + fg := loadFixture(t, Options{Heartbeat: 50 * time.Millisecond}) + h = fg.NewRoom(cfg, sdk.Services{Log: quietLog()}) + if hb := h.(*wasmHandler).heartbeat; hb != 50*time.Millisecond { + t.Fatalf("undeclared game's heartbeat: %v", hb) + } +} diff --git a/host/gameabi/lifecycle_test.go b/host/gameabi/lifecycle_test.go new file mode 100644 index 0000000..2b08d49 --- /dev/null +++ b/host/gameabi/lifecycle_test.go @@ -0,0 +1,42 @@ +package gameabi + +// Lifecycle conformance at the wasm seam: a resident room's guest keeps +// receiving wakes with ZERO members; a non-resident room's empty wakes are +// suppressed (the historical behavior). Observed via CallbackSplit — the +// guest-call accounting advances only when wakes actually reach the guest. + +import ( + "testing" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +func tickEmptyRoom(t *testing.T, lifecycle sdk.Lifecycle) (delta time.Duration) { + t.Helper() + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeQuick, Capacity: 2, MinPlayers: 1, Seed: 7, SeedSet: true, Lifecycle: lifecycle} + h := g.NewRoom(cfg, sdk.Services{Log: quietLog()}) + tr := sdk.NewTestRoomFor(h, cfg, sdk.Services{Log: quietLog()}) + tr.Start() // one callback: start + + before, _ := CallbackSplit(h) + for i := 0; i < 10; i++ { + tr.Advance(50 * time.Millisecond) + tr.Tick() + } + after, _ := CallbackSplit(h) + return after - before +} + +func TestResidentWakesWhileEmpty(t *testing.T) { + if d := tickEmptyRoom(t, sdk.LifecycleResident); d == 0 { + t.Fatal("resident room's empty wakes never reached the guest") + } +} + +func TestNonResidentEmptyWakesSuppressed(t *testing.T) { + if d := tickEmptyRoom(t, sdk.LifecycleResumable); d != 0 { + t.Fatalf("empty non-resident room's wakes reached the guest (%v of guest time)", d) + } +} diff --git a/host/gameabi/loadspike_test.go b/host/gameabi/loadspike_test.go new file mode 100644 index 0000000..66701b5 --- /dev/null +++ b/host/gameabi/loadspike_test.go @@ -0,0 +1,246 @@ +package gameabi + +// BONEYARD Model A load spike: ONE room, a ramp of roster sizes (50 → 1000), +// a real WASM guest (testdata/loadspike — a BONEYARD-shaped roguelike: 12 +// floors of 140x40, wandering monsters, per-player scrolling 80x24 viewports +// composed and Sent on every wake). +// +// Shape: Go benchmarks — one sub-benchmark per roster size, one b.N iteration +// = one 50ms tick cycle (input fan-in + Advance + Tick + frame drain), so +// -benchtime=200x measures 200 ticks per size. ns/op is the full tick cycle; +// custom metrics break it down: +// +// tick_p50_ms / tick_p95_ms / tick_max_ms — guest wake latency alone +// over_50ms / over_100ms — heartbeat-budget / production- +// callback-deadline breaches (count) +// input_us — mean host->guest input delivery +// wire_kb_tick — host-measured guest frame bytes +// out per tick (delta containers) +// guest_mb / heap_mb — guest linear memory, host heap +// snap_ms / snap_mb — hibernation snapshot encode+size +// join_us — mean per-player join cost (setup) +// +// Run: +// +// go test ./internal/gameabi -bench LoadSpike -run '^$' -benchtime=200x -timeout 30m +// +// The callback deadline is raised to 5s so latency is MEASURED rather than +// killed; production-budget breaches are reported as counters instead. +import ( + "fmt" + "runtime" + "sort" + "sync/atomic" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// Two builds of the same guest: the production -gc=leaking profile (every +// allocation is permanent — fine for 8-player short-lived rooms, structurally +// incompatible with a resident room because the kit SDK's decodeCall decodes +// the FULL roster afresh on every callback), and TinyGo's default conservative +// GC (the candidate profile for resident rooms). +const ( + loadspikeLeakingPath = "testdata/loadspike/loadspike.wasm" + loadspikeConsPath = "testdata/loadspike/loadspike-cons.wasm" +) + +// spikeMetrics implements Options.Metrics: host-measured byte counters (the +// host counts bytes it moved across the module boundary — a guest cannot +// inflate them). Atomics for safety; the TestRoom drive is single-goroutine. +type spikeMetrics struct { + frameBytes atomic.Int64 + inputBytes atomic.Int64 + faults atomic.Int64 +} + +func (m *spikeMetrics) GameFrameBytesOut(_ string, n int) { m.frameBytes.Add(int64(n)) } +func (m *spikeMetrics) GameInputBytesIn(_ string, n int) { m.inputBytes.Add(int64(n)) } +func (m *spikeMetrics) GameFault(string) { m.faults.Add(1) } +func (m *spikeMetrics) GameCallback(_, _ string, _ float64) {} +func (m *spikeMetrics) GameCallbackDeadline(_, _ string) {} +func (m *spikeMetrics) GameHostIODeadline(_, _ string) {} +func (m *spikeMetrics) GameKVError(_, _ string) {} +func (m *spikeMetrics) GameLinearMemoryDelta(_ string, _ int64) {} + +func BenchmarkLoadSpike(b *testing.B) { + // The real scaling table: conservative GC, must complete every size. + for _, n := range []int{50, 100, 250, 500, 1000} { + b.Run(fmt.Sprintf("gc=cons/players=%d", n), func(b *testing.B) { + runSpike(b, n, loadspikeConsPath, 2048, false) + }) + } + // The leak documentation runs: production -gc=leaking profile with a + // 512 MiB cap; expected to OOM — survival is the metric, not a failure. + for _, n := range []int{100, 500, 1000} { + b.Run(fmt.Sprintf("gc=leaking/players=%d", n), func(b *testing.B) { + runSpike(b, n, loadspikeLeakingPath, 8192, true) + }) + } +} + +// runSpike drives one roster size. oomTolerant treats a guest fault as a +// measurement (oom_at_join / oom_at_tick metrics, early return) instead of a +// benchmark failure — used for the leaking-GC documentation runs. +func runSpike(b *testing.B, n int, path string, memPages uint32, oomTolerant bool) { + m := &spikeMetrics{} + g, err := LoadGame(path, Options{ + Heartbeat: 50 * time.Millisecond, + MemoryPages: memPages, + CallbackDeadline: 5 * time.Second, + Metrics: m, + }) + if err != nil { + b.Fatalf("LoadGame(%s): %v", path, err) + } + + cfg := sdk.RoomConfig{Mode: sdk.ModeQuick, Capacity: 1000, MinPlayers: 1, Seed: 42, SeedSet: true} + svc := sdk.Services{Log: quietLog()} + h := g.NewRoom(cfg, svc) + tr := sdk.NewTestRoomFor(h, cfg, svc) + tr.Start() + + players := make([]sdk.Player, n) + for i := range players { + players[i] = sdk.Player{ + AccountID: fmt.Sprintf("acct-%04d", i), + Handle: fmt.Sprintf("bot%04d", i), + Kind: sdk.KindMember, + Conn: fmt.Sprintf("conn-%04d", i), + } + } + + // ---- setup (untimed): join the roster, warm up, sanity-check rendering ---- + joinStart := time.Now() + for i := range players { + tr.Join(players[i]) + if exit, cerr, faulted := LastCallback(h); faulted { + if oomTolerant { + b.ReportMetric(float64(i+1), "oom_at_join") + b.ReportMetric(float64(GuestMemorySize(h))/(1<<20), "guest_mb") + return + } + b.Fatalf("join %d faulted (exit=%d err=%v)", i+1, exit, cerr) + } + } + joinUs := float64(time.Since(joinStart).Microseconds()) / float64(n) + + const ( + tickStep = 50 * time.Millisecond // the production heartbeat + movesPerSec = 1.5 // per-player input cadence (active play) + ) + moves := []rune{'h', 'j', 'l', 'k'} + inputsPerTick := int(float64(n) * movesPerSec * tickStep.Seconds()) + + for w := 0; w < 5; w++ { // warmup: populate baselines, JIT paths, lazy allocs + tr.Advance(tickStep) + tr.Tick() + if exit, cerr, faulted := LastCallback(h); faulted { + if oomTolerant { + b.ReportMetric(float64(w+1), "oom_at_warmup") + b.ReportMetric(float64(GuestMemorySize(h))/(1<<20), "guest_mb") + return + } + b.Fatalf("warmup %d faulted (exit=%d err=%v)", w, exit, cerr) + } + } + if fr, ok := tr.LastFrame(players[0]); !ok { + b.Fatal("no frame recorded for player 0 after warmup") + } else { + found := false + for r := 0; r < 22 && !found; r++ { + for c := 0; c < 80 && !found; c++ { + found = fr.Cells[r][c].Rune == '@' + } + } + if !found { + b.Fatal("player 0's frame has no '@' — guest render broken") + } + } + + tickDur := make([]time.Duration, 0, b.N) + var inputTotal time.Duration + var inputCount int64 + over50, over100 := 0, 0 + m.frameBytes.Store(0) + cbTotal0, cbHost0 := CallbackSplit(h) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Drain the previous tick's recorded frames, keeping backing arrays (a + // real room hands one frame copy per player to a consumer channel; + // accumulating history would skew host memory by gigabytes). + for p, fs := range tr.Frames { + tr.Frames[p] = fs[:0] + } + for j := 0; j < inputsPerTick; j++ { + p := players[(i*inputsPerTick+j)%n] + in := sdk.Input{Kind: sdk.InputRune, Rune: moves[(i+j)%len(moves)]} + st := time.Now() + tr.Input(p, in) + inputTotal += time.Since(st) + inputCount++ + } + tr.Advance(tickStep) + st := time.Now() + tr.Tick() + d := time.Since(st) + tickDur = append(tickDur, d) + if d > 50*time.Millisecond { + over50++ + } + if d > 100*time.Millisecond { + over100++ + } + if exit, cerr, faulted := LastCallback(h); faulted { + if oomTolerant { + b.StopTimer() + b.ReportMetric(float64(i+1), "oom_at_tick") + b.ReportMetric(float64(GuestMemorySize(h))/(1<<20), "guest_mb") + return + } + b.Fatalf("tick %d faulted (exit=%d err=%v)", i, exit, cerr) + } + } + b.StopTimer() + + // ---- custom metrics ---- + sort.Slice(tickDur, func(i, j int) bool { return tickDur[i] < tickDur[j] }) + msAt := func(q float64) float64 { + idx := int(q * float64(len(tickDur)-1)) + return float64(tickDur[idx].Microseconds()) / 1000 + } + b.ReportMetric(msAt(0.50), "tick_p50_ms") + b.ReportMetric(msAt(0.95), "tick_p95_ms") + b.ReportMetric(msAt(1.00), "tick_max_ms") + b.ReportMetric(float64(over50), "over_50ms") + b.ReportMetric(float64(over100), "over_100ms") + if inputCount > 0 { + b.ReportMetric(float64(inputTotal.Microseconds())/float64(inputCount), "input_us") + } + b.ReportMetric(float64(m.frameBytes.Load())/float64(b.N)/1024, "wire_kb_tick") + // Guest vs host split: total guest-call wall time vs the portion inside + // the send/identical host functions (delta apply + decode + fan-out). + cbTotal1, cbHost1 := CallbackSplit(h) + total := (cbTotal1 - cbTotal0).Seconds() * 1000 / float64(b.N) + host := (cbHost1 - cbHost0).Seconds() * 1000 / float64(b.N) + b.ReportMetric(total-host, "guest_ms_tick") + b.ReportMetric(host, "host_ms_tick") + b.ReportMetric(joinUs, "join_us") + b.ReportMetric(float64(GuestMemorySize(h))/(1<<20), "guest_mb") + + var ms runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&ms) + b.ReportMetric(float64(ms.HeapInuse)/(1<<20), "heap_mb") + + snapStart := time.Now() + blob, serr := SnapshotHandler(h) + if serr != nil { + b.Fatalf("snapshot failed: %v", serr) + } + b.ReportMetric(float64(time.Since(snapStart).Microseconds())/1000, "snap_ms") + b.ReportMetric(float64(len(blob))/(1<<20), "snap_mb") +} diff --git a/host/gameabi/metrics_test.go b/host/gameabi/metrics_test.go new file mode 100644 index 0000000..032a7eb --- /dev/null +++ b/host/gameabi/metrics_test.go @@ -0,0 +1,238 @@ +package gameabi + +import ( + "sync" + "testing" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// recordingMetrics is a test double for the Options.Metrics surface. +type recordingMetrics struct { + mu sync.Mutex + bytesOut map[string]int + bytesIn map[string]int + faults map[string]int + callbacks map[string]int // slug/callback -> count + deadlines map[string]int // slug/callback -> count + hostIO map[string]int // slug/callback -> count (host-I/O deadline kills) + kvErrors map[string]int // slug/op -> count + memBytes map[string]int64 // slug -> summed linear-memory deltas (the gauge value) +} + +func newRecordingMetrics() *recordingMetrics { + return &recordingMetrics{bytesOut: map[string]int{}, bytesIn: map[string]int{}, faults: map[string]int{}, callbacks: map[string]int{}, deadlines: map[string]int{}, hostIO: map[string]int{}, kvErrors: map[string]int{}, memBytes: map[string]int64{}} +} + +func (r *recordingMetrics) GameFrameBytesOut(slug string, n int) { + r.mu.Lock() + defer r.mu.Unlock() + r.bytesOut[slug] += n +} +func (r *recordingMetrics) GameInputBytesIn(slug string, n int) { + r.mu.Lock() + defer r.mu.Unlock() + r.bytesIn[slug] += n +} +func (r *recordingMetrics) GameFault(slug string) { + r.mu.Lock() + defer r.mu.Unlock() + r.faults[slug] += 1 +} +func (r *recordingMetrics) GameCallback(slug, callback string, seconds float64) { + r.mu.Lock() + defer r.mu.Unlock() + r.callbacks[slug+"/"+callback]++ +} +func (r *recordingMetrics) GameCallbackDeadline(slug, callback string) { + r.mu.Lock() + defer r.mu.Unlock() + r.deadlines[slug+"/"+callback]++ +} +func (r *recordingMetrics) GameHostIODeadline(slug, callback string) { + r.mu.Lock() + defer r.mu.Unlock() + r.hostIO[slug+"/"+callback]++ +} +func (r *recordingMetrics) GameKVError(slug, op string) { + r.mu.Lock() + defer r.mu.Unlock() + r.kvErrors[slug+"/"+op]++ +} +func (r *recordingMetrics) GameLinearMemoryDelta(slug string, delta int64) { + r.mu.Lock() + defer r.mu.Unlock() + r.memBytes[slug] += delta +} + +func (r *recordingMetrics) memOf(slug string) int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.memBytes[slug] +} + +func (r *recordingMetrics) snapshot() (out, in, faults map[string]int) { + r.mu.Lock() + defer r.mu.Unlock() + cp := func(m map[string]int) map[string]int { + c := make(map[string]int, len(m)) + for k, v := range m { + c[k] = v + } + return c + } + return cp(r.bytesOut), cp(r.bytesIn), cp(r.faults) +} + +func (r *recordingMetrics) totalCallbacks() int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, c := range r.callbacks { + n += c + } + return n +} + +func (r *recordingMetrics) totalHostIODeadlines() int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, c := range r.hostIO { + n += c + } + return n +} + +func (r *recordingMetrics) totalKVErrors() int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, c := range r.kvErrors { + n += c + } + return n +} + +func (r *recordingMetrics) totalDeadlines() int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, c := range r.deadlines { + n += c + } + return n +} + +// TestHostMeasuredCounters proves the byte counters move only from data the +// HOST moved across the module boundary: frames the host accepted (send / +// identical, logical frame size) and the normalized input payload it +// delivered — never module-reported figures. +func TestHostMeasuredCounters(t *testing.T) { + rec := newRecordingMetrics() + g := loadFixture(t, Options{Metrics: rec}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, sdk.Services{Log: quietLog()}) + tr.Start() + tr.Join(p1) + + out, in, faults := rec.snapshot() + if out["fixture"] <= 0 { + t.Fatalf("frame bytes out after start+join = %d, want > 0 (fixture renders a frame)", out["fixture"]) + } + if len(faults) != 0 { + t.Fatalf("faults after clean start = %v, want none", faults) + } + if rec.totalCallbacks() == 0 { + t.Fatal("no callback durations recorded after start+join") + } + if d := rec.totalDeadlines(); d != 0 { + t.Fatalf("deadline kills after clean start = %d, want 0", d) + } + baseOut, baseIn := out["fixture"], in["fixture"] + if baseIn != 0 { + t.Fatalf("input bytes before any input = %d, want 0", baseIn) + } + + // One input: the normalized payload is idx(4) + kind(1) + rune(4) + key(1). + tr.Input(p1, runeIn('x')) + out, in, _ = rec.snapshot() + if got := in["fixture"]; got != 10 { + t.Fatalf("input bytes after one input = %d, want 10", got) + } + if out["fixture"] <= baseOut { + t.Fatalf("frame bytes did not grow after an input callback: %d -> %d", baseOut, out["fixture"]) + } +} + +// TestLinearMemoryGauge proves the per-game linear-memory gauge is HOST-sampled +// from the actual wazero instance: a room's grown guest memory is folded in at +// birth, kept fresh on the heartbeat (OnTick — including the empty-room idle +// path, which still pins memory), summed across the game's rooms, and retired +// when an instance closes. The memory-pressure attribution series for a +// hoarding game that never faults. +func TestLinearMemoryGauge(t *testing.T) { + rec := newRecordingMetrics() + g := loadFixture(t, Options{Metrics: rec}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + svc := sdk.Services{Log: quietLog()} + + hA := g.NewRoom(cfg, svc).(*wasmHandler) + trA := sdk.NewTestRoomFor(hA, cfg, svc) + trA.Start() + sizeA := int64(GuestMemorySize(hA)) + if sizeA <= 0 { + t.Fatal("started room reports no guest linear memory") + } + if got := rec.memOf("fixture"); got != sizeA { + t.Fatalf("gauge after start = %d, want the room's actual memory %d", got, sizeA) + } + + // The heartbeat keeps the sample fresh even for an EMPTY room (no members + // joined yet): OnTick skips the guest wake but still samples, and an + // unchanged size reports no delta. + trA.Tick() + if got := rec.memOf("fixture"); got != int64(GuestMemorySize(hA)) { + t.Fatalf("gauge after idle tick = %d, want %d", got, GuestMemorySize(hA)) + } + + // A second room of the same game adds its own contribution: the series is + // the per-game SUM, not a last-writer-wins per-room value. + hB := g.NewRoom(cfg, svc).(*wasmHandler) + trB := sdk.NewTestRoomFor(hB, cfg, svc) + trB.Start() + trB.Join(p1) + trB.Tick() + sizeA, sizeB := int64(GuestMemorySize(hA)), int64(GuestMemorySize(hB)) + if got := rec.memOf("fixture"); got != sizeA+sizeB { + t.Fatalf("gauge with two rooms = %d, want %d + %d", got, sizeA, sizeB) + } + + // Closing a room's instance retires exactly its contribution. + hB.OnClose(trB) + if got := rec.memOf("fixture"); got != sizeA { + t.Fatalf("gauge after closing one room = %d, want %d", got, sizeA) + } + hA.OnClose(trA) + if got := rec.memOf("fixture"); got != 0 { + t.Fatalf("gauge after closing all rooms = %d, want 0", got) + } +} + +// TestFaultCounter proves a guest trap reaches the metrics fault counter via +// the host's fault path (the same hook quarantine uses). +func TestFaultCounter(t *testing.T) { + rec := newRecordingMetrics() + g := loadFixture(t, Options{Metrics: rec}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, sdk.Services{Log: quietLog()}) + tr.Start() + tr.Join(p1) + + tr.Input(p1, runeIn('p')) // the fixture's panic command: a guest trap + + _, _, faults := rec.snapshot() + if faults["fixture"] != 1 { + t.Fatalf("faults after guest panic = %v, want fixture:1", faults) + } +} diff --git a/host/gameabi/quarantine.go b/host/gameabi/quarantine.go new file mode 100644 index 0000000..4219e8e --- /dev/null +++ b/host/gameabi/quarantine.go @@ -0,0 +1,126 @@ +package gameabi + +import ( + "fmt" + "log/slog" + "sync" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// Quarantine is the fault-count watchdog: wire its RecordFault into +// Options.OnFault and a game that faults Threshold times within Window is +// removed from the live roster (new rooms and lobby listing stop; running +// rooms are spared — they hold their own Game reference). Removal is +// admin-reversible via Restore. Every transition is audit-logged. +type Quarantine struct { + reg *sdk.Registry + threshold int + window time.Duration + log *slog.Logger + now func() time.Time // injectable for tests + + // OnQuarantine, when set, is told the slug of a game the watchdog has just + // pulled from the live roster, so the catalog can flip its metadata state to + // quarantined. Called under the watchdog lock from a room-actor goroutine; + // keep it quick and non-blocking. Optional (nil for non-catalog callers, e.g. + // dev sideloads). + OnQuarantine func(slug string) + + mu sync.Mutex + faults map[string][]time.Time + removed map[string]sdk.Game +} + +// NewQuarantine builds a watchdog over reg. threshold <= 0 defaults to 3 +// faults; window <= 0 defaults to 10 minutes. +func NewQuarantine(reg *sdk.Registry, threshold int, window time.Duration, log *slog.Logger) *Quarantine { + if threshold <= 0 { + threshold = 3 + } + if window <= 0 { + window = 10 * time.Minute + } + if log == nil { + log = slog.Default() + } + return &Quarantine{ + reg: reg, + threshold: threshold, + window: window, + log: log, + now: time.Now, + faults: map[string][]time.Time{}, + removed: map[string]sdk.Game{}, + } +} + +// RecordFault counts one guest fault for slug and quarantines the game when +// the in-window count reaches the threshold. Safe from any goroutine (room +// actors report concurrently). +func (q *Quarantine) RecordFault(slug string) { + q.mu.Lock() + defer q.mu.Unlock() + now := q.now() + keep := q.faults[slug][:0] + for _, at := range q.faults[slug] { + if now.Sub(at) < q.window { + keep = append(keep, at) + } + } + keep = append(keep, now) + q.faults[slug] = keep + if len(keep) < q.threshold { + return + } + if _, already := q.removed[slug]; already { + return // faults from rooms still running a quarantined game + } + g, ok := q.reg.Remove(slug) + if !ok { + return // not in the live roster (sideload removed, never added) + } + q.removed[slug] = g + delete(q.faults, slug) + q.log.Warn("audit: game quarantined", + "event", "game.quarantine", + "slug", slug, + "faults", len(keep), + "window", q.window.String(), + ) + if q.OnQuarantine != nil { + q.OnQuarantine(slug) + } +} + +// Quarantined returns the slugs currently held out of the roster. +func (q *Quarantine) Quarantined() []string { + q.mu.Lock() + defer q.mu.Unlock() + out := make([]string, 0, len(q.removed)) + for slug := range q.removed { + out = append(out, slug) + } + return out +} + +// Restore returns a quarantined game to the live roster with a clean fault +// count (the admin-reversible half of the contract). +func (q *Quarantine) Restore(slug string) error { + q.mu.Lock() + defer q.mu.Unlock() + g, ok := q.removed[slug] + if !ok { + return fmt.Errorf("gameabi: %q is not quarantined", slug) + } + if err := q.reg.Add(g); err != nil { + return err + } + delete(q.removed, slug) + q.log.Warn("audit: game restored from quarantine", + "event", "game.quarantine.restore", + "slug", slug, + ) + return nil +} diff --git a/host/gameabi/quarantine_test.go b/host/gameabi/quarantine_test.go new file mode 100644 index 0000000..c6d1ff3 --- /dev/null +++ b/host/gameabi/quarantine_test.go @@ -0,0 +1,133 @@ +package gameabi + +import ( + "testing" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// TestQuarantineWindow pins the counting rule: only faults inside the window +// count toward the threshold, and Restore returns the game with a clean slate. +func TestQuarantineWindow(t *testing.T) { + g := loadFixture(t, Options{}) + reg := sdk.NewRegistry() + reg.MustAdd(g) + q := NewQuarantine(reg, 2, time.Minute, quietLog()) + clock := time.Unix(1_700_000_000, 0) + q.now = func() time.Time { return clock } + + q.RecordFault("fixture") + clock = clock.Add(2 * time.Minute) // first fault ages out of the window + q.RecordFault("fixture") + if _, ok := reg.Get("fixture"); !ok { + t.Fatal("quarantined on faults outside one window") + } + + clock = clock.Add(time.Second) + q.RecordFault("fixture") // second in-window fault: threshold reached + if _, ok := reg.Get("fixture"); ok { + t.Fatal("game still in roster after threshold faults in window") + } + if qs := q.Quarantined(); len(qs) != 1 || qs[0] != "fixture" { + t.Fatalf("Quarantined() = %v, want [fixture]", qs) + } + + if err := q.Restore("fixture"); err != nil { + t.Fatalf("Restore: %v", err) + } + if _, ok := reg.Get("fixture"); !ok { + t.Fatal("restored game missing from roster") + } + if len(q.Quarantined()) != 0 { + t.Fatal("Quarantined() not empty after restore") + } + if err := q.Restore("fixture"); err == nil { + t.Fatal("double restore accepted") + } +} + +// TestQuarantineOnQuarantineHook pins the catalog hook (3.4): OnQuarantine fires +// exactly ONCE with the slug when the watchdog pulls the game — and not again on +// later faults from rooms still running the quarantined game. +func TestQuarantineOnQuarantineHook(t *testing.T) { + g := loadFixture(t, Options{}) + reg := sdk.NewRegistry() + reg.MustAdd(g) + q := NewQuarantine(reg, 2, time.Minute, quietLog()) + var got []string + q.OnQuarantine = func(slug string) { got = append(got, slug) } + + q.RecordFault("fixture") + if len(got) != 0 { + t.Fatalf("hook fired before threshold: %v", got) + } + q.RecordFault("fixture") // threshold reached -> quarantined + if len(got) != 1 || got[0] != "fixture" { + t.Fatalf("OnQuarantine = %v, want one [fixture]", got) + } + // A later fault (from a room still running the quarantined game) must NOT + // re-fire the hook — the game is already removed. + q.RecordFault("fixture") + if len(got) != 1 { + t.Fatalf("OnQuarantine fired again after removal: %v", got) + } +} + +// TestQuarantineLive is the end-to-end story: two rooms of a wasm game each +// fault, the watchdog pulls the game from the roster, a third room of the +// same game keeps running (removal spares running rooms), and an admin +// restore brings the game back. +func TestQuarantineLive(t *testing.T) { + reg := sdk.NewRegistry() + q := NewQuarantine(reg, 2, time.Minute, quietLog()) + g, err := LoadGame(fixturePath, Options{OnFault: q.RecordFault}) + if err != nil { + t.Fatalf("LoadGame: %v", err) + } + reg.MustAdd(g) + + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + survivor := newLiveRoom(t, g, cfg) + if err := survivor.Join(p2); err != nil { + t.Fatalf("join survivor: %v", err) + } + framesSurvivor := survivor.Frames(p2) + + for i := 0; i < 2; i++ { + ctl := newLiveRoom(t, g, cfg) + if err := ctl.Join(p1); err != nil { + t.Fatalf("join faulting room %d: %v", i, err) + } + ctl.Input(p1, runeIn('p')) + select { + case <-ctl.Done(): + case <-time.After(10 * time.Second): + t.Fatalf("faulting room %d did not settle", i) + } + } + + waitFor(t, "quarantine removal", func() bool { + _, ok := reg.Get("fixture") + return !ok + }) + + // The already-running room is spared. + drain(framesSurvivor) + survivor.Input(p2, runeIn('x')) + select { + case _, ok := <-framesSurvivor: + if !ok { + t.Fatal("survivor frame stream closed by quarantine") + } + case <-time.After(5 * time.Second): + t.Fatal("survivor room unresponsive after quarantine") + } + + if err := q.Restore("fixture"); err != nil { + t.Fatalf("Restore: %v", err) + } + if _, ok := reg.Get("fixture"); !ok { + t.Fatal("restore did not return the game to the roster") + } +} diff --git a/host/gameabi/slug.go b/host/gameabi/slug.go new file mode 100644 index 0000000..8e5ccc3 --- /dev/null +++ b/host/gameabi/slug.go @@ -0,0 +1,9 @@ +package gameabi + +// ValidateBareName reports whether slug is a valid bare game name — the +// lower-case kebab-case rule (`^[a-z0-9-]{1,32}$`) every declared meta.slug +// must satisfy at load time. Exported so author-facing tooling +// (`shellcade-kit new`) can reject an invalid name at scaffold time with the +// same error text the loader would produce at the first `check`, instead of +// after a game has been built around the slug. +func ValidateBareName(slug string) error { return validateBareName(slug) } diff --git a/host/gameabi/slug_test.go b/host/gameabi/slug_test.go new file mode 100644 index 0000000..5b687fe --- /dev/null +++ b/host/gameabi/slug_test.go @@ -0,0 +1,108 @@ +package gameabi + +import ( + "strings" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// stubGame is a registry entry carrying an arbitrary (here: namespaced) slug, +// so the quarantine watchdog can be exercised without a wasm artifact — it keys +// purely on the registry slug string. +type stubGame struct { + sdk.GameBase + slug string +} + +func (g stubGame) Meta() sdk.GameMeta { + return sdk.GameMeta{Slug: g.slug, Name: g.slug, MinPlayers: 1, MaxPlayers: 2} +} +func (stubGame) NewRoom(cfg sdk.RoomConfig, svc sdk.Services) sdk.Handler { return nil } + +// TestQuarantineKeysOnSlashSlug proves the fault-count watchdog keys on the +// full namespaced slug (a plain map key): faults under "bcook/pokies" remove +// exactly that game and Restore brings it back — a slash in the slug is inert. +func TestQuarantineKeysOnSlashSlug(t *testing.T) { + const slug = "bcook/pokies" + reg := sdk.NewRegistry() + reg.MustAdd(stubGame{slug: slug}) + q := NewQuarantine(reg, 2, time.Minute, quietLog()) + clock := time.Unix(1_700_000_000, 0) + q.now = func() time.Time { return clock } + + q.RecordFault(slug) + q.RecordFault(slug) // threshold reached within the window + if _, ok := reg.Get(slug); ok { + t.Fatalf("slash slug %q still in roster after threshold faults", slug) + } + if qs := q.Quarantined(); len(qs) != 1 || qs[0] != slug { + t.Fatalf("Quarantined() = %v, want [%s]", qs, slug) + } + if err := q.Restore(slug); err != nil { + t.Fatalf("Restore(%q): %v", slug, err) + } + if _, ok := reg.Get(slug); !ok { + t.Fatalf("restored slash slug %q missing from roster", slug) + } +} + +// TestValidateBareName proves LoadGame's meta gate: a guest may declare ONLY a +// bare game name; the host composes the / namespace, so a binary +// can never ship a slug that claims one. This is the unit under the LoadGame +// rejection (decodeMeta -> validateBareName), tested directly so it needs no +// hand-rolled malformed wasm artifact. +func TestValidateBareName(t *testing.T) { + valid := []string{ + "fixture", + "pokies", + "shellracer", + "a", + "chess", + "2048", + strings.Repeat("a", 32), // exactly the length cap + } + for _, s := range valid { + if err := validateBareName(s); err != nil { + t.Errorf("validateBareName(%q) = %v, want nil (a bare name must load)", s, err) + } + } + + invalid := []string{ + "bcook/pokies", // a namespaced slug: the host owns the prefix + "alan/chess", // ditto + "a/b/c", // multiple separators + "/pokies", // leading slash + "pokies/", // trailing slash + "Pokies", // upper-case + "type_racer", // underscore is not in the bare-name alphabet + "my game", // space + "poké", // non-ASCII + "", // empty (also caught by wire, belt-and-suspenders) + strings.Repeat("a", 33), // over the 32-rune cap + "name@1", // punctuation + } + for _, s := range invalid { + if err := validateBareName(s); err == nil { + t.Errorf("validateBareName(%q) = nil, want a rejection (only bare names are allowed)", s) + } + } +} + +// TestValidateBareNameErrorIsActionable ensures the rejection names the slug and +// explains that the host composes the namespace — an author debugging a failed +// load gets a clear cause, not a bare regex mismatch. +func TestValidateBareNameErrorIsActionable(t *testing.T) { + err := validateBareName("bcook/pokies") + if err == nil { + t.Fatal("expected an error for a namespaced guest slug") + } + msg := err.Error() + if !strings.Contains(msg, "bcook/pokies") { + t.Errorf("error %q should echo the offending slug", msg) + } + if !strings.Contains(msg, "bare name") { + t.Errorf("error %q should explain the bare-name rule", msg) + } +} diff --git a/host/gameabi/snapshot.go b/host/gameabi/snapshot.go new file mode 100644 index 0000000..5efc65c --- /dev/null +++ b/host/gameabi/snapshot.go @@ -0,0 +1,361 @@ +package gameabi + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "reflect" + "unsafe" + + extism "github.com/extism/go-sdk" + "github.com/klauspost/compress/zstd" + "github.com/tetratelabs/wazero/api" + + "github.com/shellcade/kit/v2/wire" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// guestMemory returns the GUEST module's linear memory — the wasm program's own +// TinyGo heap and data section, which is where all persistent room state lives. +// +// extism.Plugin.Memory() returns the EXTISM RUNTIME module's memory (a separate +// instance holding only the transient per-call input/output buffers), NOT the +// guest's. The guest module (p.mainModule) is unexported and Plugin.Module() +// wraps it without surfacing Memory(), so we reach the api.Module by reflection +// and then use its public Memory() — the only supported field we need, read +// once per snapshot/restore. If extism ever exposes the main module's memory +// publicly, this helper is the single place to switch over. +func guestMemory(p *extism.Plugin) api.Memory { + v := reflect.ValueOf(p).Elem().FieldByName("mainModule") + if !v.IsValid() { + return nil + } + v = reflect.NewAt(v.Type(), unsafe.Pointer(v.UnsafeAddr())).Elem() + mod, ok := v.Interface().(api.Module) + if !ok || mod == nil { + return nil + } + return mod.Memory() +} + +// Snapshot/Restore (D9 hibernation, ABI task 6.1): freeze a live wasm room into +// a portable blob and rehydrate it into a fresh handler. A snapshot captures the +// guest's full linear memory plus the host state needed to resume +// deterministically — roster, room clock, terminal flags, current input +// context, and the entropy stream +// position (so the seeded source replays to the exact byte the guest +// last drew). The blob is zstd-compressed; linear memory is mostly zero pages, +// so it compresses hard. +// +// Hibernation TRIGGERS (when to snapshot, where the blob is stored, how a room +// is resumed in the lobby) are a later lane — this lane provides only the codec +// the engine will call. Snapshot must be taken at a quiescent point: no guest +// call on the stack (h.cur == nil), so linear memory and the entropy counter +// are not mid-mutation. + +// snapshotMagic + snapshotFormat version the host blob independently of the ABI +// (a blob layout change bumps the format without touching the wasm ABI). +// +// format 2 adds the room's Mode/Capacity/MinPlayers. The guest sees the full +// RoomConfig in every callback's CallContext (encodeCtx), so a restore that +// dropped these fields handed the resumed guest a DIFFERENT context than the +// control — diverging any guest whose behavior reads them (directly, or via the +// per-callback context the seeded RNG + allocations are laid out against). The +// fixture, which ignores RoomConfig entirely, was blind to the loss; a real game +// (pokies) surfaced it as a hibernation-determinism failure. +// +// format 3 (ABI v2 frame diffing, D6) appends a u32 epoch HIGH-WATER: the max +// frame-delta epoch the host had issued before the snapshot. On resume the host +// seeds epochSeq strictly above it so every snapshot-surviving guest epoch is +// stale and the guest's first post-restore send per consumer is epoch-rejected +// into a keyframe. The host-side baseline CACHE itself is ephemeral host memory +// and is deliberately NOT snapshotted; only this single counter crosses. +// +// format 4 (leaderboard idempotency) appends the u32 leaderboard post-sequence +// counter. The durable leaderboard derives a round id deterministically from +// (roomID, postSeq), so the counter MUST survive a restore: a room reclaimed +// from a pre-settle checkpoint that re-settles the same round replays the same +// sequence and the insert dedupes instead of double-counting. A format-3 blob +// that dropped the counter would reset it to 0 and re-mint colliding sequences +// for rounds already recorded under DIFFERENT (post-format-4) ids — so old +// blobs are refused, like every prior format bump. +const ( + snapshotMagic = 0x53434b31 // "SCK1" + snapshotFormat = 4 +) + +// ErrArtifactMismatch is returned (wrapped) by Restore when the snapshot was +// taken under a DIFFERENT wasm artifact than the game it is being restored +// into — the inevitable outcome of a catalog promotion or rollback moving the +// slug's live-version pointer while rooms were parked. The blob itself is +// intact; it is the live game that moved. Callers use errors.Is to tell this +// apart from genuine corruption: the lobby surfaces "your saved game was +// retired by a game update" instead of a corrupt/expired notice, and the +// resident bring-up logs the discarded drain snapshot explicitly. +var ErrArtifactMismatch = errors.New("artifact mismatch") + +// snapState is the decoded host blob (everything but the linear-memory bytes, +// which the caller handles as a length-prefixed raw region). +type snapState struct { + abiVersion uint32 + artifact [sha256.Size]byte + nowNanos int64 + seed int64 + consumed int64 // entropy bytes drawn + ended bool + dead bool + inputCtx uint8 + mode sdk.Mode // room mode (sent to the guest in every CallContext) + capacity int32 + minPlayers int32 + epochHW uint32 // frame-delta epoch high-water (format 3; D6 resume re-seed) + postSeq uint32 // leaderboard post-sequence counter (format 4; round-id idempotency) + roster []sdk.Player +} + +// Snapshot freezes handler h into a compressed, self-describing blob. It MUST be +// called at a quiescent point (no guest callback on the stack) — Snapshot +// enforces this and refuses an in-flight handler. A dead instance (faulted / +// closed) cannot be snapshotted. +func (h *wasmHandler) Snapshot() ([]byte, error) { + if h.cur != nil { + return nil, fmt.Errorf("gameabi: snapshot during a guest call (not quiescent)") + } + if h.inst == nil { + return nil, fmt.Errorf("gameabi: snapshot of a closed/never-started room") + } + mem := guestMemory(h.inst) + if mem == nil { + return nil, fmt.Errorf("gameabi: snapshot: no guest linear memory") + } + size := mem.Size() + view, ok := mem.Read(0, size) + if !ok { + return nil, fmt.Errorf("gameabi: snapshot: memory read failed (size %d)", size) + } + // Read returns a write-through VIEW into the live module — copy it before the + // instance can mutate it again. + linmem := make([]byte, len(view)) + copy(linmem, view) + + var w wire.Buf + w.U32(snapshotMagic) + w.U32(snapshotFormat) + w.U32(Version) + w.B = append(w.B, h.game.wasmSHA[:]...) + w.I64(h.nowNanos) + w.I64(h.seed) + w.I64(h.consumed()) + w.Bool(h.ended) + w.Bool(h.dead) + w.U8(uint8(h.inputCtx)) + // Full RoomConfig (minus Seed, carried above): the guest reads Mode, + // Capacity, and MinPlayers from the CallContext on every callback, so they + // must survive a restore byte-for-byte or the resumed guest diverges. + w.Str(string(h.cfg.Mode)) + w.U32(uint32(int32(h.cfg.Capacity))) + w.U32(uint32(int32(h.cfg.MinPlayers))) + // Frame-delta epoch high-water (format 3, D6): the max epoch the host issued + // before this snapshot. On resume the host seeds epochSeq strictly above it. + // The baseline cache itself is ephemeral host memory and is NOT snapshotted. + w.U32(h.baselines.epochSeq) + // Leaderboard post-sequence counter (format 4): must survive a restore so a + // re-settled round re-derives the SAME round id and dedupes (idempotency). + w.U32(h.postSeq) + // Character is deliberately NOT snapshotted: h.roster is overwritten with + // the live roster on every invoke before any consumer reads it, and the + // post-restore forceFullRoster resend carries live characters — the + // restored roster only seeds the fingerprint and quiescent re-snapshot. + w.U16(uint16(len(h.roster))) + for _, p := range h.roster { + w.Str(p.Handle) + w.Str(p.AccountID) + w.Str(p.Conn) + w.Str(string(p.Kind)) + } + w.U32(uint32(len(linmem))) + w.B = append(w.B, linmem...) + + return zstdEncode(w.B), nil +} + +// Restore decompresses a blob and rehydrates it into a FRESH handler bound to +// game g (g must be the same artifact the blob was taken from — the embedded +// sha256 + ABI version are verified). The returned handler holds a live instance +// resumed at the snapshot's memory, clock, roster, input context, and entropy +// position; the next callback continues exactly where the snapshot left off. +// +// The handler is NOT yet attached to a Room (h.cur == nil); the engine drives it +// the same way the actor drives a fresh handler. +func (g *wasmGame) Restore(blob []byte) (*wasmHandler, error) { + raw, err := zstdDecode(blob) + if err != nil { + return nil, fmt.Errorf("gameabi: restore: decompress: %w", err) + } + st, linmem, err := decodeSnapshot(raw) + if err != nil { + return nil, err + } + if st.abiVersion != Version { + return nil, fmt.Errorf("gameabi: restore: blob targets ABI v%d, host implements v%d", st.abiVersion, Version) + } + if st.artifact != g.wasmSHA { + return nil, fmt.Errorf("gameabi: restore: %w (blob %x… vs game %x…)", ErrArtifactMismatch, st.artifact[:4], g.wasmSHA[:4]) + } + + // Reconstruct the FULL RoomConfig the room ran with: the guest reads Mode, + // Capacity, and MinPlayers from the per-callback CallContext, so a restore + // that resumed with a partial cfg would feed the guest a different context + // and diverge (hibernation-determinism failure). + cfg := sdk.RoomConfig{ + Mode: st.mode, + Capacity: int(st.capacity), + MinPlayers: int(st.minPlayers), + Seed: st.seed, + SeedSet: true, + } + h := &wasmHandler{ + game: g, + cfg: cfg, + heartbeat: g.opts.Heartbeat, + deadline: g.opts.CallbackDeadline, + epochMode: g.meta.CtxFeatures&wire.CtxFeatRosterEpoch != 0, + // rosterFP is seeded from the snapshot roster below, so the + // fingerprint bump won't fire on a same-roster resume — force the + // first post-restore callback to the full form explicitly. + forceFullRoster: g.meta.CtxFeatures&wire.CtxFeatRosterEpoch != 0, + seed: st.seed, + nowNanos: st.nowNanos, + ended: st.ended, + dead: st.dead, + inputCtx: sdk.InputContext(st.inputCtx), + postSeq: st.postSeq, + roster: st.roster, + } + // Roster-epoch mode: rosterEpoch/lastFullEpoch are ephemeral host memory + // (zero on this fresh handler), so the first post-restore callback always + // carries the 0xFFFE full form — the guest re-caches unconditionally and + // no cross-restore epoch reasoning is needed. (h.rosterFP is also zero, + // so the first callback's fingerprint mismatch bumps rosterEpoch to 1.) + // D6 hibernation resync: the host's baseline cache is ephemeral host memory + // and was NOT snapshotted, so it starts fresh (every slot not-present). Seed + // its epoch counter strictly above the pre-snapshot high-water so every + // snapshot-surviving GUEST epoch is now stale: the restored guest's first + // send per consumer (a delta against its surviving baseline, stamped with its + // surviving epoch) epoch-mismatches and is rejected, forcing a keyframe. The + // engine's OnResume re-applies this; seeding here makes a directly-driven + // restore (tests, and any non-OnResume driver) correct too. rosterFP is also + // seeded from the restored roster so a same-roster resume is NOT mistaken for + // a roster mutation (its own invalidateAll would be harmless but redundant). + h.baselines.reseed(st.epochHW) + h.rosterFP = rosterFingerprint(st.roster) + + // Instantiate with the same virtualized WASI surface and a seeded entropy + // source (positioned by resumeEntropy below, after the runtime is primed). + inst, err := g.compiled.Instance(context.Background(), + extism.PluginInstanceConfig{ModuleConfig: h.moduleConfig(st.seed)}) + if err != nil { + return nil, fmt.Errorf("gameabi: restore: instantiate: %w", err) + } + + // Prime the guest runtime BEFORE overwriting memory: extism runs the wasm + // `_initialize` lazily on the first non-start Call, which would re-zero the + // data section (the kit room handler global, the wake counter, …) and clobber + // the restored memory. A throwaway `shellcade_abi` call forces init to run + // now, so the subsequent Write is the final word and later callbacks skip it. + if _, _, err := inst.CallWithContext(context.Background(), wire.ExpABI, nil); err != nil { + _ = inst.Close(context.Background()) + return nil, fmt.Errorf("gameabi: restore: prime runtime: %w", err) + } + // Now re-seek the entropy stream to the snapshot position, discarding the + // bytes the prime's runtime-init drew (otherwise the stream would be off by + // the init draw and the resumed guest would diverge). + h.resumeEntropy(st.seed, st.consumed) + + // Grow the fresh GUEST memory to the snapshot size, then overwrite it with + // the captured bytes. + mem := guestMemory(inst) + if mem == nil { + _ = inst.Close(context.Background()) + return nil, fmt.Errorf("gameabi: restore: no guest linear memory") + } + if cur := mem.Size(); cur < uint32(len(linmem)) { + const pageSize = 64 * 1024 + need := (uint32(len(linmem)) - cur + pageSize - 1) / pageSize + if _, ok := mem.Grow(need); !ok { + _ = inst.Close(context.Background()) + return nil, fmt.Errorf("gameabi: restore: grow memory to %d bytes failed", len(linmem)) + } + } + if ok := mem.Write(0, linmem); !ok { + _ = inst.Close(context.Background()) + return nil, fmt.Errorf("gameabi: restore: write %d bytes failed", len(linmem)) + } + h.inst = inst + return h, nil +} + +// decodeSnapshot reads the host blob, returning the decoded state and the raw +// linear-memory bytes (a slice into raw — the caller copies on Write). +func decodeSnapshot(raw []byte) (snapState, []byte, error) { + r := wire.Rd{B: raw} + if r.U32() != snapshotMagic { + return snapState{}, nil, fmt.Errorf("gameabi: snapshot: bad magic") + } + if f := r.U32(); f != snapshotFormat { + return snapState{}, nil, fmt.Errorf("gameabi: snapshot: format v%d, want v%d", f, snapshotFormat) + } + var st snapState + st.abiVersion = r.U32() + if r.Off+sha256.Size > len(r.B) { + return snapState{}, nil, fmt.Errorf("gameabi: snapshot: truncated artifact hash") + } + copy(st.artifact[:], r.B[r.Off:r.Off+sha256.Size]) + r.Off += sha256.Size + st.nowNanos = r.I64() + st.seed = r.I64() + st.consumed = r.I64() + st.ended = r.Bool() + st.dead = r.Bool() + st.inputCtx = r.U8() + st.mode = sdk.Mode(r.Str()) + st.capacity = int32(r.U32()) + st.minPlayers = int32(r.U32()) + st.epochHW = r.U32() // format 3: frame-delta epoch high-water (D6) + st.postSeq = r.U32() // format 4: leaderboard post-sequence counter + n := int(r.U16()) + for i := 0; i < n; i++ { + p := sdk.Player{ + Handle: r.Str(), + AccountID: r.Str(), + Conn: r.Str(), + Kind: sdk.Kind(r.Str()), + } + st.roster = append(st.roster, p) + } + memLen := int(r.U32()) + if err := r.Err(); err != nil { + return snapState{}, nil, fmt.Errorf("gameabi: snapshot: %w", err) + } + if memLen < 0 || r.Off+memLen > len(r.B) { + return snapState{}, nil, fmt.Errorf("gameabi: snapshot: truncated linear memory (want %d)", memLen) + } + linmem := r.B[r.Off : r.Off+memLen] + return st, linmem, nil +} + +// ---- zstd ---------------------------------------------------------------------- + +// One process-wide encoder/decoder pair: both are safe for concurrent use by +// the stateless EncodeAll/DecodeAll calls. +var ( + zstdEnc, _ = zstd.NewWriter(nil) + zstdDec, _ = zstd.NewReader(nil) +) + +func zstdEncode(b []byte) []byte { return zstdEnc.EncodeAll(b, nil) } +func zstdDecode(b []byte) ([]byte, error) { + return zstdDec.DecodeAll(b, nil) +} diff --git a/host/gameabi/snapshot_cfg_test.go b/host/gameabi/snapshot_cfg_test.go new file mode 100644 index 0000000..a383e86 --- /dev/null +++ b/host/gameabi/snapshot_cfg_test.go @@ -0,0 +1,88 @@ +package gameabi + +import ( + "testing" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// TestSnapshotPreservesRoomConfig is the regression guard for the +// hibernation-determinism bug: a restore that dropped Mode/Capacity/MinPlayers +// handed the resumed guest a different CallContext than the control, diverging +// any game that reads those fields (the kit guest decodes the full RoomConfig +// every callback). Snapshot must carry the WHOLE RoomConfig (the resolved seed +// plus mode/capacity/minplayers) so Restore rebuilds the exact context. +func TestSnapshotPreservesRoomConfig(t *testing.T) { + g := loadFixture(t, Options{}).(*wasmGame) + cfg := sdk.RoomConfig{ + Mode: sdk.ModePrivate, + Capacity: 5, + MinPlayers: 2, + Seed: 9991, + SeedSet: true, + } + h := g.NewRoom(cfg, sdk.Services{Log: quietLog()}).(*wasmHandler) + r := newReplayRoom([]sdk.Player{p1}, cfg, time.Unix(1_700_000_000, 0)) + h.OnStart(r) + h.OnJoin(r, p1) + + blob, err := h.Snapshot() + if err != nil { + t.Fatalf("snapshot: %v", err) + } + hB, err := g.Restore(blob) + if err != nil { + t.Fatalf("restore: %v", err) + } + + if hB.cfg.Mode != cfg.Mode { + t.Errorf("restored Mode = %q, want %q", hB.cfg.Mode, cfg.Mode) + } + if hB.cfg.Capacity != cfg.Capacity { + t.Errorf("restored Capacity = %d, want %d", hB.cfg.Capacity, cfg.Capacity) + } + if hB.cfg.MinPlayers != cfg.MinPlayers { + t.Errorf("restored MinPlayers = %d, want %d", hB.cfg.MinPlayers, cfg.MinPlayers) + } + if hB.cfg.Seed != cfg.Seed { + t.Errorf("restored Seed = %d, want %d", hB.cfg.Seed, cfg.Seed) + } + if !hB.cfg.SeedSet { + t.Error("restored SeedSet = false, want true") + } +} + +// TestBindServicesRestoresServices guards the second half of the fix: a snapshot +// deliberately does not carry host services, so RestoreHandler returns a handler +// with no services until BindServices rewires them. A restored room without +// services would no-op kv/config/leaderboard host calls and diverge from a live +// room. +func TestBindServicesRestoresServices(t *testing.T) { + g := loadFixture(t, Options{}).(*wasmGame) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + svc := sdk.Services{Log: quietLog()} + h := g.NewRoom(cfg, svc).(*wasmHandler) + r := newReplayRoom([]sdk.Player{p1}, cfg, time.Unix(1_700_000_000, 0)) + h.OnStart(r) + + blob, err := h.Snapshot() + if err != nil { + t.Fatalf("snapshot: %v", err) + } + hB, err := g.Restore(blob) + if err != nil { + t.Fatalf("restore: %v", err) + } + // A fresh restore carries no services (Log is nil) — the snapshot is + // host-resource-free by design. + if hB.svc.Log != nil { + t.Error("restored handler unexpectedly carried services from the blob") + } + if !BindServices(hB, svc) { + t.Fatal("BindServices reported false for a wasm handler") + } + if hB.svc.Log == nil { + t.Error("BindServices did not attach the services") + } +} diff --git a/host/gameabi/snapshot_test.go b/host/gameabi/snapshot_test.go new file mode 100644 index 0000000..f544b5d --- /dev/null +++ b/host/gameabi/snapshot_test.go @@ -0,0 +1,330 @@ +package gameabi + +import ( + "errors" + "log/slog" + "math/rand" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// replayRoom is a minimal, deterministic sdk.Room the snapshot tests drive a +// handler against directly: a fixed roster + a settable clock, recording every +// frame the guest pushes (broadcast or personal). Unlike TestRoom it never runs +// a join/start callback of its own, so a restored handler can resume mid-script +// with no extra guest calls — the control and the restored continuation see the +// exact same callback sequence, making frame equality a true determinism check. +type replayRoom struct { + roster []sdk.Player + cfg sdk.RoomConfig + clock time.Time + log *slog.Logger + svc sdk.Services + + frames []sdk.Frame // every frame, in push order (broadcast counts once) + ended bool + res sdk.Result + ctx sdk.InputContext +} + +func newReplayRoom(roster []sdk.Player, cfg sdk.RoomConfig, clock time.Time) *replayRoom { + return &replayRoom{roster: roster, cfg: cfg, clock: clock, log: quietLog()} +} + +func (r *replayRoom) Members() []sdk.Player { return append([]sdk.Player(nil), r.roster...) } +func (r *replayRoom) Has(p sdk.Player) bool { + for _, m := range r.roster { + if m == p { + return true + } + } + return false +} +func (r *replayRoom) Count() int { return len(r.roster) } +func (r *replayRoom) Config() sdk.RoomConfig { return r.cfg } +func (r *replayRoom) Rand() *rand.Rand { return rand.New(rand.NewSource(r.cfg.Seed)) } +func (r *replayRoom) Now() time.Time { return r.clock } + +func (r *replayRoom) Send(p sdk.Player, f sdk.Frame) { r.frames = append(r.frames, f) } +func (r *replayRoom) Identical(f sdk.Frame) { r.frames = append(r.frames, f) } +func (r *replayRoom) BroadcastFunc(compose func(p sdk.Player) sdk.Frame) { + for _, p := range r.roster { + r.frames = append(r.frames, compose(p)) + } +} + +func (r *replayRoom) After(time.Duration, func(sdk.Room)) sdk.TimerID { return 0 } +func (r *replayRoom) Every(time.Duration, func(sdk.Room)) sdk.TimerID { return 0 } +func (r *replayRoom) Cancel(sdk.TimerID) {} +func (r *replayRoom) SetSimRate(time.Duration) {} +func (r *replayRoom) SetFrameRate(time.Duration) {} +func (r *replayRoom) SetPhase(string, bool, time.Time) {} +func (r *replayRoom) SetInputContext(ctx sdk.InputContext) { r.ctx = ctx } + +func (r *replayRoom) End(res sdk.Result) { + if r.ended { + return + } + r.ended, r.res = true, res +} +func (r *replayRoom) Result() (sdk.Result, bool) { + if r.ended { + return r.res, true + } + return sdk.Result{}, false +} +func (r *replayRoom) Services() sdk.Services { return r.svc } +func (r *replayRoom) Log() *slog.Logger { return r.log } + +// ---- round trip -------------------------------------------------------------- + +// snapScript is a deterministic continuation the control and the restored room +// each run: a few wakes, an entropy draw, an input-context cycle, a personal +// frame. Time advances per step so the guest clock moves like a live room. +func snapScript(h *wasmHandler, r *replayRoom, who sdk.Player) { + for i := 0; i < 3; i++ { + r.clock = r.clock.Add(50 * time.Millisecond) + h.OnTick(r, r.clock) + } + h.OnInput(r, who, runeIn('r')) // draw entropy + h.OnInput(r, who, runeIn('i')) // cycle input context + h.OnInput(r, who, runeIn('f')) // personal frame + r.clock = r.clock.Add(50 * time.Millisecond) + h.OnTick(r, r.clock) +} + +// TestSnapshotRoundTrip plays the fixture to a checkpoint, snapshots it, restores +// into a FRESH handler, and runs an identical continuation on both an +// uninterrupted control and the restored room. The continuation frames must be +// byte-identical — proving memory + entropy-stream + clock + roster all resume. +func TestSnapshotRoundTrip(t *testing.T) { + g := loadFixture(t, Options{}).(*wasmGame) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 12345, SeedSet: true} + roster := []sdk.Player{p1} + start := time.Unix(1_700_000_000, 0) + + // --- prefix: drive a handler to a checkpoint (start, join, wakes, entropy) --- + prefix := func(h *wasmHandler) *replayRoom { + r := newReplayRoom(roster, cfg, start) + h.OnStart(r) // instantiate + start + h.OnJoin(r, p1) // join the member + for i := 0; i < 4; i++ { // some wakes to advance the wake counter + r.clock = r.clock.Add(50 * time.Millisecond) + h.OnTick(r, r.clock) + } + h.OnInput(r, p1, runeIn('r')) // draw entropy (advances the stream) + h.OnInput(r, p1, runeIn('i')) // set input context + return r + } + + // Control: one handler runs prefix THEN continuation, uninterrupted. + hCtl := g.NewRoom(cfg, sdk.Services{Log: quietLog()}).(*wasmHandler) + rCtl := prefix(hCtl) + checkpointClock := rCtl.clock + rCtl.frames = nil // compare only the continuation + snapScript(hCtl, rCtl, p1) + wantFrames := rCtl.frames + + // Snapshot path: a second handler runs the SAME prefix, snapshots at the + // checkpoint, then a restored handler runs the continuation. + hA := g.NewRoom(cfg, sdk.Services{Log: quietLog()}).(*wasmHandler) + rA := prefix(hA) + if rA.clock != checkpointClock { + t.Fatalf("prefix nondeterministic clock: %v vs %v", rA.clock, checkpointClock) + } + blob, err := hA.Snapshot() + if err != nil { + t.Fatalf("snapshot: %v", err) + } + t.Logf("snapshot blob: %d bytes (compressed)", len(blob)) + + hB, err := g.Restore(blob) + if err != nil { + t.Fatalf("restore: %v", err) + } + if len(hB.roster) != 1 || hB.roster[0] != p1 { + t.Fatalf("restored roster = %+v, want [p1]", hB.roster) + } + if hB.inputCtx != hA.inputCtx { + t.Fatalf("restored input ctx = %v, want %v", hB.inputCtx, hA.inputCtx) + } + if hB.nowNanos != hA.nowNanos { + t.Fatalf("restored clock = %d, want %d", hB.nowNanos, hA.nowNanos) + } + + rB := newReplayRoom(roster, cfg, checkpointClock) + // ABI v2 resync (D6): the host's per-consumer baseline cache is ephemeral and + // is NOT snapshotted, so on resume it re-seeds the epoch above the snapshot + // high-water and marks every slot not-present. The restored guest's surviving + // baseline makes its first send a DELTA, which the host epoch-rejects — and + // the SDK immediately retries the same frame as a keyframe in the same call + // (kit >= v2.0.1), so NO frame is dropped and no priming is needed: the + // restored room renders the SAME continuation as the uninterrupted control, + // frame-for-frame, byte-for-byte (canonical-zero cells). + hB.OnResume(rB) + snapScript(hB, rB, p1) + gotFrames := rB.frames + + if len(gotFrames) != len(wantFrames) { + t.Fatalf("frame count: restored %d, control %d", len(gotFrames), len(wantFrames)) + } + for i := range wantFrames { + if !framesEqual(wantFrames[i], gotFrames[i]) { + t.Fatalf("continuation frame %d differs between control and restored room", i) + } + } +} + +// TestRestoreRejectsMismatch: Restore verifies the artifact hash + ABI version. +// A tampered artifact-hash byte makes Restore refuse the blob. +func TestRestoreRejectsMismatch(t *testing.T) { + g := loadFixture(t, Options{}).(*wasmGame) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + h := g.NewRoom(cfg, sdk.Services{Log: quietLog()}).(*wasmHandler) + r := newReplayRoom([]sdk.Player{p1}, cfg, time.Unix(1_700_000_000, 0)) + h.OnStart(r) + h.OnJoin(r, p1) + blob, err := h.Snapshot() + if err != nil { + t.Fatalf("snapshot: %v", err) + } + + // Corrupt the decompressed artifact hash, re-compress, and expect a refusal. + raw, err := zstdDecode(blob) + if err != nil { + t.Fatalf("decode: %v", err) + } + st, _, err := decodeSnapshot(raw) + if err != nil { + t.Fatalf("decodeSnapshot: %v", err) + } + if st.artifact != g.wasmSHA { + t.Fatal("snapshot did not record the artifact hash") + } + // Flip a hash byte at its fixed offset (after magic+format+abi = 12 bytes). + raw[12] ^= 0xff + _, err = g.Restore(zstdEncode(raw)) + if err == nil { + t.Fatal("Restore accepted a blob with a mismatched artifact hash") + } + // The refusal is the TYPED sentinel: callers (lobby resume, resident + // bring-up) tell "the live version moved under this snapshot" apart from + // genuine corruption and surface "retired by a game update" instead. + if !errors.Is(err, ErrArtifactMismatch) { + t.Fatalf("mismatch error does not wrap ErrArtifactMismatch: %v", err) + } +} + +// TestCloseHandlerReleasesUnadoptedRestore: RestoreHandler returns a handler +// holding a LIVE instance with grown, written linear memory; before a runtime +// adopts it (sdk.NewRoomRuntime), CloseHandler is the only disposal path — the +// guard every Restore call site applies on its error/lost-race returns so an +// unadopted handler never pins up to 32MiB in the game's shared wazero runtime. +func TestCloseHandlerReleasesUnadoptedRestore(t *testing.T) { + g := loadFixture(t, Options{}).(*wasmGame) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + h := g.NewRoom(cfg, sdk.Services{Log: quietLog()}).(*wasmHandler) + r := newReplayRoom([]sdk.Player{p1}, cfg, time.Unix(1_700_000_000, 0)) + h.OnStart(r) + h.OnJoin(r, p1) + blob, err := h.Snapshot() + if err != nil { + t.Fatalf("snapshot: %v", err) + } + + restored, err := RestoreHandler(g, blob) + if err != nil { + t.Fatalf("restore: %v", err) + } + if GuestMemorySize(restored) == 0 { + t.Fatal("restored handler reports no live linear memory — the leak premise changed") + } + if !CloseHandler(restored) { + t.Fatal("CloseHandler refused a wasm handler") + } + if got := GuestMemorySize(restored); got != 0 { + t.Fatalf("instance still live after CloseHandler: %d bytes", got) + } + if !CloseHandler(restored) { + t.Fatal("CloseHandler is not idempotent on an already-closed handler") + } + if CloseHandler(sdk.Base{}) { + t.Fatal("CloseHandler claimed to close a non-wasm handler") + } +} + +// framesEqual is the byte-identity check: the 80x24 cell grid is a comparable +// fixed-size array, so equality is exact. +func framesEqual(a, b sdk.Frame) bool { return a.Cells == b.Cells } + +// ---- leaderboard post sequence (format 4) -------------------------------------- + +// postCapture records every leaderboard post the host issues, with the +// host-stamped room-scoped RoundSeq. +type postCapture struct { + slugs []string + seqs []uint64 +} + +func (c *postCapture) Post(slug string, r sdk.Result) { + c.slugs = append(c.slugs, slug) + c.seqs = append(c.seqs, r.RoundSeq) +} + +// TestSnapshotPostSeqIdempotency: the host stamps each leaderboard post with a +// monotonic room-scoped sequence, and the counter survives a snapshot/restore +// (format 4). A room reclaimed from a PRE-settle checkpoint that re-settles +// the same round must replay the SAME sequence the control issued — that +// sequence is what the durable leaderboard derives the idempotent round id +// from, so the re-settle dedupes instead of double-counting. +func TestSnapshotPostSeqIdempotency(t *testing.T) { + g := loadFixture(t, Options{}).(*wasmGame) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 99, SeedSet: true} + roster := []sdk.Player{p1} + start := time.Unix(1_700_000_000, 0) + + // Control: post once, checkpoint here, then settle (post again). + ctl := &postCapture{} + hCtl := g.NewRoom(cfg, sdk.Services{Log: quietLog(), Leaderboard: ctl}).(*wasmHandler) + rCtl := newReplayRoom(roster, cfg, start) + hCtl.OnStart(rCtl) + hCtl.OnJoin(rCtl, p1) + hCtl.OnInput(rCtl, p1, runeIn('s')) // fixture posts a result + if hCtl.postSeq != 1 { + t.Fatalf("postSeq=%d want 1 after first post", hCtl.postSeq) + } + blob, err := hCtl.Snapshot() // pre-settle checkpoint + if err != nil { + t.Fatalf("snapshot: %v", err) + } + hCtl.OnInput(rCtl, p1, runeIn('s')) // the settle the crash will replay + if want := []uint64{1, 2}; len(ctl.seqs) != 2 || ctl.seqs[0] != want[0] || ctl.seqs[1] != want[1] { + t.Fatalf("control seqs=%v want %v", ctl.seqs, want) + } + + // Reclaim: restore the pre-settle checkpoint and re-settle. The replayed + // post must carry the control's sequence (2), not restart at 1. + hRe, err := g.Restore(blob) + if err != nil { + t.Fatalf("restore: %v", err) + } + defer func() { _ = CloseHandler(hRe) }() + if hRe.postSeq != 1 { + t.Fatalf("restored postSeq=%d want 1 (format 4 must carry the counter)", hRe.postSeq) + } + re := &postCapture{} + if !BindServices(hRe, sdk.Services{Log: quietLog(), Leaderboard: re}) { + t.Fatal("BindServices refused the restored handler") + } + rRe := newReplayRoom(roster, cfg, rCtl.clock) + hRe.OnResume(rRe) + hRe.OnInput(rRe, p1, runeIn('s')) + if len(re.seqs) != 1 || re.seqs[0] != 2 { + t.Fatalf("replayed seqs=%v want [2] (same round -> same sequence -> same round id)", re.seqs) + } + if re.slugs[0] != g.meta.Slug { + t.Fatalf("replayed slug=%q want %q", re.slugs[0], g.meta.Slug) + } +} diff --git a/host/gameabi/storefault_test.go b/host/gameabi/storefault_test.go new file mode 100644 index 0000000..cde55b0 --- /dev/null +++ b/host/gameabi/storefault_test.go @@ -0,0 +1,296 @@ +package gameabi + +import ( + "context" + "errors" + "log/slog" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// These tests pin the slow-store → deadline-kill attribution path: a kv host +// call whose store outlives the callback kill switch must (a) release the room +// actor AT the deadline (the store context is derived from the callback ctx, +// not detached), and (b) settle the room WITHOUT a fault — host I/O slowness +// (a shared-Postgres brownout) must never feed quarantine. A genuine +// spin-to-deadline still faults. Store ERRORS must be logged host-side with +// slug/account/key instead of silently conflating into key-absent. +// +// The doubles are wired the only way a real game reaches storage: a fake +// sdk.AccountStore via sdk.Services.Accounts (AccountStore → Account.Store()). + +// fakeAccounts yields accounts whose KVStore is the injected double. +type fakeAccounts struct{ kv sdk.KVStore } + +func (f fakeAccounts) For(p sdk.Player) sdk.Account { return fakeAccount{p: p, kv: f.kv} } + +type fakeAccount struct { + p sdk.Player + kv sdk.KVStore +} + +func (a fakeAccount) ID() string { return a.p.AccountID } +func (a fakeAccount) Handle() string { return a.p.Handle } +func (a fakeAccount) Kind() sdk.Kind { return a.p.Kind } +func (a fakeAccount) Store() sdk.KVStore { return a.kv } + +// slowKV blocks every operation for d or until the caller's context dies — +// exactly how a ctx-honoring Postgres driver behaves during a brownout. +type slowKV struct{ d time.Duration } + +func (s slowKV) wait(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(s.d): + return nil + } +} + +func (s slowKV) Get(ctx context.Context, key string) ([]byte, bool, error) { + if err := s.wait(ctx); err != nil { + return nil, false, err + } + return nil, false, nil +} + +func (s slowKV) Set(ctx context.Context, key string, value []byte, rule sdk.MergeRule) error { + return s.wait(ctx) +} + +func (s slowKV) Delete(ctx context.Context, key string) error { return s.wait(ctx) } + +// errKV fails every operation immediately (a DB refusing connections). +type errKV struct{ err error } + +func (e errKV) Get(ctx context.Context, key string) ([]byte, bool, error) { return nil, false, e.err } +func (e errKV) Set(ctx context.Context, key string, value []byte, rule sdk.MergeRule) error { + return e.err +} +func (e errKV) Delete(ctx context.Context, key string) error { return e.err } + +// attrCapture is a slog.Handler recording message + attrs, so a test can +// assert the host logged a kv failure WITH its slug/account/key context. +type attrCapture struct { + mu sync.Mutex + recs []capturedRec +} + +type capturedRec struct { + msg string + attrs map[string]string +} + +func (c *attrCapture) Enabled(context.Context, slog.Level) bool { return true } +func (c *attrCapture) Handle(_ context.Context, r slog.Record) error { + attrs := map[string]string{} + r.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = a.Value.String() + return true + }) + c.mu.Lock() + c.recs = append(c.recs, capturedRec{msg: r.Message, attrs: attrs}) + c.mu.Unlock() + return nil +} +func (c *attrCapture) WithAttrs([]slog.Attr) slog.Handler { return c } +func (c *attrCapture) WithGroup(string) slog.Handler { return c } + +func (c *attrCapture) find(msg string) (capturedRec, bool) { + c.mu.Lock() + defer c.mu.Unlock() + for _, r := range c.recs { + if r.msg == msg { + return r, true + } + } + return capturedRec{}, false +} + +func (c *attrCapture) count(msg string) int { + c.mu.Lock() + defer c.mu.Unlock() + n := 0 + for _, r := range c.recs { + if r.msg == msg { + n++ + } + } + return n +} + +// TestSlowStoreDeadlineIsNotAGameFault: the fixture's 'k' command does a kv set +// on the sender's store; the store stalls far past the 50ms callback deadline. +// The room actor must come back at the deadline (NOT after the store's full +// latency, and NOT after the detached 2s kvTimeout the old code used), the room +// must settle (wazero condemned the instance at the deadline), and NO fault may +// be booked — quarantine must never count a Postgres brownout against the game. +func TestSlowStoreDeadlineIsNotAGameFault(t *testing.T) { + var faults atomic.Int32 + rec := newRecordingMetrics() + g := loadFixture(t, Options{ + CallbackDeadline: 50 * time.Millisecond, + OnFault: func(string) { faults.Add(1) }, + Metrics: rec, + }) + svc := sdk.Services{Log: quietLog(), Accounts: fakeAccounts{kv: slowKV{d: 30 * time.Second}}} + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, svc) + tr.Start() + tr.Join(p1) + + start := time.Now() + tr.Input(p1, runeIn('k')) // guest blocks in the host's own kv_set + elapsed := time.Since(start) + + // The actor was released by the kill switch: well under the old detached + // 2s kvTimeout (the bound is ~50ms; 1s leaves generous CI slack). + if elapsed >= time.Second { + t.Fatalf("actor blocked %v in a slow kv host call, want release at the ~50ms deadline", elapsed) + } + if !tr.Ended { + t.Fatal("room did not settle after the deadline condemned the instance") + } + if n := faults.Load(); n != 0 { + t.Fatalf("host-I/O deadline booked %d fault(s) — DB slowness would feed quarantine", n) + } + if _, _, f := rec.snapshot(); len(f) != 0 { + t.Fatalf("fault metric moved on a host-I/O deadline: %v", f) + } + if got := rec.totalHostIODeadlines(); got != 1 { + t.Fatalf("host-I/O deadline metric = %d, want 1", got) + } + if got := rec.totalDeadlines(); got != 0 { + t.Fatalf("spin-to-deadline metric = %d, want 0 (this was host I/O, not a spin)", got) + } +} + +// TestErroringStoreIsLoggedNotFatal: a store that ERRORS (DB down, not slow) +// must not kill the room or fault the game — the guest sees the ABI's silent +// key-absent/dropped-write result — but the host must log each failure with +// slug/account/key (the old code discarded kv_set errors entirely) and count it. +func TestErroringStoreIsLoggedNotFatal(t *testing.T) { + var faults atomic.Int32 + rec := newRecordingMetrics() + g := loadFixture(t, Options{ + OnFault: func(string) { faults.Add(1) }, + Metrics: rec, + }) + logCap := &attrCapture{} + svc := sdk.Services{Log: slog.New(logCap), Accounts: fakeAccounts{kv: errKV{err: errors.New("pg: connection refused")}}} + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, svc) + tr.Start() + tr.Join(p1) + + tr.Input(p1, runeIn('k')) // guest kv set + get, both erroring + + if tr.Ended { + t.Fatal("room settled on a store ERROR — an erroring DB must not kill rooms") + } + if n := faults.Load(); n != 0 { + t.Fatalf("store error booked %d fault(s)", n) + } + for _, op := range []string{"kv_set", "kv_get"} { + r, ok := logCap.find("gameabi: " + op + " failed") + if !ok { + t.Fatalf("no host-side log for the failed %s; got %+v", op, logCap.recs) + } + if r.attrs["slug"] != "fixture" || r.attrs["account"] != p1.AccountID || r.attrs["key"] != "visits" { + t.Fatalf("%s failure logged without slug/account/key context: %+v", op, r.attrs) + } + } + if got := rec.totalKVErrors(); got != 2 { + t.Fatalf("kv error metric = %d, want 2 (set + get)", got) + } +} + +// TestSpinToDeadlineStillFaults guards the discrimination from the other side: +// a guest that genuinely burns its budget ('l' spins forever) is still a fault +// feeding quarantine, even with a kv store wired — host-I/O exemption must not +// become quarantine evasion. +func TestSpinToDeadlineStillFaults(t *testing.T) { + var faults atomic.Int32 + rec := newRecordingMetrics() + g := loadFixture(t, Options{ + CallbackDeadline: 50 * time.Millisecond, + OnFault: func(string) { faults.Add(1) }, + Metrics: rec, + }) + svc := sdk.Services{Log: quietLog(), Accounts: fakeAccounts{kv: slowKV{d: time.Second}}} + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, svc) + tr.Start() + tr.Join(p1) + + tr.Input(p1, runeIn('l')) // spin to the deadline + + if !tr.Ended { + t.Fatal("room did not settle after a spin-to-deadline") + } + if n := faults.Load(); n != 1 { + t.Fatalf("spin-to-deadline faults = %d, want 1", n) + } + if got := rec.totalDeadlines(); got != 1 { + t.Fatalf("spin-to-deadline metric = %d, want 1", got) + } + if got := rec.totalHostIODeadlines(); got != 0 { + t.Fatalf("host-I/O deadline metric = %d, want 0 (the guest spun; no host I/O expired)", got) + } +} + +// TestGuestLogCaps pins the guest log meters shared by BOTH guest log paths +// (stdout/stderr logWriter and the `log` host function): per-write truncation +// at guestLogMaxWrite, a per-window byte budget with exactly one rate-limited +// Warn marker, and budget recovery in the next window. +func TestGuestLogCaps(t *testing.T) { + logCap := &attrCapture{} + h := &wasmHandler{ + game: &wasmGame{meta: sdk.GameMeta{Slug: "fixture"}}, + svc: sdk.Services{Log: slog.New(logCap)}, + } + w := &logWriter{h: h} + + big := strings.Repeat("x", guestLogMaxWrite+1000) + if n, err := w.Write([]byte(big)); n != len(big) || err != nil { + t.Fatalf("Write = (%d, %v), want full length consumed", n, err) + } + r, ok := logCap.find("guest") + if !ok { + t.Fatal("guest stdout write produced no log record") + } + if got, want := len(r.attrs["out"]), len(truncateGuestLog(big)); got != want { + t.Fatalf("guest write logged %d bytes, want truncated %d", got, want) + } + if !strings.HasSuffix(r.attrs["out"], "…[truncated]") { + t.Fatal("oversized guest write not marked truncated") + } + + // Exhaust the window budget: emission stops, ONE Warn marker fires. + for i := 0; i < guestLogBudget/guestLogMaxWrite+3; i++ { + _, _ = w.Write([]byte(big)) + } + if got := logCap.count("gameabi: guest log output rate-limited"); got != 1 { + t.Fatalf("rate-limited markers = %d, want exactly 1 per window", got) + } + emitted := logCap.count("guest") + if emitted > guestLogBudget/guestLogMaxWrite+1 { + t.Fatalf("emitted %d guest records, want the budget to stop emission", emitted) + } + _, _ = w.Write([]byte("still over budget")) + if got := logCap.count("guest"); got != emitted { + t.Fatalf("write emitted past the exhausted budget (%d -> %d records)", emitted, got) + } + + // A new window restores the budget (and re-arms the marker). + h.logWindowStart = time.Now().Add(-2 * guestLogWindow) + _, _ = w.Write([]byte("fresh window")) + if got := logCap.count("guest"); got != emitted+1 { + t.Fatalf("budget did not recover in a new window (%d -> %d records)", emitted, got) + } +} diff --git a/host/gameabi/testdata/fixture-rs-kit/.gitignore b/host/gameabi/testdata/fixture-rs-kit/.gitignore new file mode 100644 index 0000000..4ff0417 --- /dev/null +++ b/host/gameabi/testdata/fixture-rs-kit/.gitignore @@ -0,0 +1,4 @@ +# Build outputs are not committed (the canonical artifact is copied to +# target/wasm32-wasip1/release/fixture_rs_kit.wasm by `make fixture-rs-kit-wasm`). +target/ +Cargo.lock diff --git a/host/gameabi/testdata/fixture-rs-kit/Cargo.toml b/host/gameabi/testdata/fixture-rs-kit/Cargo.toml new file mode 100644 index 0000000..072c473 --- /dev/null +++ b/host/gameabi/testdata/fixture-rs-kit/Cargo.toml @@ -0,0 +1,31 @@ +# fixture-rs-kit — the conformance fixture built ON the shellcade-kit Rust SDK +# crate (kit/rust). It JOINS the hand-rolled fixture-rs (which stays SDK-free as +# the proof that ABI.md is implementable from the document alone): this one +# proves the SDK itself passes the server's own acceptance gate, including +# hibernation byte-identity and fault containment. +# +# Build recipe: `make fixture-rs-kit-wasm` (see Makefile; the committed +# fixture-rs-kit.wasm is what CI tests against — no cargo in private CI). +# The dependency pins the kit release tag; before that tag exists (or to test +# unreleased SDK changes), override with a patch: +# cargo build --release --target wasm32-wasip1 \ +# --config 'patch."https://github.com/shellcade/kit".shellcade-kit.path="/rust"' + +[package] +name = "fixture-rs-kit" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +shellcade-kit = { git = "https://github.com/shellcade/kit", tag = "v2.2.0" } + +# The artifact contract: profiles apply only at this (leaf) crate. +[profile.release] +opt-level = "s" +lto = true +strip = true +panic = "abort" diff --git a/host/gameabi/testdata/fixture-rs-kit/src/lib.rs b/host/gameabi/testdata/fixture-rs-kit/src/lib.rs new file mode 100644 index 0000000..3c25fa4 --- /dev/null +++ b/host/gameabi/testdata/fixture-rs-kit/src/lib.rs @@ -0,0 +1,110 @@ +//! fixture-rs-kit — the conformance fixture built ON the `shellcade-kit` Rust +//! SDK crate. Same observable surface as the hand-rolled fixture-rs: +//! +//! start/join/leave -> render the status frame +//! input 'e' -> end the room (winner = sender, metric 42) +//! input 'p' -> panic (guest trap; panic=abort -> wasm unreachable) +//! wake -> increment the wake counter, then render +//! +//! Where fixture-rs proves ABI.md is implementable from the document alone, +//! THIS fixture proves the SDK passes the server's own gate: the SDK's delta +//! path (baselines, epochs, keyframe retry) runs under the full conformance +//! script including the snapshot/restore hibernation byte-identity check and +//! the guest-fault containment case. Note this game contains ZERO unsafe and +//! zero wire code. +#![forbid(unsafe_code)] + +use shellcade_kit::prelude::*; + +struct FixtureGame; + +impl Game for FixtureGame { + fn meta(&self) -> Meta { + Meta { + slug: "fixture-rs-kit", + name: "Fixture (Rust, kit crate)", + short_description: "Conformance fixture built on the shellcade-kit Rust SDK.", + min_players: 1, + max_players: 2, + ..Meta::DEFAULT + } + } + fn new_room(&self, _cfg: &RoomConfig) -> Box { + Box::new(FixtureRoom { frame: Frame::new(), wakes: 0 }) + } +} + +struct FixtureRoom { + frame: Frame, + wakes: u64, +} + +impl FixtureRoom { + fn render(&mut self, r: &mut Room) { + let st = Style::new(WHITE, 0); + self.frame.clear(); + self.frame.text(0, 0, "FIXTURE-RS-KIT", st); + self.frame.text(1, 0, &format!("players={}", r.count()), st); + self.frame.text(2, 0, &format!("wakes={}", self.wakes), st); + r.identical(&self.frame); + } +} + +impl Handler for FixtureRoom { + fn on_start(&mut self, r: &mut Room) { + self.render(r); + } + + fn on_join(&mut self, r: &mut Room, _p: Player) { + self.render(r); + } + + fn on_leave(&mut self, r: &mut Room, _p: Player) { + self.render(r); + } + + fn on_input(&mut self, r: &mut Room, p: Player, input: Input) { + match input { + Input::Char('e') => { + // Settle: winner = sender, metric 42 (the fixture contract). + r.end(&Outcome { + rankings: vec![PlayerResult { + player: p, + metric: 42, + rank: 1, + status: Status::Finished, + }], + }); + } + Input::Char('p') => panic!("fixture-rs-kit: deliberate guest panic"), + _ => {} + } + self.render(r); + } + + fn on_wake(&mut self, r: &mut Room) { + self.wakes += 1; + self.render(r); + } +} + +shellcade_kit::shellcade_game!(FixtureGame); + +#[cfg(test)] +mod tests { + use super::*; + use shellcade_kit::{reset_test_host, with_test_host}; + + // Native sanity: the fixture's room logic against the SDK's test host — + // the full conformance run happens in Go off the committed wasm. + #[test] + fn renders_and_settles() { + reset_test_host(); + let game = FixtureGame; + let mut h = game.new_room(&RoomConfig::default()); + // The Handler is exercised through the SDK dispatch in production; + // here we only smoke the Frame composition path compiles and runs. + let _ = &mut h; + with_test_host(|t| assert!(t.sent.is_empty())); + } +} diff --git a/host/gameabi/testdata/fixture-rs/.gitignore b/host/gameabi/testdata/fixture-rs/.gitignore new file mode 100644 index 0000000..508747e --- /dev/null +++ b/host/gameabi/testdata/fixture-rs/.gitignore @@ -0,0 +1,4 @@ +# Cargo build output. The committed artifact is fixture-rs.wasm (copied from +# target/wasm32-wasip1/release/fixture_rs.wasm by `make fixture-rs-wasm`). +/target +Cargo.lock diff --git a/host/gameabi/testdata/fixture-rs/Cargo.toml b/host/gameabi/testdata/fixture-rs/Cargo.toml new file mode 100644 index 0000000..f6c7476 --- /dev/null +++ b/host/gameabi/testdata/fixture-rs/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "fixture-rs" +version = "0.1.0" +edition = "2021" +publish = false + +# A second fixture GUEST written in Rust, implemented from the public ABI +# contract (kit/ABI.md + wire.go) alone. It proves the shellcade game ABI is +# language-neutral: the same host adapter and conformance harness run it green. +# +# Build (dev profile — `make fixture-rs-wasm` from the repo root): +# cargo build --release --target wasm32-wasip1 +# The artifact lands at target/wasm32-wasip1/release/fixture_rs.wasm and is +# committed alongside as fixture-rs.wasm. + +[lib] +# cdylib on wasm32-wasip1 produces a WASI reactor: a `memory` export and an +# `_initialize` export, exactly like the TinyGo c-shared fixture. The 8 ABI +# entry points are added via #[no_mangle] extern "C" exports. +crate-type = ["cdylib"] + +[dependencies] +# extism-pdk supplies only the kernel plumbing (input/output regions, the +# Memory allocator over extism:host/env). The 11 shellcade host functions are +# declared directly as raw wasm imports in src/lib.rs, because the PDK's +# #[host_fn] macro allocates EVERY argument into memory and passes an offset — +# wrong for our raw i64 scalar params (playerIdx, log level, input context). +extism-pdk = "1.4" + +[profile.release] +# Match the dev-profile intent of the TinyGo fixture: small, fast-to-build. +opt-level = "s" +lto = true +strip = true +panic = "abort" diff --git a/host/gameabi/testdata/fixture-rs/src/lib.rs b/host/gameabi/testdata/fixture-rs/src/lib.rs new file mode 100644 index 0000000..b9f2203 --- /dev/null +++ b/host/gameabi/testdata/fixture-rs/src/lib.rs @@ -0,0 +1,436 @@ +//! fixture-rs — a second gameabi test guest, written in RUST from the public +//! shellcade ABI contract (kit/ABI.md + wire.go) alone. +//! +//! It proves the ABI is language-neutral: the SAME host adapter +//! (internal/gameabi) and the SAME conformance harness that drive the TinyGo +//! Go fixture also drive this artifact green. Only the CORE surface is mirrored +//! (full command parity with the Go fixture is a non-goal): +//! +//! shellcade_abi -> u32 ABI major version (2) +//! meta -> slug "fixture-rs", 1..=2 players +//! start/join/leave -> render the status frame +//! input 'e' -> end the room (winner = sender, metric 42) +//! input 'p' -> panic (guest trap; panic=abort -> wasm unreachable) +//! wake -> increment the wake counter, then render +//! +//! The rendered frame matches the Go fixture's layout (banner / players / wakes) +//! with the banner text FIXTURE-RS so a reader can tell the two artifacts apart. +//! +//! Transport is Extism: the kernel memory/IO plumbing comes from extism-pdk, +//! the 11 host functions are imported from the `extism:host/user` namespace, and +//! the 8 entry points are bare `extern "C"` exports returning an i32 status +//! (0 = ok) — the mechanics the Go runtime's //go:export trampolines also use. + +use extism_pdk::extism; +use extism_pdk::Memory; + +// --------------------------------------------------------------------------- +// Host functions (namespace `extism:host/user`). Declared as RAW wasm imports +// so scalar params (i64 playerIdx) pass as integers and pointer params pass as +// memory offsets (u64) — mirroring the host's (i64 playerIdx, ptr frame) +// signatures exactly. Only the functions the core surface calls are declared. +// --------------------------------------------------------------------------- +#[link(wasm_import_module = "extism:host/user")] +extern "C" { + /// identical(ptr deltaContainer) -> i64 epoch: deliver one frame to every + /// player. In ABI v2 the payload is the frame-delta container (§4.5) and the + /// return value's low 32 bits carry the host-issued epoch the guest must + /// stamp its baseline with; the upper 32 bits are reserved-zero. + fn identical(frame_off: u64) -> u64; + /// end(ptr result): settle the room exactly once. + fn end(result_off: u64); + /// log(i64 level, ptr msg): 0 debug · 1 info · 2 warn · 3 error. + fn log(level: i64, msg_off: u64); +} + +// --------------------------------------------------------------------------- +// Wire constants (ABI v2). Mirrors of kit/wire. +// --------------------------------------------------------------------------- +const ABI_VERSION: u32 = 2; + +const ROWS: usize = 24; +const COLS: usize = 80; +const CELL_BYTES: usize = 24; // v2 grapheme cell: rune@0 cp2@4 cp3@8 fg@12 bg@16 attr@20 cont@21 pad@22 +const FRAME_BYTES: usize = ROWS * COLS * CELL_BYTES; // 46080 +const FRAME_CELLS: usize = ROWS * COLS; // 1920 + +// Frame-delta container header (§4.5): u8 flags, u32 epoch, u16 runCount, u8 +// rows, u8 cols, then runs of {u16 startIndex, u16 runLen, runLen*CELL_BYTES}. +const DELTA_HEADER_BYTES: usize = 9; +const RUN_HEADER_BYTES: usize = 4; +const FLAG_KEYFRAME: u8 = 0x01; +// Keyframe form = header + one run {0, 1920} + the full grid = 46093 bytes. +const KEYFRAME_BYTES: usize = DELTA_HEADER_BYTES + RUN_HEADER_BYTES + FRAME_BYTES; + +const INPUT_RUNE: u8 = 0; // input kind: printable rune +const STATUS_FINISHED: u8 = 0; // ranking status + +// --------------------------------------------------------------------------- +// Per-room state. One plugin instance == one room, so a single mutable global +// holds the entire room state in linear memory (the ABI's state model). Guest +// code only ever runs serially inside a host callback, so this is sound. +// --------------------------------------------------------------------------- +struct Room { + wakes: i64, + players: usize, + // Host-issued broadcast epoch, mirrored from the last identical() return so + // each container stamps the epoch the host expects. This hand-rolled guest + // always ships a KEYFRAME (the host accepts a keyframe regardless of epoch + // and adopts the header epoch), so mirroring keeps the stamp consistent but + // is not strictly required for correctness. + epoch: u32, +} + +static mut ROOM: Room = Room { + wakes: 0, + players: 0, + epoch: 0, +}; + +#[allow(static_mut_refs)] +fn room() -> &'static mut Room { + // SAFETY: callbacks are invoked serially per room (ABI §1); there is never + // concurrent access to ROOM. + unsafe { &mut ROOM } +} + +// --------------------------------------------------------------------------- +// Little-endian append encoder (the wire format is all little-endian). +// --------------------------------------------------------------------------- +struct Buf { + b: Vec, +} + +impl Buf { + fn new() -> Self { + Buf { b: Vec::new() } + } + fn u8(&mut self, v: u8) { + self.b.push(v); + } + fn u16(&mut self, v: u16) { + self.b.extend_from_slice(&v.to_le_bytes()); + } + fn u32(&mut self, v: u32) { + self.b.extend_from_slice(&v.to_le_bytes()); + } + fn i64(&mut self, v: i64) { + self.b.extend_from_slice(&v.to_le_bytes()); + } + fn str(&mut self, s: &str) { + let bytes = s.as_bytes(); + let n = bytes.len().min(0xffff); + self.u16(n as u16); + self.b.extend_from_slice(&bytes[..n]); + } +} + +// --------------------------------------------------------------------------- +// Bounds-checked little-endian decoder (matches wire.Rd semantics: short reads +// degrade to zero/empty and set the bad flag rather than panicking). +// --------------------------------------------------------------------------- +struct Rd<'a> { + b: &'a [u8], + off: usize, + bad: bool, +} + +impl<'a> Rd<'a> { + fn new(b: &'a [u8]) -> Self { + Rd { b, off: 0, bad: false } + } + fn ok(&mut self, n: usize) -> bool { + if self.bad || self.off + n > self.b.len() { + self.bad = true; + return false; + } + true + } + fn u8(&mut self) -> u8 { + if !self.ok(1) { + return 0; + } + let v = self.b[self.off]; + self.off += 1; + v + } + fn u16(&mut self) -> u16 { + if !self.ok(2) { + return 0; + } + let v = u16::from_le_bytes([self.b[self.off], self.b[self.off + 1]]); + self.off += 2; + v + } + fn u32(&mut self) -> u32 { + if !self.ok(4) { + return 0; + } + let mut a = [0u8; 4]; + a.copy_from_slice(&self.b[self.off..self.off + 4]); + self.off += 4; + u32::from_le_bytes(a) + } + fn i64(&mut self) -> i64 { + if !self.ok(8) { + return 0; + } + let mut a = [0u8; 8]; + a.copy_from_slice(&self.b[self.off..self.off + 8]); + self.off += 8; + i64::from_le_bytes(a) + } + fn skip_str(&mut self) { + let n = self.u16() as usize; + if self.ok(n) { + self.off += n; + } + } +} + +/// Decoded slice of the CallContext (§4.1) the core surface needs. We read just +/// the member count (for `players=`) and skip the rest of each member entry. +struct Ctx { + member_count: usize, +} + +fn decode_ctx<'a>(input: &'a [u8]) -> (Ctx, Rd<'a>) { + let mut r = Rd::new(input); + r.i64(); // nowUnixNanos + r.i64(); // seed + r.u8(); // seedSet + r.u8(); // mode + r.u16(); // capacity + r.u16(); // minPlayers + let n = r.u16() as usize; // memberCount + for _ in 0..n { + if r.bad { + break; + } + r.skip_str(); // handle + r.skip_str(); // accountID + r.skip_str(); // conn + r.u8(); // kind + } + r.u8(); // settled + (Ctx { member_count: n }, r) +} + +// --------------------------------------------------------------------------- +// Frame composition. A frame is ROWS*COLS cells of CELL_BYTES each (§4.3); we +// write ASCII text into a zeroed buffer. Unset fg/bg/attr/cont are all 0, so a +// zero buffer is already a blank frame. +// --------------------------------------------------------------------------- +fn blank_frame() -> Vec { + vec![0u8; FRAME_BYTES] +} + +/// put_cell writes a single rune into the frame at (row, col). The 24-byte v2 +/// cell is canonical-zero: only the base rune is set; cp2/cp3 (4..12), fg/bg +/// (12..20), attr/cont (20..22) and pad (22..24) stay zero. +fn put_cell(buf: &mut [u8], row: usize, col: usize, rune: u32) { + if row >= ROWS || col >= COLS { + return; + } + let o = (row * COLS + col) * CELL_BYTES; + buf[o..o + 4].copy_from_slice(&rune.to_le_bytes()); + // bytes 4..24 stay zero (canonical-zero rule). +} + +/// text writes an ASCII string starting at (row, col), one cell per byte. +fn text(buf: &mut [u8], row: usize, col: usize, s: &str) { + for (i, ch) in s.bytes().enumerate() { + put_cell(buf, row, col + i, ch as u32); + } +} + +/// render composes and broadcasts the status frame: banner, player count, wake +/// count — the Go fixture's layout, with a distinct banner. +fn render() { + let rm = room(); + let mut f = blank_frame(); + text(&mut f, 0, 0, "FIXTURE-RS"); + text(&mut f, 1, 0, &format!("players={}", rm.players)); + text(&mut f, 2, 0, &format!("wakes={}", rm.wakes)); + send_identical(&f); +} + +/// keyframe_container wraps a full packed grid in the v2 frame-delta KEYFRAME +/// form (§4.5): a 9-byte header {flags bit0, epoch, runCount=1, rows=24, cols=80} +/// + one run {startIndex=0, runLen=1920} + the 46080-byte grid = 46093 bytes. +/// This hand-rolled guest always ships a keyframe: the host accepts a keyframe +/// regardless of epoch and adopts the header epoch, so it is always correct +/// (and is judged on reconstructed frames, not wire-byte equality with the +/// reference encoder). epoch is the host's last-issued broadcast epoch. +fn keyframe_container(frame: &[u8], epoch: u32) -> Vec { + let mut c = Vec::with_capacity(KEYFRAME_BYTES); + c.push(FLAG_KEYFRAME); // u8 flags: keyframe + c.extend_from_slice(&epoch.to_le_bytes()); // u32 epoch + c.extend_from_slice(&1u16.to_le_bytes()); // u16 runCount = 1 + c.push(ROWS as u8); // u8 rows = 24 + c.push(COLS as u8); // u8 cols = 80 + c.extend_from_slice(&0u16.to_le_bytes()); // u16 startIndex = 0 + c.extend_from_slice(&(FRAME_CELLS as u16).to_le_bytes()); // u16 runLen = 1920 + c.extend_from_slice(frame); // 1920 * 24 bytes + c +} + +/// send_identical wraps the frame in a keyframe container, allocates it into +/// Extism memory, calls the host `identical`, mirrors the returned epoch (low 32 +/// bits), then frees the block (kernel memory is not GC'd). +fn send_identical(frame: &[u8]) { + let epoch = room().epoch; + let container = keyframe_container(frame, epoch); + let mem = Memory::from_bytes(&container).expect("alloc frame"); + let off = mem.offset(); + let returned = unsafe { identical(off) }; + mem.free(); + room().epoch = returned as u32; // low 32 bits carry the epoch; upper is reserved-zero +} + +/// host_log emits a guest log line at info level (level 1). +fn host_log(msg: &str) { + let mem = Memory::from_bytes(msg).expect("alloc log"); + let off = mem.offset(); + unsafe { log(1, off) }; + mem.free(); +} + +/// end_room settles the room with a single ranking (winner = the given roster +/// index, metric 42, rank 1, finished) — the Go fixture's 'e' result. +fn end_room(player_idx: u32, metric: i64) { + let mut w = Buf::new(); + w.u16(1); // rankingCount + w.u32(player_idx); // playerIdx + w.i64(metric); // metric + w.u16(1); // rank + w.u8(STATUS_FINISHED); // status + let mem = Memory::from_bytes(&w.b).expect("alloc result"); + let off = mem.offset(); + unsafe { end(off) }; + mem.free(); +} + +// --------------------------------------------------------------------------- +// Input helpers. +// --------------------------------------------------------------------------- +fn read_input() -> Vec { + unsafe { extism::load_input() } +} + +/// write_output stores a byte slice as the export's output value. +fn write_output(b: &[u8]) { + let mem = Memory::from_bytes(b).expect("alloc output"); + mem.set_output(); +} + +// --------------------------------------------------------------------------- +// The 8 ABI exports. Bare `extern "C" fn() -> i32` (0 = ok), matching the Go +// runtime's //go:export int32 trampolines. They read input via the Extism +// kernel and drive the host effect functions directly. +// --------------------------------------------------------------------------- + +/// shellcade_abi: 4 bytes, u32 ABI major version (little-endian). +#[no_mangle] +pub extern "C" fn shellcade_abi() -> i32 { + write_output(&ABI_VERSION.to_le_bytes()); + 0 +} + +/// meta: packed Meta (§4.2). slug "fixture-rs", 1..=2 players, no leaderboard. +#[no_mangle] +pub extern "C" fn meta() -> i32 { + let mut w = Buf::new(); + w.str("fixture-rs"); // slug + w.str("Fixture (Rust)"); // name + w.str("gameabi rust test guest"); // shortDescription + w.u16(1); // minPlayers + w.u16(2); // maxPlayers + w.u16(0); // tagCount + w.str(""); // quickModeLabel + w.str(""); // soloModeLabel + w.str(""); // privateInviteLine + w.u8(0); // hasLeaderboard = false + write_output(&w.b); + 0 +} + +/// start: render the opening frame. +#[no_mangle] +pub extern "C" fn start() -> i32 { + let input = read_input(); + let (ctx, _) = decode_ctx(&input); + room().players = ctx.member_count; + render(); + 0 +} + +/// join: roster grew; re-render. +#[no_mangle] +pub extern "C" fn join() -> i32 { + let input = read_input(); + let (ctx, _) = decode_ctx(&input); + room().players = ctx.member_count; + render(); + 0 +} + +/// leave: roster shrank (the departed player is the final roster entry, so the +/// living member count is memberCount-1); re-render. +#[no_mangle] +pub extern "C" fn leave() -> i32 { + let input = read_input(); + let (ctx, _) = decode_ctx(&input); + // On leave the departed player is appended as the final roster entry (§2), + // so the living count is one less. + room().players = ctx.member_count.saturating_sub(1); + render(); + 0 +} + +/// input: Ctx ‖ u32 playerIdx ‖ u8 kind ‖ u32 rune ‖ u8 key. We act on +/// printable runes: 'e' ends the room, 'p' panics (trap), anything else +/// re-renders. +#[no_mangle] +pub extern "C" fn input() -> i32 { + let input = read_input(); + let (_ctx, mut r) = decode_ctx(&input); + let player_idx = r.u32(); + let kind = r.u8(); + let rune = r.u32(); + let _key = r.u8(); + if kind == INPUT_RUNE { + match char::from_u32(rune) { + Some('e') => { + host_log("fixture-rs: end"); + end_room(player_idx, 42); + return 0; + } + Some('p') => { + // Deliberate guest trap. panic=abort lowers this to a wasm + // `unreachable`, which the host settles as a fault. + panic!("fixture-rs: deliberate panic"); + } + _ => {} + } + } + render(); + 0 +} + +/// wake: the host heartbeat. Increment the wake counter, then render. +#[no_mangle] +pub extern "C" fn wake() -> i32 { + let _input = read_input(); + room().wakes += 1; + render(); + 0 +} + +/// close: room teardown. Nothing to release beyond linear memory (reclaimed by +/// instance destruction); no-op. +#[no_mangle] +pub extern "C" fn close() -> i32 { + let _input = read_input(); + 0 +} diff --git a/host/gameabi/testdata/fixture/exports.go b/host/gameabi/testdata/fixture/exports.go new file mode 100644 index 0000000..ef09369 --- /dev/null +++ b/host/gameabi/testdata/fixture/exports.go @@ -0,0 +1,33 @@ +//go:build wasip1 || tinygo.wasm + +package main + +import kit "github.com/shellcade/kit/v2" + +func init() { kit.Run(Game{}) } + +// The eight ABI exports, trampolined to the gamekit SDK. + +//go:export shellcade_abi +func expABI() int32 { return kit.ExportABI() } + +//go:export meta +func expMeta() int32 { return kit.ExportMeta() } + +//go:export start +func expStart() int32 { return kit.ExportStart() } + +//go:export join +func expJoin() int32 { return kit.ExportJoin() } + +//go:export leave +func expLeave() int32 { return kit.ExportLeave() } + +//go:export input +func expInput() int32 { return kit.ExportInput() } + +//go:export wake +func expWake() int32 { return kit.ExportWake() } + +//go:export close +func expClose() int32 { return kit.ExportClose() } diff --git a/host/gameabi/testdata/fixture/go.mod b/host/gameabi/testdata/fixture/go.mod new file mode 100644 index 0000000..e6388c1 --- /dev/null +++ b/host/gameabi/testdata/fixture/go.mod @@ -0,0 +1,11 @@ +module fixture + +go 1.26.3 + +require github.com/shellcade/kit/v2 v2.0.2 + +require ( + github.com/extism/go-pdk v1.1.3 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/term v0.43.0 // indirect +) diff --git a/host/gameabi/testdata/fixture/go.sum b/host/gameabi/testdata/fixture/go.sum new file mode 100644 index 0000000..61694ef --- /dev/null +++ b/host/gameabi/testdata/fixture/go.sum @@ -0,0 +1,8 @@ +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.0.2 h1:ycs/iNteXxeh95qmgf3XAfB2P916ajgiLPNIYH9aggE= +github.com/shellcade/kit/v2 v2.0.2/go.mod h1:Y+PvPuxYvam8djiTZVJVcVmGq9IXJeVMznwK+oDJ/6o= +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= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= diff --git a/host/gameabi/testdata/fixture/main.go b/host/gameabi/testdata/fixture/main.go new file mode 100644 index 0000000..d99c4c6 --- /dev/null +++ b/host/gameabi/testdata/fixture/main.go @@ -0,0 +1,199 @@ +// fixture — the gameabi test guest. Renders a tiny deterministic status frame +// and misbehaves (or exercises an ABI surface) on command, so host tests can +// prove containment, derived joinability, and the virtualized WASI story +// against a real artifact: +// +// 'p' panic (guest trap) 'l' spin forever (callback deadline) +// 'o' allocate forever (linear-memory cap) +// 'e' end the room (winner = sender, metric 42) +// 's' post a leaderboard result (metric 7) +// 'k' kv round-trip on the sender's store, logged +// 'i' cycle the input context (nav -> command -> text -> nav) +// 'f' send a PERSONAL frame to the inputting player only (distinct content) +// 'c' config_get("greeting"), logged +// 't' log the guest's own time.Now().UnixNano() (== the room clock) +// 'r' log 8 bytes from crypto/rand (the WASI-virtualized entropy source) +// 'd' arm a 250ms countdown deadline; each wake renders remaining ms, then +// when CallContext time passes the deadline it renders BOOM and ends +// +// Build (dev profile — `make fixture-wasm` from the repo root): +// +// tinygo build -o fixture.wasm -opt=1 -no-debug -gc=leaking \ +// -target wasip1 -buildmode=c-shared . +package main + +import ( + "context" + crand "crypto/rand" + "strconv" + + kit "github.com/shellcade/kit/v2" +) + +func main() { kit.Main(Game{}) } + +// Game is the fixture registry entry. +type Game struct{} + +func (Game) Meta() kit.GameMeta { + return kit.GameMeta{ + Slug: "fixture", + Name: "Fixture", + ShortDescription: "gameabi test guest", + MinPlayers: 1, + MaxPlayers: 2, + } +} + +func (Game) NewRoom(cfg kit.RoomConfig, svc kit.Services) kit.Handler { + return &room{frame: kit.NewFrame(), pframe: kit.NewFrame()} +} + +// Globals keep the misbehavior loops observable so the optimizer cannot drop +// them (gc=leaking: 'o' grows linear memory until the cap traps the instance). +var ( + sink [][]byte + spin uint64 +) + +// countdownMs is the 'd' command's armed window. Held in guest memory across +// wakes so the host can drive the deadline purely via wake + CallContext time. +const countdownMs = 250 + +type room struct { + kit.Base + frame *kit.Frame // reused per render (allocation-free steady state) + pframe *kit.Frame // reused for personal ('f') frames + wakes int + + ctxIdx int // 'i' cycle position + + // 'd' countdown: deadlineAt is the room-clock nanos at which the room ends. + // armed is false until 'd' is pressed; once armed, every wake renders the + // remaining ms (or BOOM + End once the clock passes the deadline). + armed bool + deadlineAt int64 + + entropy [8]byte // 'r' scratch (allocation-free crypto/rand read) +} + +func (rm *room) OnStart(r kit.Room) { rm.render(r) } +func (rm *room) OnJoin(r kit.Room, p kit.Player) { rm.render(r) } +func (rm *room) OnLeave(r kit.Room, p kit.Player) { rm.render(r) } + +func (rm *room) OnWake(r kit.Room) { + rm.wakes++ + if rm.armed { + rm.tickCountdown(r) + return + } + rm.render(r) +} + +func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { + if in.Kind != kit.InputRune { + return + } + switch in.Rune { + case 'p': + panic("fixture: deliberate panic") + case 'l': + for { // burn until the host's callback deadline kills the instance + spin++ + if spin == 0 { + r.Log("fixture: spin wrapped") + } + } + case 'o': + for { // allocate past the linear-memory cap + sink = append(sink, make([]byte, 1<<20)) + } + case 'e': + r.End(kit.Result{Rankings: []kit.PlayerResult{{Player: p, Metric: 42, Rank: 1}}}) + case 's': + r.Post(kit.Result{Rankings: []kit.PlayerResult{{Player: p, Metric: 7, Rank: 1}}}) + case 'k': + store := r.Services().Accounts.For(p).Store() + _ = store.Set(context.Background(), "visits", []byte("1"), kit.MergeSum) + if v, ok, _ := store.Get(context.Background(), "visits"); ok { + r.Log("fixture: visits=" + string(v)) + } + case 'i': + // Cycle nav -> command -> text -> nav, publishing each. + ctxs := [...]kit.InputContext{kit.CtxNav, kit.CtxCommand, kit.CtxText} + rm.ctxIdx = (rm.ctxIdx + 1) % len(ctxs) + r.SetInputContext(ctxs[rm.ctxIdx]) + r.Log("fixture: ctx=" + strconv.Itoa(int(ctxs[rm.ctxIdx]))) + case 'f': + // A PERSONAL frame to the inputting player only — distinct content from + // the broadcast banner so the host can tell them apart. + f := rm.pframe + f.Clear() + f.Text(0, 0, "PERSONAL", kit.Style{}) + f.Text(1, 0, "you="+p.Handle, kit.Style{}) + r.Send(p, f) + case 'c': + v, ok, _ := r.Services().Config.Get(context.Background(), "greeting") + if ok { + r.Log("fixture: greeting=" + string(v)) + } else { + r.Log("fixture: greeting=") + } + case 't': + // The guest's own clock — virtualized to the room clock by the host, so + // this nanos value equals the callback's CallContext time. + r.Log("fixture: now=" + strconv.FormatInt(r.Now().UnixNano(), 10)) + case 'r': + // Read 8 bytes from crypto/rand: in wasip1 this hits the WASI + // random_get the host virtualizes with the room-seeded source, so two + // rooms with the same seed log identical entropy. + _, _ = crand.Read(rm.entropy[:]) + r.Log("fixture: rand=" + hex8(rm.entropy)) + case 'd': + // Arm a countdown anchored to the room clock; the host drives it forward + // with wakes (CallContext time), no host-side timer involved. + rm.armed = true + rm.deadlineAt = r.Now().UnixNano() + countdownMs*1_000_000 + rm.tickCountdown(r) + default: + rm.render(r) + } +} + +// tickCountdown renders the remaining ms until the armed deadline, or BOOM + End +// once the room clock has passed it. Time is read from the CallContext only. +func (rm *room) tickCountdown(r kit.Room) { + rem := (rm.deadlineAt - r.Now().UnixNano()) / 1_000_000 + f := rm.frame + f.Clear() + if rem <= 0 { + f.Text(0, 0, "BOOM", kit.Style{}) + r.Identical(f) + r.End(kit.Result{}) + return + } + f.Text(0, 0, "COUNTDOWN", kit.Style{}) + f.Text(1, 0, "remaining_ms="+strconv.FormatInt(rem, 10), kit.Style{}) + r.Identical(f) +} + +func (rm *room) render(r kit.Room) { + f := rm.frame + f.Clear() + f.Text(0, 0, "FIXTURE", kit.Style{}) + f.Text(1, 0, "players="+strconv.Itoa(r.Count()), kit.Style{}) + f.Text(2, 0, "wakes="+strconv.Itoa(rm.wakes), kit.Style{}) + r.Identical(f) +} + +// hex8 formats 8 bytes as lowercase hex without allocating a slice per call +// (a fixed-size scratch array keeps the 'r' path allocation-light). +func hex8(b [8]byte) string { + const digits = "0123456789abcdef" + var out [16]byte + for i, c := range b { + out[i*2] = digits[c>>4] + out[i*2+1] = digits[c&0x0f] + } + return string(out[:]) +} diff --git a/host/gameabi/testdata/loadspike/exports.go b/host/gameabi/testdata/loadspike/exports.go new file mode 100644 index 0000000..ef09369 --- /dev/null +++ b/host/gameabi/testdata/loadspike/exports.go @@ -0,0 +1,33 @@ +//go:build wasip1 || tinygo.wasm + +package main + +import kit "github.com/shellcade/kit/v2" + +func init() { kit.Run(Game{}) } + +// The eight ABI exports, trampolined to the gamekit SDK. + +//go:export shellcade_abi +func expABI() int32 { return kit.ExportABI() } + +//go:export meta +func expMeta() int32 { return kit.ExportMeta() } + +//go:export start +func expStart() int32 { return kit.ExportStart() } + +//go:export join +func expJoin() int32 { return kit.ExportJoin() } + +//go:export leave +func expLeave() int32 { return kit.ExportLeave() } + +//go:export input +func expInput() int32 { return kit.ExportInput() } + +//go:export wake +func expWake() int32 { return kit.ExportWake() } + +//go:export close +func expClose() int32 { return kit.ExportClose() } diff --git a/host/gameabi/testdata/loadspike/go.mod b/host/gameabi/testdata/loadspike/go.mod new file mode 100644 index 0000000..0c51a6d --- /dev/null +++ b/host/gameabi/testdata/loadspike/go.mod @@ -0,0 +1,16 @@ +module loadspike + +go 1.26.3 + +// kit v2.7.0 ships the large-room callbacks + lifecycle declarations this +// guest exercises. The committed .wasm artifacts +// are what tests/benchmarks load; the module exists to rebuild them (same +// pattern as testdata/fixture). +require github.com/shellcade/kit/v2 v2.7.0 + +require ( + github.com/extism/go-pdk v1.1.3 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/term v0.43.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/host/gameabi/testdata/loadspike/go.sum b/host/gameabi/testdata/loadspike/go.sum new file mode 100644 index 0000000..7b08fec --- /dev/null +++ b/host/gameabi/testdata/loadspike/go.sum @@ -0,0 +1,12 @@ +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.7.0 h1:1SanqDAnK8Ty93ABhy7kPlpd/1WxVaoUEY16vqnhV/8= +github.com/shellcade/kit/v2 v2.7.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= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/host/gameabi/testdata/loadspike/main.go b/host/gameabi/testdata/loadspike/main.go new file mode 100644 index 0000000..f446d0d --- /dev/null +++ b/host/gameabi/testdata/loadspike/main.go @@ -0,0 +1,429 @@ +// loadspike — the BONEYARD Model A load-spike guest. A deliberately +// representative "single resident room" roguelike load: a multi-floor dungeon +// world held in guest memory, every joined player an independent @ with a +// scrolling per-player 80x24 viewport composed and Sent on EVERY wake, wandering +// monsters mutating the world, and bones records littering the floors. +// +// The composition is intentionally the WORST-CASE shape for the wire layer: the +// camera is always centered on the player, so every player movement scrolls the +// whole viewport and produces a keyframe-sized delta (a real game could soften +// this with a camera deadzone — measure first, optimize later). +// +// Steady-state allocation discipline matters because production games build with +// -gc=leaking: the world is allocated once at OnStart, frames are reused, the +// per-wake render path writes cells directly and formats numbers into a fixed +// scratch — no per-player-per-wake heap allocation. +// +// Build (same profile as the fixture / production games): +// +// tinygo build -opt=1 -no-debug -gc=leaking -o loadspike.wasm \ +// -target wasip1 -buildmode=c-shared . +package main + +import ( + kit "github.com/shellcade/kit/v2" +) + +func main() { kit.Main(Game{}) } + +const ( + floors = 12 // dungeon depth (BONEYARD MVP band B1..B12) + fh = 40 // floor height (BONEYARD spec floor size) + fw = 140 // floor width + nMon = 24 // monsters per floor + nBones = 12 // rendered bones per floor (BONEYARD render cap) + mapRows = 22 // viewport rows 0..21; row 22 HUD, row 23 messages +) + +type Game struct{} + +func (Game) Meta() kit.GameMeta { + return kit.GameMeta{ + Slug: "loadspike", + Name: "Load Spike", + ShortDescription: "BONEYARD Model A load-spike guest", + MinPlayers: 1, + MaxPlayers: 1000, + // Large-room callbacks (GUIDE.md "Large rooms"): roster only on + // change, and the gentle tick this game actually needs. + CtxFeatures: kit.CtxFeatRosterEpoch, + HeartbeatMS: 100, + // The resident lifecycle: this guest doubles as the engine's + // resident-world testbed (granted in tests, never in production). + Lifecycle: kit.LifecycleResident, + } +} + +func (Game) NewRoom(cfg kit.RoomConfig, svc kit.Services) kit.Handler { + return &room{frame: kit.NewFrame()} +} + +type mob struct{ x, y int8x2 } + +// int8x2 packs a floor-local coordinate pair; fw<256 and fh<256 so uint8 works, +// but keep int16 for arithmetic simplicity under TinyGo. +type int8x2 = int16 + +type plr struct { + floor, x, y int + hp, gold int + moves int + dirty bool // re-compose this player's viewport on the next wake +} + +type room struct { + kit.Base + frame *kit.Frame // one frame reused for every per-player Send + + tiles [floors][fh][fw]byte // '#' wall, '.' open + + monX, monY [floors][nMon]int16 + bonX, bonY [floors][nBones]int16 + + players map[string]*plr // keyed by AccountID (hibernation-safe) + roster []kit.Player // join-ordered; maintained on join/leave + byFloor [floors][]*plr // rebuilt once per wake for O(floor) viewports + + wakes int + rng uint64 // xorshift64 for monster wander (seeded from room RNG) + + numScratch [12]byte // digit scratch for allocation-free HUD numbers +} + +// ---- world generation -------------------------------------------------------- + +func (rm *room) OnStart(r kit.Room) { + rng := r.Rand() // room-seeded: deterministic world per seed + rm.players = make(map[string]*plr, 1024) + rm.rng = uint64(rng.Int63()) | 1 + + for f := 0; f < floors; f++ { + // Random interior walls on a bordered floor... + for y := 0; y < fh; y++ { + for x := 0; x < fw; x++ { + t := byte('.') + if x == 0 || y == 0 || x == fw-1 || y == fh-1 || rng.Intn(100) < 22 { + t = '#' + } + rm.tiles[f][y][x] = t + } + } + // ...then carve drunkard's-walk corridors so the floor is roamable. + cx, cy := fw/2, fh/2 + for i := 0; i < 6000; i++ { + rm.tiles[f][cy][cx] = '.' + switch rng.Intn(4) { + case 0: + if cx < fw-2 { + cx++ + } + case 1: + if cx > 1 { + cx-- + } + case 2: + if cy < fh-2 { + cy++ + } + case 3: + if cy > 1 { + cy-- + } + } + } + for i := 0; i < nMon; i++ { + x, y := rm.openTile(rng.Intn, f) + rm.monX[f][i], rm.monY[f][i] = int16(x), int16(y) + } + for i := 0; i < nBones; i++ { + x, y := rm.openTile(rng.Intn, f) + rm.bonX[f][i], rm.bonY[f][i] = int16(x), int16(y) + } + } +} + +// openTile finds a random '.' tile on floor f using the provided intn. +func (rm *room) openTile(intn func(int) int, f int) (int, int) { + for { + x, y := 1+intn(fw-2), 1+intn(fh-2) + if rm.tiles[f][y][x] == '.' { + return x, y + } + } +} + +// xorshift64 — allocation-free wander PRNG (determinism of the wander path is +// irrelevant to the spike; the seed still derives from the room RNG). +func (rm *room) next() uint64 { + x := rm.rng + x ^= x << 13 + x ^= x >> 7 + x ^= x << 17 + rm.rng = x + return x +} + +// ---- roster ------------------------------------------------------------------ + +func (rm *room) OnJoin(r kit.Room, p kit.Player) { + if pl, ok := rm.players[p.AccountID]; ok { + // Rejoin (same seat, same position) — including post-restore + // re-seats. The viewer's baselines were invalidated, so they need a + // frame: render-on-change games MUST dirty the re-seated player or a + // resumed session stares at nothing (GUIDE.md "Large rooms"). + pl.dirty = true + return + } + f := len(rm.players) % floors + x, y := rm.openTile(func(n int) int { return int(rm.next() % uint64(n)) }, f) + rm.players[p.AccountID] = &plr{floor: f, x: x, y: y, hp: 24 + len(rm.players)%17, gold: 0, dirty: true} + rm.roster = append(rm.roster, p) + rm.allDirty() // roster change: every viewport re-renders (keyframes anyway) +} + +func (rm *room) OnLeave(r kit.Room, p kit.Player) { + delete(rm.players, p.AccountID) + for i := range rm.roster { + if rm.roster[i].AccountID == p.AccountID { + rm.roster = append(rm.roster[:i], rm.roster[i+1:]...) + break + } + } + rm.allDirty() +} + +func (rm *room) allDirty() { + for _, pl := range rm.players { + pl.dirty = true + } +} + +// ---- input ------------------------------------------------------------------- + +func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { + pl, ok := rm.players[p.AccountID] + if !ok { + return + } + dx, dy := 0, 0 + if in.Kind == kit.InputRune { + switch in.Rune { + case 'h': + dx = -1 + case 'l': + dx = 1 + case 'k': + dy = -1 + case 'j': + dy = 1 + case 'b': // bones interaction stand-in + pl.gold++ + return + default: + return + } + } else if in.Kind == kit.InputKey { + switch in.Key { + case kit.KeyUp: + dy = -1 + case kit.KeyDown: + dy = 1 + case kit.KeyLeft: + dx = -1 + case kit.KeyRight: + dx = 1 + default: + return + } + } + nx, ny := pl.x+dx, pl.y+dy + if nx >= 0 && nx < fw && ny >= 0 && ny < fh && rm.tiles[pl.floor][ny][nx] == '.' { + pl.x, pl.y = nx, ny + pl.moves++ + pl.dirty = true + // Same-floor players with the mover inside their viewport see the + // @ move — their views are dirty too (render-on-change rule 2). + rm.dirtyWitnesses(pl.floor, nx, ny, pl) + } +} + +// dirtyWitnesses marks players on floor f dirty when world cell (x,y) is +// inside their (clamped, centered) viewport. +func (rm *room) dirtyWitnesses(f, x, y int, except *plr) { + for _, o := range rm.byFloor[f] { + if o == except || o.dirty { + continue + } + ox := clamp(o.x-kit.Cols/2, 0, fw-kit.Cols) + oy := clamp(o.y-mapRows/2, 0, fh-mapRows) + if x >= ox && x < ox+kit.Cols && y >= oy && y < oy+mapRows { + o.dirty = true + } + } +} + +// ---- wake: simulate + compose + fan out --------------------------------------- + +var ( + stWall = kit.Style{FG: kit.DimGray} + stFloor = kit.Style{FG: kit.Gray(0x40)} + stBones = kit.Style{FG: kit.White} + stMon = kit.Style{FG: kit.Red} + stOther = kit.Style{FG: kit.Cyan} + stSelf = kit.Style{FG: kit.White, Attr: kit.AttrBold} + stHUD = kit.Style{FG: kit.Yellow} +) + +func (rm *room) OnWake(r kit.Room) { + rm.wakes++ + + // Rebuild the per-floor player index once (O(N)), so witness checks and + // viewports scan only their own floor's occupants. + for f := range rm.byFloor { + rm.byFloor[f] = rm.byFloor[f][:0] + } + for _, pl := range rm.players { + rm.byFloor[pl.floor] = append(rm.byFloor[pl.floor], pl) + } + + // Monsters wander every 4th wake (the BONEYARD "gentle tick"); each move + // dirties only the viewports that can see it (render-on-change rule 2). + if rm.wakes%4 == 0 { + for f := 0; f < floors; f++ { + for i := 0; i < nMon; i++ { + x, y := int(rm.monX[f][i]), int(rm.monY[f][i]) + switch rm.next() % 4 { + case 0: + x++ + case 1: + x-- + case 2: + y++ + case 3: + y-- + } + if x > 0 && x < fw-1 && y > 0 && y < fh-1 && rm.tiles[f][y][x] == '.' { + rm.monX[f][i], rm.monY[f][i] = int16(x), int16(y) + rm.dirtyWitnesses(f, x, y, nil) + } + } + } + } + + // Ambient HUD clock at ~1Hz, not per-wake (render-on-change rule 3): a + // per-wake counter would force a nonzero delta for every player on every + // wake, defeating both the dirty tracking and the delta encoder. + if rm.wakes%20 == 0 { + rm.allDirty() + } + + // Per-player viewport composition + Send — DIRTY VIEWS ONLY. + for _, p := range rm.roster { + pl, ok := rm.players[p.AccountID] + if !ok || !pl.dirty { + continue + } + pl.dirty = false + rm.compose(pl) + r.Send(p, rm.frame) + } +} + +// compose renders pl's centered viewport into rm.frame (reused across players; +// every cell of the map area is overwritten so no Clear is needed for rows +// 0..21; HUD rows are fully rewritten too). +func (rm *room) compose(pl *plr) { + f := rm.frame + fl := pl.floor + + // Camera: centered on the player, clamped to floor bounds. Centered camera + // = every move scrolls the viewport = worst-case deltas (intentional). + ox := clamp(pl.x-kit.Cols/2, 0, fw-kit.Cols) + oy := clamp(pl.y-mapRows/2, 0, fh-mapRows) + + for vy := 0; vy < mapRows; vy++ { + wy := oy + vy + rowTiles := &rm.tiles[fl][wy] + for vx := 0; vx < kit.Cols; vx++ { + t := rowTiles[ox+vx] + if t == '#' { + f.Cells[vy][vx] = kit.Cell{Rune: '#', FG: cellFG(stWall)} + } else { + f.Cells[vy][vx] = kit.Cell{Rune: '.', FG: cellFG(stFloor)} + } + } + } + + // Bones, monsters, players on this floor (plot if inside the window). + for i := 0; i < nBones; i++ { + rm.plot(int(rm.bonX[fl][i]), int(rm.bonY[fl][i]), ox, oy, '†', stBones) + } + for i := 0; i < nMon; i++ { + rm.plot(int(rm.monX[fl][i]), int(rm.monY[fl][i]), ox, oy, 'k', stMon) + } + for _, other := range rm.byFloor[fl] { + if other != pl { + rm.plot(other.x, other.y, ox, oy, '@', stOther) + } + } + rm.plot(pl.x, pl.y, ox, oy, '@', stSelf) + + // HUD row 22: HP, floor, gold, wake counter (the counter guarantees a + // nonzero delta every wake even for idle players — like a real game's + // clock/torch readout). + for vx := 0; vx < kit.Cols; vx++ { + f.Cells[22][vx] = kit.Cell{} + } + f.Text(22, 0, "HP", stHUD) + rm.putNum(22, 3, pl.hp) + f.Text(22, 8, "B", stHUD) + rm.putNum(22, 9, fl+1) + f.Text(22, 14, "$", stHUD) + rm.putNum(22, 15, pl.gold) + f.Text(22, 24, "t", stHUD) + rm.putNum(22, 25, rm.wakes/20) // ~1Hz ambient clock (HUD throttle) + + // Message row 23: static hint (cheap, rarely changes). + for vx := 0; vx < kit.Cols; vx++ { + f.Cells[23][vx] = kit.Cell{} + } + f.Text(23, 0, "[hjkl]move [b]bones", kit.Style{FG: kit.DimGray}) +} + +// plot writes a world-coordinate glyph into the viewport if visible. +func (rm *room) plot(wx, wy, ox, oy int, g rune, st kit.Style) { + vx, vy := wx-ox, wy-oy + if vx >= 0 && vx < kit.Cols && vy >= 0 && vy < mapRows { + rm.frame.Cells[vy][vx] = kit.Cell{Rune: g, FG: cellFG(st), Attr: st.Attr} + } +} + +// putNum writes n's decimal digits at (row,col) without allocating. +func (rm *room) putNum(row, col, n int) { + i := len(rm.numScratch) + if n == 0 { + i-- + rm.numScratch[i] = '0' + } + for n > 0 && i > 0 { + i-- + rm.numScratch[i] = byte('0' + n%10) + n /= 10 + } + for j := i; j < len(rm.numScratch); j++ { + rm.frame.Cells[row][col+(j-i)] = kit.Cell{Rune: rune(rm.numScratch[j]), FG: cellFG(stHUD)} + } +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// cellFG extracts the style FG for direct Cell writes. +func cellFG(st kit.Style) kit.Color { return st.FG } From 860f9417701ed5209603a2a8f115f758473695fb Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Tue, 16 Jun 2026 08:02:46 +1000 Subject: [PATCH 05/10] host: extract session + render; session uses sdk.Player not identity Move internal/session and internal/render from the private shellcade repo into the public kit module under host/. Break session's one private edge by switching its identity token from internal/identity.Identity to the public sdk.Player (session treats it as an opaque value-comparable token; no field access). render's identity coupling was indirect through session, so it is clean once internal/canvas -> host/canvas and internal/session -> host/session. Co-Authored-By: Claude Opus 4.8 --- host/render/grid.go | 190 +++++++++++++++++++++++++++++++++ host/render/grid_test.go | 126 ++++++++++++++++++++++ host/render/render.go | 80 ++++++++++++++ host/render/render_test.go | 143 +++++++++++++++++++++++++ host/render/sgr.go | 123 +++++++++++++++++++++ host/session/heartbeat.go | 46 ++++++++ host/session/heartbeat_test.go | 76 +++++++++++++ host/session/intent.go | 37 +++++++ host/session/memsession.go | 96 +++++++++++++++++ host/session/session.go | 120 +++++++++++++++++++++ host/session/signout.go | 20 ++++ 11 files changed, 1057 insertions(+) create mode 100644 host/render/grid.go create mode 100644 host/render/grid_test.go create mode 100644 host/render/render.go create mode 100644 host/render/render_test.go create mode 100644 host/render/sgr.go create mode 100644 host/session/heartbeat.go create mode 100644 host/session/heartbeat_test.go create mode 100644 host/session/intent.go create mode 100644 host/session/memsession.go create mode 100644 host/session/session.go create mode 100644 host/session/signout.go diff --git a/host/render/grid.go b/host/render/grid.go new file mode 100644 index 0000000..0bd8755 --- /dev/null +++ b/host/render/grid.go @@ -0,0 +1,190 @@ +package render + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/shellcade/kit/v2/host/canvas" + "github.com/shellcade/kit/v2/host/session" +) + +// GridToANSI renders g as 24 rows of SGR-styled glyphs separated by "\n", with NO +// cursor positioning. Each row emits a full SGR for its first cell, re-emits SGR +// only when the style changes, and ends with an SGR reset so rows compose cleanly +// into a larger frame. Colors are downgraded to caps.ColorDepth; non-ASCII runes +// degrade to ASCII when caps.UTF8 is false. Every row is exactly canvas.Cols +// visible columns wide (one glyph per cell; continuation cells emit nothing). +// +// This is the full-frame, diff-free encoder used by the bubbletea front door: +// the model converts the active grid to this string for View(), and bubbletea's +// renderer owns the wire diffing. +func GridToANSI(g canvas.Grid, caps session.Caps) string { + var b strings.Builder + for row := 0; row < canvas.Rows; row++ { + var cur styleKey + haveCur := false + var line []byte + for col := 0; col < canvas.Cols; col++ { + c := g.Cells[row][col] + k := keyOf(c) + if !haveCur || k != cur { + line = appendSGR(line, k, caps.ColorDepth) + cur, haveCur = k, true + } + line = appendGlyphCaps(line, c, caps.UTF8) + } + line = append(line, "\x1b[0m"...) + b.Write(line) + if row < canvas.Rows-1 { + b.WriteByte('\n') + } + } + return b.String() +} + +// appendGlyphCaps encodes a cell's glyph (mirroring the Renderer's appendGlyph): +// rune 0 / control bytes become spaces, and non-ASCII degrades via +// asciiFallback when UTF-8 is unavailable. +// +// In ABI v2 a cell may carry up to three grapheme code points (base + Cp2 + +// Cp3). When UTF-8 is on, all present code points are emitted as one contiguous +// burst BEFORE the next cell's glyph, so a VS16/skin-tone/ZWJ/keycap sequence +// reaches the terminal adjacent and unbroken. With UTF-8 off the base degrades +// to ASCII and Cp2/Cp3 are dropped (a legal single-glyph fallback). +// +// Continuation cells emit nothing when UTF-8 is on (the wide glyph to their +// left renders both columns) but pad a space when it is off (that glyph +// degraded to ONE ASCII char) — a substitution must never change the row's +// column count. +func appendGlyphCaps(buf []byte, c canvas.Cell, utf8On bool) []byte { + if c.Cont { + if !utf8On { + return append(buf, ' ') + } + return buf + } + ru := c.Rune + if ru == 0 || ru < 0x20 { + ru = ' ' + } + if ru > 0x7e && !utf8On { + ru = asciiFallback(ru) + } + var tmp [4]byte + n := utf8.EncodeRune(tmp[:], ru) + buf = append(buf, tmp[:n]...) + if !utf8On { + return buf // ASCII fallback: base only, drop the cluster's extra code points + } + if c.Cp2 != 0 { + n = utf8.EncodeRune(tmp[:], c.Cp2) + buf = append(buf, tmp[:n]...) + } + if c.Cp3 != 0 { + n = utf8.EncodeRune(tmp[:], c.Cp3) + buf = append(buf, tmp[:n]...) + } + return buf +} + +// Letterbox centers an 80×24 body (as produced by GridToANSI — exactly +// canvas.Cols visible columns per row) within a termCols×termRows terminal, +// drawing a dim border in the surrounding margin. When the terminal is not larger +// than the canvas in either dimension, the body is returned unchanged (the +// undersized case is handled separately by Undersized). The result is termRows +// newline-separated lines so it can be the whole View() output. +func Letterbox(body string, termCols, termRows int, depth session.ColorDepth) string { + if termCols <= canvas.Cols && termRows <= canvas.Rows { + return body + } + offX := (termCols - canvas.Cols) / 2 + if offX < 0 { + offX = 0 + } + offY := (termRows - canvas.Rows) / 2 + if offY < 0 { + offY = 0 + } + + rows := strings.Split(body, "\n") + border := string(appendSGR(nil, styleKey{fg: canvas.DimGray, attr: canvas.AttrDim}, depth)) + const reset = "\x1b[0m" + + sideW := 0 + if offX > 0 { + sideW = 2 + } + horiz := func() string { + var b strings.Builder + if offX > 1 { + b.WriteString(strings.Repeat(" ", offX-1)) + } + b.WriteString(border) + b.WriteString(strings.Repeat("-", canvas.Cols+sideW)) + b.WriteString(reset) + return b.String() + } + + out := make([]string, termRows) + blank := strings.Repeat(" ", termCols) + for r := 0; r < termRows; r++ { + switch { + case offY > 0 && (r == offY-1 || r == offY+canvas.Rows): + out[r] = horiz() + case r >= offY && r < offY+canvas.Rows: + i := r - offY + line := "" + if i < len(rows) { + line = rows[i] + } + var b strings.Builder + if offX > 0 { + if offX > 1 { + b.WriteString(strings.Repeat(" ", offX-1)) + } + b.WriteString(border) + b.WriteString("|") + b.WriteString(reset) + } + b.WriteString(line) + if offX > 0 { + b.WriteString(border) + b.WriteString("|") + b.WriteString(reset) + } + out[r] = b.String() + default: + out[r] = blank + } + } + return strings.Join(out, "\n") +} + +// Undersized renders the centered "please resize" interstitial that replaces the +// active screen when the terminal is smaller than 80×24, matching the wording the +// retired renderer used. The result is termRows newline-separated lines. +func Undersized(termCols, termRows int) string { + msg := fmt.Sprintf("Please resize your terminal to at least 80x24. Current size: %dx%d.", termCols, termRows) + if termRows < 1 { + termRows = 1 + } + if termCols < 1 { + termCols = 1 + } + row := termRows / 2 + col := (termCols - len(msg)) / 2 + if col < 0 { + col = 0 + } + out := make([]string, termRows) + blank := strings.Repeat(" ", termCols) + for r := 0; r < termRows; r++ { + if r == row { + out[r] = strings.Repeat(" ", col) + msg + } else { + out[r] = blank + } + } + return strings.Join(out, "\n") +} diff --git a/host/render/grid_test.go b/host/render/grid_test.go new file mode 100644 index 0000000..d9a2b4f --- /dev/null +++ b/host/render/grid_test.go @@ -0,0 +1,126 @@ +package render + +import ( + "strings" + "testing" + + "github.com/shellcade/kit/v2/host/canvas" + "github.com/shellcade/kit/v2/host/session" +) + +func TestGridToANSIShape(t *testing.T) { + g := canvas.New() + g.Text(0, 0, "hi", canvas.Style{FG: canvas.Cyan}) + out := GridToANSI(g, session.Caps{ColorDepth: session.ColorTrue, UTF8: true}) + + lines := strings.Split(out, "\n") + if len(lines) != canvas.Rows { + t.Fatalf("expected %d lines, got %d", canvas.Rows, len(lines)) + } + for i, ln := range lines { + if !strings.HasSuffix(ln, "\x1b[0m") { + t.Errorf("line %d does not end with an SGR reset: %q", i, ln) + } + } + if !strings.Contains(lines[0], "hi") { + t.Errorf("row 0 missing content: %q", lines[0]) + } +} + +func TestGridToANSIColorDowngrade(t *testing.T) { + g := canvas.New() + g.Text(0, 0, "x", canvas.Style{FG: canvas.RGB(0x12, 0x34, 0x56)}) + // truecolor uses 38;2;r;g;b ; 16-color must not. + tc := GridToANSI(g, session.Caps{ColorDepth: session.ColorTrue}) + if !strings.Contains(tc, "38;2;") { + t.Errorf("truecolor output should use 24-bit SGR, got %q", firstLine(tc)) + } + c16 := GridToANSI(g, session.Caps{ColorDepth: session.Color16}) + if strings.Contains(c16, "38;2;") { + t.Errorf("16-color output must not use 24-bit SGR, got %q", firstLine(c16)) + } +} + +func TestGridToANSIASCIIFallback(t *testing.T) { + g := canvas.New() + g.Text(0, 0, "│", canvas.Style{}) // box-drawing vertical + noUTF := GridToANSI(g, session.Caps{UTF8: false}) + if !strings.Contains(firstLine(noUTF), "|") { + t.Errorf("expected ASCII fallback '|' without UTF-8, got %q", firstLine(noUTF)) + } +} + +// TestGridToANSIWideEmojiASCIIFallback: a width-2 glyph (emoji or fullwidth +// form) degrades to a mnemonic ASCII char, and its continuation cell pads with +// a space so the substitution never changes the row's column count (the +// viewport spec's capability-aware styling requirement). +func TestGridToANSIWideEmojiASCIIFallback(t *testing.T) { + g := canvas.New() + wide := func(col int, r rune) { + g.Set(0, col, canvas.Cell{Rune: r}) + g.Set(0, col+1, canvas.Cell{Cont: true}) + } + wide(0, '\U0001F352') // 🍒 cherry + wide(2, '\U0001F514') // 🔔 bell + wide(4, '⭐') // ⭐ star + wide(6, '\U0001F48E') // 💎 gem + wide(8, '7') // U+FF17 fullwidth seven + + vis := stripSGR(firstLine(GridToANSI(g, session.Caps{UTF8: false}))) + if want := "C B * D 7 "; !strings.HasPrefix(vis, want) { + t.Errorf("ASCII row = %q…, want prefix %q", vis[:20], want) + } + if n := len([]rune(vis)); n != canvas.Cols { + t.Errorf("ASCII row is %d visible columns, want %d (degraded wide glyphs must keep the column count)", n, canvas.Cols) + } +} + +// stripSGR removes ANSI SGR sequences, leaving only visible glyphs. +func stripSGR(s string) string { + var b strings.Builder + for i := 0; i < len(s); { + if s[i] == 0x1b { + j := strings.IndexByte(s[i:], 'm') + if j < 0 { + break + } + i += j + 1 + continue + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +func TestLetterboxExactSizeReturnsBody(t *testing.T) { + body := GridToANSI(canvas.New(), session.Caps{}) + if got := Letterbox(body, canvas.Cols, canvas.Rows, session.ColorNone); got != body { + t.Error("80x24 terminal must return the body unchanged (no letterbox)") + } +} + +func TestLetterboxFillsTerminal(t *testing.T) { + body := GridToANSI(canvas.New(), session.Caps{}) + out := Letterbox(body, 100, 30, session.ColorNone) + if n := strings.Count(out, "\n") + 1; n != 30 { + t.Fatalf("expected 30 rows for a 100x30 terminal, got %d", n) + } +} + +func TestUndersizedMessage(t *testing.T) { + out := Undersized(70, 20) + if !strings.Contains(out, "Please resize your terminal to at least 80x24. Current size: 70x20.") { + t.Errorf("undersized message missing/incorrect: %q", out) + } + if n := strings.Count(out, "\n") + 1; n != 20 { + t.Fatalf("expected 20 rows for a 70x20 terminal, got %d", n) + } +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/host/render/render.go b/host/render/render.go new file mode 100644 index 0000000..274fa08 --- /dev/null +++ b/host/render/render.go @@ -0,0 +1,80 @@ +// Package render converts the fixed 80x24 canvas.Grid into the styled, ANSI- +// encoded strings the bubbletea front door renders. bubbletea's renderer owns the +// wire — the per-frame diffing and writing — while this package provides the +// full-frame Grid→ANSI encoder (GridToANSI), the letterbox and undersized-resize +// interstitial composers (Letterbox / Undersized), the per-session SGR color +// downgrade (sgr.go), and the non-UTF8 ASCII glyph fallback (asciiFallback). +// +// The former custom cell-diff "renderer of record" was retired when the front +// door moved to bubbletea; see the bubbletea-frontdoor OpenSpec change. +package render + +// asciiFallback degrades a non-ASCII rune to a plain-ASCII stand-in for +// sessions without a UTF-8 locale, so box-drawing chassis art reads as +-| +// instead of a wall of '?'. Anything unmapped stays '?'. +func asciiFallback(r rune) rune { + // Fullwidth ASCII variants (U+FF01..U+FF5E) fold to their ASCII originals + // — e.g. the pokies fullwidth seven '7' reads as '7'. + if r >= 0xFF01 && r <= 0xFF5E { + return r - 0xFEE0 + } + switch r { + case '─', '═': + return '-' + case '│', '║': + return '|' + case '╭', '╮', '╰', '╯', '┌', '┐', '└', '┘', + '├', '┤', '┬', '┴', '┼', + '╔', '╗', '╚', '╝', '╠', '╣', '╬': + return '+' + case '●', '○', '◌': + return 'o' + case '►', '▶', '◄', '◀': + return '>' + case '♠': + return 'S' + case '♥': + return 'H' + case '♦': + return 'D' + case '♣': + return 'C' + // Chess figurines degrade to their piece letter; the white (outline) set folds + // to upper-case and the black (filled) set to lower-case, preserving the side. + case '♔': + return 'K' + case '♕': + return 'Q' + case '♖': + return 'R' + case '♗': + return 'B' + case '♘': + return 'N' + case '♙': + return 'P' + case '♚': + return 'k' + case '♛': + return 'q' + case '♜': + return 'r' + case '♝': + return 'b' + case '♞': + return 'n' + case '♟': + return 'p' + // Slot-machine faces degrade to a mnemonic letter, mirroring the suit map + // (pokies' reel art; the symbol's config ID where one exists). + case '\U0001F352': // 🍒 cherry + return 'C' + case '\U0001F514': // 🔔 bell + return 'B' + case '⭐': // ⭐ star + return '*' + case '\U0001F48E': // 💎 gem + return 'D' + } + return '?' +} diff --git a/host/render/render_test.go b/host/render/render_test.go new file mode 100644 index 0000000..5a25e4f --- /dev/null +++ b/host/render/render_test.go @@ -0,0 +1,143 @@ +package render + +import ( + "strings" + "testing" + + "github.com/shellcade/kit/v2/host/canvas" + "github.com/shellcade/kit/v2/host/session" +) + +func TestAsciiFallbackForBoxDrawingWhenNoUTF8(t *testing.T) { + cases := map[rune]string{ + '─': "-", '│': "|", + '╭': "+", '╮': "+", '╰': "+", '╯': "+", + '┌': "+", '┐': "+", '└': "+", '┘': "+", + '●': "o", '○': "o", + } + for in, want := range cases { + got := string(appendGlyphCaps(nil, canvas.Cell{Rune: in}, false)) + if got != want { + t.Errorf("appendGlyphCaps(%q) without UTF8 = %q, want %q", in, got, want) + } + } +} + +func TestAsciiFallbackForCardSuitsWhenNoUTF8(t *testing.T) { + cases := map[rune]string{'♠': "S", '♥': "H", '♦': "D", '♣': "C"} + for in, want := range cases { + got := string(appendGlyphCaps(nil, canvas.Cell{Rune: in}, false)) + if got != want { + t.Errorf("appendGlyphCaps(%q) without UTF8 = %q, want %q", in, got, want) + } + } +} + +func TestAsciiFallbackForChessFigurinesWhenNoUTF8(t *testing.T) { + cases := map[rune]string{ + '♔': "K", '♕': "Q", '♖': "R", '♗': "B", '♘': "N", '♙': "P", + '♚': "k", '♛': "q", '♜': "r", '♝': "b", '♞': "n", '♟': "p", + } + for in, want := range cases { + got := string(appendGlyphCaps(nil, canvas.Cell{Rune: in}, false)) + if got != want { + t.Errorf("appendGlyphCaps(%q) without UTF8 = %q, want %q", in, got, want) + } + } +} + +func TestFigurinesAndBoxDrawingPassThroughWithUTF8(t *testing.T) { + for _, r := range []rune{'─', '♔', '♟', '♛'} { + if got := string(appendGlyphCaps(nil, canvas.Cell{Rune: r}, true)); got != string(r) { + t.Errorf("with UTF8, %q = %q, want it passed through", r, got) + } + } +} + +func TestUnmappedNonAsciiStillBecomesQuestionMark(t *testing.T) { + // 🍒 graduated to a mapped slot face; a unicorn stays unmapped. + if got := string(appendGlyphCaps(nil, canvas.Cell{Rune: '🦄'}, false)); got != "?" { + t.Errorf("unmapped non-ascii without UTF8 = %q, want ?", got) + } +} + +func TestContinuationCellEmitsNothing(t *testing.T) { + if got := appendGlyphCaps(nil, canvas.Cell{Cont: true}, true); len(got) != 0 { + t.Errorf("continuation cell emitted %q, want nothing", string(got)) + } +} + +// TestGraphemeBurstContiguousWithUTF8: a cell carrying base+Cp2+Cp3 emits all +// three code points as one contiguous UTF-8 burst (a VS16 emoji, a skin-tone +// modifier, a keycap base+U+20E3), with nothing interleaved (D3b). +func TestGraphemeBurstContiguousWithUTF8(t *testing.T) { + cases := []struct { + name string + cell canvas.Cell + wantRunes []rune + }{ + {"vs16", canvas.Cell{Rune: '☂', Cp2: 0xFE0F}, []rune{'☂', 0xFE0F}}, + {"skin-tone", canvas.Cell{Rune: '👍', Cp2: 0x1F3FD}, []rune{'👍', 0x1F3FD}}, + {"keycap", canvas.Cell{Rune: '1', Cp2: 0xFE0F, Cp3: 0x20E3}, []rune{'1', 0xFE0F, 0x20E3}}, + } + for _, tc := range cases { + got := string(appendGlyphCaps(nil, tc.cell, true)) + if want := string(tc.wantRunes); got != want { + t.Errorf("%s: burst = %q (% x), want %q (% x)", tc.name, got, got, want, want) + } + } +} + +// TestGraphemeBurstDroppedWithoutUTF8: with UTF-8 off the base degrades to ASCII +// and Cp2/Cp3 are dropped (a legal single-glyph fallback). +func TestGraphemeBurstDroppedWithoutUTF8(t *testing.T) { + // base '1' is ASCII so it survives; the VS16 + keycap are dropped. + got := string(appendGlyphCaps(nil, canvas.Cell{Rune: '1', Cp2: 0xFE0F, Cp3: 0x20E3}, false)) + if got != "1" { + t.Errorf("UTF-8-off keycap = %q, want \"1\" (cps dropped)", got) + } +} + +// TestUnknownAttrBitsNeitherErrorNorLeakSGR: a cell whose attr byte sets a bit +// the renderer does not assign (only Bold/Dim/Underline/Reverse are assigned) +// renders identically to the same cell with that bit cleared — the unknown bit +// leaks no SGR parameter and does not error (evolution rule §5 / task 3.4). +func TestUnknownAttrBitsNeitherErrorNorLeakSGR(t *testing.T) { + caps := session.Caps{ColorDepth: session.ColorTrue, UTF8: true} + known := canvas.AttrBold | canvas.AttrDim | canvas.AttrUnderline | canvas.AttrReverse + for bit := 0; bit < 8; bit++ { + attr := canvas.Attr(1 << bit) + if attr&known != 0 { + continue // skip assigned bits + } + g := canvas.New() + g.Set(0, 0, canvas.Cell{Rune: 'X', Attr: attr}) + withUnknown := GridToANSI(g, caps) + + g2 := canvas.New() + g2.Set(0, 0, canvas.Cell{Rune: 'X'}) + plain := GridToANSI(g2, caps) + + if withUnknown != plain { + t.Errorf("attr bit %d leaked into the SGR stream:\n with=%q\n plain=%q", bit, withUnknown, plain) + } + // And the bit must not surface as a raw SGR parameter anywhere. + if strings.Contains(withUnknown, ";"+itoa(1< 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} diff --git a/host/render/sgr.go b/host/render/sgr.go new file mode 100644 index 0000000..009f436 --- /dev/null +++ b/host/render/sgr.go @@ -0,0 +1,123 @@ +package render + +import ( + "strconv" + + "github.com/shellcade/kit/v2/host/canvas" + "github.com/shellcade/kit/v2/host/session" +) + +// styleKey is the resolved styling of a cell for diffing. +type styleKey struct { + fg canvas.Color + bg canvas.Color + attr canvas.Attr +} + +// knownAttr is the set of attribute bits the renderer assigns an SGR code to. +// The host MUST ignore unknown attr bits (ABI v2 evolution rule §5): masking +// here means an undefined bit neither leaks an SGR parameter NOR triggers a +// spurious style change (which would re-emit a redundant full SGR), so a future +// minor can assign a new attribute additively. +const knownAttr = canvas.AttrBold | canvas.AttrDim | canvas.AttrUnderline | canvas.AttrReverse + +func keyOf(c canvas.Cell) styleKey { return styleKey{fg: c.FG, bg: c.BG, attr: c.Attr & knownAttr} } + +// appendSGR writes a full SGR reset followed by this style's parameters, +// downgraded to the session's color depth. +func appendSGR(buf []byte, k styleKey, depth session.ColorDepth) []byte { + buf = append(buf, "\x1b[0"...) // reset, then add params + if k.attr&canvas.AttrBold != 0 { + buf = append(buf, ";1"...) + } + if k.attr&canvas.AttrDim != 0 { + buf = append(buf, ";2"...) + } + if k.attr&canvas.AttrUnderline != 0 { + buf = append(buf, ";4"...) + } + if k.attr&canvas.AttrReverse != 0 { + buf = append(buf, ";7"...) + } + if depth != session.ColorNone { + buf = appendColor(buf, k.fg, depth, true) + buf = appendColor(buf, k.bg, depth, false) + } + buf = append(buf, 'm') + return buf +} + +func appendColor(buf []byte, c canvas.Color, depth session.ColorDepth, fg bool) []byte { + if !c.IsSet() { + return buf + } + r, g, b := colorRGB(c) + switch depth { + case session.ColorTrue: + base := 48 + if fg { + base = 38 + } + buf = append(buf, ';') + buf = strconv.AppendInt(buf, int64(base), 10) + buf = append(buf, ";2;"...) + buf = strconv.AppendInt(buf, int64(r), 10) + buf = append(buf, ';') + buf = strconv.AppendInt(buf, int64(g), 10) + buf = append(buf, ';') + buf = strconv.AppendInt(buf, int64(b), 10) + case session.Color256: + base := 48 + if fg { + base = 38 + } + buf = append(buf, ';') + buf = strconv.AppendInt(buf, int64(base), 10) + buf = append(buf, ";5;"...) + buf = strconv.AppendInt(buf, int64(rgbTo256(r, g, b)), 10) + case session.Color16: + code := rgbTo16(r, g, b, fg) + buf = append(buf, ';') + buf = strconv.AppendInt(buf, int64(code), 10) + } + return buf +} + +func colorRGB(c canvas.Color) (uint8, uint8, uint8) { return c.RGB() } + +func rgbTo256(r, g, b uint8) int { + if r == g && g == b { + // grayscale ramp 232..255 (24 steps), plus endpoints + if r < 8 { + return 16 + } + if r > 248 { + return 231 + } + return 232 + int((int(r)-8)*24/247) + } + q := func(v uint8) int { return int(v) * 5 / 255 } + return 16 + 36*q(r) + 6*q(g) + q(b) +} + +func rgbTo16(r, g, b uint8, fg bool) int { + bright := 0 + if int(r)+int(g)+int(b) > 384 { + bright = 60 + } + code := 0 + if r > 96 { + code |= 1 + } + if g > 96 { + code |= 2 + } + if b > 96 { + code |= 4 + } + base := 30 + if !fg { + base = 40 + } + return base + code + bright +} diff --git a/host/session/heartbeat.go b/host/session/heartbeat.go new file mode 100644 index 0000000..6c94188 --- /dev/null +++ b/host/session/heartbeat.go @@ -0,0 +1,46 @@ +package session + +import ( + "context" + "time" +) + +// RunHeartbeat probes a peer to detect a vanished connection that never sent a +// clean close. On each interval it calls ping with a context bounded by timeout; +// after maxMiss consecutive failures it invokes onDead (e.g. close the session) +// and returns. A successful ping resets the miss counter. It returns when ctx is +// cancelled. The shared logic backs both the WebSocket ping and the SSH +// keepalive front doors. A nil ping, non-positive interval, or non-positive +// maxMiss makes it a no-op. +func RunHeartbeat(ctx context.Context, interval, timeout time.Duration, maxMiss int, ping func(context.Context) error, onDead func()) { + if ping == nil || interval <= 0 || maxMiss <= 0 { + return + } + t := time.NewTicker(interval) + defer t.Stop() + misses := 0 + for { + select { + case <-ctx.Done(): + return + case <-t.C: + pctx, cancel := context.WithTimeout(ctx, timeout) + err := ping(pctx) + cancel() + if ctx.Err() != nil { + return // session ended while we were probing + } + if err != nil { + misses++ + if misses >= maxMiss { + if onDead != nil { + onDead() + } + return + } + continue + } + misses = 0 + } + } +} diff --git a/host/session/heartbeat_test.go b/host/session/heartbeat_test.go new file mode 100644 index 0000000..32dbe35 --- /dev/null +++ b/host/session/heartbeat_test.go @@ -0,0 +1,76 @@ +package session + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +func TestRunHeartbeatClosesAfterConsecutiveMisses(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var pings, dead atomic.Int32 + done := make(chan struct{}) + go func() { + RunHeartbeat(ctx, 5*time.Millisecond, 5*time.Millisecond, 2, + func(context.Context) error { pings.Add(1); return errors.New("no pong") }, + func() { dead.Add(1) }) + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("heartbeat did not give up on a dead peer") + } + if dead.Load() != 1 { + t.Errorf("onDead calls = %d, want 1", dead.Load()) + } + if pings.Load() < 2 { + t.Errorf("pings = %d, want >= 2 before declaring dead", pings.Load()) + } +} + +func TestRunHeartbeatStaysAliveWhenAnswered(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + var dead atomic.Int32 + done := make(chan struct{}) + go func() { + RunHeartbeat(ctx, 5*time.Millisecond, 5*time.Millisecond, 2, + func(context.Context) error { return nil }, // always pongs + func() { dead.Add(1) }) + close(done) + }() + time.Sleep(80 * time.Millisecond) // many intervals, all answered + if dead.Load() != 0 { + t.Errorf("onDead called %d times for a healthy peer, want 0", dead.Load()) + } + cancel() // session ends -> heartbeat returns + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("heartbeat did not return after ctx cancel") + } +} + +// A single failure followed by a success must not trip the dead detector. +func TestRunHeartbeatResetsOnSuccess(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var n atomic.Int32 + var dead atomic.Int32 + go RunHeartbeat(ctx, 5*time.Millisecond, 5*time.Millisecond, 2, + func(context.Context) error { + // fail, ok, fail, ok, ... never two failures in a row + if n.Add(1)%2 == 1 { + return errors.New("miss") + } + return nil + }, + func() { dead.Add(1) }) + time.Sleep(120 * time.Millisecond) + if dead.Load() != 0 { + t.Errorf("onDead fired on alternating miss/ok, want 0 (got %d)", dead.Load()) + } +} diff --git a/host/session/intent.go b/host/session/intent.go new file mode 100644 index 0000000..8ca01c3 --- /dev/null +++ b/host/session/intent.go @@ -0,0 +1,37 @@ +package session + +// StartupIntentKind is the routing a session requested at connect time. +type StartupIntentKind uint8 + +const ( + // StartupQuickMatch routes straight into a quick match for Slug. + StartupQuickMatch StartupIntentKind = iota + // StartupJoin routes straight into the lobby named by Code. + StartupJoin + // StartupInvalid carries a human-readable Reason for an unparseable request; + // the lobby shows it and falls through to the root menu. + StartupInvalid +) + +// StartupIntent is a transport-neutral routing hint a Session may carry when its +// transport let the client name a destination at connect time (the SSH command +// string). It is a hint only: the front door does not resolve the slug/code or +// contact the matchmaker — the lobby validates and routes it. A session with no +// intent (the common case, and every /ws session) behaves exactly as before. +type StartupIntent struct { + Kind StartupIntentKind + Slug string // StartupQuickMatch: the exact author/name game slug. + Code string // StartupJoin: the invite code. + Reason string // StartupInvalid: why the request could not be parsed. +} + +// StartupRouter is an OPTIONAL capability a Session implements when its transport +// carries a connect-time command (the SSH front door parses one from +// sess.Command()). The lobby type-asserts for it once at startup; a transport +// that never carries a command (the /ws mobile/web front door) does not +// implement it, so the lobby sees no intent and behaves as it does today. +type StartupRouter interface { + // StartupIntent reports the parsed connect-time intent. ok is false when the + // session carried no command (normal lobby). + StartupIntent() (StartupIntent, bool) +} diff --git a/host/session/memsession.go b/host/session/memsession.go new file mode 100644 index 0000000..be19b55 --- /dev/null +++ b/host/session/memsession.go @@ -0,0 +1,96 @@ +package session + +import ( + "bytes" + "io" + "sync" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// MemSession is the test-only in-memory Session double: scripted keystroke input +// and captured frame output, with no socket. It is the forcing function that +// keeps lobby and game code honest about the Session boundary. +type MemSession struct { + id sdk.Player + caps Caps + + inR *io.PipeReader + inW *io.PipeWriter + + mu sync.Mutex + cols, rows int + out bytes.Buffer + + win chan Size + closeOnce sync.Once +} + +// NewMemSession builds an in-memory session. +func NewMemSession(id sdk.Player, caps Caps, cols, rows int) *MemSession { + pr, pw := io.Pipe() + return &MemSession{ + id: id, + caps: caps, + inR: pr, + inW: pw, + cols: cols, + rows: rows, + win: make(chan Size, 8), + } +} + +func (s *MemSession) Read(p []byte) (int, error) { return s.inR.Read(p) } + +func (s *MemSession) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.out.Write(p) +} + +func (s *MemSession) Identity() sdk.Player { return s.id } + +func (s *MemSession) Window() (int, int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.cols, s.rows +} + +func (s *MemSession) WindowChanges() <-chan Size { return s.win } +func (s *MemSession) Capabilities() Caps { return s.caps } +func (s *MemSession) RemoteIP() string { return "test" } + +func (s *MemSession) Close() error { + s.closeOnce.Do(func() { + close(s.win) + _ = s.inW.Close() + _ = s.inR.Close() + }) + return nil +} + +// ---- test helpers ---------------------------------------------------------- + +// Feed writes scripted bytes as keystrokes (blocks until consumed). +func (s *MemSession) Feed(b []byte) (int, error) { return s.inW.Write(b) } + +// CloseInputWriter half-closes the session: only the input pipe's write end, +// so the program's reader sees a clean io.EOF while the transport (output, +// window channel) stays up — the state a disconnect-detection test needs to +// stage before the full Close. +func (s *MemSession) CloseInputWriter() error { return s.inW.Close() } + +// Output returns the bytes written by the renderer so far. +func (s *MemSession) Output() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.out.String() +} + +// Resize updates the window size and emits a window-change event. +func (s *MemSession) Resize(cols, rows int) { + s.mu.Lock() + s.cols, s.rows = cols, rows + s.mu.Unlock() + s.win <- Size{Cols: cols, Rows: rows} +} diff --git a/host/session/session.go b/host/session/session.go new file mode 100644 index 0000000..415acae --- /dev/null +++ b/host/session/session.go @@ -0,0 +1,120 @@ +// Package session defines the transport-agnostic Session boundary. Lobby and +// game code read keystrokes and write frames through a Session and never branch +// on which transport produced it. +package session + +import ( + "io" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// ColorDepth is the session's color capability. +type ColorDepth uint8 + +const ( + ColorNone ColorDepth = iota + Color16 + Color256 + ColorTrue +) + +func (c ColorDepth) String() string { + switch c { + case ColorTrue: + return "truecolor" + case Color256: + return "256" + case Color16: + return "16" + default: + return "none" + } +} + +// Caps are per-session capabilities derived from the SSH environment. +type Caps struct { + ColorDepth ColorDepth + UTF8 bool + Mouse bool +} + +// Size is a terminal size. +type Size struct { + Cols int + Rows int +} + +// Session is the unified transport boundary. The default window is 80x24 when +// unknown. +type Session interface { + io.ReadWriter + Identity() sdk.Player + Window() (cols, rows int) + WindowChanges() <-chan Size + Capabilities() Caps + // RemoteIP is the client's IP (host portion only), used for per-login audit + // records. "?" when unknown. + RemoteIP() string + Close() error +} + +// InputContextNotifier is an OPTIONAL capability a Session may implement when +// its transport renders a client-side control surface that adapts to the +// game's published input context (the web front door's touch deck). The SSH +// transport does not implement it. The lobby type-asserts for it and calls +// NotifyInputContext on game enter/leave and whenever the active game's +// context changes; the transport forwards it out-of-band (a JSON control +// frame), invisible to hub.Serve and the games. Contexts are the wire +// strings "nav", "command", and "text". +type InputContextNotifier interface { + NotifyInputContext(ctx string) +} + +// ControlItem is one tappable control surfaced on a client-side deck: a short +// display label and the literal bytes a tap sends on the terminal stream — +// indistinguishable from the corresponding keypress. +type ControlItem struct { + Label string `json:"label"` + Send string `json:"send"` +} + +// ControlsNotifier is an OPTIONAL capability, sibling to InputContextNotifier, +// for transports whose control surface can render per-game tappable controls +// (the web touch deck's chips). The SSH transport does not implement it. The +// lobby calls NotifyControls with the active game's declared controls on game +// enter and with nil on return to the lobby; the transport forwards the set +// out-of-band, invisible to hub.Serve and the games. +type ControlsNotifier interface { + NotifyControls(items []ControlItem) +} + +// PasskeyRegistrar is an OPTIONAL capability a Session may implement when its +// transport can run a WebAuthn registration ceremony out-of-band (the web / +// WebSocket front door). The SSH transport does not implement it. The lobby +// type-asserts for it to decide whether to offer the "Create Passkey" item, and +// invokes RegisterPasskey to run the ceremony — keeping all WebAuthn protocol +// confined to the transport, invisible to hub.Serve and the games. +type PasskeyRegistrar interface { + // CanRegisterPasskey reports whether a passkey ceremony is actually available + // (the transport is web AND WebAuthn is configured). + CanRegisterPasskey() bool + // RegisterPasskey runs the ceremony and, on success, returns the promoted + // (member) player; the session's own Identity() also reflects it. + RegisterPasskey() (sdk.Player, error) +} + +// PasskeyAuthenticator is an OPTIONAL capability, sibling to PasskeyRegistrar, +// for transports that can run a discoverable-credential WebAuthn *login* ceremony +// out-of-band (the web / WebSocket front door). The SSH transport does not +// implement it. The lobby type-asserts for it to offer the "Log in with a +// passkey" item and invokes LoginPasskey to resolve a returning account and swap +// the live session's identity to it in place — no page reload. +type PasskeyAuthenticator interface { + // CanLoginPasskey reports whether a login ceremony is available (the transport + // is web AND WebAuthn is configured). + CanLoginPasskey() bool + // LoginPasskey runs the ceremony and, on success, returns the resolved account + // player; the session's own Identity() also reflects it. + LoginPasskey() (sdk.Player, error) +} diff --git a/host/session/signout.go b/host/session/signout.go new file mode 100644 index 0000000..c12c50e --- /dev/null +++ b/host/session/signout.go @@ -0,0 +1,20 @@ +package session + +import "github.com/shellcade/kit/v2/host/sdk" + +// SignOuter is an OPTIONAL capability a Session may implement when its transport +// holds a durable, revocable identity the user can drop in place — the web / +// WebSocket front door, whose identity is a signed guest cookie. The SSH +// transport does NOT implement it (its identity is the connection's key, nothing +// to sign out of). The lobby type-asserts for it to offer the "Sign out" item and +// invokes SignOut to revert the live session to a fresh guest and clear the cookie. +type SignOuter interface { + // CanSignOut reports whether a sign-out is available (the transport is web with + // a durable cookie configured). + CanSignOut() bool + // SignOut reverts the live session to a brand-new guest account, clears the + // durable cookie (browser-side, via the same one-time-token mechanism passkey + // login uses to SET it), and returns the new guest player; the session's own + // Identity() also reflects it. + SignOut() (sdk.Player, error) +} From c1559fc81d3f6c1df88f1ce359646a6dba3fc1fd Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Tue, 16 Jun 2026 08:43:18 +1000 Subject: [PATCH 06/10] host: extract game conformance harness (memsvc + sdk.Player seams) conformance moves to host/gameabi/conformance (subpackage so ../testdata resolves). services.NewFactory -> memsvc.New() / memsvc.NewFactory; seatPlayer builds sdk.Player directly instead of identity.Identity{}.Player(). No private imports remain. Co-Authored-By: Claude Opus 4.8 --- host/gameabi/conformance/conformance.go | 579 ++++++++++++++++++ host/gameabi/conformance/conformance_test.go | 217 +++++++ .../conformance/delta_conformance_test.go | 139 +++++ host/gameabi/conformance/diffdetail_test.go | 46 ++ .../conformance/fixture_rs_kit_test.go | 113 ++++ host/gameabi/conformance/fixture_rs_test.go | 117 ++++ host/gameabi/conformance/script.go | 86 +++ host/gameabi/conformance/smoke.go | 120 ++++ host/gameabi/conformance/smoke_test.go | 121 ++++ 9 files changed, 1538 insertions(+) create mode 100644 host/gameabi/conformance/conformance.go create mode 100644 host/gameabi/conformance/conformance_test.go create mode 100644 host/gameabi/conformance/delta_conformance_test.go create mode 100644 host/gameabi/conformance/diffdetail_test.go create mode 100644 host/gameabi/conformance/fixture_rs_kit_test.go create mode 100644 host/gameabi/conformance/fixture_rs_test.go create mode 100644 host/gameabi/conformance/script.go create mode 100644 host/gameabi/conformance/smoke.go create mode 100644 host/gameabi/conformance/smoke_test.go diff --git a/host/gameabi/conformance/conformance.go b/host/gameabi/conformance/conformance.go new file mode 100644 index 0000000..3e4bd77 --- /dev/null +++ b/host/gameabi/conformance/conformance.go @@ -0,0 +1,579 @@ +package conformance + +import ( + "fmt" + "log/slog" + "math/rand" + "time" + + "github.com/shellcade/kit/v2/host/gameabi" + "github.com/shellcade/kit/v2/host/memsvc" + "github.com/shellcade/kit/v2/host/sdk" +) + +// Report is the outcome of a conformance run: the artifact's declared ABI/meta, +// a metric row per executed step, the peak guest linear memory, and the budget +// verdicts. Pass reports true only when every verdict passed. +type Report struct { + ABIVersion uint32 + Meta sdk.GameMeta + + Steps []StepMetric + PeakMem uint64 // bytes, peak guest linear memory sampled after each callback + MemCap uint64 // bytes, the load-time memory cap + Deadline time.Duration + + Verdicts []Verdict + + // HibernationChecked is true if the script contained a snapshot/restore + // checkpoint and the determinism comparison ran; HibernationOK is its result. + HibernationChecked bool + HibernationOK bool +} + +// Pass reports whether every verdict passed. +func (r Report) Pass() bool { + for _, v := range r.Verdicts { + if !v.OK { + return false + } + } + return true +} + +// StepMetric is one executed step's measurements. +type StepMetric struct { + Index int + Desc string // human-readable ("input rune 'p' -> seat 0") + Callback string // the ABI callback this step drove ("input", "wake", …) or "" for non-callbacks + Latency time.Duration // wall time of the driven callback + Exit uint32 // wasm exit code (0 = clean) + Faulted bool // the callback trapped or hit the deadline + Frames int // frames the guest pushed during this step + MemBytes uint32 // guest linear memory sampled after the step +} + +// Verdict is a named budget check. On a breach, Limit/Measured/Step name the +// rule, the offending value, and the step index that breached it. +type Verdict struct { + Name string // "callback deadline", "linear memory", "guest fault" + OK bool + Limit string // the limit, formatted ("100ms", "5 MiB") + Measured string // the measured value that breached, formatted + Step int // breaching step index (-1 if not step-specific) + Detail string +} + +// Run executes the script against the artifact at path through the real adapter +// with the given Options (limits ON) and returns a Report. An error is returned +// only for setup failures (load/instantiate); per-step budget breaches are +// reported as failing Verdicts, not errors. +func Run(path string, opts gameabi.Options, script Script) (Report, error) { + game, err := gameabi.LoadGame(path, opts) + if err != nil { + return Report{}, fmt.Errorf("conformance: load: %w", err) + } + meta := game.Meta() + + rep := Report{ + ABIVersion: gameabi.Version, + Meta: meta, + } + if cap, ok := gameabi.MemoryCapBytes(game); ok { + rep.MemCap = cap + } + + // Control run: the whole script, no checkpoint interruption. Captures the + // per-step metrics + peak memory, and (if the script has a checkpoint) the + // post-checkpoint frames the hibernation comparison expects. + ctrl := newRun(game, meta) + ctrlFrames, checkpointAt := ctrl.execute(script, -1) // -1: never snapshot, just record + rep.Steps = ctrl.metrics + rep.PeakMem = ctrl.peakMem + rep.Deadline = ctrl.deadline + + // Hibernation determinism: if the script has a checkpoint, re-run it with a + // real snapshot/restore at the checkpoint and compare the post-checkpoint + // frames to the control's. + var hibDetail string + if checkpointAt >= 0 { + rep.HibernationChecked = true + hib := newRun(game, meta) + hibFrames, _ := hib.execute(script, checkpointAt) + // v2 resync (D6): a rejected delta is retried as a keyframe within the + // same send, so the restored stream is frame-for-frame identical to the + // control — NO dropped-frame tolerance (kit >= v2.0.1; ABI.md §4.6). + rep.HibernationOK = framesEqual(ctrlFrames, hibFrames) + if !rep.HibernationOK { + hibDetail = diffDetail(ctrlFrames, hibFrames) + } + } + + rep.Verdicts = ctrl.verdicts(rep) + if rep.HibernationChecked { + v := Verdict{ + Name: "hibernation determinism", OK: rep.HibernationOK, Step: -1, + Detail: "post-checkpoint frames must match an uninterrupted control", + } + if !rep.HibernationOK { + // Name what actually diverged so an author can find the offending + // idiom (host-derived state, map-iteration order, an unsnapshotted + // global) instead of staring at an opaque PASS/FAIL. + v.Detail = hibDetail + } + rep.Verdicts = append(rep.Verdicts, v) + } + return rep, nil +} + +// ---- the instrumented run ---------------------------------------------------- + +type run struct { + game sdk.Game + meta sdk.GameMeta + cfg sdk.RoomConfig + factory sdk.ServicesFactory + svc sdk.Services + handler sdk.Handler + room *instRoom + deadline time.Duration + peakMem uint64 + metrics []StepMetric +} + +func newRun(game sdk.Game, meta sdk.GameMeta) *run { + cfg := sdk.RoomConfig{ + Mode: sdk.ModePrivate, + Capacity: max(meta.MaxPlayers, 1), + MinPlayers: max(meta.MinPlayers, 1), + Seed: 42, + SeedSet: true, + } + factory := memsvc.New() + svc := factory.For("conformance", meta.Slug) + h := game.NewRoom(cfg, svc) + r := &instRoom{cfg: cfg, svc: svc, clock: time.Unix(1_700_000_000, 0), log: quietLog()} + d, _ := gameabi.HandlerDeadline(h) + return &run{game: game, meta: meta, cfg: cfg, factory: factory, svc: svc, handler: h, room: r, deadline: d} +} + +// execute drives the script. If snapAt >= 0, a real snapshot/restore is +// performed at the step with that index (which must be a SnapshotRestore step); +// otherwise SnapshotRestore steps are no-ops (the control path). It returns the +// frames pushed AFTER the checkpoint (for the determinism comparison) and the +// index of the checkpoint step (-1 if none). +func (rn *run) execute(script Script, snapAt int) (postFrames []sdk.Frame, checkpointAt int) { + checkpointAt = -1 + rn.handler.OnStart(rn.room) + rn.sample("start", "start", 0) + + postCheckpoint := false + for i, step := range script { + switch step.Kind { + case StepSnapshotRestore: + checkpointAt = i + postCheckpoint = true + if snapAt == i { + rn.doSnapshotRestore(i) + } + rn.metrics = append(rn.metrics, StepMetric{Index: i, Desc: "snapshot/restore checkpoint", MemBytes: gameabi.GuestMemorySize(rn.handler)}) + case StepAdvance: + rn.room.clock = rn.room.clock.Add(time.Duration(step.Dur) * time.Millisecond) + rn.metrics = append(rn.metrics, StepMetric{Index: i, Desc: fmt.Sprintf("advance clock %dms", step.Dur), MemBytes: gameabi.GuestMemorySize(rn.handler)}) + case StepJoin: + p := rn.room.seatPlayer(step.Seat) + rn.room.join(p) + rn.drive(i, fmt.Sprintf("join seat %d (%s)", step.Seat, p.Handle), "join", func() { rn.handler.OnJoin(rn.room, p) }) + case StepLeave: + p := rn.room.seatPlayer(step.Seat) + rn.room.leave(p) + rn.drive(i, fmt.Sprintf("leave seat %d (%s)", step.Seat, p.Handle), "leave", func() { rn.handler.OnLeave(rn.room, p) }) + case StepInput: + p := rn.room.seatPlayer(step.Seat) + in := inputFor(step) + rn.drive(i, fmt.Sprintf("input %s -> seat %d", describeInput(step), step.Seat), "input", func() { rn.handler.OnInput(rn.room, p, in) }) + case StepWake: + rn.drive(i, "wake", "wake", func() { rn.handler.OnTick(rn.room, rn.room.clock) }) + } + if postCheckpoint { + postFrames = append(postFrames, rn.room.takeFrames()...) + } else { + rn.room.takeFrames() // discard pre-checkpoint frames + } + } + return postFrames, checkpointAt +} + +// drive runs one callback, timing it, then records the per-step metric and +// samples guest memory. +func (rn *run) drive(idx int, desc, callback string, call func()) { + rn.room.frameMark = len(rn.room.frames) + start := time.Now() + call() + lat := time.Since(start) + exit, _, faulted := gameabi.LastCallback(rn.handler) + rn.recordStep(idx, desc, callback, lat, exit, faulted) +} + +func (rn *run) recordStep(idx int, desc, callback string, lat time.Duration, exit uint32, faulted bool) { + mem := gameabi.GuestMemorySize(rn.handler) + if uint64(mem) > rn.peakMem { + rn.peakMem = uint64(mem) + } + rn.metrics = append(rn.metrics, StepMetric{ + Index: idx, Desc: desc, Callback: callback, Latency: lat, + Exit: exit, Faulted: faulted, + Frames: len(rn.room.frames) - rn.room.frameMark, + MemBytes: mem, + }) +} + +// sample records a non-step callback (OnStart) and updates peak memory. +func (rn *run) sample(desc, callback string, idx int) { + mem := gameabi.GuestMemorySize(rn.handler) + if uint64(mem) > rn.peakMem { + rn.peakMem = uint64(mem) + } + exit, _, faulted := gameabi.LastCallback(rn.handler) + rn.metrics = append(rn.metrics, StepMetric{Index: -1, Desc: desc, Callback: callback, Exit: exit, Faulted: faulted, MemBytes: mem}) + rn.room.takeFrames() +} + +// doSnapshotRestore snapshots the live handler and replaces it with a restored +// one bound to the same game, continuing from the restored state. +func (rn *run) doSnapshotRestore(idx int) { + blob, err := gameabi.SnapshotHandler(rn.handler) + if err != nil { + rn.room.log.Error("conformance: snapshot failed", "step", idx, "err", err) + return + } + h, err := gameabi.RestoreHandler(rn.game, blob) + if err != nil { + rn.room.log.Error("conformance: restore failed", "step", idx, "err", err) + return + } + // Rebind the room's live services onto the restored handler: services are + // host resources the snapshot does not carry, so a resumed room must be + // rewired to the running instance's services — exactly as the engine will + // when it restores a hibernated room. Without this, kv/config/leaderboard + // host calls no-op after the checkpoint and the resumed room diverges from + // the uninterrupted control. + gameabi.BindServices(h, rn.svc) + // The restored handler supersedes the live one — close the replaced + // instance so its linear memory is not pinned in the game's shared wazero + // runtime for the rest of the harness run (the unadopted-handler rule). + gameabi.CloseHandler(rn.handler) + rn.handler = h + + // Drive the engine's resume entry point (OnResume) on the restored handler, + // exactly as the lobby resume flow does. In ABI v2 this re-seeds the host's + // ephemeral frame-delta epoch above the snapshot high-water and marks every + // per-consumer baseline slot not-present (D6), so the restored guest's first + // post-restore send is epoch-rejected and self-heals to a keyframe. + if rsm, ok := rn.handler.(sdk.Resumed); ok { + rsm.OnResume(rn.room) + rn.room.takeFrames() // the resume callback itself renders no compared frame + } +} + +// verdicts derives the named budget checks from the recorded metrics. +func (rn *run) verdicts(rep Report) []Verdict { + var vs []Verdict + + // Guest-fault verdict: any callback that trapped or hit the deadline. + faultStep, faulted := -1, false + for _, m := range rn.metrics { + if m.Faulted { + faultStep, faulted = m.Index, true + break + } + } + if faulted { + // Distinguish a deadline kill (latency at/over the deadline) from a trap. + name, limit, measured := "guest fault", "exit 0", "non-zero exit/trap" + for _, m := range rn.metrics { + if m.Index == faultStep && m.Faulted { + if rn.deadline > 0 && m.Latency >= rn.deadline { + name = "callback deadline" + limit = rn.deadline.String() + measured = m.Latency.Round(time.Millisecond).String() + } else { + measured = fmt.Sprintf("exit %d", m.Exit) + } + break + } + } + vs = append(vs, Verdict{Name: name, OK: false, Limit: limit, Measured: measured, Step: faultStep, + Detail: "a guest callback faulted; the room was force-settled"}) + } else { + vs = append(vs, Verdict{Name: "guest fault", OK: true, Limit: "none", Measured: "no fault", Step: -1}) + } + + // Deadline verdict (even without a fault, a callback may run long): the worst + // latency must stay under the per-callback deadline. + if rn.deadline > 0 { + worst, worstStep := time.Duration(0), -1 + for _, m := range rn.metrics { + if m.Latency > worst { + worst, worstStep = m.Latency, m.Index + } + } + ok := worst < rn.deadline + v := Verdict{Name: "callback latency", OK: ok, Limit: rn.deadline.String(), + Measured: worst.Round(time.Microsecond).String(), Step: worstStep} + if !ok { + v.Detail = "a callback's wall time reached the per-callback deadline" + } + vs = append(vs, v) + } + + // Memory verdict: peak guest memory under the cap. + if rep.MemCap > 0 { + ok := rep.PeakMem < rep.MemCap + v := Verdict{Name: "linear memory", OK: ok, Limit: humanBytes(rep.MemCap), + Measured: humanBytes(rep.PeakMem), Step: -1} + if !ok { + v.Detail = "peak guest linear memory reached the manifest cap" + } + vs = append(vs, v) + } + + return vs +} + +// ---- helpers ----------------------------------------------------------------- + +func inputFor(s Step) sdk.Input { + if s.Rune != 0 { + return sdk.Input{Kind: sdk.InputRune, Rune: s.Rune} + } + return sdk.Input{Kind: sdk.InputKey, Key: sdk.Key(s.Key)} +} + +func describeInput(s Step) string { + if s.Rune != 0 { + return fmt.Sprintf("rune %q", s.Rune) + } + return fmt.Sprintf("key %d", s.Key) +} + +func framesEqual(a, b []sdk.Frame) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Cells != b[i].Cells { + return false + } + } + return true +} + +// (framesEqualAfterResync removed.) Under the ABI v2 hibernation contract the +// restored guest's first send per consumer slot is epoch-rejected — and the +// guest retries the same frame as a keyframe within the same call (ABI.md +// §4.6, kit >= v2.0.1), so the restored stream must match the control +// frame-for-frame (framesEqual) with no dropped-frame tolerance. + +// diffDetail pinpoints the FIRST way the restored frame stream diverged from the +// control's, so the failure message teaches the author what to fix rather than +// just reporting PASS/FAIL. It names a frame-count mismatch, or the first frame +// and the first cell (row/col) whose rune differs, with both sides rendered. +func diffDetail(ctrl, hib []sdk.Frame) string { + if len(ctrl) != len(hib) { + return fmt.Sprintf("restored room pushed %d post-checkpoint frame(s); the control pushed %d "+ + "(a callback took a different path after restore — likely host-derived state the snapshot does not carry)", + len(hib), len(ctrl)) + } + for fi := range ctrl { + if ctrl[fi].Cells == hib[fi].Cells { + continue + } + // Prefer a rune divergence (the loudest, most actionable signal); fall + // back to naming a style-only divergence at the first differing cell. + styleRow, styleCol := -1, -1 + for row := 0; row < canvasRows; row++ { + for col := 0; col < canvasCols; col++ { + cc, hc := ctrl[fi].Cells[row][col], hib[fi].Cells[row][col] + if cc == hc { + continue + } + if cc.Rune != hc.Rune { + return fmt.Sprintf("post-checkpoint frame %d diverged at cell (row %d, col %d): "+ + "control rune %q, restored rune %q — the resumed guest read state the snapshot did not "+ + "reproduce (e.g. a host-time/wall-offset animation, map-iteration order, or an unsnapshotted runtime global)", + fi, row, col, runeOrDot(cc.Rune), runeOrDot(hc.Rune)) + } + if styleRow < 0 { + styleRow, styleCol = row, col + } + } + } + // No rune differed in this frame, but its Cells array did — style-only. + return fmt.Sprintf("post-checkpoint frame %d diverged in cell styling at (row %d, col %d) "+ + "though every rune matched — the resumed guest produced a different color/attribute", + fi, styleRow, styleCol) + } + return "post-checkpoint frames differ (no specific cell located)" +} + +const ( + canvasRows = 24 + canvasCols = 80 +) + +func runeOrDot(r rune) string { + if r == 0 || r == ' ' { + return "·" + } + return string(r) +} + +func humanBytes(b uint64) string { + switch { + case b >= 1<<20: + return fmt.Sprintf("%d MiB", b/(1<<20)) + case b >= 1<<10: + return fmt.Sprintf("%d KiB", b/(1<<10)) + default: + return fmt.Sprintf("%d B", b) + } +} + +func quietLog() *slog.Logger { + return slog.New(slog.NewTextHandler(nopWriter{}, &slog.HandlerOptions{Level: slog.LevelError})) +} + +type nopWriter struct{} + +func (nopWriter) Write(p []byte) (int, error) { return len(p), nil } + +// ---- instrumented Room ------------------------------------------------------- + +// instRoom is the synchronous Room the harness drives the handler against: a +// seat-indexed roster, a virtual clock the harness advances, and a frame log it +// samples per step. It mirrors the engine's roster/clock/effect surface without +// a goroutine, so the harness can measure each callback in isolation. +type instRoom struct { + cfg sdk.RoomConfig + svc sdk.Services + clock time.Time + log *slog.Logger + + members []sdk.Player + frames []sdk.Frame + frameMark int + last map[string]sdk.Frame // accountID -> latest frame (for RunShots) + + ended bool + res sdk.Result + ctx sdk.InputContext +} + +// seatPlayer returns a stable Player for a seat index (deterministic identity so +// rejoin resolves to the same account). Conformance validates ABI/limits/ +// determinism/post-on-leave, not character rendering, so it uses a zero +// character rather than projecting a default one. +func (r *instRoom) seatPlayer(seat int) sdk.Player { + return sdk.Player{ + AccountID: fmt.Sprintf("seat-%d", seat), + Handle: fmt.Sprintf("seat%d", seat), + Kind: sdk.KindMember, + Conn: fmt.Sprintf("conn-%d", seat), + } +} + +func (r *instRoom) join(p sdk.Player) { + for _, m := range r.members { + if m == p { + return + } + } + r.members = append(r.members, p) +} + +func (r *instRoom) leave(p sdk.Player) { + out := r.members[:0] + for _, m := range r.members { + if m != p { + out = append(out, m) + } + } + r.members = out +} + +func (r *instRoom) takeFrames() []sdk.Frame { + f := r.frames + r.frames = nil + return f +} + +// ---- sdk.Room implementation ---- + +func (r *instRoom) Members() []sdk.Player { return append([]sdk.Player(nil), r.members...) } +func (r *instRoom) Has(p sdk.Player) bool { + for _, m := range r.members { + if m == p { + return true + } + } + return false +} +func (r *instRoom) Count() int { return len(r.members) } +func (r *instRoom) Config() sdk.RoomConfig { return r.cfg } +func (r *instRoom) Rand() *rand.Rand { return rand.New(rand.NewSource(r.cfg.Seed)) } +func (r *instRoom) Now() time.Time { return r.clock } + +func (r *instRoom) Send(p sdk.Player, f sdk.Frame) { + r.frames = append(r.frames, f) + r.remember(p, f) +} + +func (r *instRoom) Identical(f sdk.Frame) { + r.frames = append(r.frames, f) + for _, p := range r.members { + r.remember(p, f) + } +} + +func (r *instRoom) BroadcastFunc(compose func(p sdk.Player) sdk.Frame) { + for _, p := range r.members { + f := compose(p) + r.frames = append(r.frames, f) + r.remember(p, f) + } +} + +// remember keeps the latest frame per seat for RunShots' capture markers. +// sdk.Frame is a value (the canvas grid array), so the map entry is a copy. +func (r *instRoom) remember(p sdk.Player, f sdk.Frame) { + if r.last == nil { + r.last = map[string]sdk.Frame{} + } + r.last[p.AccountID] = f +} + +func (r *instRoom) After(time.Duration, func(sdk.Room)) sdk.TimerID { return 0 } +func (r *instRoom) Every(time.Duration, func(sdk.Room)) sdk.TimerID { return 0 } +func (r *instRoom) Cancel(sdk.TimerID) {} +func (r *instRoom) SetSimRate(time.Duration) {} +func (r *instRoom) SetFrameRate(time.Duration) {} +func (r *instRoom) SetPhase(string, bool, time.Time) {} +func (r *instRoom) SetInputContext(ctx sdk.InputContext) { r.ctx = ctx } + +func (r *instRoom) End(res sdk.Result) { + if r.ended { + return + } + r.ended, r.res = true, res +} +func (r *instRoom) Result() (sdk.Result, bool) { + if r.ended { + return r.res, true + } + return sdk.Result{}, false +} +func (r *instRoom) Services() sdk.Services { return r.svc } +func (r *instRoom) Log() *slog.Logger { return r.log } + +var _ sdk.Room = (*instRoom)(nil) diff --git a/host/gameabi/conformance/conformance_test.go b/host/gameabi/conformance/conformance_test.go new file mode 100644 index 0000000..7e1b004 --- /dev/null +++ b/host/gameabi/conformance/conformance_test.go @@ -0,0 +1,217 @@ +package conformance_test + +import ( + "strings" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/gameabi" + "github.com/shellcade/kit/v2/host/gameabi/conformance" +) + +const fixturePath = "../testdata/fixture/fixture.wasm" + +// defaultScript exercises every export against the fixture: a two-seat roster +// sequence (joins, interleaved inputs, leave, rejoin), all the benign ABI +// commands, and a snapshot/restore checkpoint for the hibernation determinism +// check. +func defaultScript() conformance.Script { + return conformance.Script{ + conformance.Join(0), + conformance.Join(1), + conformance.Input(0, 'i'), // set input context + conformance.Input(1, 'c'), // config_get + conformance.Wake(), + conformance.Advance(50), + conformance.Wake(), + conformance.Input(0, 'r'), // entropy draw + conformance.Input(1, 'f'), // personal frame + conformance.SnapshotRestore(), + conformance.Input(0, 't'), // time + conformance.Advance(50), + conformance.Wake(), + conformance.Leave(1), + conformance.Wake(), + conformance.Join(1), // rejoin + conformance.Wake(), + } +} + +// TestConformanceFixturePasses: the well-behaved fixture passes every budget +// verdict and the hibernation-determinism check. +func TestConformanceFixturePasses(t *testing.T) { + rep, err := conformance.Run(fixturePath, gameabi.Options{CallbackDeadline: time.Second}, defaultScript()) + if err != nil { + t.Fatalf("run: %v", err) + } + if rep.ABIVersion != gameabi.Version { + t.Errorf("abi = %d, want %d", rep.ABIVersion, gameabi.Version) + } + if rep.Meta.Slug != "fixture" { + t.Errorf("meta slug = %q, want fixture", rep.Meta.Slug) + } + if !rep.HibernationChecked || !rep.HibernationOK { + t.Errorf("hibernation: checked=%v ok=%v, want both true", rep.HibernationChecked, rep.HibernationOK) + } + if !rep.Pass() { + for _, v := range rep.Verdicts { + if !v.OK { + t.Errorf("verdict %q failed: limit=%s measured=%s step=%d", v.Name, v.Limit, v.Measured, v.Step) + } + } + } + if rep.PeakMem == 0 { + t.Error("peak memory not sampled") + } + // Every benign callback ran clean. + for _, m := range rep.Steps { + if m.Faulted { + t.Errorf("step %d (%s) faulted unexpectedly", m.Index, m.Desc) + } + } +} + +// TestConformanceTwoSeatRoster: the report has roster-driving callbacks for both +// seats (join, input, leave, rejoin) and the room never faulted. +func TestConformanceTwoSeatRoster(t *testing.T) { + rep, err := conformance.Run(fixturePath, gameabi.Options{CallbackDeadline: time.Second}, defaultScript()) + if err != nil { + t.Fatalf("run: %v", err) + } + joins, leaves, inputs := 0, 0, 0 + for _, m := range rep.Steps { + switch m.Callback { + case "join": + joins++ + case "leave": + leaves++ + case "input": + inputs++ + } + } + if joins != 3 { // seat0, seat1, seat1-rejoin + t.Errorf("join callbacks = %d, want 3", joins) + } + if leaves != 1 { + t.Errorf("leave callbacks = %d, want 1", leaves) + } + if inputs < 4 { + t.Errorf("input callbacks = %d, want >= 4", inputs) + } +} + +// TestConformanceDeadlineFails: the 'l' spin variant breaches the per-callback +// deadline; the failing verdict names the limit, the measured latency, and the +// step. +func TestConformanceDeadlineFails(t *testing.T) { + script := conformance.Script{ + conformance.Join(0), + conformance.Input(0, 'l'), // spin past the deadline + } + rep, err := conformance.Run(fixturePath, gameabi.Options{CallbackDeadline: 50 * time.Millisecond}, script) + if err != nil { + t.Fatalf("run: %v", err) + } + if rep.Pass() { + t.Fatal("expected a budget breach for the spin variant") + } + v, ok := findVerdict(rep, "callback deadline") + if !ok { + // The latency verdict also names the deadline; accept either. + v, ok = findFailing(rep) + } + if !ok { + t.Fatalf("no failing verdict; verdicts=%+v", rep.Verdicts) + } + if v.Step < 0 { + t.Errorf("breach verdict %q did not name a step", v.Name) + } + if v.Limit == "" || v.Measured == "" { + t.Errorf("breach verdict %q must name limit (%q) and measured (%q)", v.Name, v.Limit, v.Measured) + } + // The breaching step must be the spin input (index 1). + if v.Step != 1 { + t.Errorf("breach step = %d, want 1 (the 'l' input)", v.Step) + } + if !strings.Contains(v.Limit, "ms") { + t.Errorf("deadline limit %q should be a duration", v.Limit) + } +} + +// TestConformanceTrapFails: the 'p' panic variant traps; the failing verdict +// names the fault and the step. +func TestConformanceTrapFails(t *testing.T) { + script := conformance.Script{ + conformance.Join(0), + conformance.Input(0, 'p'), // deliberate panic + } + rep, err := conformance.Run(fixturePath, gameabi.Options{CallbackDeadline: time.Second}, script) + if err != nil { + t.Fatalf("run: %v", err) + } + if rep.Pass() { + t.Fatal("expected a fault verdict for the panic variant") + } + v, ok := findVerdict(rep, "guest fault") + if !ok { + t.Fatalf("no guest-fault verdict; verdicts=%+v", rep.Verdicts) + } + if v.OK { + t.Fatal("guest-fault verdict passed despite the panic") + } + if v.Step != 1 { + t.Errorf("fault step = %d, want 1 (the 'p' input)", v.Step) + } + if v.Measured == "" { + t.Errorf("fault verdict must name the measured value (exit/trap)") + } +} + +// TestConformanceMemoryNamed: a tiny memory cap + the 'o' allocate-forever +// command breaches the linear-memory budget, and the verdict names the cap and +// the measured peak. +func TestConformanceMemoryNamed(t *testing.T) { + script := conformance.Script{ + conformance.Join(0), + conformance.Input(0, 'o'), // allocate past the cap + } + // 5 MiB cap (80 pages); allocate-forever trips the cap (the guest traps). + rep, err := conformance.Run(fixturePath, gameabi.Options{MemoryPages: 80, CallbackDeadline: 5 * time.Second}, script) + if err != nil { + t.Fatalf("run: %v", err) + } + if rep.Pass() { + t.Fatal("expected a breach for allocate-forever") + } + mv, ok := findVerdict(rep, "linear memory") + if !ok { + t.Fatalf("no linear-memory verdict; verdicts=%+v", rep.Verdicts) + } + if mv.Limit == "" { + t.Errorf("memory verdict must name the cap") + } + // The allocate-forever guest traps when it hits the cap, so the guest-fault + // verdict must also fail and name the step. + fv, _ := findVerdict(rep, "guest fault") + if fv.OK { + t.Errorf("guest-fault verdict passed despite hitting the memory cap") + } +} + +func findVerdict(r conformance.Report, name string) (conformance.Verdict, bool) { + for _, v := range r.Verdicts { + if v.Name == name { + return v, true + } + } + return conformance.Verdict{}, false +} + +func findFailing(r conformance.Report) (conformance.Verdict, bool) { + for _, v := range r.Verdicts { + if !v.OK { + return v, true + } + } + return conformance.Verdict{}, false +} diff --git a/host/gameabi/conformance/delta_conformance_test.go b/host/gameabi/conformance/delta_conformance_test.go new file mode 100644 index 0000000..30c70ee --- /dev/null +++ b/host/gameabi/conformance/delta_conformance_test.go @@ -0,0 +1,139 @@ +package conformance_test + +import ( + "testing" + "time" + + "github.com/shellcade/kit/v2/host/gameabi" + "github.com/shellcade/kit/v2/host/gameabi/conformance" +) + +// Frame-delta conformance through the REAL host adapter (tasks 6.2–6.4). The +// fixture renders via the v2 SDK (deltas + keyframes); the harness reconstructs +// every frame on the host's per-consumer baseline and compares post-checkpoint +// streams against an uninterrupted control. The byte-identity, solo-rehydrate, +// and Identical-then-Send/mid-join behaviours are exercised end-to-end here; +// the host-side codec round-trip, fuzz, grapheme, and reconciliation invariants +// are unit-tested in internal/gameabi (delta_conformance_test.go). + +// TestConformanceDeltaSoloRehydrate is the NAMED solo / same-account rehydrate +// regression (task 6.3 / D6): a solo room is snapshotted mid-script, restored +// into a fresh instance with the SAME account (seat 0) returning, and continued. +// Because the host's baseline cache is ephemeral (re-seeded above the snapshot +// epoch high-water on resume), the restored guest's first post-restore delta is +// epoch-rejected and self-heals to a keyframe; every frame thereafter is +// byte-identical to the uninterrupted control. A divergence beyond the single +// resync frame fails and names the first differing step. +func TestConformanceDeltaSoloRehydrate(t *testing.T) { + // A solo single-seat script: establish a baseline, snapshot/restore, then + // continue rendering. The same account (seat 0) is in the room before and + // after the checkpoint — the hardest case for a guest-infers approach. + script := conformance.Script{ + conformance.Join(0), + conformance.Wake(), + conformance.Advance(50), + conformance.Wake(), + conformance.SnapshotRestore(), // snapshot mid-script; same account returns + conformance.Advance(50), + conformance.Wake(), + conformance.Advance(50), + conformance.Wake(), + conformance.Advance(50), + conformance.Wake(), + } + rep, err := conformance.Run(fixturePath, gameabi.Options{CallbackDeadline: time.Second}, script) + if err != nil { + t.Fatalf("run: %v", err) + } + if !rep.HibernationChecked { + t.Fatal("hibernation determinism was not checked (no checkpoint ran)") + } + if !rep.HibernationOK { + v, _ := findVerdict(rep, "hibernation determinism") + t.Fatalf("solo same-account rehydrate diverged: %s", v.Detail) + } + if !rep.Pass() { + for _, v := range rep.Verdicts { + if !v.OK { + t.Errorf("verdict %q failed: %s", v.Name, v.Detail) + } + } + } +} + +// TestConformanceDeltaIdenticalThenSend drives a broadcast Identical (the +// fixture's default render) interleaved with a per-player Send (the 'f' personal +// frame), so the host's Identical-reconciles-all-slots + later per-player Send +// path runs end-to-end (task 6.4 / D7). The per-player frame after the broadcast +// must reconstruct correctly; the harness asserts the room never faulted and the +// personal frame was delivered. +func TestConformanceDeltaIdenticalThenSend(t *testing.T) { + script := conformance.Script{ + conformance.Join(0), + conformance.Join(1), + conformance.Wake(), // Identical (broadcast) — reconciles all slots + conformance.Input(1, 'f'), // per-player Send to seat 1 (PERSONAL frame) + conformance.Wake(), // Identical again + conformance.Input(0, 'f'), // per-player Send to seat 0 + } + rep, err := conformance.Run(fixturePath, gameabi.Options{CallbackDeadline: time.Second}, script) + if err != nil { + t.Fatalf("run: %v", err) + } + if !rep.Pass() { + for _, v := range rep.Verdicts { + if !v.OK { + t.Errorf("verdict %q failed: %s", v.Name, v.Detail) + } + } + } + // The personal-frame inputs must have produced frames without faulting (the + // host applied the per-player deltas against the reconciled baseline). + personalSteps := 0 + for _, m := range rep.Steps { + if m.Callback == "input" { + if m.Faulted { + t.Errorf("personal-frame input step %d faulted", m.Index) + } + if m.Frames > 0 { + personalSteps++ + } + } + } + if personalSteps < 2 { + t.Errorf("personal-frame inputs produced frames in %d steps, want >= 2", personalSteps) + } +} + +// TestConformanceDeltaMidJoin exercises a mid-room join: a second player joins an +// active room (roster indices renumber), so the host bumps the epoch and marks +// affected slots not-present — the joiner's first frame is a keyframe and the +// room renders correctly without faulting (task 6.4 / D7 mid-join scenario). +func TestConformanceDeltaMidJoin(t *testing.T) { + script := conformance.Script{ + conformance.Join(0), + conformance.Wake(), + conformance.Advance(50), + conformance.Wake(), + conformance.Join(1), // mid-room join: indices renumber -> keyframe path + conformance.Wake(), + conformance.Advance(50), + conformance.Wake(), + } + rep, err := conformance.Run(fixturePath, gameabi.Options{CallbackDeadline: time.Second}, script) + if err != nil { + t.Fatalf("run: %v", err) + } + if !rep.Pass() { + for _, v := range rep.Verdicts { + if !v.OK { + t.Errorf("verdict %q failed: %s", v.Name, v.Detail) + } + } + } + for _, m := range rep.Steps { + if m.Faulted { + t.Errorf("step %d (%s) faulted during the mid-join sequence", m.Index, m.Desc) + } + } +} diff --git a/host/gameabi/conformance/diffdetail_test.go b/host/gameabi/conformance/diffdetail_test.go new file mode 100644 index 0000000..e966f11 --- /dev/null +++ b/host/gameabi/conformance/diffdetail_test.go @@ -0,0 +1,46 @@ +package conformance + +import ( + "strings" + "testing" + + "github.com/shellcade/kit/v2/host/canvas" + "github.com/shellcade/kit/v2/host/sdk" +) + +// TestDiffDetailNamesCell: when post-checkpoint frames diverge, the hibernation +// failure message must name the offending frame and cell so an author can find +// the non-deterministic idiom instead of staring at an opaque FAIL. +func TestDiffDetailNamesCell(t *testing.T) { + var ctrl, hib sdk.Frame + ctrl.Cells[3][7] = sdk.Cell{Rune: 'A'} + hib.Cells[3][7] = sdk.Cell{Rune: 'B'} + + got := diffDetail([]sdk.Frame{ctrl}, []sdk.Frame{hib}) + for _, want := range []string{"frame 0", "row 3", "col 7", `"A"`, `"B"`} { + if !strings.Contains(got, want) { + t.Errorf("diff detail missing %q\n got: %s", want, got) + } + } +} + +// TestDiffDetailFrameCount: a frame-count mismatch is reported as a path +// divergence (a callback took a different branch after restore). +func TestDiffDetailFrameCount(t *testing.T) { + got := diffDetail([]sdk.Frame{{}, {}}, []sdk.Frame{{}}) + if !strings.Contains(got, "pushed 1") || !strings.Contains(got, "pushed 2") { + t.Errorf("frame-count detail did not name both counts: %s", got) + } +} + +// TestDiffDetailStyleOnly: identical runes but a differing style is named as a +// styling divergence (color/attr) rather than a rune mismatch. +func TestDiffDetailStyleOnly(t *testing.T) { + var ctrl, hib sdk.Frame + ctrl.Cells[0][0] = sdk.Cell{Rune: 'X', Attr: canvas.AttrBold} + hib.Cells[0][0] = sdk.Cell{Rune: 'X'} + got := diffDetail([]sdk.Frame{ctrl}, []sdk.Frame{hib}) + if !strings.Contains(got, "styling") { + t.Errorf("style-only divergence not named: %s", got) + } +} diff --git a/host/gameabi/conformance/fixture_rs_kit_test.go b/host/gameabi/conformance/fixture_rs_kit_test.go new file mode 100644 index 0000000..44e24b9 --- /dev/null +++ b/host/gameabi/conformance/fixture_rs_kit_test.go @@ -0,0 +1,113 @@ +package conformance_test + +import ( + "os" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/gameabi" + "github.com/shellcade/kit/v2/host/gameabi/conformance" +) + +// fixture-rs-kit is the conformance fixture built ON the shellcade-kit Rust SDK +// crate (kit/rust). Where fixture-rs proves the ABI is implementable from +// ABI.md alone (and stays SDK-free for exactly that reason), THIS fixture +// proves the SDK passes the server's own gate: its delta path (per-slot +// baselines, host-authoritative epochs, keyframe bootstrap, in-call retry on +// epoch rejection) runs under the same script — including the snapshot/restore +// hibernation byte-identity check, which rejects the post-restore delta and +// forces the SDK's keyframe-retry path to actually fire. +// +// The artifact is committed (build recipe: `make fixture-rs-kit-wasm`); the +// t.Skip guard only matters for a checkout where it was removed, so CI without +// a Rust toolchain still passes off the committed .wasm. +const fixtureRSKitPath = "../testdata/fixture-rs-kit/fixture-rs-kit.wasm" + +func skipIfNoRustKitFixture(t *testing.T) { + t.Helper() + if _, err := os.Stat(fixtureRSKitPath); err != nil { + t.Skipf("rust kit fixture artifact absent (%v); build with `make fixture-rs-kit-wasm`", err) + } +} + +// TestConformanceRustKitFixturePasses: the SDK-built guest passes every budget +// verdict and the hibernation-determinism check. The script mirrors the +// fixture-rs core script (joins, leave, rejoin, wakes with clock advances, a +// snapshot/restore checkpoint, settle via 'e'). +func TestConformanceRustKitFixturePasses(t *testing.T) { + skipIfNoRustKitFixture(t) + script := conformance.Script{ + conformance.Join(0), + conformance.Join(1), + conformance.Wake(), + conformance.Advance(50), + conformance.Wake(), + conformance.SnapshotRestore(), + conformance.Advance(50), + conformance.Wake(), + conformance.Leave(1), + conformance.Wake(), + conformance.Join(1), // rejoin + conformance.Wake(), + conformance.Input(0, 'e'), // settle (winner seat 0, metric 42) + } + rep, err := conformance.Run(fixtureRSKitPath, gameabi.Options{CallbackDeadline: time.Second}, script) + if err != nil { + t.Fatalf("run: %v", err) + } + if rep.ABIVersion != gameabi.Version { + t.Errorf("abi = %d, want %d", rep.ABIVersion, gameabi.Version) + } + if rep.Meta.Slug != "fixture-rs-kit" { + t.Errorf("meta slug = %q, want fixture-rs-kit", rep.Meta.Slug) + } + if rep.Meta.MinPlayers != 1 || rep.Meta.MaxPlayers != 2 { + t.Errorf("meta players = %d..%d, want 1..2", rep.Meta.MinPlayers, rep.Meta.MaxPlayers) + } + if !rep.HibernationChecked || !rep.HibernationOK { + t.Errorf("hibernation: checked=%v ok=%v, want both true", rep.HibernationChecked, rep.HibernationOK) + } + if !rep.Pass() { + for _, v := range rep.Verdicts { + if !v.OK { + t.Errorf("verdict %q failed: limit=%s measured=%s step=%d", v.Name, v.Limit, v.Measured, v.Step) + } + } + } + if rep.PeakMem == 0 { + t.Error("peak memory not sampled") + } + for _, m := range rep.Steps { + if m.Faulted { + t.Errorf("step %d (%s) faulted unexpectedly", m.Index, m.Desc) + } + } +} + +// TestConformanceRustKitFixtureTraps: the SDK guest's 'p' command panics +// (panic=abort -> wasm trap) and the host contains it — the same fault story +// as the hand-rolled fixtures, through the SDK's dispatch. +func TestConformanceRustKitFixtureTraps(t *testing.T) { + skipIfNoRustKitFixture(t) + script := conformance.Script{ + conformance.Join(0), + conformance.Input(0, 'p'), // deliberate panic + } + rep, err := conformance.Run(fixtureRSKitPath, gameabi.Options{CallbackDeadline: time.Second}, script) + if err != nil { + t.Fatalf("run: %v", err) + } + if rep.Pass() { + t.Fatal("report passed; want a failing guest-fault verdict") + } + v, ok := findVerdict(rep, "guest fault") + if !ok { + t.Fatalf("no guest-fault verdict; verdicts=%+v", rep.Verdicts) + } + if v.OK { + t.Fatal("guest-fault verdict passed despite the panic") + } + if v.Step != 1 { + t.Errorf("fault step = %d, want 1 (the 'p' input)", v.Step) + } +} diff --git a/host/gameabi/conformance/fixture_rs_test.go b/host/gameabi/conformance/fixture_rs_test.go new file mode 100644 index 0000000..60f4689 --- /dev/null +++ b/host/gameabi/conformance/fixture_rs_test.go @@ -0,0 +1,117 @@ +package conformance_test + +import ( + "os" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/gameabi" + "github.com/shellcade/kit/v2/host/gameabi/conformance" +) + +// The Rust fixture is a SECOND test guest, implemented in Rust from the public +// ABI contract (kit/ABI.md + wire.go) alone — no Go SDK, no shared code with the +// TinyGo fixture. Running it green through the same host adapter and conformance +// harness proves the ABI is language-neutral (add-wasm-game-abi task 5.4). +// +// The artifact is committed (build recipe: `make fixture-rs-wasm`); the t.Skip +// guard only matters for a checkout where it was somehow removed, so CI without +// a Rust toolchain still passes off the committed .wasm. +const fixtureRSPath = "../testdata/fixture-rs/fixture-rs.wasm" + +// rustCoreScript exercises the Rust fixture's CORE surface: a two-seat roster +// (joins, a leave, a rejoin), wakes interleaved with clock advances, a +// snapshot/restore checkpoint for the hibernation-determinism check, and an 'e' +// input that ends the room as the final step. Commands the Rust guest does not +// implement fall through to a benign re-render, so the script stays clean. +func rustCoreScript() conformance.Script { + return conformance.Script{ + conformance.Join(0), + conformance.Join(1), + conformance.Wake(), + conformance.Advance(50), + conformance.Wake(), + conformance.SnapshotRestore(), + conformance.Advance(50), + conformance.Wake(), + conformance.Leave(1), + conformance.Wake(), + conformance.Join(1), // rejoin + conformance.Wake(), + conformance.Input(0, 'e'), // settle the room (winner seat 0, metric 42) + } +} + +func skipIfNoRustFixture(t *testing.T) { + t.Helper() + if _, err := os.Stat(fixtureRSPath); err != nil { + t.Skipf("rust fixture artifact absent (%v); build with `make fixture-rs-wasm`", err) + } +} + +// TestConformanceRustFixturePasses: the Rust guest passes every budget verdict +// and the hibernation-determinism check, proving a non-Go artifact satisfies the +// same ABI through the same host. +func TestConformanceRustFixturePasses(t *testing.T) { + skipIfNoRustFixture(t) + rep, err := conformance.Run(fixtureRSPath, gameabi.Options{CallbackDeadline: time.Second}, rustCoreScript()) + if err != nil { + t.Fatalf("run: %v", err) + } + if rep.ABIVersion != gameabi.Version { + t.Errorf("abi = %d, want %d", rep.ABIVersion, gameabi.Version) + } + if rep.Meta.Slug != "fixture-rs" { + t.Errorf("meta slug = %q, want fixture-rs", rep.Meta.Slug) + } + if rep.Meta.MinPlayers != 1 || rep.Meta.MaxPlayers != 2 { + t.Errorf("meta players = %d..%d, want 1..2", rep.Meta.MinPlayers, rep.Meta.MaxPlayers) + } + if !rep.HibernationChecked || !rep.HibernationOK { + t.Errorf("hibernation: checked=%v ok=%v, want both true", rep.HibernationChecked, rep.HibernationOK) + } + if !rep.Pass() { + for _, v := range rep.Verdicts { + if !v.OK { + t.Errorf("verdict %q failed: limit=%s measured=%s step=%d", v.Name, v.Limit, v.Measured, v.Step) + } + } + } + if rep.PeakMem == 0 { + t.Error("peak memory not sampled") + } + for _, m := range rep.Steps { + if m.Faulted { + t.Errorf("step %d (%s) faulted unexpectedly", m.Index, m.Desc) + } + } +} + +// TestConformanceRustFixtureTraps: the Rust guest's 'p' command panics +// (panic=abort -> wasm trap), and the host settles the room with a failing +// guest-fault verdict naming the breaching step — the same containment story the +// Go fixture proves, from a different language. +func TestConformanceRustFixtureTraps(t *testing.T) { + skipIfNoRustFixture(t) + script := conformance.Script{ + conformance.Join(0), + conformance.Input(0, 'p'), // deliberate panic + } + rep, err := conformance.Run(fixtureRSPath, gameabi.Options{CallbackDeadline: time.Second}, script) + if err != nil { + t.Fatalf("run: %v", err) + } + if rep.Pass() { + t.Fatal("expected a fault verdict for the panic variant") + } + v, ok := findVerdict(rep, "guest fault") + if !ok { + t.Fatalf("no guest-fault verdict; verdicts=%+v", rep.Verdicts) + } + if v.OK { + t.Fatal("guest-fault verdict passed despite the panic") + } + if v.Step != 1 { + t.Errorf("fault step = %d, want 1 (the 'p' input)", v.Step) + } +} diff --git a/host/gameabi/conformance/script.go b/host/gameabi/conformance/script.go new file mode 100644 index 0000000..9acbb54 --- /dev/null +++ b/host/gameabi/conformance/script.go @@ -0,0 +1,86 @@ +// Package conformance runs a scripted scenario against a wasm game through the +// REAL gameabi adapter (limits ON) and reports per-callback latency, exit codes, +// frames, and peak linear memory, plus budget verdicts that name the breached +// limit, the measured value, and the step that breached it. It is the shared +// engine behind `shellcade-kit check` and is importable by internal/catalog for +// release-intake gating. +// +// The harness drives the handler's callbacks SYNCHRONOUSLY against an +// instrumented Room (not the live actor goroutine): synchronous control is what +// lets it sample guest memory after each callback, advance a virtual clock in +// fixed steps, and snapshot/restore mid-script for the hibernation-determinism +// check — none of which the async RoomCtl surface exposes. The adapter, limits, +// virtualized WASI, and host functions exercised are the production ones. +package conformance + +// StepKind tags a scripted step. +type StepKind uint8 + +const ( + StepJoin StepKind = iota + StepLeave + StepInput + StepWake + StepAdvance + StepSnapshotRestore + StepShot // capture marker for RunShots; a no-op under Run +) + +// Step is one scripted action. Construct steps with the helpers below rather than +// building this struct directly. +type Step struct { + Kind StepKind + + Seat int // seat index for Join/Leave/Input (0-based) + Rune rune // for StepInput (rune input; 0 means a key input) + Key uint8 // for StepInput (key code when Rune == 0) + Dur DurationMS // for StepAdvance + Note string // human-readable label echoed in the report + + Name string // for StepShot: the shot name + Seats []int // for StepShot: captured seats, ascending (nil = all members) +} + +// DurationMS is a millisecond duration carried in the script (kept simple so a +// script literal reads cleanly and so JSON/flag wiring stays trivial later). +type DurationMS int64 + +// Join admits the player at seat into the room. +func Join(seat int) Step { return Step{Kind: StepJoin, Seat: seat, Note: "join seat"} } + +// Leave removes the player at seat. +func Leave(seat int) Step { return Step{Kind: StepLeave, Seat: seat, Note: "leave seat"} } + +// Input delivers a rune to the player at seat. +func Input(seat int, r rune) Step { + return Step{Kind: StepInput, Seat: seat, Rune: r, Note: "input rune"} +} + +// Key delivers a key code to the player at seat. +func Key(seat int, key uint8) Step { + return Step{Kind: StepInput, Seat: seat, Key: key, Note: "input key"} +} + +// Wake fires one host-heartbeat wake. +func Wake() Step { return Step{Kind: StepWake, Note: "wake"} } + +// Advance moves the virtual clock forward by ms milliseconds (the next callback +// reads the new clock as its CallContext time). +func Advance(ms int64) Step { + return Step{Kind: StepAdvance, Dur: DurationMS(ms), Note: "advance clock"} +} + +// SnapshotRestore checkpoints the room: snapshot the current state, restore into +// a fresh handler, and continue the script against the restored room. The runner +// also verifies the post-checkpoint frames match an uninterrupted control +// (hibernation determinism). +func SnapshotRestore() Step { return Step{Kind: StepSnapshotRestore, Note: "snapshot/restore"} } + +// Shot marks a capture point for RunShots: the latest frame of each listed +// seat (nil = every member) is recorded under name. Run ignores shot steps. +func Shot(name string, seats []int) Step { + return Step{Kind: StepShot, Name: name, Seats: seats, Note: "shot"} +} + +// Script is an ordered list of steps. +type Script []Step diff --git a/host/gameabi/conformance/smoke.go b/host/gameabi/conformance/smoke.go new file mode 100644 index 0000000..ca0ea3c --- /dev/null +++ b/host/gameabi/conformance/smoke.go @@ -0,0 +1,120 @@ +package conformance + +import ( + "fmt" + "time" + + "github.com/shellcade/kit/v2/host/gameabi" + "github.com/shellcade/kit/v2/host/memsvc" + "github.com/shellcade/kit/v2/host/sdk" +) + +// SmokeRun configures RunShots: the deterministic inputs from a game's +// smoke.yaml. The clock contract (Epoch) and the room shape (mode, capacity, +// identities) mirror the kit smoke runner exactly — that is what makes the +// wasm path's shot bytes identical to `go run . -smoke`. +type SmokeRun struct { + Seed int64 + Seats int // seats joined before the first step (seat-0..) + Config map[string]string // per-game config values + Epoch time.Time // virtual clock start: kit smoke.SeedEpoch(Seed) + Script Script // Input/Wake/Advance/Shot steps (no joins) +} + +// SeatFrames is one captured shot: the latest frame of each captured seat at +// the moment the Shot step ran. +type SeatFrames struct { + Name string + Seats []int + Frames []sdk.Frame +} + +// RunShots executes a smoke script against the artifact at path through the +// real adapter (limits ON) and returns the frames captured at each Shot step. +// Unlike Run it does not produce a Report: a guest fault or a shot with no +// frame is an error — smoke wants screens or a reason there are none. +func RunShots(path string, opts gameabi.Options, req SmokeRun) ([]SeatFrames, error) { + game, err := gameabi.LoadGame(path, opts) + if err != nil { + return nil, fmt.Errorf("smoke: load: %w", err) + } + meta := game.Meta() + if req.Seats < 1 || req.Seats > max(meta.MaxPlayers, 1) { + return nil, fmt.Errorf("smoke: seats %d outside the game's 1..%d", req.Seats, meta.MaxPlayers) + } + + mode := sdk.ModePrivate + if req.Seats == 1 { + mode = sdk.ModeSolo + } + cfg := sdk.RoomConfig{ + Mode: mode, + Capacity: req.Seats, + MinPlayers: min(max(meta.MinPlayers, 1), req.Seats), + Seed: req.Seed, + SeedSet: true, + } + factory := memsvc.NewFactory(quietLog(), nil) + for k, v := range req.Config { + factory.SetConfig(meta.Slug, k, []byte(v)) + } + svc := factory.For("smoke", meta.Slug) + h := game.NewRoom(cfg, svc) + room := &instRoom{cfg: cfg, svc: svc, clock: req.Epoch, log: quietLog()} + + fault := func(what string) error { + if exit, detail, faulted := gameabi.LastCallback(h); faulted { + return fmt.Errorf("smoke: guest faulted during %s (exit %d): %s", what, exit, detail) + } + return nil + } + + h.OnStart(room) + if err := fault("start"); err != nil { + return nil, err + } + for seat := 0; seat < req.Seats; seat++ { + p := room.seatPlayer(seat) + room.join(p) + h.OnJoin(room, p) + if err := fault(fmt.Sprintf("join seat %d", seat)); err != nil { + return nil, err + } + } + + var shots []SeatFrames + for i, step := range req.Script { + switch step.Kind { + case StepAdvance: + room.clock = room.clock.Add(time.Duration(step.Dur) * time.Millisecond) + case StepInput: + h.OnInput(room, room.seatPlayer(step.Seat), inputFor(step)) + case StepWake: + h.OnTick(room, room.clock) + case StepShot: + seats := step.Seats + if seats == nil { + seats = make([]int, req.Seats) + for s := range seats { + seats[s] = s + } + } + shot := SeatFrames{Name: step.Name, Seats: seats} + for _, seat := range seats { + f, ok := room.last[room.seatPlayer(seat).AccountID] + if !ok { + return nil, fmt.Errorf("smoke: shot %q: seat %d has no frame yet — the game has not rendered for it", step.Name, seat) + } + shot.Frames = append(shot.Frames, f) + } + shots = append(shots, shot) + default: + return nil, fmt.Errorf("smoke: step %d: kind %d not allowed in a smoke script", i, step.Kind) + } + if err := fault(fmt.Sprintf("step %d (%s)", i, step.Note)); err != nil { + return nil, err + } + } + h.OnClose(room) + return shots, nil +} diff --git a/host/gameabi/conformance/smoke_test.go b/host/gameabi/conformance/smoke_test.go new file mode 100644 index 0000000..c3a2f4f --- /dev/null +++ b/host/gameabi/conformance/smoke_test.go @@ -0,0 +1,121 @@ +package conformance_test + +import ( + "strings" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/gameabi" + "github.com/shellcade/kit/v2/host/gameabi/conformance" +) + +func smokeRun(t *testing.T, script conformance.Script, seats int) []conformance.SeatFrames { + t.Helper() + shots, err := conformance.RunShots(fixturePath, gameabi.Options{}, conformance.SmokeRun{ + Seed: 42, + Seats: seats, + Epoch: time.Date(2000, 1, 1, 0, 0, 42, 0, time.UTC), + Script: script, + }) + if err != nil { + t.Fatal(err) + } + return shots +} + +func TestRunShotsCapturesBroadcast(t *testing.T) { + shots := smokeRun(t, conformance.Script{ + conformance.Shot("start", nil), + conformance.Wake(), + conformance.Shot("after-wake", []int{1, 0}), + }, 2) + if len(shots) != 2 { + t.Fatalf("shots: %d", len(shots)) + } + s := shots[0] + if s.Name != "start" || len(s.Frames) != 2 { + t.Fatalf("shot 0: %q frames=%d", s.Name, len(s.Frames)) + } + // The fixture broadcasts identically — both seats' grids match. + if s.Frames[0] != s.Frames[1] { + t.Fatal("broadcast frames should be identical") + } +} + +func TestRunShotsPersonalFrameDiffers(t *testing.T) { + // 'f' sends a personal frame to the inputting player only. + shots := smokeRun(t, conformance.Script{ + conformance.Input(0, 'f'), + conformance.Shot("personal", nil), + }, 2) + s := shots[0] + if s.Frames[0] == s.Frames[1] { + t.Fatal("seat 0 received a personal frame; seats must differ") + } +} + +func TestRunShotsAdvanceMovesGuestClock(t *testing.T) { + // 't' renders the guest's own clock reading; two shots around an advance + // must differ (the guest sees the new CallContext time). + a := smokeRun(t, conformance.Script{ + conformance.Input(0, 't'), + conformance.Shot("before", nil), + }, 1) + b := smokeRun(t, conformance.Script{ + conformance.Advance(500), conformance.Wake(), + conformance.Input(0, 't'), + conformance.Shot("after", nil), + }, 1) + if a[0].Frames[0] == b[0].Frames[0] { + t.Fatal("advancing the clock must be visible to the guest") + } +} + +func TestRunShotsDeterministic(t *testing.T) { + script := conformance.Script{ + conformance.Input(0, 'f'), + conformance.Advance(50), conformance.Wake(), + conformance.Shot("end", nil), + } + a := smokeRun(t, script, 2) + b := smokeRun(t, script, 2) + if len(a) != len(b) { + t.Fatal("shot count differs") + } + for i := range a { + for j := range a[i].Frames { + if a[i].Frames[j] != b[i].Frames[j] { + t.Fatalf("shot %d frame %d differs across identical runs", i, j) + } + } + } +} + +func TestRunShotsErrors(t *testing.T) { + if _, err := conformance.RunShots(fixturePath, gameabi.Options{}, conformance.SmokeRun{ + Seed: 1, Seats: 99, Epoch: time.Unix(0, 0), Script: conformance.Script{conformance.Shot("a", nil)}, + }); err == nil || !strings.Contains(err.Error(), "seats") { + t.Fatalf("want seats error, got %v", err) + } + if _, err := conformance.RunShots(fixturePath, gameabi.Options{}, conformance.SmokeRun{ + Seed: 1, Seats: 1, Epoch: time.Unix(0, 0), Script: conformance.Script{conformance.Join(0)}, + }); err == nil || !strings.Contains(err.Error(), "not allowed") { + t.Fatalf("want step-kind error, got %v", err) + } +} + +func TestRunIgnoresShotSteps(t *testing.T) { + // The existing Run treats Shot markers as no-ops — a smoke script must not + // perturb conformance metrics. + rep, err := conformance.Run(fixturePath, gameabi.Options{}, conformance.Script{ + conformance.Join(0), + conformance.Shot("ignored", nil), + conformance.Wake(), + }) + if err != nil { + t.Fatal(err) + } + if !rep.Pass() { + t.Fatal("script with a shot marker should still pass") + } +} From b50f7ff245328ec625d6ebd2e18c5dd4e1d2fe2a Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Tue, 16 Jun 2026 08:50:51 +1000 Subject: [PATCH 07/10] host: move the shellcade-kit CLI into the kit module cmd/shellcade-kit now builds on the public reference host: services.NewFactory -> memsvc.NewFactory (play/check), identity.Identity{}.Player() -> direct sdk.Player (local hot-seat). Adds charmbracelet/x/term for raw-mode play. The CLI and host import zero private packages. Co-Authored-By: Claude Opus 4.8 --- cmd/shellcade-kit/artifact.go | 71 +++ cmd/shellcade-kit/artifact_test.go | 64 +++ cmd/shellcade-kit/license.go | 144 ++++++ cmd/shellcade-kit/main.go | 352 ++++++++++++++ cmd/shellcade-kit/new.go | 451 ++++++++++++++++++ cmd/shellcade-kit/new_test.go | 235 +++++++++ cmd/shellcade-kit/play.go | 222 +++++++++ cmd/shellcade-kit/smoke.go | 160 +++++++ cmd/shellcade-kit/smoke_test.go | 117 +++++ .../testdata/paritygame/exports.go | 33 ++ cmd/shellcade-kit/testdata/paritygame/go.mod | 12 + cmd/shellcade-kit/testdata/paritygame/go.sum | 12 + cmd/shellcade-kit/testdata/paritygame/main.go | 63 +++ .../testdata/paritygame/smoke.yaml | 13 + go.mod | 1 + go.sum | 2 + 16 files changed, 1952 insertions(+) create mode 100644 cmd/shellcade-kit/artifact.go create mode 100644 cmd/shellcade-kit/artifact_test.go create mode 100644 cmd/shellcade-kit/license.go create mode 100644 cmd/shellcade-kit/main.go create mode 100644 cmd/shellcade-kit/new.go create mode 100644 cmd/shellcade-kit/new_test.go create mode 100644 cmd/shellcade-kit/play.go create mode 100644 cmd/shellcade-kit/smoke.go create mode 100644 cmd/shellcade-kit/smoke_test.go create mode 100644 cmd/shellcade-kit/testdata/paritygame/exports.go create mode 100644 cmd/shellcade-kit/testdata/paritygame/go.mod create mode 100644 cmd/shellcade-kit/testdata/paritygame/go.sum create mode 100644 cmd/shellcade-kit/testdata/paritygame/main.go create mode 100644 cmd/shellcade-kit/testdata/paritygame/smoke.yaml diff --git a/cmd/shellcade-kit/artifact.go b/cmd/shellcade-kit/artifact.go new file mode 100644 index 0000000..40c183b --- /dev/null +++ b/cmd/shellcade-kit/artifact.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// resolveArtifact turns a check/play/smoke argument into (wasm path, game +// dir): a .wasm is used as-is; a directory is built per its module marker — +// go.mod via the pinned TinyGo profile, Cargo.toml via cargo wasm32-wasip1 — +// into a temp artifact (cleanup removes it). This is the whole-toolchain +// inner loop in one command: `shellcade-kit play .` (or check/smoke) builds +// and runs the real artifact regardless of the game's source language. +func resolveArtifact(arg string) (wasm, dir string, cleanup func(), err error) { + if strings.HasSuffix(arg, ".wasm") { + return arg, filepath.Dir(arg), nil, nil + } + info, err := os.Stat(arg) + if err != nil { + return "", "", nil, err + } + if !info.IsDir() { + return "", "", nil, fmt.Errorf("%s is neither a .wasm nor a game directory", arg) + } + dir = arg + + tmp, err := os.MkdirTemp("", "shellcade-kit-*") + if err != nil { + return "", "", nil, err + } + cleanup = func() { os.RemoveAll(tmp) } + wasm = filepath.Join(tmp, "game.wasm") + + var cmd *exec.Cmd + switch { + case exists(filepath.Join(dir, "go.mod")): + // The pinned TinyGo profile — the same build games CI runs. + cmd = exec.Command("tinygo", "build", "-opt=1", "-no-debug", "-gc=conservative", + "-o", wasm, "-target", "wasip1", "-buildmode=c-shared", ".") + case exists(filepath.Join(dir, "Cargo.toml")): + cmd = exec.Command("cargo", "build", "--release", "--target", "wasm32-wasip1") + default: + cleanup() + return "", "", nil, fmt.Errorf("%s has neither go.mod nor Cargo.toml", dir) + } + cmd.Dir = dir + cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr + if err := cmd.Run(); err != nil { + cleanup() + return "", "", nil, fmt.Errorf("build %s: %w", dir, err) + } + + if exists(filepath.Join(dir, "Cargo.toml")) { + // cargo writes into target/; find the produced cdylib. + matches, _ := filepath.Glob(filepath.Join(dir, "target", "wasm32-wasip1", "release", "*.wasm")) + if len(matches) != 1 { + cleanup() + return "", "", nil, fmt.Errorf("expected exactly one wasm under target/wasm32-wasip1/release, found %d", len(matches)) + } + wasm = matches[0] + } + return wasm, dir, cleanup, nil +} + +func exists(p string) bool { + _, err := os.Stat(p) + return err == nil +} diff --git a/cmd/shellcade-kit/artifact_test.go b/cmd/shellcade-kit/artifact_test.go new file mode 100644 index 0000000..a4401c9 --- /dev/null +++ b/cmd/shellcade-kit/artifact_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// TestResolveArtifactPassthroughAndErrors pins the shared check/play/smoke +// argument contract: a .wasm passes through untouched (its directory is the +// game dir), and everything that is neither a .wasm nor a buildable game +// directory fails before any toolchain is invoked. +func TestResolveArtifactPassthroughAndErrors(t *testing.T) { + wasm, dir, cleanup, err := resolveArtifact(filepath.Join("some", "path", "game.wasm")) + if err != nil { + t.Fatal(err) + } + if wasm != filepath.Join("some", "path", "game.wasm") { + t.Errorf("a .wasm must pass through untouched, got %q", wasm) + } + if dir != filepath.Join("some", "path") { + t.Errorf("game dir must be the artifact's directory, got %q", dir) + } + if cleanup != nil { + t.Error("passthrough must not allocate a temp dir") + } + + if _, _, _, err := resolveArtifact(filepath.Join(t.TempDir(), "absent")); err == nil { + t.Error("a nonexistent path must be refused") + } + + plain := filepath.Join(t.TempDir(), "notes.txt") + if err := os.WriteFile(plain, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, _, err := resolveArtifact(plain); err == nil { + t.Error("a non-.wasm regular file must be refused") + } + + if _, _, _, err := resolveArtifact(t.TempDir()); err == nil { + t.Error("a directory without go.mod or Cargo.toml must be refused") + } +} + +// TestCheckAcceptsGameDirectory is the dir-aware inner-loop contract for +// `check` (and, via the same resolveArtifact, `play`): pointing it at a game +// directory builds the artifact and runs the full harness. Requires tinygo; +// skipped when missing. +func TestCheckAcceptsGameDirectory(t *testing.T) { + if testing.Short() { + t.Skip("short mode") + } + if _, err := exec.LookPath("tinygo"); err != nil { + t.Skip("tinygo not on PATH") + } + dir, err := filepath.Abs(filepath.Join("testdata", "paritygame")) + if err != nil { + t.Fatal(err) + } + if err := check(dir); err != nil { + t.Fatalf("check : %v", err) + } +} diff --git a/cmd/shellcade-kit/license.go b/cmd/shellcade-kit/license.go new file mode 100644 index 0000000..f38ff3f --- /dev/null +++ b/cmd/shellcade-kit/license.go @@ -0,0 +1,144 @@ +package main + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// licenseText renders the LICENSE body for an allowlisted license id +// (case-insensitive SPDX id), substituting the current year and a +// "the authors" copyright holder. The allowlist mirrors the games +// catalog CI validator (validate_game_dir.py), which recognizes a license +// from the first five lines of the file — so every body below leads with its +// canonical title line. +func licenseText(id, name string) (string, error) { + for spdx, tmpl := range licenses { + if strings.EqualFold(spdx, id) { + s := strings.ReplaceAll(tmpl, "YEAR", fmt.Sprint(time.Now().Year())) + return strings.ReplaceAll(s, "HOLDER", "the "+name+" authors"), nil + } + } + return "", fmt.Errorf("unknown license %q — the catalog allowlist is %s", id, strings.Join(licenseIDs(), ", ")) +} + +// licenseIDs lists the allowlisted SPDX ids, sorted, for error/usage text. +func licenseIDs() []string { + ids := make([]string, 0, len(licenses)) + for id := range licenses { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +// licenses maps each catalog-allowlisted SPDX id to its LICENSE body. +// MIT, BSD-3-Clause, and Unlicense embed the full canonical text. Apache-2.0 +// and MPL-2.0 use their licenses' own incorporation-by-reference forms (both +// texts explicitly provide one), with the canonical URL; the catalog +// validator recognizes them from the leading title line. +var licenses = map[string]string{ + "MIT": `MIT License + +Copyright (c) YEAR HOLDER + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +`, + + "Apache-2.0": `Apache License, Version 2.0 + +Copyright YEAR HOLDER + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +`, + + "BSD-3-Clause": `BSD 3-Clause License + +Copyright (c) YEAR, HOLDER + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +`, + + "MPL-2.0": `Mozilla Public License Version 2.0 + +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at https://mozilla.org/MPL/2.0/. +`, + + "Unlicense": `This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to +`, +} diff --git a/cmd/shellcade-kit/main.go b/cmd/shellcade-kit/main.go new file mode 100644 index 0000000..4ff6438 --- /dev/null +++ b/cmd/shellcade-kit/main.go @@ -0,0 +1,352 @@ +// Command shellcade-kit is the shellcade game developer kit (PROTOTYPE): +// +// shellcade-kit version print kit/ABI compatibility info +// shellcade-kit new [--rust] [--license ID] scaffold a complete, catalog-submittable kit game +// shellcade-kit check run the conformance harness (limits ON) + print a report +// shellcade-kit play [flags] play the game in a local 80x24 terminal room +// shellcade-kit smoke run the game's smoke.yaml and write the shot files +// +// check/play/smoke accept either a built .wasm or the game directory — a +// directory is built first (TinyGo for go.mod, cargo wasm32-wasip1 for +// Cargo.toml), so `shellcade-kit play .` is the whole inner loop for any +// source language. +// +// play flags: +// +// --seed N seed the room RNG (reproducible runs) +// --heartbeat DUR wake cadence (default 50ms) +// --config KEY=VALUE inject a per-game config value (repeatable; value may be @file) +// --players N scripted extra players that join alongside you (default 0) +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log/slog" + "os" + "runtime/debug" + "strings" + "time" + + "github.com/shellcade/kit/v2/host/gameabi" + "github.com/shellcade/kit/v2/host/gameabi/conformance" + "github.com/shellcade/kit/v2/host/memsvc" + "github.com/shellcade/kit/v2/host/sdk" +) + +func main() { + if len(os.Args) >= 2 && os.Args[1] == "version" { + printVersion() + return + } + if len(os.Args) < 3 { + usage() + } + cmd, path := os.Args[1], os.Args[2] + switch cmd { + case "new": + // new [--rust] [--license ID] : flags come before the name. + fs := flag.NewFlagSet("new", flag.ExitOnError) + rust := fs.Bool("rust", false, "scaffold a Rust game (default: Go)") + license := fs.String("license", "MIT", "LICENSE to emit; one of the catalog allowlist: "+strings.Join(licenseIDs(), ", ")) + if err := fs.Parse(os.Args[2:]); err != nil { + usage() + } + if fs.NArg() != 1 { + usage() + } + name := strings.ToLower(fs.Arg(0)) + if err := runNew(name, *rust, *license); err != nil { + fmt.Fprintln(os.Stderr, "shellcade-kit:", err) + os.Exit(1) + } + if *rust { + fmt.Printf("Scaffolded %s/ — try it now:\n\n rustup target add wasm32-wasip1 # once\n cd %s && cargo test && shellcade-kit play .\n", name, name) + } else { + fmt.Printf("Scaffolded %s/ — try it now:\n\n cd %s && go mod tidy && go run .\n", name, name) + } + case "check": + if err := check(path); err != nil { + fmt.Fprintln(os.Stderr, "FAIL:", err) + os.Exit(1) + } + case "meta": + if err := printMeta(path); err != nil { + fmt.Fprintln(os.Stderr, "shellcade-kit:", err) + os.Exit(1) + } + case "play": + if err := play(path, os.Args[3:]); err != nil { + fmt.Fprintln(os.Stderr, "shellcade-kit:", err) + os.Exit(1) + } + case "smoke": + if err := runSmoke(path, os.Args[3:]); err != nil { + fmt.Fprintln(os.Stderr, "shellcade-kit:", err) + os.Exit(1) + } + default: + usage() + } +} + +// printVersion reports the three version facts from build info: this binary's +// version, the kit module it was built against, and the ABI major it enforces. +// The ABI major is the only one that MUST match an artifact (the load handshake +// enforces it); the kit version answers "which SDK release does this verify?". +func printVersion() { + own := "(devel)" + kitv := "(unknown)" + if bi, ok := debug.ReadBuildInfo(); ok { + if v := bi.Main.Version; v != "" { + own = v + } + for _, d := range bi.Deps { + if d.Path == "github.com/shellcade/kit/v2" { + kitv = d.Version + } + } + } + fmt.Printf("shellcade-kit %s\nkit %s (github.com/shellcade/kit/v2)\nabi v%d\n", own, kitv, gameabi.Version) +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: shellcade-kit version | new [--rust] [--license ID] | check | meta | play [flags] | smoke [--out dir]") + os.Exit(2) +} + +// printMeta loads the artifact (full ABI handshake) and prints its decoded +// metadata as JSON — the machine-readable source of truth catalog CI asserts +// against (dir name == bare meta slug; player bounds; display fields). The +// artifact is the ONLY place game metadata lives; there is no manifest file. +func printMeta(path string) error { + game, err := gameabi.LoadGame(path, gameabi.Options{}) + if err != nil { + return err + } + out := struct { + ABI uint32 `json:"abi"` + sdk.GameMeta + }{gameabi.Version, game.Meta()} + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +// configFlags collects repeated --config KEY=VALUE flags. +type configFlags map[string]string + +func (c configFlags) String() string { return "" } +func (c configFlags) Set(v string) error { + k, val, ok := strings.Cut(v, "=") + if !ok { + return fmt.Errorf("--config wants KEY=VALUE, got %q", v) + } + if strings.HasPrefix(val, "@") { + b, err := os.ReadFile(val[1:]) + if err != nil { + return err + } + val = string(b) + } + c[k] = val + return nil +} + +// newRoom loads the artifact and builds a live engine room around it with the +// in-memory services factory (the same double serve --dev style tests use). +func newRoom(path string, seed int64, seedSet bool, heartbeat time.Duration, cfgVals map[string]string, log *slog.Logger) (sdk.Game, sdk.RoomCtl, error) { + game, err := gameabi.LoadGame(path, gameabi.Options{Heartbeat: heartbeat}) + if err != nil { + return nil, nil, err + } + meta := game.Meta() + + reg := sdk.NewRegistry() + if err := reg.Add(game); err != nil { + return nil, nil, err + } + factory := memsvc.NewFactory(log, reg) + for k, v := range cfgVals { + factory.SetConfig(meta.Slug, k, []byte(v)) + } + svc := factory.For("devkit", meta.Slug) + + cfg := sdk.RoomConfig{ + Mode: sdk.ModePrivate, + Capacity: meta.MaxPlayers, + MinPlayers: meta.MinPlayers, + Seed: seed, + SeedSet: seedSet, + } + ctl := sdk.NewRoomRuntime("devkit", game.NewRoom(cfg, svc), cfg, svc) + return game, ctl, nil +} + +// ---- check ------------------------------------------------------------------- + +// check runs the full conformance harness against an artifact with a default +// scripted scenario — all exports exercised, a two-seat roster sequence, and a +// hibernation-determinism checkpoint — and prints a human-readable report. It +// returns an error (non-zero exit) when any budget verdict fails. The +// argument may be a built .wasm or the game directory (built first), so +// `shellcade-kit check .` is the one-command merge-gate rehearsal for any +// source language. +func check(arg string) error { + path, _, cleanup, err := resolveArtifact(arg) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() + } + + // Probe the meta so the default script can size the roster to the game. + game, err := gameabi.LoadGame(path, gameabi.Options{}) + if err != nil { + return err + } + meta := game.Meta() + + rep, err := conformance.Run(path, gameabi.Options{}, defaultCheckScript(meta)) + if err != nil { + return err + } + printReport(rep) + if !rep.Pass() { + return fmt.Errorf("%d budget verdict(s) failed", countFailing(rep)) + } + return nil +} + +// defaultCheckScript drives every export against any game: join up to two seats, +// a spread of generic inputs and host-heartbeat wakes with clock advances, a +// snapshot/restore checkpoint (hibernation determinism), then a leave + rejoin. +func defaultCheckScript(meta sdk.GameMeta) conformance.Script { + seats := 1 + if meta.MaxPlayers >= 2 { + seats = 2 + } + s := conformance.Script{conformance.Join(0)} + if seats == 2 { + s = append(s, conformance.Join(1)) + } + // Generic inputs every game tolerates (Enter, space) interleaved across seats. + s = append(s, + conformance.Key(0, uint8(sdk.KeyEnter)), + conformance.Wake(), + conformance.Advance(50), + conformance.Wake(), + ) + if seats == 2 { + s = append(s, conformance.Input(1, ' '), conformance.Wake()) + } + s = append(s, + conformance.SnapshotRestore(), // hibernation checkpoint + conformance.Input(0, ' '), + conformance.Advance(50), + conformance.Wake(), + ) + if seats == 2 { + s = append(s, + conformance.Leave(1), + conformance.Wake(), + conformance.Join(1), // rejoin + conformance.Wake(), + ) + } + return s +} + +// printReport renders a conformance Report: header, the verdict-per-requirement +// list, the per-callback latency/memory table, and any named-budget failures. +func printReport(rep conformance.Report) { + m := rep.Meta + fmt.Printf("abi: v%d\n", rep.ABIVersion) + fmt.Printf("game: %s (%q) players %d-%d\n", m.Slug, m.Name, m.MinPlayers, m.MaxPlayers) + fmt.Printf("peak: %s linear memory (cap %s) deadline %s\n\n", + humanBytes(rep.PeakMem), humanBytes(rep.MemCap), rep.Deadline) + + fmt.Println("verdicts:") + for _, v := range rep.Verdicts { + mark := "PASS" + if !v.OK { + mark = "FAIL" + } + fmt.Printf(" [%s] %-22s limit=%-10s measured=%s\n", mark, v.Name, dash(v.Limit), dash(v.Measured)) + if !v.OK { + if v.Step >= 0 { + fmt.Printf(" ^ breached at step %d\n", v.Step) + } + if v.Detail != "" { + fmt.Printf(" %s\n", v.Detail) + } + } + } + + fmt.Printf("\nper-callback (latency / mem / frames):\n") + for _, st := range rep.Steps { + if st.Callback == "" { + continue // non-callback steps (advance/checkpoint) carry no latency + } + flag := "" + if st.Faulted { + flag = " <-- FAULT" + } + fmt.Printf(" %-26s %10s %8s %2d frames%s\n", + truncate(st.Desc, 26), st.Latency.Round(time.Microsecond), humanBytes(uint64(st.MemBytes)), st.Frames, flag) + } + + if rep.HibernationChecked { + mark := "PASS" + if !rep.HibernationOK { + mark = "FAIL" + } + fmt.Printf("\nhibernation determinism: %s\n", mark) + } + + fmt.Println() + if rep.Pass() { + fmt.Println("check: OK — all budgets within limits") + } else { + fmt.Printf("check: FAIL — %d verdict(s) breached (see above)\n", countFailing(rep)) + } +} + +func countFailing(rep conformance.Report) int { + n := 0 + for _, v := range rep.Verdicts { + if !v.OK { + n++ + } + } + return n +} + +func humanBytes(b uint64) string { + switch { + case b == 0: + return "0" + case b >= 1<<20: + return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20)) + case b >= 1<<10: + return fmt.Sprintf("%d KiB", b/(1<<10)) + default: + return fmt.Sprintf("%d B", b) + } +} + +func dash(s string) string { + if s == "" { + return "-" + } + return s +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} diff --git a/cmd/shellcade-kit/new.go b/cmd/shellcade-kit/new.go new file mode 100644 index 0000000..ec2b9a2 --- /dev/null +++ b/cmd/shellcade-kit/new.go @@ -0,0 +1,451 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime/debug" + "strings" + + "github.com/shellcade/kit/v2/host/gameabi" +) + +// runNew scaffolds a complete, catalog-submittable kit game in .// — +// the same shape the published games catalog (github.com/shellcade/games) +// requires: sources, LICENSE (allowlisted; MIT default), and a working +// smoke.yaml. Scaffolds pin the kit version THIS binary was built against +// (drift-proof by construction): the Go path pins the module version, the +// Rust path pins the matching kit release tag as a cargo git dependency. +func runNew(name string, rust bool, license string) error { + name = strings.ToLower(name) + lic, err := licenseText(license, name) + if err != nil { + return err + } + if rust { + return scaffoldRust(name, lic) + } + return scaffold(name, lic) +} + +// validName enforces the platform's bare-name slug rule at scaffold time — +// the SAME validator the wasm loader applies to a declared meta.slug — so an +// invalid name fails here, with the loader's own error text, instead of at +// the first `shellcade-kit check` after a game has been built around it. +func validName(name string) error { + if err := gameabi.ValidateBareName(name); err != nil { + return err + } + if _, err := os.Stat(name); err == nil { + return fmt.Errorf("%s already exists", name) + } + return nil +} + +func writeScaffold(name string, files map[string]string) error { + if err := os.MkdirAll(name, 0o755); err != nil { + return err + } + for f, content := range files { + p := filepath.Join(name, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + return err + } + } + return nil +} + +func scaffold(name, license string) error { + if err := validName(name); err != nil { + return err + } + gomod := strings.ReplaceAll(tmplGoMod, "NAME", name) + gomod = strings.ReplaceAll(gomod, "KITVERSION", kitVersion()) + return writeScaffold(name, map[string]string{ + "main.go": strings.ReplaceAll(tmplMain, "NAME", name), + "exports.go": tmplExports, + "go.mod": gomod, + "README.md": strings.ReplaceAll(tmplReadme, "NAME", name), + "smoke.yaml": tmplSmoke, + "LICENSE": license, + }) +} + +// scaffoldRust scaffolds the Rust equivalent: a cdylib crate on the +// shellcade-kit Rust SDK, pinned to this binary's kit release tag. The +// generated README carries the EXACT artifact path (cargo underscores dashes +// in the crate name) so the author never has to discover that rule. +func scaffoldRust(name, license string) error { + if err := validName(name); err != nil { + return err + } + expand := func(tmpl string) string { + s := strings.ReplaceAll(tmpl, "NAME_US", strings.ReplaceAll(name, "-", "_")) + s = strings.ReplaceAll(s, "NAME", name) + return strings.ReplaceAll(s, "KITTAG", kitVersion()) + } + return writeScaffold(name, map[string]string{ + "Cargo.toml": expand(tmplCargoToml), + "src/lib.rs": expand(tmplLibRs), + "README.md": expand(tmplRustReadme), + "smoke.yaml": tmplSmoke, + "LICENSE": license, + }) +} + +const tmplMain = `// NAME — a shellcade game. Run it right now: go run . +package main + +import ( + "fmt" + "time" + + kit "github.com/shellcade/kit/v2" +) + +func main() { kit.Main(Game{}) } + +// Game is the registry entry: metadata + a per-room behavior factory. +type Game struct{} + +func (Game) Meta() kit.GameMeta { + return kit.GameMeta{ + Slug: "NAME", + Name: "NAME", + ShortDescription: "Describe your game in one line.", + MinPlayers: 1, + MaxPlayers: 4, + } +} + +func (Game) NewRoom(cfg kit.RoomConfig, svc kit.Services) kit.Handler { + return &room{} +} + +// room is one live room. ALL state lives here (and only here) — the host can +// snapshot and restore it, so key anything durable by Player.AccountID. +type room struct { + kit.Base + presses int + deadline time.Time // a wake-driven one-shot: see OnWake +} + +func (rm *room) OnStart(r kit.Room) { + r.SetInputContext(kit.CtxNav) +} + +func (rm *room) OnJoin(r kit.Room, p kit.Player) { rm.render(r) } + +func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { + switch kit.Resolve(in, kit.CtxNav) { + case kit.ActConfirm: + rm.presses++ + // One-shot timer, the wake way: store a deadline, check it in OnWake. + rm.deadline = r.Now().Add(2 * time.Second) + } + rm.render(r) +} + +// OnWake is the host heartbeat — the ONLY time your code runs without input. +// Drive every animation, countdown, and timeout from CallContext time here. +func (rm *room) OnWake(r kit.Room) { + if !rm.deadline.IsZero() && r.Now().After(rm.deadline) { + rm.deadline = time.Time{} + rm.presses = 0 // the timeout fired: reset + } + rm.render(r) +} + +func (rm *room) render(r kit.Room) { + f := kit.NewFrame() // frames are POINTERS, always (see ABI.md §6) + title := kit.Style{FG: kit.Cyan, Attr: kit.AttrBold} + dim := kit.Style{FG: kit.DimGray} + + f.Text(2, 4, "*** NAME ***", title) + f.Text(10, 4, fmt.Sprintf("SPACE pressed %d times", rm.presses), kit.Style{FG: kit.White}) + if !rm.deadline.IsZero() { + left := rm.deadline.Sub(r.Now()).Round(100 * time.Millisecond) + f.Text(12, 4, fmt.Sprintf("resetting in %s...", left), kit.Style{FG: kit.Yellow}) + } + f.Text(kit.Rows-1, 2, "SPACE press Esc leave", dim) + + for _, p := range r.Members() { + r.Send(p, f) + } +} +` + +const tmplExports = `//go:build wasip1 || tinygo.wasm + +package main + +import kit "github.com/shellcade/kit/v2" + +func init() { kit.Run(Game{}) } + +// The eight shellcade ABI exports, trampolined to the SDK. + +//go:export shellcade_abi +func expABI() int32 { return kit.ExportABI() } + +//go:export meta +func expMeta() int32 { return kit.ExportMeta() } + +//go:export start +func expStart() int32 { return kit.ExportStart() } + +//go:export join +func expJoin() int32 { return kit.ExportJoin() } + +//go:export leave +func expLeave() int32 { return kit.ExportLeave() } + +//go:export input +func expInput() int32 { return kit.ExportInput() } + +//go:export wake +func expWake() int32 { return kit.ExportWake() } + +//go:export close +func expClose() int32 { return kit.ExportClose() } +` + +// tmplSmoke is the scaffold's smoke.yaml — the deterministic scripted-screens +// contract every catalog game must ship (CI hard-fails without it). It passes +// against the template game as scaffolded (joining renders the opening +// screen; space resolves to Confirm and bumps the press counter), so it +// doubles as a working example of the schema. +const tmplSmoke = `# Scripted screens: catalog CI runs this on every PR and posts the named +# dumps as a visual preview. Run it locally with +# +# shellcade-kit smoke . +# +# (Go authors can also run it natively: go run . -smoke smoke.yaml.) +# Schema + authoring guidance: kit GUIDE.md "Smoke scripts". +seed: 42 +seats: 1 +steps: + - shot: opening + - key: space # Confirm in the template game: bumps the press counter + - shot: pressed +` + +// fallbackKitVersion is used when build info lacks the dependency — only +// go.work/replace-style dev builds of this CLI; release binaries embed the +// real kit version. It MUST stay a valid /v2 module version (and existing +// kit release tag), or a dev-built CLI scaffolds an untidyable go.mod and an +// unresolvable cargo git pin. +const fallbackKitVersion = "v2.8.0" + +// kitVersion is the kit module version this binary was BUILT AGAINST — the +// scaffold pins the same version, so templates can never drift. +func kitVersion() string { + if bi, ok := debug.ReadBuildInfo(); ok { + for _, d := range bi.Deps { + if d.Path == "github.com/shellcade/kit/v2" && d.Version != "" && d.Version != "(devel)" { + return d.Version + } + } + } + return fallbackKitVersion +} + +const tmplGoMod = `module NAME + +go 1.25 + +require github.com/shellcade/kit/v2 KITVERSION +` + +const tmplReadme = `# NAME — a shellcade game + +## Develop (instant, no wasm) + + go run . # play in this terminal; Esc leaves + go run . -seats 2 # hot-seat multiplayer; Ctrl-T switches seats + go run . -seed 42 # reproducible runs + +## Build the wasm artifact (~4s) + + tinygo build -opt=1 -no-debug -gc=conservative \ + -o NAME.wasm -target wasip1 -buildmode=c-shared . + +Then verify with the shellcade developer kit: shellcade-kit check NAME.wasm — and play the +real artifact with shellcade-kit play NAME.wasm. (Both also accept the game +directory: shellcade-kit check . builds the artifact for you.) + +## Submit + +This directory is already catalog-shaped: LICENSE and smoke.yaml are required +by github.com/shellcade/games CI (see its SCHEMA.md), and smoke.yaml's screens +are posted on your PR as a visual preview. Preview them locally: + + shellcade-kit smoke . # or natively: go run . -smoke smoke.yaml + +## Learn more + +- GUIDE.md in github.com/shellcade/kit/v2 — the authoring guide +- ABI.md — the contract your game targets +- github.com/shellcade/games — published example games using every SDK feature +` + +// ---- Rust scaffold templates ------------------------------------------------ + +const tmplCargoToml = `[package] +name = "NAME" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +# cdylib = the WASI reactor the arcade instantiates; rlib so cargo test links +# your game logic natively (no wasm runtime in the inner loop). +crate-type = ["cdylib", "rlib"] + +[dependencies] +# Pinned to the kit release this shellcade-kit binary shipped with. Upgrade by +# bumping the tag to a newer kit release. +shellcade-kit = { git = "https://github.com/shellcade/kit", tag = "KITTAG" } + +# The release profile IS the artifact contract: cargo applies profiles only at +# this (leaf) crate, never from a dependency. Without this block your artifact +# is a multi-megabyte debug build that fails size review. +[profile.release] +opt-level = "s" +lto = true +strip = true +panic = "abort" +` + +const tmplLibRs = `// NAME — a shellcade game in Rust. +// +// cargo test # game logic, natively +// shellcade-kit play . # build the wasm artifact (wasm32-wasip1) + play it +// shellcade-kit check . # the conformance harness, the catalog merge gate +// +// The built artifact is target/wasm32-wasip1/release/NAME_US.wasm. +#![forbid(unsafe_code)] + +use shellcade_kit::prelude::*; + +struct TheGame; + +impl Game for TheGame { + fn meta(&self) -> Meta { + Meta { + slug: "NAME", // == your catalog directory name + name: "NAME", + short_description: "Describe your game in one line.", + min_players: 1, + max_players: 4, + ..Meta::DEFAULT + } + } + fn new_room(&self, _cfg: &RoomConfig) -> Box { + Box::new(TheRoom { frame: Frame::new(), presses: 0, deadline: None }) + } +} + +// One live room. ALL state lives here (and only here) — the host can snapshot +// and restore it, so key anything durable by player.account_id. +struct TheRoom { + frame: Frame, + presses: u32, + deadline: Option, // a wake-driven one-shot: see on_wake +} + +impl Handler for TheRoom { + fn on_start(&mut self, r: &mut Room) { + r.set_input_context(InputContext::Nav); + } + + fn on_join(&mut self, r: &mut Room, _p: Player) { + self.render(r); + } + + fn on_input(&mut self, r: &mut Room, _p: Player, input: Input) { + if input.resolve(InputContext::Nav) == Action::Confirm { + self.presses += 1; + // One-shot timer, the wake way: store a deadline, check it in on_wake. + self.deadline = Some(r.now_unix_nanos() + 2_000_000_000); + } + self.render(r); + } + + // on_wake is the host heartbeat — the ONLY time your code runs without + // input. Drive every animation, countdown, and timeout from room time here. + fn on_wake(&mut self, r: &mut Room) { + if let Some(d) = self.deadline { + if r.now_unix_nanos() > d { + self.deadline = None; + self.presses = 0; // the timeout fired: reset + } + } + self.render(r); + } +} + +impl TheRoom { + fn render(&mut self, r: &mut Room) { + let title = Style { fg: CYAN, attr: ATTR_BOLD, ..Style::default() }; + let dim = Style::new(DIM_GRAY, 0); + + let f = &mut self.frame; + f.clear(); // reuse one frame: the allocation-free steady state + f.text(2, 4, "*** NAME ***", title); + f.text(10, 4, &format!("SPACE pressed {} times", self.presses), Style::new(WHITE, 0)); + if let Some(d) = self.deadline { + let secs = (d - r.now_unix_nanos()) as f64 / 1e9; + f.text(12, 4, &format!("resetting in {secs:.1}s..."), Style::new(YELLOW, 0)); + } + f.text(ROWS - 1, 2, "SPACE press Esc leave", dim); + + for p in r.members().to_vec() { + r.send(&p, &self.frame); + } + } +} + +shellcade_kit::shellcade_game!(TheGame); +` + +const tmplRustReadme = `# NAME — a shellcade game (Rust) + +## Develop + + rustup target add wasm32-wasip1 # once + cargo test # game logic runs natively (no wasm runtime) + shellcade-kit play . # build the wasm artifact AND play it, one command + shellcade-kit smoke . # build + run smoke.yaml, write the shot files + +The see-it-on-screen loop is cargo test for logic plus a wasm build to play — +shellcade-kit play . does the build for you each iteration. + +## Build + verify the artifact by hand + + cargo build --release --target wasm32-wasip1 + +The artifact is target/wasm32-wasip1/release/NAME_US.wasm (cargo converts +dashes in the crate name to underscores). + +Then verify with the shellcade developer kit (check also accepts the +directory: shellcade-kit check .): + + shellcade-kit check target/wasm32-wasip1/release/NAME_US.wasm + +## Submit + +This directory is already catalog-shaped: LICENSE and smoke.yaml are required +by github.com/shellcade/games CI (see its SCHEMA.md), and smoke.yaml's screens +are posted on your PR as a visual preview. + +## Learn more + +- rust/README.md in github.com/shellcade/kit — the Rust quickstart + Go↔Rust dictionary +- GUIDE.md — the authoring guide (the mental model carries over one-for-one) +- ABI.md — the contract your game targets +- github.com/shellcade/games — published example games +` diff --git a/cmd/shellcade-kit/new_test.go b/cmd/shellcade-kit/new_test.go new file mode 100644 index 0000000..9f67d26 --- /dev/null +++ b/cmd/shellcade-kit/new_test.go @@ -0,0 +1,235 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + kitsmoke "github.com/shellcade/kit/v2/smoke" +) + +// chtemp runs the scaffolder from a temp cwd (scaffold writes .//). +func chtemp(t *testing.T) { + t.Helper() + dir := t.TempDir() + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) +} + +func read(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} + +func TestScaffoldRustEmitsPinnedTagAndArtifactPath(t *testing.T) { + chtemp(t) + if err := runNew("My-Game", true, "MIT"); err != nil { + t.Fatal(err) + } + + // Expected file set, lowercased name. + for _, f := range []string{"Cargo.toml", "src/lib.rs", "README.md"} { + if _, err := os.Stat(filepath.Join("my-game", f)); err != nil { + t.Errorf("missing %s: %v", f, err) + } + } + + cargo := read(t, "my-game/Cargo.toml") + // Drift-proof pin: the cargo git dep carries the exact kit version this + // binary was built against (same mechanism as the Go /v2 module pin). + wantTag := `tag = "` + kitVersion() + `"` + if !strings.Contains(cargo, wantTag) { + t.Errorf("Cargo.toml must pin %s, got:\n%s", wantTag, cargo) + } + if !strings.Contains(cargo, `git = "https://github.com/shellcade/kit"`) { + t.Error("Cargo.toml must depend on the kit repo by git URL") + } + // The artifact contract: cdylib leaf + the FULL release profile (cargo + // ignores a dependency's profiles — this block must live in the game). + for _, want := range []string{ + `crate-type = ["cdylib", "rlib"]`, + `opt-level = "s"`, "lto = true", "strip = true", `panic = "abort"`, + } { + if !strings.Contains(cargo, want) { + t.Errorf("Cargo.toml missing %q", want) + } + } + + lib := read(t, "my-game/src/lib.rs") + if !strings.Contains(lib, "#![forbid(unsafe_code)]") { + t.Error("scaffolded game must forbid unsafe_code") + } + if !strings.Contains(lib, `slug: "my-game"`) { + t.Error("meta slug must be the lowercased name") + } + if !strings.Contains(lib, "shellcade_game!(TheGame)") { + t.Error("scaffold must register via shellcade_game!") + } + + // The exact underscored artifact path is baked into README and lib.rs — + // never left as folklore. + readme := read(t, "my-game/README.md") + const artifact = "target/wasm32-wasip1/release/my_game.wasm" + if !strings.Contains(readme, artifact) { + t.Errorf("README must carry the exact artifact path %s", artifact) + } + if !strings.Contains(lib, artifact) { + t.Errorf("lib.rs header must carry the exact artifact path %s", artifact) + } +} + +func TestScaffoldRustRefusesBadNamesAndExisting(t *testing.T) { + chtemp(t) + if err := runNew("has space", true, "MIT"); err == nil { + t.Error("name with space must be refused") + } + if err := runNew("ok-name", true, "MIT"); err != nil { + t.Fatal(err) + } + if err := runNew("ok-name", true, "MIT"); err == nil { + t.Error("existing directory must be refused") + } +} + +func TestScaffoldGoStillEmitsModulePin(t *testing.T) { + chtemp(t) + if err := runNew("gogame", false, "MIT"); err != nil { + t.Fatal(err) + } + gomod := read(t, "gogame/go.mod") + if !strings.Contains(gomod, "github.com/shellcade/kit/v2 "+kitVersion()) { + t.Errorf("go.mod must pin the kit module version, got:\n%s", gomod) + } +} + +// TestScaffoldEmitsCatalogRequiredFiles asserts both scaffolds are +// catalog-submittable out of the box: smoke.yaml (parsing under the kit +// schema, with at least one shot) and a LICENSE the games-repo CI validator +// recognizes from its first five lines. +func TestScaffoldEmitsCatalogRequiredFiles(t *testing.T) { + chtemp(t) + if err := runNew("go-gate", false, "MIT"); err != nil { + t.Fatal(err) + } + if err := runNew("rust-gate", true, "MIT"); err != nil { + t.Fatal(err) + } + + for _, dir := range []string{"go-gate", "rust-gate"} { + sc, err := kitsmoke.Parse([]byte(read(t, dir+"/smoke.yaml"))) + if err != nil { + t.Fatalf("%s/smoke.yaml does not parse under the kit smoke schema: %v", dir, err) + } + if sc.Seats < 1 { + t.Errorf("%s/smoke.yaml: want at least one seat, got %d", dir, sc.Seats) + } + shots := 0 + for _, st := range sc.Steps { + if st.Kind == kitsmoke.StepShot { + shots++ + } + } + if shots < 2 { + t.Errorf("%s/smoke.yaml: want at least two shots (a working example), got %d", dir, shots) + } + + lic := read(t, dir+"/LICENSE") + head := strings.Join(strings.SplitN(lic, "\n", 6)[:5], "\n") + if !strings.Contains(head, "MIT License") { + t.Errorf("%s/LICENSE head must carry the MIT title line for the CI validator, got:\n%s", dir, head) + } + if !strings.Contains(lic, "the "+dir+" authors") { + t.Errorf("%s/LICENSE must name a copyright holder, got:\n%s", dir, head) + } + } +} + +// TestScaffoldLicenseFlagCoversTheCatalogAllowlist drives --license through +// every allowlisted SPDX id and asserts the emitted LICENSE matches the SAME +// first-five-lines patterns the games-repo CI validator +// (validate_game_dir.py) applies — and that an off-list id is refused with +// the allowlist named. +func TestScaffoldLicenseFlagCoversTheCatalogAllowlist(t *testing.T) { + chtemp(t) + validator := map[string]*regexp.Regexp{ + "MIT": regexp.MustCompile(`(?i)MIT License`), + "Apache-2.0": regexp.MustCompile(`(?i)Apache License\s*$|Apache License,? Version 2\.0`), + "BSD-3-Clause": regexp.MustCompile(`(?i)BSD 3-Clause`), + "MPL-2.0": regexp.MustCompile(`(?i)Mozilla Public License,? (Version )?2\.0`), + "Unlicense": regexp.MustCompile(`(?i)free and unencumbered software`), + } + if len(validator) != len(licenseIDs()) { + t.Fatalf("allowlist drift: CLI offers %v, test mirrors %d validator patterns", licenseIDs(), len(validator)) + } + for i, id := range licenseIDs() { + pat, ok := validator[id] + if !ok { + t.Errorf("--license %s is not in the catalog CI allowlist", id) + continue + } + dir := fmt.Sprintf("lic-%d", i) + if err := runNew(dir, false, id); err != nil { + t.Fatalf("runNew --license %s: %v", id, err) + } + head := strings.Join(strings.SplitN(read(t, dir+"/LICENSE"), "\n", 6)[:5], "\n") + if !pat.MatchString(head) { + t.Errorf("--license %s: first five lines do not match the CI validator pattern %s:\n%s", id, pat, head) + } + } + // Case-insensitive ids are accepted; off-list ids are refused loudly. + if err := runNew("lic-lower", false, "mit"); err != nil { + t.Errorf("--license mit (case-insensitive) must be accepted: %v", err) + } + err := runNew("lic-bogus", false, "GPL-3.0") + if err == nil { + t.Fatal("--license GPL-3.0 (off the catalog allowlist) must be refused") + } + if !strings.Contains(err.Error(), "MIT") || !strings.Contains(err.Error(), "Unlicense") { + t.Errorf("the refusal must name the allowlist, got: %v", err) + } + if _, statErr := os.Stat("lic-bogus"); statErr == nil { + t.Error("a refused scaffold must not leave a directory behind") + } +} + +// TestScaffoldNameValidationMatchesThePlatform asserts `new` applies the +// host's own bareName rule (^[a-z0-9-]{1,32}$) at scaffold time, so an +// invalid slug fails in the first minute instead of at the first `check` +// after a game has been built around it. +func TestScaffoldNameValidationMatchesThePlatform(t *testing.T) { + chtemp(t) + for _, bad := range []string{ + "my_game", // underscore + "my.game", // dot + "name!", // punctuation + strings.Repeat("a", 33), // over-long + "", "spa ce", "sla/sh", // the previously-caught cases still fail + } { + for _, rust := range []bool{false, true} { + if err := runNew(bad, rust, "MIT"); err == nil { + t.Errorf("name %q (rust=%v) must be refused at scaffold time", bad, rust) + } + } + } + // Upper case is folded, not refused (existing behavior); the result is a + // valid platform slug. + if err := runNew("Upper-Case", false, "MIT"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat("upper-case"); err != nil { + t.Errorf("scaffold must land in the lowercased directory: %v", err) + } +} diff --git a/cmd/shellcade-kit/play.go b/cmd/shellcade-kit/play.go new file mode 100644 index 0000000..ac97183 --- /dev/null +++ b/cmd/shellcade-kit/play.go @@ -0,0 +1,222 @@ +package main + +import ( + "flag" + "fmt" + "log/slog" + "os" + "strings" + "time" + + xterm "github.com/charmbracelet/x/term" + + "github.com/shellcade/kit/v2/host/canvas" + "github.com/shellcade/kit/v2/host/render" + "github.com/shellcade/kit/v2/host/sdk" + "github.com/shellcade/kit/v2/host/session" +) + +// play runs the artifact in a local interactive 80x24 room: real engine, real +// renderer, raw-mode stdin/stdout, in-memory services. The argument may be a +// built .wasm or the game directory — a directory is built first, which makes +// `shellcade-kit play .` the whole edit-see loop for Rust authors (who have +// no native runner) and Go authors without a tinygo invocation memorized. +// +// Multiplayer testing is hot-seat: --seats N joins N players to the one room; +// your keyboard controls the ACTIVE seat and Ctrl-T cycles seats, so you can +// drive both sides of a duel (or all five pokies cabinets) from one terminal. +// Each seat keeps its own per-player frame stream — switching seats shows that +// seat's latest frame, which exercises per-viewer composition for real. +func play(arg string, args []string) error { + fs := flag.NewFlagSet("play", flag.ExitOnError) + seed := fs.Int64("seed", 0, "room RNG seed (0 = time-based)") + heartbeat := fs.Duration("heartbeat", gameabiHeartbeat, "wake cadence") + seats := fs.Int("seats", 1, "players joined to the room; Ctrl-T switches the active seat") + cfgVals := configFlags{} + fs.Var(cfgVals, "config", "KEY=VALUE per-game config (repeatable; value may be @file)") + if err := fs.Parse(args); err != nil { + return err + } + if *seats < 1 { + *seats = 1 + } + + // Build-or-passthrough BEFORE touching the terminal, so compiler output + // lands on a normal screen and a build failure exits cleanly. + path, _, cleanup, err := resolveArtifact(arg) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() + } + + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + game, ctl, err := newRoom(path, *seed, *seed != 0, *heartbeat, cfgVals, log) + if err != nil { + return err + } + if max := game.Meta().MaxPlayers; *seats > max { + *seats = max + } + + players := make([]sdk.Player, *seats) + for i := range players { + // A zero character keeps local hot-seat play dependency-free; the + // public devkit doesn't resolve account default characters. + players[i] = sdk.Player{ + AccountID: fmt.Sprintf("seat-%d", i+1), + Handle: fmt.Sprintf("seat%d", i+1), + Kind: sdk.KindMember, + Conn: fmt.Sprintf("local-%d", i+1), + } + if err := ctl.Join(players[i]); err != nil { + return fmt.Errorf("join seat %d: %w", i+1, err) + } + } + + // Terminal: raw mode + alt screen + hidden cursor. + fd := os.Stdin.Fd() + state, err := xterm.MakeRaw(fd) + if err != nil { + return fmt.Errorf("raw mode (need a real terminal): %w", err) + } + restore := func() { + _ = xterm.Restore(fd, state) + fmt.Print("\x1b[?25h\x1b[?1049l") + } + defer restore() + fmt.Print("\x1b[?1049h\x1b[?25l\x1b[2J") + + caps := session.Caps{ColorDepth: session.ColorTrue, UTF8: true} + termCols, termRows := 80, 24 + if c, r, err := xterm.GetSize(fd); err == nil && c > 0 { + termCols, termRows = c, r + } + + // Input pump: raw bytes -> sdk.Input for the ACTIVE seat; Ctrl-T cycles + // seats; Esc/Ctrl-C leaves. + seatCh := make(chan int, 4) // seat-switch requests + inputs := make(chan sdk.Input, 16) + quit := make(chan struct{}) + go func() { + defer close(quit) + buf := make([]byte, 64) + for { + n, err := os.Stdin.Read(buf) + if err != nil { + return + } + i := 0 + for i < n { + b := buf[i] + switch { + case b == 0x03: // Ctrl-C + return + case b == 0x14: // Ctrl-T: next seat + seatCh <- 1 + case b == 0x1b: + if i+2 < n && buf[i+1] == '[' { + switch buf[i+2] { + case 'A': + inputs <- sdk.KeyInput(sdk.KeyUp) + case 'B': + inputs <- sdk.KeyInput(sdk.KeyDown) + case 'C': + inputs <- sdk.KeyInput(sdk.KeyRight) + case 'D': + inputs <- sdk.KeyInput(sdk.KeyLeft) + } + i += 3 + continue + } + return // bare Esc: leave + case b == '\r' || b == '\n': + inputs <- sdk.KeyInput(sdk.KeyEnter) + case b == 0x7f: + inputs <- sdk.KeyInput(sdk.KeyBackspace) + case b == '\t': + inputs <- sdk.KeyInput(sdk.KeyTab) + case b >= 0x20: + inputs <- sdk.RuneInput(rune(b)) + } + i++ + } + } + }() + + // Frame pumps: one per seat; latest frame kept per seat, active rendered. + type seatFrame struct { + seat int + g canvas.Grid + ok bool + } + frames := make(chan seatFrame, *seats*2) + for i, p := range players { + i, p := i, p + go func() { + ch := ctl.Frames(p) + for g := range ch { + frames <- seatFrame{seat: i, g: g, ok: true} + } + frames <- seatFrame{seat: i} + }() + } + + last := make([]canvas.Grid, *seats) + have := make([]bool, *seats) + active := 0 + closedSeats := 0 + + draw := func() { + if !have[active] { + return + } + body := render.GridToANSI(last[active], caps) + if termCols > canvas.Cols || termRows > canvas.Rows { + body = render.Letterbox(body, termCols, termRows, caps.ColorDepth) + } + out := "\x1b[H" + strings.ReplaceAll(body, "\n", "\r\n") + if *seats > 1 && termRows > canvas.Rows { + status := fmt.Sprintf(" seat %d/%d — Ctrl-T switches ", active+1, *seats) + out += fmt.Sprintf("\x1b[%d;%dH\x1b[2m%s\x1b[0m", termRows, max(1, (termCols-len(status))/2), status) + } + _, _ = os.Stdout.WriteString(out) + } + + for { + select { + case f := <-frames: + if !f.ok { + closedSeats++ + if closedSeats >= *seats { + return nil // room settled + } + continue + } + last[f.seat], have[f.seat] = f.g, true + if f.seat == active { + draw() + } + case <-seatCh: + active = (active + 1) % *seats + draw() + case in := <-inputs: + ctl.Input(players[active], in) + case <-quit: + for _, p := range players { + ctl.Leave(p) + } + select { + case <-ctl.Done(): + case <-time.After(time.Second): + } + return nil + case <-ctl.Done(): + return nil + } + } +} + +// gameabiHeartbeat mirrors gameabi.Heartbeat for the flag default. +const gameabiHeartbeat = 50 * time.Millisecond diff --git a/cmd/shellcade-kit/smoke.go b/cmd/shellcade-kit/smoke.go new file mode 100644 index 0000000..1d8ffe1 --- /dev/null +++ b/cmd/shellcade-kit/smoke.go @@ -0,0 +1,160 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "time" + + kit "github.com/shellcade/kit/v2" + kitsmoke "github.com/shellcade/kit/v2/smoke" + + "github.com/shellcade/kit/v2/host/canvas" + "github.com/shellcade/kit/v2/host/gameabi" + "github.com/shellcade/kit/v2/host/gameabi/conformance" +) + +// runSmoke is `shellcade-kit smoke [--out dir]`: run the +// game's smoke.yaml against the built wasm artifact through the conformance +// harness and write the named shot files. This is the canonical smoke path — +// the script semantics, room shape, clock epoch, and renderer are the kit +// smoke contract, so the files are byte-identical to `go run . -smoke` for a +// deterministic game (and it is the only path for non-Go games). +func runSmoke(arg string, rest []string) error { + fs := flag.NewFlagSet("smoke", flag.ExitOnError) + out := fs.String("out", "smoke-out", "directory for shot files") + script := fs.String("script", "", "smoke script (default: smoke.yaml next to the game)") + if err := fs.Parse(rest); err != nil { + return err + } + + wasm, dir, cleanup, err := resolveArtifact(arg) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() + } + + scriptPath := *script + if scriptPath == "" { + scriptPath = filepath.Join(dir, "smoke.yaml") + } + b, err := os.ReadFile(scriptPath) + if err != nil { + return err + } + sc, err := kitsmoke.Parse(b) + if err != nil { + return err + } + shots, err := runWasmSmoke(wasm, sc) + if err != nil { + return err + } + names, err := kitsmoke.WriteShots(*out, shots) + if err != nil { + return err + } + for _, n := range names { + fmt.Println(n) + } + fmt.Printf("smoke: %d shots → %s\n", len(shots), *out) + return nil +} + +// runWasmSmoke executes a parsed smoke script against a wasm artifact and +// returns kit shots (frames converted from the host grid form). +func runWasmSmoke(wasm string, sc *kitsmoke.Script) ([]kitsmoke.Shot, error) { + // The conformance clock carries millisecond steps; a finer heartbeat + // cannot round-trip the wasm path identically, so refuse it rather than + // quietly diverge from the native runner. + if sc.Heartbeat%time.Millisecond != 0 { + return nil, fmt.Errorf("smoke: heartbeat %s must be a whole number of milliseconds", sc.Heartbeat) + } + beatMS := int64(sc.Heartbeat / time.Millisecond) + + // Replay the script's sticky-seat semantics into explicit conformance + // steps; `advance` expands to one (clock step + wake) per heartbeat — + // exactly the native runner's sweep. + var steps conformance.Script + cur := 0 + for _, st := range sc.Steps { + switch st.Kind { + case kitsmoke.StepRune: + steps = append(steps, conformance.Input(cur, st.Rune)) + case kitsmoke.StepKey: + steps = append(steps, conformance.Key(cur, uint8(st.Key))) + case kitsmoke.StepSeat: + cur = st.Seat + case kitsmoke.StepAdvance: + for n := int64(0); n < int64(st.D/sc.Heartbeat); n++ { + steps = append(steps, conformance.Advance(beatMS), conformance.Wake()) + } + case kitsmoke.StepWake: + steps = append(steps, conformance.Wake()) + case kitsmoke.StepShot: + seats := st.Seats + if seats != nil { + seats = append([]int(nil), seats...) + for i := 1; i < len(seats); i++ { // small insertion sort, ascending + for j := i; j > 0 && seats[j] < seats[j-1]; j-- { + seats[j], seats[j-1] = seats[j-1], seats[j] + } + } + } + steps = append(steps, conformance.Shot(st.Name, seats)) + } + } + + frames, err := conformance.RunShots(wasm, gameabi.Options{}, conformance.SmokeRun{ + Seed: sc.Seed, + Seats: sc.Seats, + Config: sc.Config, + Epoch: kitsmoke.SeedEpoch(sc.Seed), + Script: steps, + }) + if err != nil { + return nil, err + } + + shots := make([]kitsmoke.Shot, len(frames)) + for i, sf := range frames { + shots[i] = kitsmoke.Shot{Ordinal: i + 1, Name: sf.Name, Seats: sf.Seats} + for _, g := range sf.Frames { + shots[i].Frames = append(shots[i].Frames, gridToKitFrame(g)) + } + } + return shots, nil +} + +// gridToKitFrame converts the host canvas grid to the kit frame form, cell by +// cell (Rune/Cp2/Cp3/FG/BG/Attr/Cont map 1:1), so shots render through kit's +// canonical encoder. +func gridToKitFrame(g canvas.Grid) *kit.Frame { + f := &kit.Frame{} + for r := 0; r < canvas.Rows; r++ { + for c := 0; c < canvas.Cols; c++ { + cell := g.Cells[r][c] + f.Cells[r][c] = kit.Cell{ + Rune: cell.Rune, + Cp2: cell.Cp2, + Cp3: cell.Cp3, + FG: kitColor(cell.FG), + BG: kitColor(cell.BG), + Attr: kit.Attr(cell.Attr), + Cont: cell.Cont, + } + } + } + return f +} + +func kitColor(c canvas.Color) kit.Color { + if !c.IsSet() { + return kit.Color{} + } + r, g, b := c.RGB() + return kit.RGB(r, g, b) +} diff --git a/cmd/shellcade-kit/smoke_test.go b/cmd/shellcade-kit/smoke_test.go new file mode 100644 index 0000000..7f28377 --- /dev/null +++ b/cmd/shellcade-kit/smoke_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "testing" + + kit "github.com/shellcade/kit/v2" + kitsmoke "github.com/shellcade/kit/v2/smoke" + + "github.com/shellcade/kit/v2/host/canvas" +) + +// TestGridToKitFrameRendersIdentically proves the conversion is faithful by +// building the same screen twice — once on the host canvas, once on a kit +// frame — and asserting kit's canonical encoder emits identical bytes. +func TestGridToKitFrameRendersIdentically(t *testing.T) { + g := canvas.New() + red := canvas.RGB(255, 0, 0) + g.Set(0, 0, canvas.Cell{Rune: 'h', FG: red, Attr: canvas.AttrBold | canvas.AttrUnderline}) + g.Set(0, 1, canvas.Cell{Rune: 'i', BG: canvas.RGB(0, 10, 20)}) + g.Set(1, 0, canvas.Cell{Rune: '❤', Cp2: 0xFE0F}) // VS16 grapheme + g.Set(2, 0, canvas.Cell{Rune: '7', Cp2: 0xFE0F, Cp3: 0x20E3}) // keycap + g.Set(3, 0, canvas.Cell{Rune: '個', Attr: canvas.AttrDim}) + g.Set(3, 1, canvas.Cell{Cont: true}) + + f := kit.NewFrame() + f.Set(0, 0, kit.Cell{Rune: 'h', FG: kit.RGB(255, 0, 0), Attr: kit.AttrBold | kit.AttrUnderline}) + f.Set(0, 1, kit.Cell{Rune: 'i', BG: kit.RGB(0, 10, 20)}) + f.Set(1, 0, kit.Cell{Rune: '❤', Cp2: 0xFE0F}) + f.Set(2, 0, kit.Cell{Rune: '7', Cp2: 0xFE0F, Cp3: 0x20E3}) + f.Set(3, 0, kit.Cell{Rune: '個', Attr: kit.AttrDim}) + f.Set(3, 1, kit.Cell{Cont: true}) + + conv := gridToKitFrame(g) + if got, want := kitsmoke.RenderANSI(conv), kitsmoke.RenderANSI(f); !bytes.Equal(got, want) { + t.Fatalf("converted grid renders differently\n got: %q\nwant: %q", got, want) + } + if got, want := kitsmoke.RenderText(conv), kitsmoke.RenderText(f); !bytes.Equal(got, want) { + t.Fatalf("text twin differs\n got: %q\nwant: %q", got, want) + } +} + +// TestNativeWasmParity is the contract test: the same smoke.yaml against the +// same game source must produce byte-identical shot files from the native +// runner (`go run . -smoke`) and the wasm path (`shellcade-kit smoke`). +// Requires go + tinygo; skipped when either is missing. +func TestNativeWasmParity(t *testing.T) { + if testing.Short() { + t.Skip("short mode") + } + for _, tool := range []string{"go", "tinygo"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not on PATH", tool) + } + } + gameDir, err := filepath.Abs("testdata/paritygame") + if err != nil { + t.Fatal(err) + } + + // Native shots via the game's own dev runner. + nativeOut := filepath.Join(t.TempDir(), "native") + cmd := exec.Command("go", "run", ".", "-smoke", "smoke.yaml", "-smoke-out", nativeOut) + cmd.Dir = gameDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go run . -smoke: %v\n%s", err, out) + } + + // Wasm shots through the conformance harness (the runSmoke path). + wasm := filepath.Join(t.TempDir(), "game.wasm") + cmd = exec.Command("tinygo", "build", "-opt=1", "-no-debug", "-gc=conservative", + "-o", wasm, "-target", "wasip1", "-buildmode=c-shared", ".") + cmd.Dir = gameDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("tinygo build: %v\n%s", err, out) + } + b, err := os.ReadFile(filepath.Join(gameDir, "smoke.yaml")) + if err != nil { + t.Fatal(err) + } + sc, err := kitsmoke.Parse(b) + if err != nil { + t.Fatal(err) + } + shots, err := runWasmSmoke(wasm, sc) + if err != nil { + t.Fatal(err) + } + wasmOut := filepath.Join(t.TempDir(), "wasm") + if _, err := kitsmoke.WriteShots(wasmOut, shots); err != nil { + t.Fatal(err) + } + + // Same file set, byte-identical contents. + nativeFiles, _ := filepath.Glob(filepath.Join(nativeOut, "*")) + wasmFiles, _ := filepath.Glob(filepath.Join(wasmOut, "*")) + if len(nativeFiles) == 0 || len(nativeFiles) != len(wasmFiles) { + t.Fatalf("file sets differ: native %d, wasm %d", len(nativeFiles), len(wasmFiles)) + } + for _, nf := range nativeFiles { + name := filepath.Base(nf) + nb, err := os.ReadFile(nf) + if err != nil { + t.Fatal(err) + } + wb, err := os.ReadFile(filepath.Join(wasmOut, name)) + if err != nil { + t.Fatalf("wasm path missing %s: %v", name, err) + } + if !bytes.Equal(nb, wb) { + t.Fatalf("%s differs between native and wasm paths\nnative: %q\nwasm: %q", name, nb, wb) + } + } +} diff --git a/cmd/shellcade-kit/testdata/paritygame/exports.go b/cmd/shellcade-kit/testdata/paritygame/exports.go new file mode 100644 index 0000000..ef09369 --- /dev/null +++ b/cmd/shellcade-kit/testdata/paritygame/exports.go @@ -0,0 +1,33 @@ +//go:build wasip1 || tinygo.wasm + +package main + +import kit "github.com/shellcade/kit/v2" + +func init() { kit.Run(Game{}) } + +// The eight ABI exports, trampolined to the gamekit SDK. + +//go:export shellcade_abi +func expABI() int32 { return kit.ExportABI() } + +//go:export meta +func expMeta() int32 { return kit.ExportMeta() } + +//go:export start +func expStart() int32 { return kit.ExportStart() } + +//go:export join +func expJoin() int32 { return kit.ExportJoin() } + +//go:export leave +func expLeave() int32 { return kit.ExportLeave() } + +//go:export input +func expInput() int32 { return kit.ExportInput() } + +//go:export wake +func expWake() int32 { return kit.ExportWake() } + +//go:export close +func expClose() int32 { return kit.ExportClose() } diff --git a/cmd/shellcade-kit/testdata/paritygame/go.mod b/cmd/shellcade-kit/testdata/paritygame/go.mod new file mode 100644 index 0000000..a23d36a --- /dev/null +++ b/cmd/shellcade-kit/testdata/paritygame/go.mod @@ -0,0 +1,12 @@ +module paritygame + +go 1.25.0 + +require github.com/shellcade/kit/v2 v2.1.1 + +require ( + github.com/extism/go-pdk v1.1.3 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/term v0.43.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cmd/shellcade-kit/testdata/paritygame/go.sum b/cmd/shellcade-kit/testdata/paritygame/go.sum new file mode 100644 index 0000000..ba9b538 --- /dev/null +++ b/cmd/shellcade-kit/testdata/paritygame/go.sum @@ -0,0 +1,12 @@ +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.1.1 h1:LebY8L7Wuk2H6Ctc3pMMKl/EVLdv0D8G/LfuvHwbuxg= +github.com/shellcade/kit/v2 v2.1.1/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= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/shellcade-kit/testdata/paritygame/main.go b/cmd/shellcade-kit/testdata/paritygame/main.go new file mode 100644 index 0000000..b4c6de4 --- /dev/null +++ b/cmd/shellcade-kit/testdata/paritygame/main.go @@ -0,0 +1,63 @@ +// paritygame is the smoke parity fixture: a deterministic per-seat game whose +// frames depend on everything the smoke contract pins — seed (RNG draw), +// virtual clock, per-seat input history, seat identities, styles, graphemes — +// so the native (`go run . -smoke`) and wasm (`shellcade-kit smoke`) paths +// must produce byte-identical shots or the contract is broken. +package main + +import ( + "fmt" + + kit "github.com/shellcade/kit/v2" +) + +type Game struct{} + +func (Game) Meta() kit.GameMeta { + return kit.GameMeta{Slug: "paritygame", Name: "Parity Game", MinPlayers: 1, MaxPlayers: 4} +} + +func (Game) NewRoom(cfg kit.RoomConfig, svc kit.Services) kit.Handler { + return &room{inputs: map[string]string{}} +} + +type room struct { + kit.Base + draw int + wakes int + inputs map[string]string +} + +func (h *room) OnStart(r kit.Room) { + h.draw = r.Rand().Intn(100000) + h.render(r) +} + +func (h *room) OnJoin(r kit.Room, p kit.Player) { h.render(r) } + +func (h *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { + if in.Kind == kit.InputRune { + h.inputs[p.AccountID] += string(in.Rune) + } else { + h.inputs[p.AccountID] += fmt.Sprintf("<%d>", in.Key) + } + h.render(r) +} + +func (h *room) OnWake(r kit.Room) { + h.wakes++ + h.render(r) +} + +func (h *room) render(r kit.Room) { + for _, p := range r.Members() { + f := kit.NewFrame() + f.Text(0, 0, fmt.Sprintf("%s wakes=%d draw=%d t=%d", p.Handle, h.wakes, h.draw, r.Now().UnixMilli()), kit.Style{FG: kit.Green, Attr: kit.AttrBold}) + f.Text(1, 0, "in="+h.inputs[p.AccountID], kit.Style{FG: kit.RGB(200, 120, 40)}) + f.SetGrapheme(2, 0, "❤️", kit.Style{}) + f.SetWide(2, 4, '個', kit.Style{BG: kit.DimGray}) + r.Send(p, f) + } +} + +func main() { kit.Main(Game{}) } diff --git a/cmd/shellcade-kit/testdata/paritygame/smoke.yaml b/cmd/shellcade-kit/testdata/paritygame/smoke.yaml new file mode 100644 index 0000000..63f7683 --- /dev/null +++ b/cmd/shellcade-kit/testdata/paritygame/smoke.yaml @@ -0,0 +1,13 @@ +seed: 1234 +seats: 2 +steps: + - shot: start + - rune: "a" + - key: enter + - seat: 1 + - text: "ok" + - advance: 250ms + - wake: + - shot: mid + - shot: only-one + seats: [1] diff --git a/go.mod b/go.mod index fdec727..10a8d53 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/shellcade/kit/v2 go 1.25.0 require ( + github.com/charmbracelet/x/term v0.2.2 github.com/extism/go-pdk v1.1.3 github.com/extism/go-sdk v1.7.1 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 931b386..65a17f4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= From 42bd88adbc3f7f685a3a41cbac9a17c4688333ec Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Tue, 16 Jun 2026 08:56:40 +1000 Subject: [PATCH 08/10] host: restore gameabi delta-conformance + hardening tests Both were deferred during the gameabi move (needed render/session/services). Those are now in kit: render/session imports repointed to host/..., services.NewFactory -> memsvc.NewFactory. Recovers the dropped coverage. Co-Authored-By: Claude Opus 4.8 --- host/gameabi/delta_conformance_test.go | 410 +++++++++++++++++++++++++ host/gameabi/hardening_test.go | 314 +++++++++++++++++++ 2 files changed, 724 insertions(+) create mode 100644 host/gameabi/delta_conformance_test.go create mode 100644 host/gameabi/hardening_test.go diff --git a/host/gameabi/delta_conformance_test.go b/host/gameabi/delta_conformance_test.go new file mode 100644 index 0000000..c197dd1 --- /dev/null +++ b/host/gameabi/delta_conformance_test.go @@ -0,0 +1,410 @@ +package gameabi + +import ( + "bytes" + "math/rand" + "strings" + "testing" + + "github.com/shellcade/kit/v2/wire" + + "github.com/shellcade/kit/v2/host/render" + "github.com/shellcade/kit/v2/host/session" +) + +// Host-side frame-delta conformance (tasks 6.1–6.5). These exercise the v2 delta +// codec and the host's baseline+epoch authority directly — the same wire codec +// and baselineCache the live send/identical host functions use — plus the host +// canvas/renderer grapheme path. Whole-guest conformance (the fixture driven +// through the real adapter) lives in internal/gameabi/conformance. + +// ---- 6.1 delta codec round-trip + fuzz (never panics / never reads OOB) ------ + +// randomFrame fills a FrameBytes buffer with canonical-zero 24-byte cells whose +// content varies per cell, so a diff against another random frame produces a +// realistic run distribution. +func randomFrame(rng *rand.Rand) []byte { + b := make([]byte, wire.FrameBytes) + for i := 0; i < wire.FrameCells; i++ { + if rng.Intn(3) == 0 { + continue // leave a blank (all-zero) cell for run boundaries + } + wire.PutCell(b, i, wire.Cell{ + Rune: rune('a' + rng.Intn(26)), + FGSet: rng.Intn(2) == 0, + FGR: uint8(rng.Intn(256)), + Attr: uint8(rng.Intn(16)), + }) + } + return b +} + +// TestDeltaRoundTrip: apply(base, diff(base, next)) == next over random 24-byte +// frame pairs, including full-change and zero-change (round-trip invariant, D2). +func TestDeltaRoundTrip(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + dst := make([]byte, wire.MaxDeltaBytes) + for iter := 0; iter < 200; iter++ { + base := randomFrame(rng) + next := randomFrame(rng) + if iter == 0 { // zero-change case + next = append([]byte(nil), base...) + } + if iter == 1 { // full-change case: every cell differs + base = bytes.Repeat([]byte{0}, wire.FrameBytes) + next = bytes.Repeat([]byte{0xAB}, wire.FrameBytes) + // 0xAB is not canonical (pad != 0), but the codec treats it as opaque + // bytes; it still must round-trip exactly. + } + n := wire.BuildFrameDelta(base, next, dst, 7) + delta := dst[:n] + if n >= wire.KeyframeBytes { + n = wire.BuildKeyframe(next, dst, 7) + delta = dst[:n] + } + if err := wire.CheckFrameDelta(delta); err != nil { + t.Fatalf("iter %d: CheckFrameDelta rejected our own encoder output: %v", iter, err) + } + recon := append([]byte(nil), base...) + if err := wire.ApplyFrameDelta(recon, delta); err != nil { + t.Fatalf("iter %d: ApplyFrameDelta: %v", iter, err) + } + if !bytes.Equal(recon, next) { + t.Fatalf("iter %d: round-trip mismatch (apply(base, diff) != next)", iter) + } + } +} + +// FuzzHostDeltaIngest: the host's delta validator/applier never panics and never +// reads out of bounds on arbitrary bytes (the drop-not-fatal contract, §4.5). +// CheckFrameDelta and ApplyFrameDelta must agree: if Check passes, Apply on a +// correctly-sized baseline must not error. +func FuzzHostDeltaIngest(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte{wire.FlagKeyframe, 0, 0, 0, 0, 1, 0, 24, 80}) + f.Add(make([]byte, wire.KeyframeBytes)) // wrong geometry header => malformed + // A structurally valid keyframe seed. + kf := make([]byte, wire.MaxDeltaBytes) + n := wire.BuildKeyframe(make([]byte, wire.FrameBytes), kf, 3) + f.Add(kf[:n]) + f.Fuzz(func(t *testing.T, b []byte) { + // Must never panic. A baselineCache.apply must drop-or-apply, never crash. + var c baselineCache + _ = c.apply(0, b, nil) + // Check/Apply agreement on a correctly-sized baseline. + err := wire.CheckFrameDelta(b) + prev := make([]byte, wire.FrameBytes) + applyErr := wire.ApplyFrameDelta(prev, b) + if err == nil && applyErr != nil { + t.Fatalf("CheckFrameDelta passed but ApplyFrameDelta failed: %v", applyErr) + } + }) +} + +// TestHostDropsMalformedDelta: the host baselineCache drops a malformed/short +// container, bumps the slot epoch, and reports not-applied — never panics (D5, +// 4.3, the "malformed or short delta is dropped, not fatal" scenario). +func TestHostDropsMalformedDelta(t *testing.T) { + cases := map[string][]byte{ + "short header": {0, 0, 0}, + "unknown flag bit": {0x02, 0, 0, 0, 0, 0, 0, 24, 80}, + "wrong geometry": {0, 0, 0, 0, 0, 0, 0, 25, 80}, + "runcount mismatch": {0, 0, 0, 0, 0, 5, 0, 24, 80}, // says 5 runs, body empty + "out of bounds run": buildOneRun(t, 1900, 100), // 1900+100 > 1920 + } + for name, b := range cases { + var c baselineCache + c.epochSeq = 41 // last issued high-water + c.epoch[0] = 41 + c.has[0] = true // pretend a baseline existed, so we can observe it cleared + logged := false + res := c.apply(0, b, func(string) { logged = true }) + if res.applied { + t.Errorf("%s: applied a malformed container", name) + } + // bump advances the monotonic high-water (epochSeq 41 -> 42) and stamps it. + if res.epoch != 42 { + t.Errorf("%s: returned epoch = %d, want 42 (bumped high-water)", name, res.epoch) + } + if c.epoch[0] != 42 { + t.Errorf("%s: slot epoch = %d, want 42", name, c.epoch[0]) + } + if c.has[0] { + t.Errorf("%s: slot left present after a dropped delta", name) + } + if !logged { + t.Errorf("%s: no 'dropped malformed delta' log", name) + } + } +} + +func buildOneRun(t *testing.T, start, runLen int) []byte { + t.Helper() + b := make([]byte, wire.DeltaHeaderBytes+wire.RunHeaderBytes+runLen*wire.CellBytes) + b[0] = 0 + b[5] = 1 // runCount = 1 + b[7], b[8] = 24, 80 + b[9], b[10] = byte(start), byte(start>>8) + b[11], b[12] = byte(runLen), byte(runLen>>8) + return b +} + +// ---- 6.2 delta-vs-keyframe byte-identical through the host apply path -------- + +// TestDeltaVsKeyframeByteIdentical: a slot driven by a SEQUENCE of deltas +// reconstructs a packed grid byte-identical to the same frames delivered as +// keyframes (D2 — the keyframe is the only full-frame form; the delta and the +// keyframe reconstruct the same frame). Mirrors the host's send apply path. +func TestDeltaVsKeyframeByteIdentical(t *testing.T) { + rng := rand.New(rand.NewSource(99)) + frames := make([][]byte, 8) + for i := range frames { + frames[i] = randomFrame(rng) + } + + // Delta path: keyframe the first frame, then deltas against the running + // baseline (exactly what the SDK + host do). + var deltaCache baselineCache + dst := make([]byte, wire.MaxDeltaBytes) + for i, fr := range frames { + var payload []byte + if i == 0 || !deltaCache.has[0] { + n := wire.BuildKeyframe(fr, dst, deltaCache.epoch[0]) + payload = dst[:n] + } else { + n := wire.BuildFrameDelta(deltaCache.prev[0][:], fr, dst, deltaCache.epoch[0]) + if n >= wire.KeyframeBytes { + n = wire.BuildKeyframe(fr, dst, deltaCache.epoch[0]) + } + payload = dst[:n] + } + res := deltaCache.apply(0, payload, nil) + if !res.applied { + t.Fatalf("frame %d: host rejected our own valid payload", i) + } + // Mirror the guest's epoch stamp for the next iteration. + deltaCache.epoch[0] = res.epoch + } + + // Keyframe path: every frame as a keyframe. + var kfCache baselineCache + for i, fr := range frames { + n := wire.BuildKeyframe(fr, dst, kfCache.epoch[0]) + res := kfCache.apply(0, dst[:n], nil) + if !res.applied { + t.Fatalf("frame %d: host rejected a keyframe", i) + } + kfCache.epoch[0] = res.epoch + } + + if !bytes.Equal(deltaCache.prev[0][:], kfCache.prev[0][:]) { + t.Fatal("delta-reconstructed baseline differs from the keyframe-reconstructed baseline") + } + // And both equal the last authored frame exactly. + if !bytes.Equal(deltaCache.prev[0][:], frames[len(frames)-1]) { + t.Fatal("reconstructed baseline differs from the last authored frame") + } +} + +// ---- 6.4 Identical-then-Send + mid-join reconciliation ----------------------- + +// TestIdenticalThenSendReconciles: a broadcast Identical reconciles every +// ALLOCATED per-index baseline, so a later per-player Send diffs against the +// baseline the broadcast left and reconstructs the exact frame (D7). +// Lazy-slot contract (large-room scale): slots never sent to are NOT +// materialized by a broadcast — they stay not-present and recover via an +// unconditionally-accepted keyframe on their first per-player Send (mirroring +// the guest SDK's lazy reconcile). Modeled on the host's identical/send apply +// paths. +func TestIdenticalThenSendReconciles(t *testing.T) { + rng := rand.New(rand.NewSource(7)) + var c baselineCache + dst := make([]byte, wire.MaxDeltaBytes) + + // Slot 3 has had a prior per-player send (allocated); slot 0 never has. + prior := randomFrame(rng) + pn := wire.BuildKeyframe(prior, dst, c.epoch[3]) + if res := c.apply(3, dst[:pn], nil); !res.applied { + t.Fatal("prior per-player keyframe to slot 3 rejected") + } + + // Broadcast a keyframe to the broadcast slot, then reconcile. + bcast := randomFrame(rng) + n := wire.BuildKeyframe(bcast, dst, c.epoch[broadcastSlot]) + res := c.apply(broadcastSlot, dst[:n], nil) + if !res.applied { + t.Fatal("broadcast keyframe rejected") + } + c.reconcileBroadcast(res.epoch) + + // The allocated slot must now hold the broadcast frame, be present, and + // carry the broadcast epoch. The never-sent slot must be left not-present. + if !c.has[3] || c.epoch[3] != res.epoch || !bytes.Equal(c.prev[3], bcast) { + t.Fatalf("allocated slot 3 not reconciled (has=%v epoch=%d)", c.has[3], c.epoch[3]) + } + if c.has[0] { + t.Fatal("never-sent slot 0 was materialized by the broadcast (lazy contract)") + } + + // A later per-player Send to slot 3: a DELTA against the reconciled baseline, + // stamped with the broadcast epoch, must apply and reconstruct the new frame. + personal := randomFrame(rng) + dn := wire.BuildFrameDelta(c.prev[3], personal, dst, c.epoch[3]) + if dn >= wire.KeyframeBytes { + dn = wire.BuildKeyframe(personal, dst, c.epoch[3]) + } + r2 := c.apply(3, dst[:dn], nil) + if !r2.applied { + t.Fatal("per-player Send after Identical was rejected (baseline left stale)") + } + if !bytes.Equal(c.prev[3], personal) { + t.Fatal("per-player Send reconstructed the wrong frame") + } + + // The never-sent slot recovers via keyframe: unconditionally accepted. + r3kf := randomFrame(rng) + kn := wire.BuildKeyframe(r3kf, dst, c.epoch[0]) + if r3 := c.apply(0, dst[:kn], nil); !r3.applied { + t.Fatal("keyframe to never-sent slot 0 rejected (recovery path broken)") + } + if !bytes.Equal(c.prev[0], r3kf) { + t.Fatal("slot 0 keyframe reconstructed the wrong frame") + } +} + +// TestMidJoinReceivesKeyframe: a roster mutation bumps the epoch and marks every +// slot not-present, so the next send to each slot is epoch-rejected — forcing +// the guest to a keyframe (the RFB incremental=0 analogue, D7/4.6). +func TestMidJoinReceivesKeyframe(t *testing.T) { + rng := rand.New(rand.NewSource(11)) + var c baselineCache + dst := make([]byte, wire.MaxDeltaBytes) + + // Establish a baseline on slot 0 via a keyframe. + fr := randomFrame(rng) + n := wire.BuildKeyframe(fr, dst, c.epoch[0]) + res := c.apply(0, dst[:n], nil) + if !res.applied || !c.has[0] { + t.Fatal("initial keyframe not established") + } + epochBefore := res.epoch + + // Mid-room join: every slot is invalidated, the epoch bumps. + c.invalidateAll() + if c.has[0] { + t.Fatal("slot 0 still present after a roster mutation") + } + if c.epoch[0] <= epochBefore { + t.Fatalf("epoch not bumped on roster mutation: %d <= %d", c.epoch[0], epochBefore) + } + + // The guest's next send is a DELTA stamped with its surviving (pre-bump) + // epoch: the host must reject it (epoch mismatch AND not-present) and bump. + staleDelta := make([]byte, wire.MaxDeltaBytes) + sn := wire.BuildFrameDelta(fr, randomFrame(rng), staleDelta, epochBefore) + rej := c.apply(0, staleDelta[:sn], nil) + if rej.applied { + t.Fatal("host applied a stale delta to an invalidated slot") + } + + // The guest then sends a keyframe stamped with the returned epoch: accepted. + kn := wire.BuildKeyframe(fr, dst, rej.epoch) + acc := c.apply(0, dst[:kn], nil) + if !acc.applied || !c.has[0] { + t.Fatal("keyframe after a roster mutation was not accepted") + } +} + +// ---- 6.5 grapheme conformance (host canvas + renderer) ----------------------- + +// TestGraphemeHostConformance drives a frame carrying grapheme clusters (a VS16 +// emoji, a skin-tone-modified emoji, a keycap base+U+20E3) through the host's +// pack -> delta -> apply -> decodeFrame -> render pipeline and asserts: +// +// (a) packed cells are canonically zero in pad and unused cp slots, +// (b) the reconstructed grid round-trips byte-identical, +// (c) an over-3-code-point cluster is NOT representable (cell left blank), +// (d) the rendered ANSI bursts each cluster's code points contiguously. +func TestGraphemeHostConformance(t *testing.T) { + type cluster struct { + col int + base rune + cp2, cp3 rune + } + clusters := []cluster{ + {0, '☂', 0xFE0F, 0}, // VS16 emoji presentation + {2, '👍', 0x1F3FD, 0}, // skin-tone modifier + {4, '1', 0xFE0F, 0x20E3}, // keycap: 1 + VS16 + U+20E3 + } + + // Author the frame via PutCell (the host's canonical-zero enforcer). + packed := make([]byte, wire.FrameBytes) + for _, cl := range clusters { + idx := 0*wire.Cols + cl.col + wire.PutCell(packed, idx, wire.Cell{Rune: cl.base, Cp2: cl.cp2, Cp3: cl.cp3}) + } + + // (a) Canonical-zero: pad (bytes 22..23) and any unused cp slot are zero. + for _, cl := range clusters { + o := (0*wire.Cols + cl.col) * wire.CellBytes + if packed[o+22] != 0 || packed[o+23] != 0 { + t.Errorf("col %d: pad bytes not zero", cl.col) + } + if cl.cp3 == 0 { + if packed[o+8] != 0 || packed[o+9] != 0 || packed[o+10] != 0 || packed[o+11] != 0 { + t.Errorf("col %d: unused cp3 slot not zero", cl.col) + } + } + } + + // (b) Round-trip through the real delta apply path: keyframe -> apply -> + // reconstructed baseline must equal the authored frame byte-for-byte. + var c baselineCache + dst := make([]byte, wire.MaxDeltaBytes) + n := wire.BuildKeyframe(packed, dst, 0) + if res := c.apply(broadcastSlot, dst[:n], nil); !res.applied { + t.Fatal("grapheme keyframe rejected") + } + if !bytes.Equal(c.prev[broadcastSlot][:], packed) { + t.Fatal("grapheme frame did not round-trip byte-identical through delta apply") + } + + // decodeFrame must carry cp2/cp3 into the canvas grid. + grid, err := decodeFrame(c.prev[broadcastSlot][:]) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + for _, cl := range clusters { + got := grid.Cells[0][cl.col] + if got.Rune != cl.base || got.Cp2 != cl.cp2 || got.Cp3 != cl.cp3 { + t.Errorf("col %d: decoded cell = {%U,%U,%U}, want {%U,%U,%U}", + cl.col, got.Rune, got.Cp2, got.Cp3, cl.base, cl.cp2, cl.cp3) + } + } + + // (c) A cluster of more than three code points is not representable: the cell + // has only base+cp2+cp3, so a family ZWJ emoji (4+ cps) cannot be carried. + // The host-side guarantee is that a cell never drawn (the guest refuses an + // over-limit cluster) stays blank — verify a never-written cell is blank. + blank := grid.Cells[0][40] + // An undrawn packed cell is all-zero: Rune 0 (the renderer treats it as a + // space), no cp2/cp3. The point is no over-limit cluster ever lands there. + if blank.Rune != 0 || blank.Cp2 != 0 || blank.Cp3 != 0 { + t.Errorf("an undrawn cell is not blank/zero: %+v", blank) + } + + // (d) Rendered ANSI bursts each cluster's code points contiguously. + out := render.GridToANSI(grid, session.Caps{ColorDepth: session.ColorTrue, UTF8: true}) + for _, cl := range clusters { + want := string(cl.base) + if cl.cp2 != 0 { + want += string(cl.cp2) + } + if cl.cp3 != 0 { + want += string(cl.cp3) + } + if !strings.Contains(out, want) { + t.Errorf("col %d: rendered ANSI does not contain the contiguous cluster %q (% x)", cl.col, want, want) + } + } +} diff --git a/host/gameabi/hardening_test.go b/host/gameabi/hardening_test.go new file mode 100644 index 0000000..685e28c --- /dev/null +++ b/host/gameabi/hardening_test.go @@ -0,0 +1,314 @@ +package gameabi + +import ( + "context" + "log/slog" + "os" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/tetratelabs/wazero" + + "github.com/shellcade/kit/v2/host/sdk" + "github.com/shellcade/kit/v2/host/memsvc" +) + +// readFixtureWasm reads the committed fixture artifact for module-level +// inspection (import enumeration). +func readFixtureWasm(t *testing.T) []byte { + t.Helper() + b, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("read %s: %v", fixturePath, err) + } + return b +} + +// logCapture is a slog.Handler that records every record's message, so a test +// can assert on what the guest logged through the host `log` function (the host +// routes a guest log line straight to the room log as the record message). +type logCapture struct { + mu sync.Mutex + msgs []string +} + +func (c *logCapture) Enabled(context.Context, slog.Level) bool { return true } +func (c *logCapture) Handle(_ context.Context, r slog.Record) error { + c.mu.Lock() + c.msgs = append(c.msgs, r.Message) + c.mu.Unlock() + return nil +} +func (c *logCapture) WithAttrs([]slog.Attr) slog.Handler { return c } +func (c *logCapture) WithGroup(string) slog.Handler { return c } + +func (c *logCapture) lines() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.msgs...) +} + +// findLine returns the first captured message carrying substr. +func (c *logCapture) findLine(substr string) (string, bool) { + for _, m := range c.lines() { + if strings.Contains(m, substr) { + return m, true + } + } + return "", false +} + +// ---- (a) per-room config-driven knobs --------------------------------------- + +// TestConfigKnobsOverrideCadence: an admin's host.heartbeat_ms / host.deadline_ms +// (slug-bound config) override the loaded Options for NEW rooms, clamped to sane +// bounds. The memory cap is NOT config-driven (load-time, manifest-fixed). +func TestConfigKnobsOverrideCadence(t *testing.T) { + g := loadFixture(t, Options{Heartbeat: 50 * time.Millisecond, CallbackDeadline: 100 * time.Millisecond}) + + cases := []struct { + name string + hb, dl string // config values ("" = unset) + wantHB, wantDL time.Duration + }{ + {"unset keeps options", "", "", 50 * time.Millisecond, 100 * time.Millisecond}, + {"valid overrides", "200", "500", 200 * time.Millisecond, 500 * time.Millisecond}, + {"clamp high", "5000", "9000", maxHeartbeat, maxDeadline}, + {"clamp low", "1", "1", minHeartbeat, minDeadline}, + {"malformed ignored", "abc", "-7", 50 * time.Millisecond, 100 * time.Millisecond}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := memsvc.NewFactory(quietLog(), nil) + if tc.hb != "" { + f.SetConfig("fixture", cfgHeartbeatMS, []byte(tc.hb)) + } + if tc.dl != "" { + f.SetConfig("fixture", cfgDeadlineMS, []byte(tc.dl)) + } + svc := f.For("room", "fixture") + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + h := g.NewRoom(cfg, svc).(*wasmHandler) + if h.heartbeat != tc.wantHB { + t.Errorf("heartbeat = %v, want %v", h.heartbeat, tc.wantHB) + } + if h.deadline != tc.wantDL { + t.Errorf("deadline = %v, want %v", h.deadline, tc.wantDL) + } + }) + } +} + +// TestConfigKnobsNilStore: a room with no ConfigStore (svc.Config == nil) keeps +// the loaded Options — the override path must tolerate a nil store. +func TestConfigKnobsNilStore(t *testing.T) { + g := loadFixture(t, Options{Heartbeat: 33 * time.Millisecond, CallbackDeadline: 77 * time.Millisecond}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + h := g.NewRoom(cfg, sdk.Services{Log: quietLog()}).(*wasmHandler) // Config nil + if h.heartbeat != 33*time.Millisecond || h.deadline != 77*time.Millisecond { + t.Fatalf("knobs = %v/%v, want 33ms/77ms (nil config keeps options)", h.heartbeat, h.deadline) + } +} + +// TestConfigKnobsDriveSimRate proves the resolved heartbeat actually reaches the +// engine: a room built with host.heartbeat_ms publishes that cadence via +// SetSimRate at OnStart. We drive OnStart against a TestRoom wrapper that +// records the published cadence (the bare TestRoom ignores SetSimRate). +func TestConfigKnobsDriveSimRate(t *testing.T) { + g := loadFixture(t, Options{Heartbeat: 50 * time.Millisecond}) + f := memsvc.NewFactory(quietLog(), nil) + f.SetConfig("fixture", cfgHeartbeatMS, []byte("250")) + svc := f.For("room", "fixture") + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + + h := g.NewRoom(cfg, svc) + rec := &simRateRoom{TestRoom: sdk.NewTestRoomFor(h, cfg, svc)} + h.OnStart(rec) + if rec.simRate != 250*time.Millisecond { + t.Fatalf("SetSimRate = %v, want 250ms", rec.simRate) + } +} + +// simRateRoom wraps a TestRoom to capture the SetSimRate cadence the handler +// publishes at OnStart (TestRoom itself ignores SetSimRate). +type simRateRoom struct { + *sdk.TestRoom + simRate time.Duration +} + +func (r *simRateRoom) SetSimRate(d time.Duration) { r.simRate = d } + +// ---- (b) virtualized WASI: time, entropy, network denial -------------------- + +// fixtureRoomWithLog builds a TestRoom over the fixture with a capturing log, +// returning the room and the capture so a test can drive commands and read the +// guest's log lines. +func fixtureRoomWithLog(t *testing.T, opts Options, seed int64) (*sdk.TestRoom, *logCapture) { + t.Helper() + g := loadFixture(t, opts) + cap := &logCapture{} + svc := sdk.Services{Log: slog.New(cap)} + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: seed, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, svc) + tr.Start() + tr.Join(p1) + return tr, cap +} + +// TestGuestTimeIsRoomClock: the guest's own time.Now() ('t') reads the room +// clock (== CallContext time), proving the host's WithWalltime/WithNanotime +// virtualization. Advancing the room clock advances the guest's clock in step. +func TestGuestTimeIsRoomClock(t *testing.T) { + tr, cap := fixtureRoomWithLog(t, Options{}, 7) + + tr.Input(p1, runeIn('t')) + line, ok := cap.findLine("fixture: now=") + if !ok { + t.Fatalf("no time log; got %v", cap.lines()) + } + got := strings.TrimPrefix(line, "fixture: now=") + want := strconv.FormatInt(tr.Clock.UnixNano(), 10) + if got != want { + t.Fatalf("guest now=%s, want room clock %s", got, want) + } + + // Advance the room clock; the guest sees the new instant on the next call. + tr.Advance(1234 * time.Millisecond) + tr.Input(p1, runeIn('t')) + lines := cap.lines() + last := lines[len(lines)-1] + got2 := strings.TrimPrefix(last, "fixture: now=") + want2 := strconv.FormatInt(tr.Clock.UnixNano(), 10) + if got2 != want2 { + t.Fatalf("after advance guest now=%s, want %s", got2, want2) + } +} + +// TestEntropyIsSeeded: two rooms with the SAME seed log identical 'r' entropy +// (the host's WithRandSource is room-seeded), and two rooms with DIFFERENT +// seeds diverge. This proves entropy is host-virtualized and reproducible — +// the guest never reaches the system CSPRNG. +func TestEntropyIsSeeded(t *testing.T) { + read := func(seed int64) string { + tr, cap := fixtureRoomWithLog(t, Options{}, seed) + tr.Input(p1, runeIn('r')) + line, ok := cap.findLine("fixture: rand=") + if !ok { + t.Fatalf("seed %d: no entropy log; got %v", seed, cap.lines()) + } + return strings.TrimPrefix(line, "fixture: rand=") + } + a1, a2 := read(7), read(7) + if a1 != a2 { + t.Fatalf("same seed produced different entropy: %s vs %s", a1, a2) + } + b := read(99) + if a1 == b { + t.Fatalf("different seeds produced identical entropy: %s", a1) + } +} + +// TestNoNetworkCapability proves the artifact cannot reach the network: the +// only modules it imports are the shellcade host namespace, the extism kernel +// env, and non-socket WASI. We compile the wasm with a vanilla wazero runtime +// (no host modules registered, so an empty extism AllowedHosts is mirrored) and +// enumerate ImportedFunctions — asserting NO socket/network import is present. +// What this proves: even if the guest tried to dial out, there is no host +// function bound for it to call; the import simply does not exist in the module. +func TestNoNetworkCapability(t *testing.T) { + wasm := readFixtureWasm(t) + rt := wazero.NewRuntime(context.Background()) + defer rt.Close(context.Background()) + compiled, err := rt.CompileModule(context.Background(), wasm) + if err != nil { + t.Fatalf("compile: %v", err) + } + defer compiled.Close(context.Background()) + + // WASI socket primitives (wasi_snapshot_preview1) and any plausible network + // host import. None must appear in the artifact's import list. + banned := map[string]bool{ + "sock_accept": true, "sock_recv": true, "sock_send": true, + "sock_shutdown": true, "sock_open": true, "sock_connect": true, + "sock_bind": true, "sock_listen": true, "sock_setsockopt": true, + "sock_getsockopt": true, "sock_addr_resolve": true, + } + bannedModules := map[string]bool{ + "wasi_snapshot_preview1_net": true, + "wasi:sockets": true, + "http": true, + } + + for _, fn := range compiled.ImportedFunctions() { + mod, name, _ := fn.Import() + if bannedModules[mod] { + t.Fatalf("artifact imports network module %q.%q", mod, name) + } + if banned[name] { + t.Fatalf("artifact imports network capability %q.%q", mod, name) + } + } + // Sanity: the artifact DOES import the shellcade host namespace (so the + // enumeration is meaningful, not just empty). + sawHost := false + for _, fn := range compiled.ImportedFunctions() { + if mod, _, _ := fn.Import(); strings.Contains(mod, "extism:host/user") { + sawHost = true + break + } + } + if !sawHost { + t.Fatal("artifact imports no shellcade host module — import enumeration is suspect") + } +} + +// ---- (c) scripted countdown (deadline driven by wake + CallContext time) ---- + +// TestScriptedCountdown drives the 'd' command through TestRoom: arm a 250ms +// countdown, then Advance the clock across Ticks. Each wake renders the +// remaining ms (read from CallContext time), and the wake that finds the clock +// past the deadline renders BOOM and ends the room — no host-side timer, purely +// wake + virtualized clock. +func TestScriptedCountdown(t *testing.T) { + g := loadFixture(t, Options{}) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, sdk.Services{Log: quietLog()}) + tr.Start() + tr.Join(p1) + + tr.Input(p1, runeIn('d')) // arm; renders the first remaining-ms frame + f, _ := tr.LastFrame(p1) + if got := rowText(f, 0); got != "COUNTDOWN" { + t.Fatalf("after arm row0 = %q, want COUNTDOWN", got) + } + if got := rowText(f, 1); got != "remaining_ms=250" { + t.Fatalf("after arm row1 = %q, want remaining_ms=250", got) + } + + // Three 100ms steps: 250 -> 150 -> 50 -> BOOM. + wantRemaining := []string{"remaining_ms=150", "remaining_ms=50"} + for i, want := range wantRemaining { + tr.Advance(100 * time.Millisecond) + tr.Tick() + if tr.Ended { + t.Fatalf("step %d: room ended early", i) + } + f, _ := tr.LastFrame(p1) + if got := rowText(f, 1); got != want { + t.Fatalf("step %d row1 = %q, want %q", i, got, want) + } + } + + tr.Advance(100 * time.Millisecond) // clock now past the deadline + tr.Tick() + f, _ = tr.LastFrame(p1) + if got := rowText(f, 0); got != "BOOM" { + t.Fatalf("final row0 = %q, want BOOM", got) + } + if !tr.Ended { + t.Fatal("room did not end after the deadline passed") + } +} From 54e7bd290fa1e9457966a58cd8877922357485f6 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Tue, 16 Jun 2026 09:12:53 +1000 Subject: [PATCH 09/10] changeset: reference host + shellcade-kit CLI (minor) Co-Authored-By: Claude Opus 4.8 --- .changeset/reference-host-and-cli.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/reference-host-and-cli.md diff --git a/.changeset/reference-host-and-cli.md b/.changeset/reference-host-and-cli.md new file mode 100644 index 0000000..c3f49c0 --- /dev/null +++ b/.changeset/reference-host-and-cli.md @@ -0,0 +1,16 @@ +--- +"kit": minor +--- + +feat: the embeddable reference host and the `shellcade-kit` CLI now ship in the module + +The host that runs a wasm game against the ABI is now public under `host/`: +`host/gameabi` (the wasm host), `host/sdk` (the room engine + service +interfaces), `host/render` + `host/canvas` (the 80×24 framebuffer and ANSI +render), `host/blobstore` (hibernation snapshots), `host/memsvc` (in-memory +service implementations), and `host/gameabi/conformance` (the game conformance +harness). `cmd/shellcade-kit` (`new` / `check` / `play` / `smoke`) is built from +that reference host, so the CLI and the conformance gate run the exact host a +game runs on — no separate host binary required. + +Additive only: the guest SDK and the ABI are unchanged. From c5d46d89554ed82bf6faa677b4868c2588cc5e55 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Tue, 16 Jun 2026 10:28:10 +1000 Subject: [PATCH 10/10] ci: commit host test fixtures, bump go to 1.25.11, drop obsolete kit-pin job - The *.wasm conformance/host fixtures were gitignored, so CI lacked them and the gameabi/conformance tests failed to LoadGame. Commit them via a .gitignore exception (the private repo commits these too). - go 1.25.0 -> 1.25.11 patches the flagged stdlib vulns (net/textproto, crypto/x509, net) the host paths now reach (govulncheck). - Remove the kit-pin lockstep job: the shellcade-kit binary is now built from this module, so the self-referential "pinned external binary embeds matching kit" check is obsolete. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 61 ------------------ .gitignore | 3 + go.mod | 2 +- .../fixture-rs-kit/fixture-rs-kit.wasm | Bin 0 -> 117963 bytes .../testdata/fixture-rs/fixture-rs.wasm | Bin 0 -> 34785 bytes host/gameabi/testdata/fixture/fixture.wasm | Bin 0 -> 56525 bytes .../testdata/loadspike/loadspike-cons.wasm | Bin 0 -> 681210 bytes .../gameabi/testdata/loadspike/loadspike.wasm | Bin 0 -> 643454 bytes 8 files changed, 4 insertions(+), 62 deletions(-) create mode 100755 host/gameabi/testdata/fixture-rs-kit/fixture-rs-kit.wasm create mode 100755 host/gameabi/testdata/fixture-rs/fixture-rs.wasm create mode 100644 host/gameabi/testdata/fixture/fixture.wasm create mode 100644 host/gameabi/testdata/loadspike/loadspike-cons.wasm create mode 100644 host/gameabi/testdata/loadspike/loadspike.wasm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a14b6f9..af5806c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,67 +18,6 @@ env: SHELLCADE_KIT_SHA256: "9db05e285fb0d9ea41c1f2e772d1658814d9048bc9bee0e18e89d3b2a2be203d" jobs: - # Toolchain pin lockstep: the SHELLCADE_KIT_VERSION pin above is a manual - # mirror of the newest published shellcade-kit binary release, and it has - # drifted before (2.2.0 lingered after v2.3.0 shipped). Two mechanical - # checks keep it honest: - # 1. the binary fetched at tag vX must EMBED kit vX — parse the `kit` - # line of `shellcade-kit version` (the kit module version baked in via - # debug.ReadBuildInfo, NOT the binary's own version line); - # 2. the pin must equal the newest release that actually carries - # shellcade-kit binaries (binaries attach to existing kit releases, so - # bare module tags without assets don't count). - # Runs on the nightly schedule too: staleness appears without a push. - kit-pin: - runs-on: ubuntu-latest - steps: - - name: fetch pinned shellcade-kit (sha256-verified) - run: | - set -euo pipefail - ver="${SHELLCADE_KIT_VERSION}" - asset="shellcade-kit_${ver}_linux_amd64.tar.gz" - base="https://github.com/shellcade/kit/releases/download/v${ver}" - curl -fsSL -o "${asset}" "${base}/${asset}" - echo "${SHELLCADE_KIT_SHA256} ${asset}" | sha256sum -c - - tar -xzf "${asset}" shellcade-kit - sudo install shellcade-kit /usr/local/bin/shellcade-kit - - name: pinned binary embeds the pinned kit version (lockstep) - run: | - set -euo pipefail - shellcade-kit version - embedded="$(shellcade-kit version | awk '$1 == "kit" { print $2 }')" - if [ "${embedded}" != "v${SHELLCADE_KIT_VERSION}" ]; then - echo "::error::lockstep violation: the v${SHELLCADE_KIT_VERSION} shellcade-kit binary embeds kit ${embedded}." - echo "A binary released at tag vX must be built against kit vX (see CLAUDE.md 'Lockstep')." - echo "Bump SHELLCADE_KIT_VERSION and SHELLCADE_KIT_SHA256 in ci.yml to a release whose binary matches its tag." - exit 1 - fi - - name: pin is the newest published binary release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - # Newest release carrying shellcade-kit binaries — NOT releases/latest - # blindly, and not bare module tags: the private repo attaches the - # binaries to this repo's existing releases after a kit tag, so only - # releases with a shellcade-kit linux/amd64 asset are candidates. - newest="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=30" --jq \ - '[.[] | select(.draft or .prerelease | not) - | select(any(.assets[].name; startswith("shellcade-kit_") and endswith("_linux_amd64.tar.gz"))) - ][0].tag_name')" - echo "pin: v${SHELLCADE_KIT_VERSION} newest binary release: ${newest}" - if [ -z "${newest}" ] || [ "${newest}" = "null" ]; then - echo "::error::could not determine the newest shellcade-kit binary release" - exit 1 - fi - if [ "${newest}" != "v${SHELLCADE_KIT_VERSION}" ] && \ - [ "$(printf '%s\n' "v${SHELLCADE_KIT_VERSION}" "${newest}" | sort -V | tail -n1)" = "${newest}" ]; then - echo "::error::SHELLCADE_KIT_VERSION (${SHELLCADE_KIT_VERSION}) is stale: the newest published shellcade-kit binary release is ${newest}." - echo "Bump SHELLCADE_KIT_VERSION to ${newest#v} and SHELLCADE_KIT_SHA256 to the" - echo "linux/amd64 entry of that release's checksums.txt in .github/workflows/ci.yml." - exit 1 - fi - test: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 78998a2..2230c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ go.work* node_modules/ dist/ target/ + +# Conformance/host test fixtures are committed (small, deterministic ABI inputs). +!host/gameabi/testdata/**/*.wasm diff --git a/go.mod b/go.mod index 10a8d53..d8b6749 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/shellcade/kit/v2 -go 1.25.0 +go 1.25.11 require ( github.com/charmbracelet/x/term v0.2.2 diff --git a/host/gameabi/testdata/fixture-rs-kit/fixture-rs-kit.wasm b/host/gameabi/testdata/fixture-rs-kit/fixture-rs-kit.wasm new file mode 100755 index 0000000000000000000000000000000000000000..9ecde5db3d54d35f736058636d5d9cca1c08049d GIT binary patch literal 117963 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 literal 0 HcmV?d00001 diff --git a/host/gameabi/testdata/fixture-rs/fixture-rs.wasm b/host/gameabi/testdata/fixture-rs/fixture-rs.wasm new file mode 100755 index 0000000000000000000000000000000000000000..c481bb86d69f0fdc7177c6c86c241242c5595892 GIT binary patch literal 34785 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 literal 0 HcmV?d00001 diff --git a/host/gameabi/testdata/fixture/fixture.wasm b/host/gameabi/testdata/fixture/fixture.wasm new file mode 100644 index 0000000000000000000000000000000000000000..c440906f543a4b8f68aebf1f62ea32f10d7f5b8d GIT binary patch literal 56525 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 literal 0 HcmV?d00001 diff --git a/host/gameabi/testdata/loadspike/loadspike-cons.wasm b/host/gameabi/testdata/loadspike/loadspike-cons.wasm new file mode 100644 index 0000000000000000000000000000000000000000..fcef225c5de5ea7daf50374fa5449ea185b1fdae GIT binary patch literal 681210 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

CS~ny%4pGMUJjWuOKRVGWqzSq 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

jEBFBU01d$Z|(pIa*8m6?TXiEiQ!06&xPF6 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


w^(f6A!!KtGbc(F)mykseMxB;L9bn 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 cI13xi&ivz9by*0FI-~a#s literal 0 HcmV?d00001 diff --git a/host/gameabi/testdata/loadspike/loadspike.wasm b/host/gameabi/testdata/loadspike/loadspike.wasm new file mode 100644 index 0000000000000000000000000000000000000000..0e62dfcac41afa286d9ccb02c44c4af91c617d9e GIT binary patch literal 643454 zcmeFa3z%KkRp)sg_f>UoRb4$@w&eKSYb7azod__paSW-gi6qOiG0ii~q{lXi2remh zWGPN)l1L#h2f}oqneH^<>oy-SzzpfmOs63*VK5LH({U5i34k`1zqz)%{8RD<58wRe?2i1+`J4GC^Ut{X#y93~yrX#2oANjD+MD%B@n&A;y|YyF zW_`uKszD#}Pw~-mY4_{zJbl;gr|-V=wWq)3o-?<<_TJ<7-F5u+U)1~NSKa>ld+$1P zJj*tDWAT+I?|J2k+t1wfn&Y>>`uLe_akKaCy!X|oZ$Ew4KRSNe-fM62UeDfBP3@)Q zZ#Z+;>DS!wE%%&0^Wx)o-`BqLByDALv-jstpSkDWDs@q?8eKwqZK8Vkc-~H+{-;!;ceZ1KGoF1*~?fgBj zJEI0pGsNDoImFkUK7Ma`@}5_3aJzN-_}#C{wr>Kn`FroY`&IY6#^G9i!`GLsJkN`w zXm#4{qEi-S(JjlO=yuB@%Zj4R^G-j{y6yI0VX#o&h+pR6x(D1S-k zmHzuc_uk`vbzSe9Uvt;pcfICyugR{TOPAlAeHpj!e1qM-f!luh&Dl%(xAXFuyY4&* z24y!coc@;MCr`fe&Q~43{myT?D|>nCHOJ4~nSEpX^qD*FJ(KXc7>>svXf9Hwg+5gxEWltZ^Zr^q1>2JOJm3Mvfx881_-Tu0}Uw;=+zGG+O z{yncvx9^;N{odpD`ZvwI9&f*LdtD32tQH`mSIq(UkoVhPe%n`m!50ql_WU3JWb5mT ztl!G6%=%Ym;YUBwx+cq7tHW37GGsSoS-axdfudDq!#n!@ah{*OG8^Spwo(lCWh!{G zQe@lmYj}^R{f^lEBidougqI#tD+r#{1Yv1_V-Y4eyA!B-P>9X zA7qr(@UqK7rZ*2S6qoVIAs+DZ!MB{_e-CEY@lm`>mE~%&TIGipvhV?`Z`=y!TJ*K# zE?2j;R;#Qk*HrUxm)$x6xJn+ajmkmZ@9X0{e1eAU6;%@ea2B#rtIumy>%_RI@?)cR zl~+a8-ZL%^job9e>aAM(bge3$nTJkdA4aXpUhroj<7wX?QbB8t$|+0QSB-Q>-RLj% zM-JYqFz7m{?QI8jQK?&enKS+MRH(N8C-ZiiDou4*T^ko*LlLfDJD6Qp>S}q7!S~k2 zt*f$lr_!C_+PGccS<#*4wQ(oi(Z~Dw`2KUPt4gkWTtnq{Z9TCUhd`nj?yfo@(ej#Z zWmyHDQhEFVPSSS#0kZlPQ>1w~T*Fe8lwtKmoS1@35cmf>yC#FX(3KB<2jh4!)07A^ zuO4+&+&Jp07>t%w>>UBfm11SoQ*r%hsG>KTQ?aa>St<67wyC&wG_RsH0##OuolGu8 zH5#bcKUz?cjTTi5M{UNmJ?uE9A+@W;wee!La5@ZMKVGN?YvV!HJ{<3RrS}#{c7HR z&R5&k#@nivy*_!u>-J=~9;RMePiTdQL;s8?-Pc12gKEC&Ry*y{q>~Jwu&#Ma8gS~m zb(kJlI|d4#P*qVaRK03f903)S)>*4y!AGA)W`h8lR55u%6=l_`cB`^+%ORd%XK+aa zwYlI4Uw0j1JmKpW^Hr>kwmU1R77i5qp~^*3T3)pe6yQZQI8am+tpmkQiunV@wG@3Z z;k?>*px8%I9w?S6h6jr4Ddr9opjFj7P^?fu&wDAl2Z|dhmJSr4PSrV3Tn&pUK$rqt zH6kdg9cuuf1flfV8s(aie1KUM*2ApYQDe3tV90r8fB)Bz$roVN5(}_CiHK2lAy!>D zhlQMmC&rm1OBk%d;l-*8v1%x+HQbr_bRPM%$ssiY?ekH-1mC+LTZ5m8nMkTlu{AV_ zf0frV2sWCX2<0heEE^Z2yjV5Rm8YD2wARM-=!B>`wBe~djlZ?EUPUKL1OaH;^XR95nAQ1sK!+#e#F^r{}}3C+P9=wAOP za|z^W8F*f4dB|Trn+855FyRJ$gEf#GE{=)`w5x(CG|334pMADGwIx7rCO-i5r#Hc$ zK`p=ydLt&vM7@gMsJIXswfe=X$@{V$F1NWkR~%EvPc39ZrB*$>OGjlIUU6AfQne4S zKsaYhN#K9t(UytFaMlJ~nq(C5p^mH*n57F$c)3WGDTON2YXcD$^Qc7@ZhuFn)nK@+&ylGPv zliHVuC$gjCu4t$Pk>QQHbN9Hk*V^f|BA};;w0^%UMW_V%+Y32_5%C|rh@L|WG})qz zZ9*bp|Gl&mc1qEa{&%SA*_@Zdy`xr|@z!NkYt)W2ULNxPNZ%@5t=eU3l7Ed_Xza(n z&s|Nl?`w<+*Jxzjg~F&7Ic&A4vkp{KDDzPs-gB-6TZOtW1Y!ohA#^3KF9cm_paSh@ z4p;#=HpiHnfOWpyLrI^2rf|@d0&N0~R9)%-vIf@(4`^B(7-SHgbV3E|c8aTdp;VQ_ z%R}kaB}Sh~Uxi&m6OxM?kso|o)gmNStyXi7 zk3zTD3b)F5cxkqpg|iqSy8Tkb*_p9MqgwS+b{njp>4;#;h0@_e-`NTa!>bZfPe6qX zbaTldlE!JOJuK*(5E&Ze1w=t+q9FXUceld!A*#a$^RZbY{r_@g$26DQ>=%_b4ZVl6 z3H$3ZZrC4%*dK-1AH@)XOtBNTK(P!Lq}YpEO;MpwQ|v>7rdUCPrnnlNnPNYBIK{Q7 z&JDQ+BXSFwubP4TkP4iz_zcBff_9uPq=` zvBCV%xLiQvN79drP@U4x9O50W$y9hzUWDu>Uh5qi=L>yw*B)>Q#hEoJ8$E95y9GU5 z2vNO5-2_GjI>Q)c2#Yaa<0aD_?7eCsyy(cnHob8I5cQ;7^ak5dDL_B!7%cU}VAK!q zzP~l<(aj=Uaf+_(I5934d)csTcb2-nrGB-fKEmc*GKX2ho9G$EBQ;yWt0Ljiy+boKT-d3&Hcp}RIRR$C1s zukOREyCL%G?Uz+u0OhP0{<0Pxe`jkY%MN52L#LW}T^s@=Jt`CH5W4~*@vFMgC#+E* zHPxtdL2tBhGTFAS(ciBd>IJHqG>SddY;-tj)FxaGnQ-F~IXj|^&KGjp@2rg$)IA1m zEVEj$K}WV}?RR4LJD^JV(|5LB45pMXVc4DUM~xngC7-y5>4(h`Jqy)>0G$Kq|GMFr zTgLf)%&iT^ydadtLSHb(&*j2exC${jV0@L}*Iv6a-!qCMXg6wWdu_vM2r`0Czf-V~ zsO^P6YxI9%i5u<7w80}GpZo;CSumbx?jNCPt1o)MBtpYc>tKlB*KsLkSljW|7ZTQj zpL=n@L|FWl!2&WAaX~sfUB+wU)pQ$!=%#9>aPJG$dGKsNhx18?2WiTe5QT}LT*MAS z&uWFrAgP|$I%srmpLeccqMo+S4b(XdOzT`#cW$oHxw%x$ROdeV)46bi?ujK7lkVXw zVt~x&5;~lBZ{QLh*v-f86~jGY-s|l*x_2#(3^@J`-Ry%|rU(H*TpIjp8A$4C z`&MmXC{62f%`FHaQdj!nla2{=i^|qy)w}BUEY&YC3Yd(vMdyQ>Y_L?5J+PWvR`Z|% z2qPwHEBIRwhC1TnFF=Hc-%@L-apPpwDP9!ogM4VK-G_%sVRiekW_I}ULiaYkKpj?U-0Cy!i=|Vc?6kzd zlGi@pK#G@*)z|R1!dF3U&^2UT1!r55YNkChRYR-BkzkmrX>_s|{`lvA?vrgX@)mQT z@l&5H4=RcsU+4(Af?);iL`+7=497dvi8*lboaEeKu8*q&!Gn)sv_14jo$y}nx3Sb6 z`!%IZCKBwjBa-kY$tSpNLL2o%BAkxQNhng~_G*r{gaCM|aQ&2La6Q3(n03$!3Eeux zc*WYQ;$9!Y&b85ev0Al?vjKj5HS9n8%7yH*fO7z{oczXdgEX7`M%88XlizI&wGzhY zclPw9j1DyjgMP-4bFf6dYO#x2dFa>5gPEQ7fhnK!hsMQ1kN5QMLRC6E3XQf`;n!_B z+llH1(uqOf206vpGlR!it_~3kgdqM|l)S*LsH}j>EpJF9F<14l#P9#E)~GaQk+|$O zA;s{Du+0%7sTZal8xIk!7f$Qj8wj-B-(M0JF}sj%m!UFoa%em+(9_TwUP%OULY@+` zd?bS|Ba7b=5ql2A-jOPFoU*RM))@>;?Q5MbziKo$KL*L%DCy(E$}(sf*Iw{^_9+HQY;&=jEXLmMJ~+X-jut@-$u z!b6QQUeXxjC8?@NH;n8iW`^PxJg*`;t(e4zl9H7?+AVWniQUY{p)XsV^Tu?Sz0Q7` z2$#;(wPWM75qzTxMToF#09lbS&3SW$d*0g@N`Q3@X@#~I; zu`8EaSBBP=OB-F;7Dv(HTRhTLCviT;h)+cBXq?@#4}Chdfc>;*REh&kbAg3Wo`g}H zy~*qV@1p8}S6EI<4){hXVSyV8$GYB;f^)APx9DF91Y48-J+INf=QaA5 zSU={pAQ+317TPH^LK5uVMDiJP^>T_Q(AJ7`0m1>WfJJNSeF96x>_7v&e> z{cwk@TH*@oJD1-(Zr_moL;KyiFFcsz<9!80Um#t1rF$YWF0X#7iN$VKbXwiA4Us00 z+&aq+Aw)n?L^LlZXf$eoV&tH>T!a`MoQ%O}&=gP`O+6t9qX{#X1OsJ$UKEKgp2$?( zff?F*cgBtGjJ-R1pL=&cV%@1V`HP3nm5uIP>D}4$+`IFzb!YCmLuX&3JNvvlmp%9H zJZ9bLJ@@WBztNrNdv`8H*)7e84qXmsZV-ks&=-km3{JDukaofkH`^Fr^= zCC|M(pR(?>Be$uJMrPBHx$vN@=FU!c>!eHrVzJBi-}uDiLr>JYe?d&8hyH|(Wk}8W#;gLRrb#!J(_EB zYE&63e%QcM-U(=Y9h%MG(1aIpQ|13^A%T!Nwmi06#Z}UVz zplHAGFca8Tyhy}3`rGk9^Mb1!amE-`}%;h`W+6CQN9 zRxJvGMd(c?cd;?K_}gk4Y)r1e)?f({Y%`O*ztOY(jh-czP>LnkKVk{K<{d!>5w2D9 z-ZPjIXcuAD!jlwL+6Me4%*qU##R<$dR&T$k(X%gV^lT%T0gWa%Fqo}lRf~X$g^LT> z3m4RRzqrw{FK%?K!KyAo=S@aVvSengtMWp4|0Rvye2MpFhlJx6@!mW!ZX&__u(X!2 z2}~y!K&uAl!N+?HuO8+OVQ~0g#cyz>pBOJJOsE;+6`$G2j+Lv&*dul1l6OPMAu6Ow{gq&3PrBJ50w{bx4{7PPJPyCSAtEJ&MT<@(+`yXj#$YR1aBw*}vtbj-m za~yTTyW|!(zOLYn+y)UBSH7$P$uINj&Ci$OLF8OhI7o7?Dd4gUFA=)|fS62mt2^+O z1L^HrECv&O=H%$Z>g$K^NFMu+=FuJl!W*o0U(~q4Tb~ovB=k`0y-DjGZ2a)R-EVI^U zm$0s1)B2!ClCom$m`$*$`ppBp<_~76<8e;$CEw`I^6O_8v1Hq;b7>LF0=*iGSU=|# z`-J~oU2$W^*Dqs98*D6NxmLrx|Gd@JnAo3>^_7ho(aLep@ICjT{et&^5fAr;Ux;6% zJp^)-6Gow;2M)&Z;^$M85{@rLDJ>B=LiCe@o4Ov{L}b!N!A)HcZX#SxpLH#^kivqS z2mn;va91%-M6M{tX_6#Z4)Y=wXWzzQ{PD|C2bP=U;3b(U0bzv+&k~Ax_sMZ0a-F5%Ipl zx4lfqGku29tV#KxQmud#$C`dCQHFwbq5y80{|W(&_sSPgZHEh(xp!2iu}($0T5w*B z(95PH^hCl@aTaQRhGDN4i77w!Tq4fC>7s(#{xgwtms~6|{$HKurkCl7UY^9QwY7T5 zT7`!#Q)n5LLg8x^lCBhluU$Bn*%-8k#1pSe*b_VySL&%zJN)k36=hkwQeD5fxPPnm z;-0-(UX-nNr`wyG?++FhmxkN6@7TF(_a)1hUM5jchEJ)X!Ja`J9F7Y34A*`zy?DpE;VLx{Hlkcz}JNuzS1^?K7?C>A|v;ElaKi+3Q zw)Mjg*u}6Pe$+0AQv26-vDjxBzYITNKNk7{{Wkop{TO%+zidDH{eT%8K4w4Wy+4XM zD_Hus3jUM*==o6p(0+8ir+;ieI{xF&?1u$>FmC(7#w~&8Z|z6vjsBheU@<@eyP!b+ zV3dd+gU$^?W@bf32RyNOEQ9^qh>-AjVPO$bv{;0wvywen{w`G^Z?0XaLf(831x*qD z=s*6{4|WtC;Y7~IC_fuV;vz?+%xM$FXBudW^cf;#C5w{4Shfh;pC~roGBTXdj>3DP zoo2{CY|leWE)bU+d8JcJOOco~wBQ~pg2oBLlsRUWOgDH;r*v(CZffdc5j)nM3#n>yGM#q} zP}ah?zbnOd!m96&*AwNBStd-}1K=n0c5#@ZeI;_uDX_hiwl}0$Pj8Ry>`m~-uNX)( z+VrN=>$&>_0J{gE))u;a_bXX_)3F zy6TWB{W9UlmZw}1(uKmOBK@kTZd>@yjww!>tXTyd4TuRNhIZh=SPG9K4#agM0(A^| zq#c19KEaEZRc)`C$rY~+&%9efvvMEi;pum$iN$lqE=($e&G}Z31uS~4R)6G?))65Q zQ6cNPulQ4;ID_443W_|SM)&PLkoXG&+-8z9Pu*z5CejBhzS_y8Q4y<@15LT zPyhue_O|u6{^D2aZ_`^3>y{Ldxj=|-;y)hO?Yt|-b*BhF0r+a?U3g@*k7b5;c`� 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

t^F9jH<`oL*%SpQI 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

@Lb^)zQ3r3lTGuR4y0v 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 literal 0 HcmV?d00001