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. 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/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 e27bb93..d8b6749 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,24 @@ module github.com/shellcade/kit/v2 -go 1.25.0 +go 1.25.11 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 + 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 ( + 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 - gopkg.in/yaml.v3 v3.0.1 // indirect + google.golang.org/protobuf v1.34.2 // indirect ) diff --git a/go.sum b/go.sum index da63828..65a17f4 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,42 @@ +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= +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= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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") + } +} 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/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/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") + } +} 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/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/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/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") + } +} 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/fixture-rs-kit.wasm b/host/gameabi/testdata/fixture-rs-kit/fixture-rs-kit.wasm new file mode 100755 index 0000000..9ecde5d Binary files /dev/null and b/host/gameabi/testdata/fixture-rs-kit/fixture-rs-kit.wasm differ 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/fixture-rs.wasm b/host/gameabi/testdata/fixture-rs/fixture-rs.wasm new file mode 100755 index 0000000..c481bb8 Binary files /dev/null and b/host/gameabi/testdata/fixture-rs/fixture-rs.wasm differ 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/fixture.wasm b/host/gameabi/testdata/fixture/fixture.wasm new file mode 100644 index 0000000..c440906 Binary files /dev/null and b/host/gameabi/testdata/fixture/fixture.wasm differ 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/loadspike-cons.wasm b/host/gameabi/testdata/loadspike/loadspike-cons.wasm new file mode 100644 index 0000000..fcef225 Binary files /dev/null and b/host/gameabi/testdata/loadspike/loadspike-cons.wasm differ diff --git a/host/gameabi/testdata/loadspike/loadspike.wasm b/host/gameabi/testdata/loadspike/loadspike.wasm new file mode 100644 index 0000000..0e62dfc Binary files /dev/null and b/host/gameabi/testdata/loadspike/loadspike.wasm differ 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 } 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 +} 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/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 +} 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) +}