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..43fd8cb 100644 --- a/kittest/kittest.go +++ b/kittest/kittest.go @@ -62,6 +62,29 @@ 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 + + // 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. + CreditsDisabled bool } // NewRoom returns a Room with the given members, a seeded RNG (seed 1), and a @@ -129,7 +152,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 +205,69 @@ 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 + } + 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 +} + 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