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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/credits-economy-abi.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 44 additions & 2 deletions ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -184,14 +205,16 @@ 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)
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crossverify/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions host/gameabi/codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
5 changes: 3 additions & 2 deletions host/gameabi/codec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion host/gameabi/controls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
121 changes: 121 additions & 0 deletions host/gameabi/gamekind_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading