From 524c18f525035bc4667b00c8ebabb7265ac4a8b7 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 2 Jul 2026 23:11:35 +1000 Subject: [PATCH 1/2] feat: casino game kind, credits host functions, CtxFeatCredits (wire revision 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the platform-credits wagering surface for casino games: - GameMeta.Kind (game | casino) + MaxPayoutMultiplier as a new presence-guarded trailing meta section — absent reads as game, so every existing artifact keeps its meaning - credits_balance / credits_wager / credits_settle host functions: host-owned account-wide wallet, atomic escrow wagers, gross (stake-inclusive) settlement clamped to stake x the declared multiplier, typed refusals incl. economy-disabled (degrade, never trap); game-kind callers rejected host-side before any service call - CtxFeatCredits declaration bit; wire revision 6 -> 7 with ledger entry - Go SDK Services.Credits + kittest wallet double + native dev-runner and memsvc backends; Rust SDK mirrors (Meta fields, Room methods, CreditsError, CTX_FEAT_CREDITS) with regenerated cross-language goldens - ABI.md section 3/4.2 + GUIDE.md casino authoring section Co-Authored-By: Claude Fable 5 --- .changeset/credits-economy-abi.md | 25 ++++++ ABI.md | 46 +++++++++++- GUIDE.md | 66 ++++++++++++++++ crossverify/Cargo.lock | 2 +- host/gameabi/codec.go | 8 ++ host/gameabi/codec_test.go | 5 +- host/gameabi/controls_test.go | 2 +- host/gameabi/gamekind_test.go | 121 ++++++++++++++++++++++++++++++ host/gameabi/host.go | 69 +++++++++++++++++ host/memsvc/memsvc.go | 85 ++++++++++++++++++++- host/sdk/errors.go | 14 ++++ host/sdk/services.go | 23 +++++- host/sdk/types.go | 33 ++++++++ internal/game/api.go | 53 +++++++++++++ internal/game/codec.go | 8 ++ internal/game/devrun.go | 52 ++++++++++++- internal/game/host.go | 13 ++++ internal/game/room.go | 49 +++++++++++- internal/game/types.go | 35 +++++++++ kit.go | 16 ++++ kittest/kittest.go | 75 +++++++++++++++++- rust/Cargo.lock | 2 +- rust/src/host.rs | 78 +++++++++++++++++++ rust/src/lib.rs | 17 +++-- rust/src/room.rs | 49 +++++++++++- rust/src/types.rs | 45 ++++++++++- rust/src/wire.rs | 50 +++++++++--- rust/tests/golden/scalars.txt | 7 +- wire/controls_test.go | 5 +- wire/gamekind_test.go | 47 ++++++++++++ wire/largeroom_test.go | 19 ++--- wire/scalar_golden_test.go | 27 +++++-- wire/wire.go | 83 +++++++++++++++++++- 33 files changed, 1173 insertions(+), 56 deletions(-) create mode 100644 .changeset/credits-economy-abi.md create mode 100644 host/gameabi/gamekind_test.go create mode 100644 wire/gamekind_test.go diff --git a/.changeset/credits-economy-abi.md b/.changeset/credits-economy-abi.md new file mode 100644 index 0000000..882d84d --- /dev/null +++ b/.changeset/credits-economy-abi.md @@ -0,0 +1,25 @@ +--- +"github.com/shellcade/kit/v2": minor +--- + +Casino games: the game-kind meta section, credits host functions, and CtxFeatCredits (wire revision 7) + +- `GameMeta.Kind` (`GameKindGame` | `GameKindCasino`) classifies a game for + the platform economy, with `GameMeta.MaxPayoutMultiplier` declaring a + casino game's per-stake payout ceiling — a new presence-guarded trailing + meta section (absent = game, so every existing artifact keeps its meaning). +- Three new host functions — `credits_balance`, `credits_wager`, + `credits_settle` — give casino-kind guests an account-wide, host-owned + wallet: atomic escrow wagers, gross (stake-inclusive) settlement clamped to + stake × the declared multiplier, typed refusals + (`ErrInsufficientCredits`, `ErrEconomyDisabled`, `ErrCreditsDenied`, + `ErrCreditsUnavailable`). Game-kind guests are rejected host-side. +- `CtxFeatCredits` (bit 2) declares that an artifact wagers + (declaration-only; no encoding change). +- Go guest SDK: `Services.Credits`; Rust guest SDK: `Room::credits_balance` / + `credits_wager` / `credits_settle`, `Meta::kind` + + `Meta::max_payout_multiplier`, `CTX_FEAT_CREDITS`, `CreditsError`. +- `kittest.Room` gains an in-memory wallet double (`Credits`, + `CreditsStakes`, `CreditsDisabled`); the native dev runner and `memsvc` + (behind `shellcade-kit check`/`play`) implement the same semantics. +- Wire revision 6 → 7; ABI.md §3/§4.2 and GUIDE.md document the contract. diff --git a/ABI.md b/ABI.md index 37c4977..d87c1f6 100644 --- a/ABI.md +++ b/ABI.md @@ -91,6 +91,9 @@ means not-found. | `kv_set` | (i64 playerIdx, ptr key, ptr val, ptr rule) | rule: `keep-winner` `keep-loser` `sum` `max`; for `sum`/`max` the value MUST be a base-10 ASCII int64 (unparsable values degrade to keep-winner at merge time) | | `kv_delete` | (i64 playerIdx, ptr key) | | | `config_get` | (ptr key) → ptr | read-only per-game config | +| `credits_balance` | (i64 playerIdx) → i64 | the player's account-wide credits balance (≥ 0), or a negative status (below); casino-kind guests only | +| `credits_wager` | (i64 playerIdx, i64 amount) → i64 | atomically escrow `amount` from the balance into the seat's open stake; 0 ok or a negative status | +| `credits_settle` | (i64 playerIdx, i64 payout) → i64 | close the seat's open stake with the GROSS (stake-inclusive) payout, clamped to stake × the declared `maxPayoutMultiplier`; 0 ok or a negative status | `send` and `identical` return an `i64` whose **low 32 bits carry the epoch** the guest MUST stamp its baseline with for that slot; the **upper 32 bits are @@ -111,6 +114,24 @@ Scoping is host-side: the guest names only a roster index and a key — the account and the game's namespace are derived by the host. A guest cannot address another game's data or a non-member account. +**Credits (casino-kind games, revision 7).** The three `credits_*` functions +exist for guests whose meta declares the casino kind (§4.2); the host rejects +calls from game-kind guests. Negative returns are shared status codes: +`-1` insufficient (the wager exceeded the balance or a platform bet limit — +the bet did not happen) · `-2` disabled (the host's economy is switched off: +render an out-of-service state, never trap) · `-3` denied (game-kind guest, +unknown seat, or no open stake to settle) · `-4` unavailable (transient +store failure). Wager semantics: repeated wagers before settlement +accumulate into ONE open stake per seat (double-down, side bets), bounded by +platform bet limits. Settle semantics: the payout is GROSS — a loss settles +`0`, a push settles the stake, a win settles stake + winnings; a win +sequence spanning several game events (free spins, a double-up ladder) +keeps the triggering stake open and settles once with the total. The host +clamps every settlement to stake × the game's declared payout multiplier +(itself clamped by a platform ceiling), refunds open stakes on paths where +no game code can run (crash, teardown), and voids in-flight stakes across a +restore — a game never persists a balance of its own. + ## 4. Payload encodings ### 4.1 CallContext (Ctx) @@ -184,7 +205,7 @@ u16 configSpecCount (trailing; see per spec: str key · str title · str description · u8 type (0 text · 1 number · 2 bool · 3 json) · str default ("" = not declared) · str schema ("" = none; json only) -u32 ctxFeatures trailing large-room section (see below); bit 0 = CtxFeatRosterEpoch · bit 1 = CtxFeatCharacter +u32 ctxFeatures trailing large-room section (see below); bit 0 = CtxFeatRosterEpoch · bit 1 = CtxFeatCharacter · bit 2 = CtxFeatCredits (declaration-only) u16 heartbeatMS 0 = no declaration u8 lifecycle trailing (see below); 0 resumable · 1 ephemeral · 2 resident u16 wireRevision trailing (see below); 0 = unknown (the meta predates the field) @@ -192,6 +213,8 @@ u16 controlCount trailing declared-controls section (see below) per control: u8 kind (0 rune · 1 key) · if rune: u32 rune · if key: u8 key (the input key codes, §2) · str label +u8 gameKind trailing game-kind section (see below); 0 game · 1 casino +u32 maxPayoutMultiplier casino payout ceiling (0 for game-kind) ``` `slug` must be non-empty; the host refuses artifacts whose slug or version it @@ -263,7 +286,8 @@ never set by the author, and declares the newest wire feature the artifact may assume the host understands. The ledger so far: `1` config-spec section · `2` large-room section + roster-epoch sentinels · `3` lifecycle byte · `4` this field itself · `5` per-member ctx character section behind -`CtxFeatCharacter` · `6` declared-controls section. `0` means unknown: the meta predates the field +`CtxFeatCharacter` · `6` declared-controls section · `7` game-kind section + +the `credits_*` host functions + `CtxFeatCredits`. `0` means unknown: the meta predates the field (revisions 1–3 existed before it, so artifacts of those eras cannot declare them — only `0` or values ≥ `4` are ever observed). A hand-rolled guest (§4.7) SHOULD stamp the revision whose features it actually uses; omitting @@ -303,6 +327,24 @@ shared rule set): a printable rune (≥ U+0020) or an assigned key code (1–9); a non-empty label of at most 16 runes; no duplicate inputs; at most 32 declarations. SDKs enforce these at `meta()` encode time. +**Game-kind section (minor addition, revision 7).** A trailing section after +the declared-controls section, presence-guarded under the same rules (absent += kind `0` with no multiplier — the reading for every pre-revision-7 +artifact): `u8 gameKind` (`0` game · `1` casino) and `u32 +maxPayoutMultiplier`. The kind classifies the game for the platform economy: +**game** titles earn platform credits from the results they post; **casino** +titles wager credits through the `credits_*` host functions (§3) and never +earn. `maxPayoutMultiplier` is a casino game's declared per-stake payout +ceiling — the host clamps every settlement to the seat's open stake times +this multiplier (after applying its own platform ceiling), so it MUST cover +the game's largest configurable outcome (top prize × any feature/gamble +compounding): a clamped honest jackpot is an authoring bug. Validation +(`wire.ValidateGameKind`, shared): a known kind; casino requires a +multiplier ≥ 1; game requires 0. SDKs enforce this at `meta()` encode time; +hosts refuse violating artifacts at load. Casino games SHOULD also declare +`CtxFeatCredits` (bit 2) — declaration-only (no encoding change), it lets +hosts and tooling see that the artifact wagers. + ### 4.3 Frame (the delta container and its cell) A frame is delivered as a **frame-delta container** (§4.5), a variable-length diff --git a/GUIDE.md b/GUIDE.md index 82e6346..f67b509 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -470,6 +470,72 @@ non-empty, and never under the reserved `host.` prefix, and `Schema` (json keys only) must parse as JSON. Admins can still set undeclared keys — specs improve the editor, they don't gate the store. +## Casino games: wagering platform credits + +Every game is one of two kinds for the platform economy. **Game** (the +default): players earn platform credits from the results you already post — +you declare nothing and change nothing. **Casino**: players gamble their +account-wide credits through your game. Declare it in your meta: + +```go +func (g Game) Meta() kit.GameMeta { + return kit.GameMeta{ + Slug: "blackjack", Name: "Blackjack", + Kind: kit.GameKindCasino, + MaxPayoutMultiplier: 10, // MUST cover your largest possible win + CtxFeatures: kit.CtxFeatCredits, + // ... + } +} +``` + +The host owns every balance — your game never stores one. Take bets and pay +out through the credits service: + +```go +credits := r.Services().Credits + +// Show the stack. +bal, err := credits.Balance(p) + +// Take the bet BEFORE the deal: the host atomically escrows it from the +// player's account-wide balance. A refused wager means the bet did not +// happen — render the error and stay in the betting phase. +if err := credits.Wager(p, 100); err != nil { + switch { + case errors.Is(err, kit.ErrInsufficientCredits): + // tell the player to earn credits in Games, or take the lobby buy-back + case errors.Is(err, kit.ErrEconomyDisabled): + // the host's economy is off: render an out-of-service table + } + return +} + +// Settle ONCE per stake with the GROSS (stake-inclusive) return: +credits.Settle(p, 0) // loss +credits.Settle(p, 100) // push: the stake back +credits.Settle(p, 250) // win: stake + winnings +``` + +Rules the host enforces (so you don't have to): repeated wagers before a +settle accumulate into one open stake (double-down, side bets); every payout +is clamped to stake × your declared `MaxPayoutMultiplier` — declare a +ceiling that covers your top prize with every feature and gamble compounding +applied, because a clamped honest jackpot is an authoring bug; a win +sequence (free spins, a double-up ladder) keeps the triggering stake open +and settles once with the total; open stakes refund if your room crashes, +and are void after a restore — treat an in-flight hand as void when your +room comes back. A player leaving mid-hand gets your normal `OnLeave`: you +may settle their open stake as a loss per your table rules (do — otherwise +leaving is a free cancel on every losing bet). + +Casino games never earn credits from posted results, and their leaderboard +is platform-computed (biggest single win) — post nothing for it. Unit-test +the flow with `kittest`: the Room double carries an in-memory wallet +(`Credits`, `CreditsStakes`, `CreditsDisabled` to script the economy-off +state). In the native dev runner (`go run .`) every player starts with 1000 +credits. + ## Multiplayer Rooms hold 1–N players: your `GameMeta` declares the range, and the platform diff --git a/crossverify/Cargo.lock b/crossverify/Cargo.lock index 8b08735..670e80c 100644 --- a/crossverify/Cargo.lock +++ b/crossverify/Cargo.lock @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "shellcade-kit" -version = "2.9.0" +version = "2.14.0" dependencies = [ "extism-pdk", ] diff --git a/host/gameabi/codec.go b/host/gameabi/codec.go index 1a0bc7c..9122fe6 100644 --- a/host/gameabi/codec.go +++ b/host/gameabi/codec.go @@ -133,6 +133,14 @@ func decodeMeta(b []byte) (sdk.GameMeta, error) { Label: cd.Label, }) } + // Game-kind trailer (minor addition, revision 7): validated like the + // other authored sections — kit SDKs fail at encode time, so a + // violation here is a malformed artifact, refused at load. + if err := wire.ValidateGameKind(wm.GameKind, wm.MaxPayoutMultiplier); err != nil { + return sdk.GameMeta{}, fmt.Errorf("gameabi: meta game kind: %w", err) + } + m.Kind = sdk.GameKind(wm.GameKind) + m.MaxPayoutMultiplier = wm.MaxPayoutMultiplier return m, nil } diff --git a/host/gameabi/codec_test.go b/host/gameabi/codec_test.go index 65820de..1ce48ed 100644 --- a/host/gameabi/codec_test.go +++ b/host/gameabi/codec_test.go @@ -115,9 +115,10 @@ func TestDecodeMetaWireRevision(t *testing.T) { // 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). + // controls u16 count (revision 6) and the game-kind u8+u32 (revision 7) + // that now trail the revision. b := wire.EncodeMeta(wire.Meta{Slug: "x", Name: "X", MinPlayers: 1, MaxPlayers: 1, WireRevision: wire.Revision}) - old, err := decodeMeta(b[:len(b)-4]) + old, err := decodeMeta(b[:len(b)-9]) if err != nil { t.Fatal(err) } diff --git a/host/gameabi/controls_test.go b/host/gameabi/controls_test.go index c874df8..360170f 100644 --- a/host/gameabi/controls_test.go +++ b/host/gameabi/controls_test.go @@ -33,7 +33,7 @@ func TestDecodeMetaControls(t *testing.T) { // 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 + pre = pre[:len(pre)-7] // strip the game-kind section + zero-count controls section m, err = decodeMeta(pre) if err != nil { t.Fatal(err) diff --git a/host/gameabi/gamekind_test.go b/host/gameabi/gamekind_test.go new file mode 100644 index 0000000..6e9072e --- /dev/null +++ b/host/gameabi/gamekind_test.go @@ -0,0 +1,121 @@ +package gameabi + +import ( + "context" + "strings" + "testing" + + "github.com/shellcade/kit/v2/host/sdk" + "github.com/shellcade/kit/v2/wire" +) + +// The game-kind trailer lands on sdk.GameMeta, defaulting to game for older +// payloads and refusing malformed declarations at load (the kit SDKs cannot +// produce them — they fail at encode time). +func TestDecodeMetaGameKind(t *testing.T) { + b := wire.EncodeMeta(wire.Meta{Slug: "casino", Name: "C", MinPlayers: 1, MaxPlayers: 4, + GameKind: wire.GameKindCasino, MaxPayoutMultiplier: 10000}) + m, err := decodeMeta(b) + if err != nil { + t.Fatal(err) + } + if m.Kind != sdk.GameKindCasino || m.MaxPayoutMultiplier != 10000 { + t.Fatalf("decoded kind/mult = %v/%d, want casino/10000", m.Kind, m.MaxPayoutMultiplier) + } + + // A pre-revision-7 payload reads as game-kind. + old, err := decodeMeta(b[:len(b)-5]) + if err != nil { + t.Fatal(err) + } + if old.Kind != sdk.GameKindGame || old.MaxPayoutMultiplier != 0 { + t.Fatalf("pre-kind payload = %v/%d, want game/0", old.Kind, old.MaxPayoutMultiplier) + } + + // A casino declaration without a payout ceiling is a malformed artifact. + bad := wire.EncodeMeta(wire.Meta{Slug: "bad", Name: "B", MinPlayers: 1, MaxPlayers: 4, + GameKind: wire.GameKindCasino}) + if _, err := decodeMeta(bad); err == nil || !strings.Contains(err.Error(), "game kind") { + t.Fatalf("casino-without-multiplier decode err = %v, want game-kind refusal", err) + } +} + +// fakeCredits is a canned sdk.CreditsService for the gating test. +type fakeCredits struct { + balance int64 + err error + calls int +} + +func (f *fakeCredits) Balance(context.Context, sdk.Player) (int64, error) { + f.calls++ + return f.balance, f.err +} +func (f *fakeCredits) Wager(context.Context, sdk.Player, int64) error { f.calls++; return f.err } +func (f *fakeCredits) Settle(context.Context, sdk.Player, int64) error { + f.calls++ + return f.err +} + +// creditsCall's gating and error mapping: game-kind guests, out-of-roster +// indices, and a host without an economy are refused BEFORE any service +// call; service errors map onto the ABI status codes. +func TestCreditsCallGating(t *testing.T) { + player := sdk.Player{AccountID: "acct", Handle: "p"} + room := sdk.NewTestRoomFor(struct{ sdk.Base }{}, sdk.RoomConfig{Capacity: 1}, sdk.Services{}) + balance := func(ctx context.Context, svc sdk.CreditsService, p sdk.Player) (int64, error) { + return svc.Balance(ctx, p) + } + newHandler := func(kind sdk.GameKind, svc sdk.CreditsService) *wasmHandler { + return &wasmHandler{ + game: &wasmGame{meta: sdk.GameMeta{Slug: "g", Kind: kind}}, + roster: []sdk.Player{player}, + cur: room, + svc: sdk.Services{Credits: svc}, + } + } + ctx := context.Background() + + // Game-kind guests are rejected before the service is consulted. + fc := &fakeCredits{balance: 1000} + h := newHandler(sdk.GameKindGame, fc) + if got := h.creditsCall(ctx, 0, balance); got != wire.CreditsErrDenied { + t.Fatalf("game-kind call = %d, want denied", got) + } + if fc.calls != 0 { + t.Fatal("game-kind call reached the credits service") + } + + // A casino guest on a host without an economy degrades, never traps. + h = newHandler(sdk.GameKindCasino, nil) + if got := h.creditsCall(ctx, 0, balance); got != wire.CreditsErrDisabled { + t.Fatalf("no-economy call = %d, want disabled", got) + } + + // Out-of-roster indices are refused. + h = newHandler(sdk.GameKindCasino, fc) + if got := h.creditsCall(ctx, 5, balance); got != wire.CreditsErrDenied { + t.Fatalf("out-of-roster call = %d, want denied", got) + } + + // The happy path returns the service value. + if got := h.creditsCall(ctx, 0, balance); got != 1000 { + t.Fatalf("balance call = %d, want 1000", got) + } + + // Service errors map onto the ABI status codes. + for _, tc := range []struct { + err error + want int64 + }{ + {sdk.ErrInsufficientCredits, wire.CreditsErrInsufficient}, + {sdk.ErrEconomyDisabled, wire.CreditsErrDisabled}, + {sdk.ErrCreditsDenied, wire.CreditsErrDenied}, + {context.DeadlineExceeded, wire.CreditsErrUnavailable}, + } { + h = newHandler(sdk.GameKindCasino, &fakeCredits{err: tc.err}) + if got := h.creditsCall(ctx, 0, balance); got != tc.want { + t.Errorf("err %v maps to %d, want %d", tc.err, got, tc.want) + } + } +} diff --git a/host/gameabi/host.go b/host/gameabi/host.go index 555ac0b..8efffbe 100644 --- a/host/gameabi/host.go +++ b/host/gameabi/host.go @@ -2,6 +2,7 @@ package gameabi import ( "context" + "errors" "crypto/sha256" "fmt" "io" @@ -1168,6 +1169,30 @@ func hostFunctions() []extism.HostFunction { stack[0] = off } }), + hf(wire.FnCreditsBalance, []extism.ValueType{i64}, []extism.ValueType{i64}, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + idx := int(stack[0]) + stack[0] = uint64(h.creditsCall(ctx, idx, func(cctx context.Context, svc sdk.CreditsService, pl sdk.Player) (int64, error) { + return svc.Balance(cctx, pl) + })) + }), + hf(wire.FnCreditsWager, []extism.ValueType{i64, i64}, []extism.ValueType{i64}, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + idx, amount := int(stack[0]), int64(stack[1]) + stack[0] = uint64(h.creditsCall(ctx, idx, func(cctx context.Context, svc sdk.CreditsService, pl sdk.Player) (int64, error) { + return wire.CreditsOK, svc.Wager(cctx, pl, amount) + })) + }), + hf(wire.FnCreditsSettle, []extism.ValueType{i64, i64}, []extism.ValueType{i64}, + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + h := currentHandler(ctx) + idx, payout := int(stack[0]), int64(stack[1]) + stack[0] = uint64(h.creditsCall(ctx, idx, func(cctx context.Context, svc sdk.CreditsService, pl sdk.Player) (int64, error) { + return wire.CreditsOK, svc.Settle(cctx, pl, payout) + })) + }), } } @@ -1203,6 +1228,50 @@ func (h *wasmHandler) kvStore(idx int) sdk.KVStore { return acct.Store() } +// creditsCall runs one credits host call under the shared host-I/O rules: +// mid-callback only, roster-index scoped (the host derives the account — a +// guest can never wager for a seat outside its roster), kind-gated (the +// credits functions exist only for casino-kind guests), store-timeout +// bounded, and mapped onto the ABI status codes. A nil Credits service +// reports economy-disabled — the functions are always exported at this ABI +// revision, so a casino guest on a host without an economy degrades instead +// of failing instantiation. +func (h *wasmHandler) creditsCall(ctx context.Context, idx int, call func(context.Context, sdk.CreditsService, sdk.Player) (int64, error)) int64 { + if h == nil || h.cur == nil { + return wire.CreditsErrDenied + } + if h.game.meta.Kind != sdk.GameKindCasino { + return wire.CreditsErrDenied + } + if idx < 0 || idx >= len(h.roster) { + return wire.CreditsErrDenied + } + if h.svc.Credits == nil { + return wire.CreditsErrDisabled + } + hfStart := time.Now() + defer func() { h.cbHostNanos += time.Since(hfStart).Nanoseconds() }() + cctx, cancel := context.WithTimeout(ctx, kvTimeout) + defer cancel() + v, err := call(cctx, h.svc.Credits, h.roster[idx]) + if ctx.Err() != nil { + h.hostIOExpired = true + } + switch { + case err == nil: + return v + case errors.Is(err, sdk.ErrInsufficientCredits): + return wire.CreditsErrInsufficient + case errors.Is(err, sdk.ErrEconomyDisabled): + return wire.CreditsErrDisabled + case errors.Is(err, sdk.ErrCreditsDenied): + return wire.CreditsErrDenied + default: + h.kvFailed("credits", idx, "", err) + return wire.CreditsErrUnavailable + } +} + // 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 diff --git a/host/memsvc/memsvc.go b/host/memsvc/memsvc.go index f8b2003..863f8fb 100644 --- a/host/memsvc/memsvc.go +++ b/host/memsvc/memsvc.go @@ -47,8 +47,9 @@ type Factory struct { results map[string][]rec // slug -> recorded results handles map[string]string // accountID -> latest seen handle (all "live" in memory) - kv *memKV - cfg *memConfig + kv *memKV + cfg *memConfig + credits map[string]*memCreditsAccount // accountID -> in-memory wallet } // rec is one recorded leaderboard result row. @@ -94,6 +95,7 @@ func NewFactory(log *slog.Logger, reg *sdk.Registry) *Factory { handles: map[string]string{}, kv: &memKV{m: map[kvKey]kvVal{}}, cfg: &memConfig{m: map[cfgKey][]byte{}}, + credits: map[string]*memCreditsAccount{}, } } @@ -101,7 +103,7 @@ func NewFactory(log *slog.Logger, reg *sdk.Registry) *Factory { // 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{ + svc := sdk.Services{ Leaderboard: &memLeaderboard{f: f}, Accounts: &memAccounts{f: f, slug: slug}, Config: &memConfigStore{cfg: f.cfg, slug: slug}, @@ -109,6 +111,12 @@ func (f *Factory) For(roomID, slug string) sdk.Services { Spectate: noopSpectate{}, Log: f.log.With("room", roomID, "slug", slug), } + // Casino-kind games get the in-memory credits backend (seed 1000, escrow + // per room+account, gross settle clamped by the game's declared + // multiplier where the registry knows it). Game-kind guests never reach + // it — the gameabi host rejects their calls before the service. + svc.Credits = &memCredits{f: f, roomID: roomID, slug: slug} + return svc } // Reader exposes the leaderboard read side (the lobby/UI surface, never handed @@ -151,6 +159,77 @@ func (l *memLeaderboard) Post(slug string, r sdk.Result) { } } +// ---- credits (casino-kind games) ---- + +// memCreditsAccount is one account's in-memory wallet: a balance seeded at +// 1000 plus the per-room open stakes. +type memCreditsAccount struct { + balance int64 + stakes map[string]int64 // roomID -> open stake +} + +// memCredits implements sdk.CreditsService for `check`/`play`/conformance: +// the real economy rules that matter to a guest (atomic escrow, gross settle, +// declared-multiplier clamp) with none of the platform's persistence. +type memCredits struct { + f *Factory + roomID string + slug string +} + +func (c *memCredits) account(p sdk.Player) *memCreditsAccount { + acc, ok := c.f.credits[p.AccountID] + if !ok { + acc = &memCreditsAccount{balance: 1000, stakes: map[string]int64{}} + c.f.credits[p.AccountID] = acc + } + return acc +} + +func (c *memCredits) Balance(_ context.Context, p sdk.Player) (int64, error) { + c.f.mu.Lock() + defer c.f.mu.Unlock() + return c.account(p).balance, nil +} + +func (c *memCredits) Wager(_ context.Context, p sdk.Player, amount int64) error { + c.f.mu.Lock() + defer c.f.mu.Unlock() + if amount <= 0 { + return sdk.ErrCreditsDenied + } + acc := c.account(p) + if amount > acc.balance { + return sdk.ErrInsufficientCredits + } + acc.balance -= amount + acc.stakes[c.roomID] += amount + return nil +} + +func (c *memCredits) Settle(_ context.Context, p sdk.Player, payout int64) error { + c.f.mu.Lock() + defer c.f.mu.Unlock() + acc := c.account(p) + stake := acc.stakes[c.roomID] + if stake == 0 { + return sdk.ErrCreditsDenied + } + if payout < 0 { + payout = 0 + } + // Clamp to the game's declared multiplier when the registry knows it — + // the same rule a production host applies, so `check` exercises it. + if g, ok := c.f.reg.Get(c.slug); ok { + if m := g.Meta().MaxPayoutMultiplier; m > 0 && payout > stake*int64(m) { + payout = stake * int64(m) + } + } + delete(acc.stakes, c.roomID) + acc.balance += payout + return nil +} + // ---- per-user KV ---- type kvKey struct{ slug, account, key string } diff --git a/host/sdk/errors.go b/host/sdk/errors.go index d865af6..ca62917 100644 --- a/host/sdk/errors.go +++ b/host/sdk/errors.go @@ -10,6 +10,20 @@ var ( ErrRoomClosed = errors.New("room is closed") ) +// Credits errors a CreditsService implementation returns; the gameabi host +// maps them onto the ABI status codes for the guest. +var ( + // ErrInsufficientCredits refuses a wager over the balance or a platform + // bet limit — the bet did not happen. + ErrInsufficientCredits = errors.New("sdk: insufficient credits") + // ErrEconomyDisabled reports the credits economy switched off host-side; + // guests render an out-of-service state. + ErrEconomyDisabled = errors.New("sdk: credits economy disabled") + // ErrCreditsDenied refuses a call outside the rules (game-kind guest, + // unknown seat, no open stake to settle). + ErrCreditsDenied = errors.New("sdk: credits denied") +) + // internal aliases kept for the actor loop's brevity. var ( errRoomFull = ErrRoomFull diff --git a/host/sdk/services.go b/host/sdk/services.go index 331a909..699cbb7 100644 --- a/host/sdk/services.go +++ b/host/sdk/services.go @@ -1,6 +1,9 @@ package sdk -import "log/slog" +import ( + "context" + "log/slog" +) // Services is the per-room bundle of shared concerns, constructed by a // ServicesFactory. Games reach shared concerns ONLY via Room.Services() and @@ -9,11 +12,29 @@ type Services struct { Leaderboard LeaderboardClient Accounts AccountStore Config ConfigStore // slug-bound, read-only per-game config (may be nil) + Credits CreditsService Chat ChatClient Spectate SpectatorClient Log *slog.Logger } +// CreditsService is the host-side credits surface behind the credits host +// functions (casino-kind games). The implementation owns every rule: atomic +// escrow debits, gross-payout settlement under the declared-multiplier and +// platform clamps, refunds. May be nil (no economy): the host functions then +// report economy-disabled to the guest. Errors are mapped to the ABI status +// codes via the Err* sentinels in this package. +type CreditsService interface { + // Balance reads the player's account-wide balance. + Balance(ctx context.Context, p Player) (int64, error) + // Wager atomically escrows amount from the player's balance into the + // room-seat's open stake. + Wager(ctx context.Context, p Player, amount int64) error + // Settle closes the seat's open stake with the gross payout (0 = loss), + // clamped by the implementation. + Settle(ctx context.Context, p Player, payout int64) error +} + // ServicesFactory constructs a per-room Services. It has distinct // implementations for production (durable) and dev (in-memory). type ServicesFactory interface { diff --git a/host/sdk/types.go b/host/sdk/types.go index bfbc7f7..c51ead5 100644 --- a/host/sdk/types.go +++ b/host/sdk/types.go @@ -214,6 +214,20 @@ type GameMeta struct { // input interpretation. Nil/empty = none declared. Controls []ControlDecl `json:"controls,omitempty"` + // Kind classifies the game for the platform economy (decoded from the + // meta payload's trailing game-kind section): GameKindGame (the zero + // value and the reading for every pre-revision-7 artifact) earns + // credits from results; GameKindCasino wagers them through the credits + // host functions and never earns. The host gates the credits functions + // on this declaration. + Kind GameKind `json:"kind,omitempty"` + + // MaxPayoutMultiplier is a casino game's declared per-stake payout + // ceiling: the host clamps every settlement to the seat's open stake + // times this multiplier, after applying the platform's own ceiling. + // 0 for game-kind games. + MaxPayoutMultiplier uint32 `json:"maxPayoutMultiplier,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 @@ -222,6 +236,25 @@ type GameMeta struct { Hidden bool `json:"-"` } +// GameKind classifies a game for the platform economy. +type GameKind uint8 + +const ( + // GameKindGame is a skill/score game: its results earn platform credits. + GameKindGame GameKind = 0 + // GameKindCasino is a gambling game: players wager account-wide credits + // through the credits host functions; it never earns. + GameKindCasino GameKind = 1 +) + +// String renders the kind for JSON-adjacent surfaces and logs. +func (k GameKind) String() string { + if k == GameKindCasino { + return "casino" + } + return "game" +} + // 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 { diff --git a/internal/game/api.go b/internal/game/api.go index 11acb06..2d2ea00 100644 --- a/internal/game/api.go +++ b/internal/game/api.go @@ -2,8 +2,11 @@ package game import ( "context" + "errors" "math/rand" "time" + + "github.com/shellcade/kit/v2/wire" ) // Room is the authoring surface: local reads answered from the cached @@ -85,8 +88,58 @@ type ConfigStore interface { Get(ctx context.Context, key string) ([]byte, bool, error) } +// Credits is the account-wide platform credits surface for casino-kind games +// (GameKindCasino; declare CtxFeatCredits). The host owns every balance: a +// Wager atomically escrows credits from the player's account-wide balance +// into the seat's open stake, and Settle closes the stake with a GROSS +// (stake-inclusive) payout — a loss settles 0, a push settles the stake, a +// win settles stake plus winnings; the host clamps the payout to stake x the +// game's declared MaxPayoutMultiplier. Repeated wagers before settlement +// accumulate one open stake (double-down, side bets). The game persists no +// balance of its own. Game-kind guests calling these get ErrCreditsDenied. +type Credits interface { + // Balance reads the player's current account-wide credits balance. + Balance(p Player) (int64, error) + // Wager escrows amount from the player's balance into the seat's open + // stake. ErrInsufficientCredits when the balance (or a platform bet + // limit) refuses it — render it; the bet did not happen. + Wager(p Player, amount int64) error + // Settle closes the seat's open stake with the gross payout (0 = loss). + Settle(p Player, payout int64) error +} + +// Credits errors, mirrored from the ABI status codes. ErrEconomyDisabled +// means the host has the economy switched off — render an out-of-service +// state, never trap. +var ( + ErrInsufficientCredits = errors.New("kit: insufficient credits") + ErrEconomyDisabled = errors.New("kit: the credits economy is disabled on this host") + ErrCreditsDenied = errors.New("kit: credits are not available to this game or seat") + ErrCreditsUnavailable = errors.New("kit: credits are temporarily unavailable") +) + +// creditsErr maps a wire status code to the typed error (nil for >= 0). +func creditsErr(code int64) error { + switch { + case code >= 0: + return nil + case code == wire.CreditsErrInsufficient: + return ErrInsufficientCredits + case code == wire.CreditsErrDisabled: + return ErrEconomyDisabled + case code == wire.CreditsErrDenied: + return ErrCreditsDenied + default: + return ErrCreditsUnavailable + } +} + // Services is the ABI v1 service bundle (no chat, no spectate). type Services struct { Accounts AccountStore Config ConfigStore + + // Credits is the platform credits surface (casino-kind games only; nil + // on hosts/harnesses without an economy). + Credits Credits } diff --git a/internal/game/codec.go b/internal/game/codec.go index 5c6fbf8..c1ce827 100644 --- a/internal/game/codec.go +++ b/internal/game/codec.go @@ -224,6 +224,14 @@ func encodeMeta(m GameMeta) []byte { if err := wire.ValidateControls(wm.Controls); err != nil { panic("kit: invalid GameMeta.Controls: " + err.Error()) } + // Game-kind trailer, validated under the same fail-fast posture: a + // casino game must declare its payout ceiling; a game-kind game must + // not. + wm.GameKind = uint8(m.Kind) + wm.MaxPayoutMultiplier = m.MaxPayoutMultiplier + if err := wire.ValidateGameKind(wm.GameKind, wm.MaxPayoutMultiplier); err != nil { + panic("kit: invalid GameMeta: " + err.Error()) + } // Stamp the wire revision this kit was built against — not // author-settable; the host uses it to warn on or refuse artifacts // declaring a revision above its own (deploy-order enforcement and diff --git a/internal/game/devrun.go b/internal/game/devrun.go index 1226acb..56a72a5 100644 --- a/internal/game/devrun.go +++ b/internal/game/devrun.go @@ -80,6 +80,7 @@ func Main(g Game) { members: []Player{}, rng: rand.New(rand.NewSource(s)), kv: map[string][]byte{}, + credits: map[string]*nativeCreditsState{}, config: cfgVals, } // Virtual clock: with -seed, the room clock starts at a fixed epoch derived @@ -271,6 +272,7 @@ type nativeRoom struct { rng *rand.Rand kv map[string][]byte // keyed by account + key config map[string]string + credits map[string]*nativeCreditsState // keyed by account (dev credits fake) ended bool virtual bool // -seed: Now() reads the virtual clock below @@ -363,7 +365,55 @@ func (r *nativeRoom) Post(Result) {} func (r *nativeRoom) Log(msg string) { fmt.Fprintln(os.Stderr, "\r"+msg) } func (r *nativeRoom) Services() Services { - return Services{Accounts: nativeAccounts{r}, Config: nativeConfig{r}} + return Services{Accounts: nativeAccounts{r}, Config: nativeConfig{r}, Credits: nativeCredits{r}} +} + +// nativeCredits is the dev-runner credits fake: every account starts at 1000, +// wagers escrow into an open stake, settles credit the gross payout — enough +// to iterate a casino game natively without a host economy. No multiplier +// clamp (that is host policy, exercised by `shellcade-kit check`). +type nativeCreditsState struct { + balance int64 + stake int64 +} + +type nativeCredits struct{ r *nativeRoom } + +func (c nativeCredits) state(p Player) *nativeCreditsState { + st, ok := c.r.credits[p.AccountID] + if !ok { + st = &nativeCreditsState{balance: 1000} + c.r.credits[p.AccountID] = st + } + return st +} + +func (c nativeCredits) Balance(p Player) (int64, error) { return c.state(p).balance, nil } + +func (c nativeCredits) Wager(p Player, amount int64) error { + st := c.state(p) + if amount <= 0 { + return ErrCreditsDenied + } + if amount > st.balance { + return ErrInsufficientCredits + } + st.balance -= amount + st.stake += amount + return nil +} + +func (c nativeCredits) Settle(p Player, payout int64) error { + st := c.state(p) + if st.stake == 0 { + return ErrCreditsDenied + } + if payout < 0 { + payout = 0 + } + st.stake = 0 + st.balance += payout + return nil } type nativeAccounts struct{ r *nativeRoom } diff --git a/internal/game/host.go b/internal/game/host.go index 7094abe..944e024 100644 --- a/internal/game/host.go +++ b/internal/game/host.go @@ -45,6 +45,19 @@ func hostKVDelete(playerIdx uint64, keyOff uint64) //go:wasmimport extism:host/user config_get func hostConfigGet(keyOff uint64) uint64 +// Credits host functions (revision 7; casino-kind guests). Each returns an +// i64 status: credits_balance returns the balance (>= 0) or a negative +// wire.CreditsErr*; wager/settle return wire.CreditsOK or a negative. + +//go:wasmimport extism:host/user credits_balance +func hostCreditsBalance(playerIdx uint64) uint64 + +//go:wasmimport extism:host/user credits_wager +func hostCreditsWager(playerIdx uint64, amount uint64) uint64 + +//go:wasmimport extism:host/user credits_settle +func hostCreditsSettle(playerIdx uint64, payout uint64) uint64 + // alloc copies b into Extism kernel memory; the caller MUST Free it after the // host call returns (kernel memory is not garbage collected). func alloc(b []byte) pdk.Memory { return pdk.AllocateBytes(b) } diff --git a/internal/game/room.go b/internal/game/room.go index 98d3665..c19fddd 100644 --- a/internal/game/room.go +++ b/internal/game/room.go @@ -139,7 +139,54 @@ func (r *room) Log(msg string) { } func (r *room) Services() Services { - return Services{Accounts: accountStore{r}, Config: configStore{}} + return Services{Accounts: accountStore{r}, Config: configStore{}, Credits: creditsSvc{r}} +} + +// creditsSvc bridges the Credits surface to the credits host functions. The +// guest passes only the roster index; the host derives the account (the same +// scoping discipline as KV). Departed players (the leave callback's final +// roster entry) resolve by account id, so a leave-time settle still lands. +type creditsSvc struct{ r *room } + +func (c creditsSvc) index(p Player) int { + idx := c.r.index(p) + if idx >= 0 { + return idx + } + for i, m := range c.r.ctx.members { + if m.AccountID == p.AccountID { + return i + } + } + return -1 +} + +func (c creditsSvc) Balance(p Player) (int64, error) { + idx := c.index(p) + if idx < 0 { + return 0, ErrCreditsDenied + } + code := int64(hostCreditsBalance(uint64(idx))) + if code < 0 { + return 0, creditsErr(code) + } + return code, nil +} + +func (c creditsSvc) Wager(p Player, amount int64) error { + idx := c.index(p) + if idx < 0 { + return ErrCreditsDenied + } + return creditsErr(int64(hostCreditsWager(uint64(idx), uint64(amount)))) +} + +func (c creditsSvc) Settle(p Player, payout int64) error { + idx := c.index(p) + if idx < 0 { + return ErrCreditsDenied + } + return creditsErr(int64(hostCreditsSettle(uint64(idx), uint64(payout)))) } type accountStore struct{ r *room } diff --git a/internal/game/types.go b/internal/game/types.go index a87e454..8f94754 100644 --- a/internal/game/types.go +++ b/internal/game/types.go @@ -234,8 +234,37 @@ type GameMeta struct { // served by the canonical vocabulary needs none. Invalid declarations // are an authoring bug and panic at meta encode time. Controls []ControlDecl + + // Kind classifies the game for the platform economy: GameKindGame (the + // zero value — players earn platform credits from the results the game + // posts) or GameKindCasino (players gamble their credits through the + // room's Credits service). Casino games MUST also declare + // MaxPayoutMultiplier and SHOULD declare CtxFeatCredits; the host + // rejects credits calls from game-kind guests. An invalid combination + // is an authoring bug and panics at meta encode time. + Kind GameKind + + // MaxPayoutMultiplier is a casino game's declared payout ceiling: the + // host clamps every settlement to the seat's open stake times this + // multiplier (the platform applies its own ceiling on top). It MUST + // cover the game's largest configurable outcome — a clamped honest + // jackpot is an authoring bug. Required >= 1 for GameKindCasino; must + // be 0 for GameKindGame. + MaxPayoutMultiplier uint32 } +// GameKind classifies a game for the platform economy. +type GameKind uint8 + +const ( + // GameKindGame is the default: a skill/score game whose results earn + // platform credits. + GameKindGame GameKind = GameKind(wire.GameKindGame) + // GameKindCasino is a gambling game: players wager their account-wide + // credits through the Credits service; the game never earns. + GameKindCasino GameKind = GameKind(wire.GameKindCasino) +) + // ControlDecl declares one extra control: the exact Input it sends (a // printable rune or a named key) and a short display label of at most 16 // runes. Build with RuneControl / KeyControl. @@ -276,6 +305,12 @@ const CtxFeatRosterEpoch = wire.CtxFeatRosterEpoch // GameMeta.CtxFeatures. const CtxFeatCharacter = wire.CtxFeatCharacter +// CtxFeatCredits declares that the game calls the credits host functions +// (casino-kind games). Declaration-only — it changes no callback encoding — +// but hosts and tooling use it to know the artifact wagers. Declare it in +// GameMeta.CtxFeatures on casino games. +const CtxFeatCredits = wire.CtxFeatCredits + // Status is a player's terminal outcome. type Status uint8 diff --git a/kit.go b/kit.go index 3fef036..442f69c 100644 --- a/kit.go +++ b/kit.go @@ -82,6 +82,7 @@ type ( ConfigKeySpec = game.ConfigKeySpec ControlDecl = game.ControlDecl Lifecycle = game.Lifecycle + GameKind = game.GameKind ConfigType = game.ConfigType Direction = game.Direction Aggregation = game.Aggregation @@ -113,6 +114,10 @@ const ( CtxFeatRosterEpoch = game.CtxFeatRosterEpoch CtxFeatCharacter = game.CtxFeatCharacter + CtxFeatCredits = game.CtxFeatCredits + + GameKindGame = game.GameKindGame + GameKindCasino = game.GameKindCasino LifecycleResumable = game.LifecycleResumable LifecycleEphemeral = game.LifecycleEphemeral @@ -191,6 +196,17 @@ type ( Account = game.Account AccountStore = game.AccountStore ConfigStore = game.ConfigStore + Credits = game.Credits +) + +// Credits errors (casino-kind games): match with errors.Is. ErrEconomyDisabled +// means the host has the economy switched off — render an out-of-service +// state, never trap. +var ( + ErrInsufficientCredits = game.ErrInsufficientCredits + ErrEconomyDisabled = game.ErrEconomyDisabled + ErrCreditsDenied = game.ErrCreditsDenied + ErrCreditsUnavailable = game.ErrCreditsUnavailable ) // (Frame).Clear resets a frame for reuse — prefer one long-lived frame plus diff --git a/kittest/kittest.go b/kittest/kittest.go index ccfdacd..323fc7e 100644 --- a/kittest/kittest.go +++ b/kittest/kittest.go @@ -62,6 +62,22 @@ type Room struct { // or write with kit.MergeMax/kit.MergeSum so a blip-era write cannot // clobber the durable value at merge time). KVUnavailable bool + + // Credits is the in-memory credits double for casino-kind games: + // accountID -> balance. Absent accounts are seeded CreditsSeed on first + // touch. CreditsStakes tracks each account's open (wagered, unsettled) + // stake; Settle credits the GROSS payout and clears it, mirroring the + // production escrow semantics. + Credits map[string]int64 + CreditsStakes map[string]int64 + + // CreditsSeed is the first-touch balance (default 1000, set by NewRoom). + CreditsSeed int64 + + // CreditsDisabled simulates a host with the economy switched off: every + // credits call returns kit.ErrEconomyDisabled, the state a casino game + // must render as out-of-service rather than trap. + CreditsDisabled bool } // NewRoom returns a Room with the given members, a seeded RNG (seed 1), and a @@ -129,7 +145,7 @@ func (r *Room) Post(res kit.Result) { r.Posted = append(r.Posted, func (r *Room) Log(msg string) { r.Logs = append(r.Logs, msg) } func (r *Room) Services() kit.Services { - return kit.Services{Accounts: accounts{r}, Config: config{r}} + return kit.Services{Accounts: accounts{r}, Config: config{r}, Credits: credits{r}} } // ---- services doubles ----------------------------------------------------------- @@ -182,6 +198,63 @@ func (k kv) Delete(_ context.Context, key string) error { return nil } +type credits struct{ r *Room } + +func (c credits) balance(id string) int64 { + if c.r.Credits == nil { + c.r.Credits = map[string]int64{} + } + if _, ok := c.r.Credits[id]; !ok { + seed := c.r.CreditsSeed + if seed == 0 { + seed = 1000 + } + c.r.Credits[id] = seed + } + return c.r.Credits[id] +} + +func (c credits) Balance(p kit.Player) (int64, error) { + if c.r.CreditsDisabled { + return 0, kit.ErrEconomyDisabled + } + return c.balance(p.AccountID), nil +} + +func (c credits) Wager(p kit.Player, amount int64) error { + if c.r.CreditsDisabled { + return kit.ErrEconomyDisabled + } + if amount <= 0 { + return kit.ErrCreditsDenied + } + bal := c.balance(p.AccountID) + if amount > bal { + return kit.ErrInsufficientCredits + } + if c.r.CreditsStakes == nil { + c.r.CreditsStakes = map[string]int64{} + } + c.r.Credits[p.AccountID] = bal - amount + c.r.CreditsStakes[p.AccountID] += amount + return nil +} + +func (c credits) Settle(p kit.Player, payout int64) error { + if c.r.CreditsDisabled { + return kit.ErrEconomyDisabled + } + if c.r.CreditsStakes[p.AccountID] == 0 { + return kit.ErrCreditsDenied + } + if payout < 0 { + payout = 0 + } + delete(c.r.CreditsStakes, p.AccountID) + c.r.Credits[p.AccountID] = c.balance(p.AccountID) + payout + return nil +} + type config struct{ r *Room } func (c config) Get(_ context.Context, key string) ([]byte, bool, error) { diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 12b173d..9c6c7b7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -304,7 +304,7 @@ dependencies = [ [[package]] name = "shellcade-kit" -version = "2.9.0" +version = "2.14.0" dependencies = [ "extism-pdk", ] diff --git a/rust/src/host.rs b/rust/src/host.rs index a181797..ada74b6 100644 --- a/rust/src/host.rs +++ b/rust/src/host.rs @@ -41,6 +41,13 @@ mod imp { fn kv_delete(player_idx: u64, key_off: u64); /// config_get(ptr key) → ptr (0 = not found). fn config_get(key_off: u64) -> u64; + /// credits_balance(i64 playerIdx) → i64 balance (>= 0) or a negative + /// CREDITS_ERR_* status (ABI.md §3; casino-kind games only). + fn credits_balance(player_idx: u64) -> i64; + /// credits_wager(i64 playerIdx, i64 amount) → i64 status. + fn credits_wager(player_idx: u64, amount: i64) -> i64; + /// credits_settle(i64 playerIdx, i64 payout) → i64 status. + fn credits_settle(player_idx: u64, payout: i64) -> i64; } /// Stage pre-serialized bytes in extism kernel memory. Infallible by @@ -150,6 +157,21 @@ mod imp { km.free(); read_free(off) } + + pub fn host_credits_balance(player_idx: usize) -> i64 { + // SAFETY: scalar-only raw import per ABI.md §3. + unsafe { credits_balance(player_idx as u64) } + } + + pub fn host_credits_wager(player_idx: usize, amount: i64) -> i64 { + // SAFETY: as host_credits_balance. + unsafe { credits_wager(player_idx as u64, amount) } + } + + pub fn host_credits_settle(player_idx: usize, payout: i64) -> i64 { + // SAFETY: as host_credits_balance. + unsafe { credits_settle(player_idx as u64, payout) } + } } // ---- native test host (cargo test) --------------------------------------------- @@ -186,6 +208,15 @@ mod imp { pub logs: Vec<(i64, String)>, pub kv: HashMap<(usize, String), Vec>, pub config: HashMap>, + /// Credits balances per roster index (seeded on first touch with + /// `credits_seed`, default 1000) and each index's open stake — + /// mirrors the production escrow semantics so casino games unit-test + /// natively. `credits_disabled` makes every call report + /// CREDITS_ERR_DISABLED (the economy-off state a game must render). + pub credits: HashMap, + pub credits_stakes: HashMap, + pub credits_seed: i64, + pub credits_disabled: bool, } thread_local! { @@ -259,6 +290,53 @@ mod imp { pub fn host_config_get(key: &str) -> Option> { with_test_host(|h| h.config.get(key).cloned()) } + + fn credits_balance_of(h: &mut TestHost, idx: usize) -> i64 { + let seed = if h.credits_seed == 0 { 1000 } else { h.credits_seed }; + *h.credits.entry(idx).or_insert(seed) + } + + pub fn host_credits_balance(player_idx: usize) -> i64 { + with_test_host(|h| { + if h.credits_disabled { + return crate::wire::CREDITS_ERR_DISABLED; + } + credits_balance_of(h, player_idx) + }) + } + + pub fn host_credits_wager(player_idx: usize, amount: i64) -> i64 { + with_test_host(|h| { + if h.credits_disabled { + return crate::wire::CREDITS_ERR_DISABLED; + } + if amount <= 0 { + return crate::wire::CREDITS_ERR_DENIED; + } + let bal = credits_balance_of(h, player_idx); + if amount > bal { + return crate::wire::CREDITS_ERR_INSUFFICIENT; + } + h.credits.insert(player_idx, bal - amount); + *h.credits_stakes.entry(player_idx).or_insert(0) += amount; + crate::wire::CREDITS_OK + }) + } + + pub fn host_credits_settle(player_idx: usize, payout: i64) -> i64 { + with_test_host(|h| { + if h.credits_disabled { + return crate::wire::CREDITS_ERR_DISABLED; + } + if h.credits_stakes.get(&player_idx).copied().unwrap_or(0) == 0 { + return crate::wire::CREDITS_ERR_DENIED; + } + h.credits_stakes.remove(&player_idx); + let bal = credits_balance_of(h, player_idx); + h.credits.insert(player_idx, bal + payout.max(0)); + crate::wire::CREDITS_OK + }) + } } pub(crate) use imp::*; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 5d4e567..5f201b9 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -88,9 +88,10 @@ pub use frame::{ pub use input::{Action, Input, InputContext, Key}; pub use room::Room; pub use types::{ - Aggregation, Character, ConfigKeySpec, ConfigType, ControlDecl, Direction, Kind, Leaderboard, - MergeRule, Meta, MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, - CTX_FEAT_CHARACTER, CTX_FEAT_ROSTER_EPOCH, Lifecycle, + Aggregation, Character, ConfigKeySpec, ConfigType, ControlDecl, CreditsError, Direction, + GameKind, Kind, Leaderboard, MergeRule, Meta, MetricFormat, Mode, Outcome, Player, + PlayerResult, RoomConfig, Status, CTX_FEAT_CHARACTER, CTX_FEAT_CREDITS, + CTX_FEAT_ROSTER_EPOCH, Lifecycle, }; // Native-only scriptable host double for `cargo test` of games and the SDK @@ -127,11 +128,11 @@ pub trait Handler { /// Everything a game file needs: `use shellcade_kit::prelude::*;` pub mod prelude { pub use crate::{ - character_cell, Action, Aggregation, Cell, Character, Color, ControlDecl, Direction, - Frame, Game, Handler, Input, InputContext, Key, Kind, Leaderboard, MergeRule, Meta, - MetricFormat, Mode, Outcome, Player, PlayerResult, Room, RoomConfig, Status, Style, - ATTR_BOLD, ATTR_DIM, ATTR_REVERSE, ATTR_UNDERLINE, COLS, CYAN, DIM_GRAY, GREEN, RED, - ROWS, WHITE, YELLOW, + character_cell, Action, Aggregation, Cell, Character, Color, ControlDecl, CreditsError, + Direction, Frame, Game, GameKind, Handler, Input, InputContext, Key, Kind, Leaderboard, + MergeRule, Meta, MetricFormat, Mode, Outcome, Player, PlayerResult, Room, RoomConfig, + Status, Style, ATTR_BOLD, ATTR_DIM, ATTR_REVERSE, ATTR_UNDERLINE, COLS, CYAN, DIM_GRAY, + GREEN, RED, ROWS, WHITE, YELLOW, }; } diff --git a/rust/src/room.rs b/rust/src/room.rs index 3df641a..0ab2cdb 100644 --- a/rust/src/room.rs +++ b/rust/src/room.rs @@ -9,7 +9,7 @@ use crate::frame::Frame; use crate::host; use crate::input::InputContext; use crate::rng::SplitMix64; -use crate::types::{MergeRule, Outcome, Player, RoomConfig}; +use crate::types::{CreditsError, MergeRule, Outcome, Player, RoomConfig}; use crate::wire::{encode_outcome, CallCtx}; thread_local! { @@ -149,6 +149,43 @@ impl Room { host::host_config_get(key) } + // ---- platform credits (casino-kind games) ------------------------------------- + + /// The player's account-wide credits balance (casino-kind games with + /// `Meta::kind = GameKind::Casino`; game-kind guests get + /// [`CreditsError::Denied`]). + pub fn credits_balance(&self, p: &Player) -> Result { + let idx = self.kv_index(p).ok_or(CreditsError::Denied)?; + let v = host::host_credits_balance(idx); + if v < 0 { + return Err(credits_err(v)); + } + Ok(v) + } + + /// Escrow `amount` from the player's balance into this seat's open stake. + /// Repeated wagers before settlement accumulate one stake (double-down, + /// side bets). On [`CreditsError::Insufficient`] the bet did not happen — + /// render it. + pub fn credits_wager(&mut self, p: &Player, amount: i64) -> Result<(), CreditsError> { + let idx = self.kv_index(p).ok_or(CreditsError::Denied)?; + match host::host_credits_wager(idx, amount) { + v if v < 0 => Err(credits_err(v)), + _ => Ok(()), + } + } + + /// Close the seat's open stake with the GROSS (stake-inclusive) payout: + /// 0 = loss, the stake = push, stake + winnings = win. The host clamps to + /// stake x the game's declared `max_payout_multiplier`. + pub fn credits_settle(&mut self, p: &Player, payout: i64) -> Result<(), CreditsError> { + let idx = self.kv_index(p).ok_or(CreditsError::Denied)?; + match host::host_credits_settle(idx, payout) { + v if v < 0 => Err(credits_err(v)), + _ => Ok(()), + } + } + // ---- internals --------------------------------------------------------------- fn index(&self, p: &Player) -> Option { @@ -164,6 +201,16 @@ impl Room { } } +/// Map a negative ABI status code to the typed error. +fn credits_err(code: i64) -> CreditsError { + match code { + crate::wire::CREDITS_ERR_INSUFFICIENT => CreditsError::Insufficient, + crate::wire::CREDITS_ERR_DISABLED => CreditsError::Disabled, + crate::wire::CREDITS_ERR_DENIED => CreditsError::Denied, + _ => CreditsError::Unavailable, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/src/types.rs b/rust/src/types.rs index 4efc832..a37bf62 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -260,6 +260,30 @@ pub struct Meta { /// at `meta()` encode time — an invalid declaration is an authoring bug /// and panics there. pub controls: &'static [ControlDecl], + + /// Classifies the game for the platform economy. [`GameKind::Game`] + /// (default) earns platform credits from posted results; + /// [`GameKind::Casino`] wagers them through the room's credits calls and + /// never earns. Casino games MUST also declare + /// [`Meta::max_payout_multiplier`] and SHOULD declare + /// [`CTX_FEAT_CREDITS`]. An invalid combination panics at `meta()` + /// encode time. + pub kind: GameKind, + + /// A casino game's declared per-stake payout ceiling: the host clamps + /// every settlement to the seat's open stake times this multiplier (the + /// platform applies its own ceiling on top). It MUST cover the game's + /// largest configurable outcome. Required `>= 1` for + /// [`GameKind::Casino`]; must be 0 for [`GameKind::Game`]. + pub max_payout_multiplier: u32, +} + +/// Game classification for the platform economy (wire values 0/1). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum GameKind { + #[default] + Game = 0, + Casino = 1, } /// One declared extra control ([`Meta::controls`]): the exact input it sends @@ -292,8 +316,25 @@ pub const CTX_FEAT_ROSTER_EPOCH: u32 = 1 << 0; /// [`Meta::ctx_features`]. pub const CTX_FEAT_CHARACTER: u32 = 1 << 1; +/// A credits call refusal (casino-kind games; ABI.md §3). `Disabled` means +/// the host has the economy switched off — render an out-of-service state, +/// never trap. `Insufficient` means the wager exceeded the balance or a +/// platform bet limit; the bet did not happen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CreditsError { + Insufficient, + Disabled, + Denied, + Unavailable, +} + +/// Declares that the game calls the credits host functions (casino-kind +/// games). Declaration-only — it changes no callback encoding. Declare it in +/// [`Meta::ctx_features`] on casino games. +pub const CTX_FEAT_CREDITS: u32 = 1 << 2; + /// The feature bits this SDK revision defines. -pub(crate) const KNOWN_CTX_FEATURES: u32 = CTX_FEAT_ROSTER_EPOCH | CTX_FEAT_CHARACTER; +pub(crate) const KNOWN_CTX_FEATURES: u32 = CTX_FEAT_ROSTER_EPOCH | CTX_FEAT_CHARACTER | CTX_FEAT_CREDITS; /// Heartbeat declaration envelope (mirrors the host clamp range). pub(crate) const HEARTBEAT_MIN_MS: u16 = 20; @@ -318,6 +359,8 @@ impl Meta { heartbeat_ms: 0, controls: &[], lifecycle: Lifecycle::Resumable, + kind: GameKind::Game, + max_payout_multiplier: 0, }; } diff --git a/rust/src/wire.rs b/rust/src/wire.rs index d681600..e5197e2 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -204,7 +204,17 @@ fn decode_members(r: &mut Rd<'_>, n: usize, features: u32) -> Vec { /// `wire.Revision` — one protocol constant, asserted equal in lockstep by /// the Go cross-check test `wire.TestRustWireRevisionMatchesWire` (which /// parses this source line; keep the declaration on one line). -pub(crate) const WIRE_REVISION: u16 = 6; +pub(crate) const WIRE_REVISION: u16 = 7; + +/// Credits host-function status codes (ABI.md §3): `credits_balance` returns +/// the balance (>= 0) or one of the negatives; wager/settle return +/// [`CREDITS_OK`] or a negative. Mirrors Go's `wire.CreditsOK`/`CreditsErr*`. +pub(crate) const CREDITS_OK: i64 = 0; +pub(crate) const CREDITS_ERR_INSUFFICIENT: i64 = -1; +pub(crate) const CREDITS_ERR_DISABLED: i64 = -2; +pub(crate) const CREDITS_ERR_DENIED: i64 = -3; +#[allow(dead_code)] // decoded by credits_err mapping's catch-all +pub(crate) const CREDITS_ERR_UNAVAILABLE: i64 = -4; /// Pack a [`Meta`] for the `meta` export — the single SDK-owned serializer. pub(crate) fn encode_meta(m: &Meta) -> Vec { @@ -287,6 +297,24 @@ pub(crate) fn encode_meta(m: &Meta) -> Vec { } w.str(cd.label); } + // Trailing game-kind section (ABI.md §4.2, revision 7): u8 kind + u32 + // payout ceiling. Always written; validated under the same fail-fast + // posture — a casino game must declare its ceiling, a game-kind game + // must not (mirrors Go's wire.ValidateGameKind). + match m.kind { + crate::types::GameKind::Game => { + if m.max_payout_multiplier != 0 { + panic!("shellcade-kit: invalid Meta: game-kind game declares max_payout_multiplier {}", m.max_payout_multiplier); + } + } + crate::types::GameKind::Casino => { + if m.max_payout_multiplier == 0 { + panic!("shellcade-kit: invalid Meta: casino game must declare max_payout_multiplier >= 1"); + } + } + } + w.u8(m.kind as u8); + w.u32(m.max_payout_multiplier); w.b } @@ -607,7 +635,7 @@ mod tests { ], ..Meta::DEFAULT }; - let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e00000000000000000000000006000000"; + let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000000000000000000070000000000000000"; let got: String = encode_meta(&m).iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(got, golden, "Rust meta encoding diverges from the Go golden"); } @@ -630,7 +658,7 @@ mod tests { ], ..Meta::DEFAULT }; - let golden = "040063746c67040043746c47000001000200000000000000000000000000000000000000060002000072000000060052455349474e01020400554e444f"; + let golden = "040063746c67040043746c47000001000200000000000000000000000000000000000000070002000072000000060052455349474e01020400554e444f0000000000"; let got: String = encode_meta(&m).iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(got, golden, "Rust controls encoding diverges from the Go golden"); } @@ -762,10 +790,10 @@ mod tests { ..Meta::DEFAULT }; let got: String = encode_meta(&m).iter().map(|b| format!("{b:02x}")).collect(); - // trailer = u32 1 LE + u16 100 LE + u8 lifecycle + u16 revision 6 LE - // + u16 controls count 0 - // = "01000000" + "6400" + "00" + "0600" + "0000" - assert!(got.ends_with("00000100000064000006000000"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-26..]); + // trailer = u32 1 LE + u16 100 LE + u8 lifecycle + u16 revision 7 LE + // + u16 controls count 0 + u8 kind 0 + u32 multiplier 0 + // = "01000000" + "6400" + "00" + "0700" + "0000" + "00" + "00000000" + assert!(got.ends_with("000001000000640000070000000000000000"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-36..]); } } @@ -795,9 +823,9 @@ mod scalar_golden { use super::*; use crate::input::{Input, Key}; use crate::types::{ - Aggregation, ConfigKeySpec, ConfigType, ControlDecl, Direction, Kind, Leaderboard, - Lifecycle, Meta, MetricFormat, Mode, Outcome, Player, PlayerResult, Status, - CTX_FEAT_ROSTER_EPOCH, + Aggregation, ConfigKeySpec, ConfigType, ControlDecl, Direction, GameKind, Kind, + Leaderboard, Lifecycle, Meta, MetricFormat, Mode, Outcome, Player, PlayerResult, + Status, CTX_FEAT_ROSTER_EPOCH, }; const VECTORS: &str = include_str!("../tests/golden/scalars.txt"); @@ -887,6 +915,8 @@ mod scalar_golden { ControlDecl { input: Input::Char('r'), label: "RESIGN" }, ControlDecl { input: Input::Key(Key::Backspace), label: "UNDO" }, ], + kind: GameKind::Casino, + max_payout_multiplier: 10000, }; assert_eq!( hex(&encode_meta(&m)), diff --git a/rust/tests/golden/scalars.txt b/rust/tests/golden/scalars.txt index 484123a..67b51e5 100644 --- a/rust/tests/golden/scalars.txt +++ b/rust/tests/golden/scalars.txt @@ -14,10 +14,11 @@ # fields and reader position); meta_trunc_* are truncated older-form metas # pinning the host-side decoder's presence guards (Go-only — the guest SDKs # carry no meta decoder). -meta_default = 070064656661756c74000000000100010000000000000000000000000000000000000006000000 -meta_full = 0b00676f6c64656e2d66756c6c0b00476f6c64656e2046756c6c170065766572792073656374696f6e20706f70756c6174656402000800020005006d756c74690400636172640a004465616c206d6520696e080050726163746963650d004a6f696e206d79207461626c65010500636869707301010202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000001000000fa0001060002000072000000060052455349474e01020400554e444f +meta_default = 070064656661756c740000000001000100000000000000000000000000000000000000070000000000000000 +meta_full = 0b00676f6c64656e2d66756c6c0b00476f6c64656e2046756c6c170065766572792073656374696f6e20706f70756c6174656402000800020005006d756c74690400636172640a004465616c206d6520696e080050726163746963650d004a6f696e206d79207461626c65010500636869707301010202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000001000000fa0001070002000072000000060052455349474e01020400554e444f0110270000 meta_trunc_pre_config = 05007472756e6305005472756e63000001000400000000000000000001050073636f7265010000 -meta_trunc_pre_controls = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400010600 +meta_trunc_pre_controls = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400010700 +meta_trunc_pre_kind = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400010700010001020400554e444f meta_trunc_pre_largeroom = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000 meta_trunc_pre_lifecycle = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400 meta_trunc_pre_revision = 05007472756e6305005472756e63000001000400000000000000000001050073636f7265010000000001000000640001 diff --git a/wire/controls_test.go b/wire/controls_test.go index 7855176..5d7c202 100644 --- a/wire/controls_test.go +++ b/wire/controls_test.go @@ -44,10 +44,11 @@ func TestMetaPreControlsBytesDecode(t *testing.T) { m2 := m m2.Controls = nil pre := EncodeMeta(m2) - if !strings.HasPrefix(string(enc), string(pre[:len(pre)-2])) { + // Drop the game-kind section (u8+u32) plus the empty controls u16 count. + if !strings.HasPrefix(string(enc), string(pre[:len(pre)-7])) { t.Fatal("controls section is not a trailing addition") } - got, err := DecodeMeta(pre[:len(pre)-2]) // drop even the empty u16 count + got, err := DecodeMeta(pre[:len(pre)-7]) // drop even the empty u16 count if err != nil { t.Fatalf("decode pre-controls payload: %v", err) } diff --git a/wire/gamekind_test.go b/wire/gamekind_test.go new file mode 100644 index 0000000..bed3966 --- /dev/null +++ b/wire/gamekind_test.go @@ -0,0 +1,47 @@ +package wire + +import "testing" + +// The game-kind trailer: round trip, presence-guarded absence, validation. +func TestMetaGameKindRoundTrip(t *testing.T) { + m := Meta{Slug: "casino", Name: "C", MinPlayers: 1, MaxPlayers: 4, + GameKind: GameKindCasino, MaxPayoutMultiplier: 10000} + got, err := DecodeMeta(EncodeMeta(m)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if got.GameKind != GameKindCasino || got.MaxPayoutMultiplier != 10000 { + t.Fatalf("round trip = kind %d mult %d, want casino/10000", got.GameKind, got.MaxPayoutMultiplier) + } + + // A payload ending before the section (a pre-revision-7 artifact) decodes + // as GameKindGame with no multiplier. + b := EncodeMeta(m) + old, err := DecodeMeta(b[:len(b)-5]) + if err != nil { + t.Fatalf("pre-kind decode: %v", err) + } + if old.GameKind != GameKindGame || old.MaxPayoutMultiplier != 0 { + t.Fatalf("pre-kind payload = kind %d mult %d, want game/0", old.GameKind, old.MaxPayoutMultiplier) + } +} + +func TestValidateGameKind(t *testing.T) { + for _, tc := range []struct { + name string + kind uint8 + mult uint32 + ok bool + }{ + {"game", GameKindGame, 0, true}, + {"game with multiplier", GameKindGame, 5, false}, + {"casino", GameKindCasino, 500, true}, + {"casino without multiplier", GameKindCasino, 0, false}, + {"unknown kind", 9, 0, false}, + } { + err := ValidateGameKind(tc.kind, tc.mult) + if (err == nil) != tc.ok { + t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok) + } + } +} diff --git a/wire/largeroom_test.go b/wire/largeroom_test.go index 3f0bab8..e0f7543 100644 --- a/wire/largeroom_test.go +++ b/wire/largeroom_test.go @@ -115,9 +115,10 @@ func TestMetaTrailerRoundTrip(t *testing.T) { t.Fatalf("trailer round-trip = features %#x heartbeat %d", got.CtxFeatures, got.HeartbeatMS) } - // Pre-large-room payload: chop the trailing 11 bytes - // (u32 + u16 large-room, u8 lifecycle, u16 wireRevision, u16 controls). - old := b[:len(b)-11] + // Pre-large-room payload: chop the trailing 16 bytes (u32 + u16 + // large-room, u8 lifecycle, u16 wireRevision, u16 controls, u8 + u32 + // game-kind). + old := b[:len(b)-16] got, err = DecodeMeta(old) if err != nil { t.Fatalf("pre-trailer decode: %v", err) @@ -127,9 +128,9 @@ func TestMetaTrailerRoundTrip(t *testing.T) { } // Pre-lifecycle payload (kit v2.6.0 era): chop the lifecycle byte, the - // wire-revision u16, and the controls u16 — the large-room section - // decodes, lifecycle defaults to resumable. - v26 := b[:len(b)-5] + // wire-revision u16, the controls u16, and the game-kind u8+u32 — the + // large-room section decodes, lifecycle defaults to resumable. + v26 := b[:len(b)-10] got, err = DecodeMeta(v26) if err != nil { t.Fatalf("pre-lifecycle decode: %v", err) @@ -155,9 +156,9 @@ func TestMetaWireRevisionRoundTrip(t *testing.T) { t.Fatalf("wire revision round-trip = %d, want %d", got.WireRevision, Revision) } - // Pre-revision payload (kit v2.7.x era): chop the trailing u16 and the - // controls u16 that follows it. - v27 := b[:len(b)-4] + // Pre-revision payload (kit v2.7.x era): chop the trailing u16, the + // controls u16, and the game-kind u8+u32 that follow it. + v27 := b[:len(b)-9] got, err = DecodeMeta(v27) if err != nil { t.Fatalf("pre-revision decode: %v", err) diff --git a/wire/scalar_golden_test.go b/wire/scalar_golden_test.go index 68cf4f3..3ef783a 100644 --- a/wire/scalar_golden_test.go +++ b/wire/scalar_golden_test.go @@ -86,6 +86,8 @@ func scalarMetaFull() Meta { {Kind: InputRune, Rune: 'r', Label: "RESIGN"}, {Kind: InputKey, Key: KeyCodeBackspace, Label: "UNDO"}, }, + GameKind: GameKindCasino, + MaxPayoutMultiplier: 10000, } } @@ -111,6 +113,9 @@ func scalarMetaTrunc() Meta { Lifecycle: LifecycleEphemeral, WireRevision: Revision, Controls: []ControlDecl{{Kind: InputKey, Key: KeyCodeBackspace, Label: "UNDO"}}, + + GameKind: GameKindCasino, + MaxPayoutMultiplier: 500, } } @@ -153,13 +158,14 @@ type scalarVector struct { // Trailing meta section widths measured from the END of an encoding with zero // config specs and ONE declared key control (u16 count + u8 kind + u8 key + // str "UNDO" = 10): config 2 | large-room 6 | lifecycle 1 | wireRevision 2 | -// controls 10. +// controls 10 | game-kind 5 (u8 + u32). const ( - truncPreControls = 10 - truncPreRevision = 10 + 2 - truncPreLifecycle = 10 + 2 + 1 - truncPreLargeRoom = 10 + 2 + 1 + 6 - truncPreConfig = 10 + 2 + 1 + 6 + 2 + truncPreKind = 5 + truncPreControls = 5 + 10 + truncPreRevision = 5 + 10 + 2 + truncPreLifecycle = 5 + 10 + 2 + 1 + truncPreLargeRoom = 5 + 10 + 2 + 1 + 6 + truncPreConfig = 5 + 10 + 2 + 1 + 6 + 2 ) func scalarVectors() []scalarVector { @@ -177,6 +183,7 @@ func scalarVectors() []scalarVector { {"meta_full", EncodeMeta(scalarMetaFull())}, {"meta_trunc_pre_config", trunc[:len(trunc)-truncPreConfig]}, {"meta_trunc_pre_controls", trunc[:len(trunc)-truncPreControls]}, + {"meta_trunc_pre_kind", trunc[:len(trunc)-truncPreKind]}, {"meta_trunc_pre_largeroom", trunc[:len(trunc)-truncPreLargeRoom]}, {"meta_trunc_pre_lifecycle", trunc[:len(trunc)-truncPreLifecycle]}, {"meta_trunc_pre_revision", trunc[:len(trunc)-truncPreRevision]}, @@ -274,21 +281,29 @@ func TestScalarGoldenMetaDecode(t *testing.T) { m.ConfigSpecs = nil m.CtxFeatures, m.HeartbeatMS, m.Lifecycle, m.WireRevision = 0, 0, 0, 0 m.Controls = nil + m.GameKind, m.MaxPayoutMultiplier = 0, 0 }}, {"meta_trunc_pre_largeroom", truncPreLargeRoom, func(m *Meta) { m.CtxFeatures, m.HeartbeatMS, m.Lifecycle, m.WireRevision = 0, 0, 0, 0 m.Controls = nil + m.GameKind, m.MaxPayoutMultiplier = 0, 0 }}, {"meta_trunc_pre_lifecycle", truncPreLifecycle, func(m *Meta) { m.Lifecycle, m.WireRevision = 0, 0 m.Controls = nil + m.GameKind, m.MaxPayoutMultiplier = 0, 0 }}, {"meta_trunc_pre_revision", truncPreRevision, func(m *Meta) { m.WireRevision = 0 m.Controls = nil + m.GameKind, m.MaxPayoutMultiplier = 0, 0 }}, {"meta_trunc_pre_controls", truncPreControls, func(m *Meta) { m.Controls = nil + m.GameKind, m.MaxPayoutMultiplier = 0, 0 + }}, + {"meta_trunc_pre_kind", truncPreKind, func(m *Meta) { + m.GameKind, m.MaxPayoutMultiplier = 0, 0 }}, } for _, tc := range cases { diff --git a/wire/wire.go b/wire/wire.go index 0c72d7d..e1995a9 100644 --- a/wire/wire.go +++ b/wire/wire.go @@ -39,6 +39,10 @@ const Version uint32 = 2 // member-bearing forms) // 6 — declared-controls meta section (u16 count of {u8 kind · // u32 rune | u8 key · str label} after wireRevision) +// 7 — game-kind meta section (u8 kind · u32 maxPayoutMultiplier after +// the controls section), the credits host functions +// (credits_balance/credits_wager/credits_settle) for casino-kind +// guests, and the CtxFeatCredits declaration bit // // Revisions 1–3 predate the field, so artifacts of those eras decode as 0; // only 0 or values ≥ 4 are ever observed on the wire. Any future change that @@ -50,7 +54,7 @@ const Version uint32 = 2 // (rust/src/wire.rs WIRE_REVISION, asserted equal to this constant by // TestRustWireRevisionMatchesWire in this package) — the two must change in // lockstep. -const Revision uint16 = 6 +const Revision uint16 = 7 // Guest export names. const ( @@ -79,6 +83,9 @@ const ( FnKVSet = "kv_set" FnKVDelete = "kv_delete" FnConfigGet = "config_get" + FnCreditsBalance = "credits_balance" + FnCreditsWager = "credits_wager" + FnCreditsSettle = "credits_settle" ) // Frame geometry: 80x24 cells, 24 bytes per v2 grapheme cell. @@ -198,8 +205,35 @@ const ( // after each member's Kind byte in both member-bearing forms. CtxFeatCharacter uint32 = 1 << 1 + // CtxFeatCredits declares that the guest calls the credits host + // functions (casino-kind games; ABI.md §3). Declaration-only: it changes + // no callback encoding, and hosts reject the calls from game-kind guests + // regardless. + CtxFeatCredits uint32 = 1 << 2 + // KnownCtxFeatures is the mask of bits this wire revision defines. - KnownCtxFeatures uint32 = CtxFeatRosterEpoch | CtxFeatCharacter + KnownCtxFeatures uint32 = CtxFeatRosterEpoch | CtxFeatCharacter | CtxFeatCredits +) + +// Game kinds (the trailing game-kind meta section, revision 7): every game +// is either a Game (players earn platform credits from results) or a Casino +// game (players gamble credits through the credits host functions). Absent +// section = GameKindGame, so every pre-revision-7 artifact keeps its meaning. +const ( + GameKindGame uint8 = 0 + GameKindCasino uint8 = 1 +) + +// Credits host-function status codes (ABI.md §3). credits_balance returns +// the balance (>= 0) or one of these negatives; credits_wager and +// credits_settle return CreditsOK or a negative. All three are i64 on the +// wire. +const ( + CreditsOK int64 = 0 + CreditsErrInsufficient int64 = -1 // wager exceeds the balance or a platform bet limit + CreditsErrDisabled int64 = -2 // the economy is disabled on this host: render out-of-service, do not trap + CreditsErrDenied int64 = -3 // not a casino-kind guest, unknown seat, or no open stake to settle + CreditsErrUnavailable int64 = -4 // transient store failure; the bet did not happen ) // Meta is the packed GameMeta. @@ -263,6 +297,20 @@ type Meta struct { // (count 0 when empty); decoders treat a payload ending after the // wire-revision field as a valid older meta with no declarations. Controls []ControlDecl + + // GameKind + MaxPayoutMultiplier form the trailing game-kind section + // (spec minor addition, revision 7; u8 + u32 after the controls + // section): GameKind classifies the game for the platform economy + // (GameKindGame earns credits from results; GameKindCasino wagers them + // through the credits host functions), and MaxPayoutMultiplier is a + // casino game's declared per-stake payout ceiling — the host clamps + // every settlement to stake x this multiplier (itself clamped by a + // platform ceiling). Encoders always write the section; decoders treat + // a payload ending after the controls section as a valid older meta of + // kind GameKindGame. MaxPayoutMultiplier MUST be 0 for GameKindGame and + // >= 1 for GameKindCasino (ValidateGameKind). + GameKind uint8 + MaxPayoutMultiplier uint32 } // Lifecycle values for the meta trailer. @@ -644,6 +692,10 @@ func EncodeMeta(m Meta) []byte { } w.Str(cd.Label) } + // Trailing game-kind section (spec minor addition, revision 7). Always + // written; older decoders ignore the bytes. + w.U8(m.GameKind) + w.U32(m.MaxPayoutMultiplier) return w.B } @@ -721,6 +773,12 @@ func DecodeMeta(b []byte) (Meta, error) { m.Controls = append(m.Controls, cd) } } + // Trailing game-kind section, presence-guarded: absent = GameKindGame + // with no declared multiplier (every pre-revision-7 artifact). + if !r.Bad && r.Off < len(r.B) { + m.GameKind = r.U8() + m.MaxPayoutMultiplier = r.U32() + } if err := r.Err(); err != nil { return Meta{}, err } @@ -854,6 +912,27 @@ func ValidateMetaTrailer(ctxFeatures uint32, heartbeatMS uint16) error { return nil } +// ValidateGameKind enforces the game-kind authoring rules, shared by SDK +// encoders and host/CLI decoders: a known kind; a casino game MUST declare +// its payout ceiling (>= 1 — the host clamps every settlement to stake x +// multiplier, so an undeclared ceiling would freeze payouts at zero); a +// game-kind game MUST NOT declare one (it cannot wager). +func ValidateGameKind(kind uint8, maxPayoutMultiplier uint32) error { + switch kind { + case GameKindGame: + if maxPayoutMultiplier != 0 { + return fmt.Errorf("wire: game-kind game declares MaxPayoutMultiplier %d (must be 0)", maxPayoutMultiplier) + } + case GameKindCasino: + if maxPayoutMultiplier == 0 { + return errors.New("wire: casino game must declare MaxPayoutMultiplier >= 1") + } + default: + return fmt.Errorf("wire: unknown game kind %d", kind) + } + return nil +} + // ---- Frames ---------------------------------------------------------------------- // PutCell writes one cell at index i (0..FrameCells-1) into a FrameBytes buffer From b866a3de9b88ed0e40505cc6362baade1cd1ddc6 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Fri, 3 Jul 2026 11:26:01 +1000 Subject: [PATCH 2/2] kittest: mirror the host's per-hand payout clamp in the credits double Review finding: the Settle double claimed production escrow semantics but omitted the MaxPayoutMultiplier clamp memsvc and the real host apply. Set Room.CreditsMaxPayoutMultiplier to your Meta declaration and unit tests exercise the same ceiling check/production enforce; 0 keeps the old unclamped behavior. Co-Authored-By: Claude Fable 5 --- kittest/kittest.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/kittest/kittest.go b/kittest/kittest.go index 323fc7e..43fd8cb 100644 --- a/kittest/kittest.go +++ b/kittest/kittest.go @@ -74,6 +74,13 @@ type Room struct { // CreditsSeed is the first-touch balance (default 1000, set by NewRoom). CreditsSeed int64 + // CreditsMaxPayoutMultiplier mirrors the host's per-hand payout clamp: + // when > 0, Settle truncates the gross payout to stake x this value — + // set it to your Meta's MaxPayoutMultiplier so unit tests exercise the + // same ceiling `shellcade-kit check` and production enforce. 0 = no + // clamp (the pre-clamp behavior). + CreditsMaxPayoutMultiplier uint32 + // CreditsDisabled simulates a host with the economy switched off: every // credits call returns kit.ErrEconomyDisabled, the state a casino game // must render as out-of-service rather than trap. @@ -244,12 +251,18 @@ func (c credits) Settle(p kit.Player, payout int64) error { if c.r.CreditsDisabled { return kit.ErrEconomyDisabled } - if c.r.CreditsStakes[p.AccountID] == 0 { + stake := c.r.CreditsStakes[p.AccountID] + if stake == 0 { return kit.ErrCreditsDenied } if payout < 0 { payout = 0 } + if m := c.r.CreditsMaxPayoutMultiplier; m > 0 { + if lim := stake * int64(m); payout > lim { + payout = lim + } + } delete(c.r.CreditsStakes, p.AccountID) c.r.Credits[p.AccountID] = c.balance(p.AccountID) + payout return nil