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
5 changes: 5 additions & 0 deletions .changeset/credits-buyback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kit": minor
---

Add the `credits_buyback` host function (wire revision 8) for casino-kind games: a broke player can trigger a mid-session rebuy without leaving the game. `Credits.Buyback(p) (int64, error)` returns the new balance (symmetric to `Balance`); the host gates it (broke-only floor + a per-day rebuy limit it owns) and makes the credited amount wagerable in the current seat. Additive — existing games are unaffected. The guest SDK (Go + Rust), the host `sdk.CreditsService`, the `memsvc`/`kittest`/dev-runner doubles, and `ABI.md` all gain the method.
17 changes: 13 additions & 4 deletions ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ means not-found.
| `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 |
| `credits_buyback` | (i64 playerIdx) → i64 | broke-relief rebuy for a player who ran out mid-session; returns the new balance (≥ 0) or a negative status. The host gates it (broke-only floor + per-day rebuy limit); a refusal is `-1` insufficient with the balance unchanged. Revision 8. |

`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 @@ -114,9 +115,10 @@ 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:
**Credits (casino-kind games, revision 7; `credits_buyback` added in
revision 8).** The `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,
Expand All @@ -130,7 +132,14 @@ 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.
restore — a game never persists a balance of its own. Buyback semantics
(`credits_buyback`, revision 8): a casino guest offers a broke player a rebuy;
the host applies it only when the player is broke (balance below a platform
floor, open stake excluded) and under a per-day rebuy limit, credits the
platform's buyback amount to the account AND makes it wagerable in the current
seat, and returns the new balance. A refusal (solvent, or the daily limit
reached) is `-1` insufficient with the balance unchanged — render it, do not
retry.

## 4. Payload encodings

Expand Down
4 changes: 4 additions & 0 deletions host/gameabi/gamekind_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ func (f *fakeCredits) Settle(context.Context, sdk.Player, int64) error {
f.calls++
return f.err
}
func (f *fakeCredits) Buyback(context.Context, sdk.Player) (int64, error) {
f.calls++
return f.balance, 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
Expand Down
8 changes: 8 additions & 0 deletions host/gameabi/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,14 @@ func hostFunctions() []extism.HostFunction {
return wire.CreditsOK, svc.Settle(cctx, pl, payout)
}))
}),
hf(wire.FnCreditsBuyback, []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.Buyback(cctx, pl)
}))
}),
}
}

Expand Down
24 changes: 24 additions & 0 deletions host/memsvc/memsvc.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,30 @@ func (c *memCredits) Settle(_ context.Context, p sdk.Player, payout int64) error
return nil
}

// memBuybackFloor / memBuybackAmount mirror the platform defaults so `check`
// and the dev runner exercise the same broke-relief shape. memsvc does NOT
// enforce the platform's per-day rebuy limit — that count cap lives only in
// the durable ledger; here a broke player can always rebuy.
const (
memBuybackFloor int64 = 100
memBuybackAmount int64 = 1000
)

func (c *memCredits) Buyback(_ context.Context, p sdk.Player) (int64, error) {
c.f.mu.Lock()
defer c.f.mu.Unlock()
acc := c.account(p)
// Broke-only: solvent players are refused (as the real floor gate does).
// The open stake counts toward solvency — a seat mid-hand is not broke.
if acc.balance+acc.stakes[c.roomID] >= memBuybackFloor {
return acc.balance, sdk.ErrInsufficientCredits
}
if acc.balance < memBuybackAmount {
acc.balance = memBuybackAmount
}
return acc.balance, nil
}

// ---- per-user KV ----

type kvKey struct{ slug, account, key string }
Expand Down
37 changes: 37 additions & 0 deletions host/memsvc/memsvc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,40 @@ func (g testGame) Meta() sdk.GameMeta { return sdk.GameMeta{Slug: g.slug, Name:
func (g testGame) NewRoom(cfg sdk.RoomConfig, svc sdk.Services) sdk.Handler {
return sdk.Base{} // the Reader path never builds a room; identity only
}

// The credits double supports the mid-session broke-relief rebuy: a solvent
// player is refused, a broke player (open stake excluded) is topped up, and a
// player mid-hand (all funds in the open stake) still counts as broke only
// once the stake is settled to a loss.
func TestCreditsBuyback(t *testing.T) {
f := quietFactory(t)
svc := f.For("room-buyback", "demo")
ctx := context.Background()
p := member("a", "Ada")

// Seeded at 1000: solvent, so buyback refuses and leaves the balance.
if bal, err := svc.Credits.Buyback(ctx, p); !errIs(err, sdk.ErrInsufficientCredits) || bal != 1000 {
t.Fatalf("solvent buyback = %d err=%v, want 1000 + ErrInsufficientCredits", bal, err)
}

// Wager the whole balance and lose it: now broke.
if err := svc.Credits.Wager(ctx, p, 1000); err != nil {
t.Fatalf("wager: %v", err)
}
// Mid-hand (stake open) is NOT broke — the stake counts toward solvency.
if _, err := svc.Credits.Buyback(ctx, p); !errIs(err, sdk.ErrInsufficientCredits) {
t.Fatalf("mid-hand buyback err = %v, want ErrInsufficientCredits (stake is not broke)", err)
}
if err := svc.Credits.Settle(ctx, p, 0); err != nil { // lose it
t.Fatalf("settle loss: %v", err)
}
if bal, _ := svc.Credits.Balance(ctx, p); bal != 0 {
t.Fatalf("balance after loss = %d, want 0", bal)
}
// Now broke: buyback tops up to the amount and returns the new balance.
if bal, err := svc.Credits.Buyback(ctx, p); err != nil || bal != 1000 {
t.Fatalf("broke buyback = %d err=%v, want 1000", bal, err)
}
}

func errIs(err, target error) bool { return err != nil && err == target }
5 changes: 5 additions & 0 deletions host/sdk/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ type CreditsService interface {
// 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
// Buyback applies the platform broke-relief rebuy for a player who ran
// out mid-session and returns the new balance. The implementation owns
// the gate (broke-only floor, per-day rebuy limit) and makes the credited
// amount wagerable in the seat; a refusal is ErrInsufficientCredits.
Buyback(ctx context.Context, p Player) (int64, error)
}

// ServicesFactory constructs a per-room Services. It has distinct
Expand Down
8 changes: 8 additions & 0 deletions internal/game/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ type Credits interface {
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
// Buyback triggers the platform broke-relief rebuy for a player who has
// run out of credits mid-session, returning the new balance. The host
// gates it (a broke-only floor and a per-day rebuy limit); a refusal
// (already solvent, or the daily limit reached) surfaces as
// ErrInsufficientCredits with the balance unchanged — render it, do not
// retry. The credited amount lands in the player's account AND is made
// wagerable in the current seat.
Buyback(p Player) (int64, error)
}

// Credits errors, mirrored from the ABI status codes. ErrEconomyDisabled
Expand Down
13 changes: 13 additions & 0 deletions internal/game/devrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,19 @@ func (c nativeCredits) Settle(p Player, payout int64) error {
return nil
}

func (c nativeCredits) Buyback(p Player) (int64, error) {
st := c.state(p)
// Broke-only, mirroring the platform floor/amount defaults; the dev
// runner does not model the per-day rebuy limit.
if st.balance+st.stake >= 100 {
return st.balance, ErrInsufficientCredits
}
if st.balance < 1000 {
st.balance = 1000
}
return st.balance, nil
}

type nativeAccounts struct{ r *nativeRoom }

func (a nativeAccounts) For(p Player) Account { return nativeAccount{a.r, p} }
Expand Down
7 changes: 7 additions & 0 deletions internal/game/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ func hostCreditsWager(playerIdx uint64, amount uint64) uint64
//go:wasmimport extism:host/user credits_settle
func hostCreditsSettle(playerIdx uint64, payout uint64) uint64

// credits_buyback (revision 8): broke-relief rebuy; returns the new balance
// (>= 0) or a negative wire.CreditsErr* (ErrInsufficient = refused: solvent
// or daily limit reached).

//go:wasmimport extism:host/user credits_buyback
func hostCreditsBuyback(playerIdx 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) }
Expand Down
12 changes: 12 additions & 0 deletions internal/game/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,18 @@ func (c creditsSvc) Settle(p Player, payout int64) error {
return creditsErr(int64(hostCreditsSettle(uint64(idx), uint64(payout))))
}

func (c creditsSvc) Buyback(p Player) (int64, error) {
idx := c.index(p)
if idx < 0 {
return 0, ErrCreditsDenied
}
code := int64(hostCreditsBuyback(uint64(idx)))
if code < 0 {
return 0, creditsErr(code)
}
return code, nil
}

type accountStore struct{ r *room }

func (s accountStore) For(p Player) Account {
Expand Down
39 changes: 39 additions & 0 deletions kittest/kittest.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ type Room struct {
// credits call returns kit.ErrEconomyDisabled, the state a casino game
// must render as out-of-service rather than trap.
CreditsDisabled bool

// CreditsBuybackFloor / CreditsBuybackAmount configure the broke-relief
// rebuy (defaults 100 / 1000, the platform defaults). Buyback refuses a
// solvent player (balance + open stake >= floor) with
// ErrInsufficientCredits and otherwise tops the balance up to the amount.
// The kittest double does NOT enforce the platform's per-day rebuy limit
// (that lives in the durable ledger); CreditsRebuys counts calls that
// credited, for test assertions.
CreditsBuybackFloor int64
CreditsBuybackAmount int64
CreditsRebuys map[string]int
}

// NewRoom returns a Room with the given members, a seeded RNG (seed 1), and a
Expand Down Expand Up @@ -268,6 +279,34 @@ func (c credits) Settle(p kit.Player, payout int64) error {
return nil
}

func (c credits) Buyback(p kit.Player) (int64, error) {
if c.r.CreditsDisabled {
return 0, kit.ErrEconomyDisabled
}
floor := c.r.CreditsBuybackFloor
if floor == 0 {
floor = 100
}
amount := c.r.CreditsBuybackAmount
if amount == 0 {
amount = 1000
}
bal := c.balance(p.AccountID)
// Broke-only: a solvent player — counting any open stake — is refused,
// exactly as the platform floor gate does.
if bal+c.r.CreditsStakes[p.AccountID] >= floor {
return bal, kit.ErrInsufficientCredits
}
if bal < amount {
c.r.Credits[p.AccountID] = amount
}
if c.r.CreditsRebuys == nil {
c.r.CreditsRebuys = map[string]int{}
}
c.r.CreditsRebuys[p.AccountID]++
return c.r.Credits[p.AccountID], nil
}

type config struct{ r *Room }

func (c config) Get(_ context.Context, key string) ([]byte, bool, error) {
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.lock

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

26 changes: 26 additions & 0 deletions rust/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ mod imp {
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;
/// credits_buyback(i64 playerIdx) → i64 new balance (>= 0) or a
/// negative CREDITS_ERR_* (ABI.md §3; revision 8).
fn credits_buyback(player_idx: u64) -> i64;
}

/// Stage pre-serialized bytes in extism kernel memory. Infallible by
Expand Down Expand Up @@ -172,6 +175,11 @@ mod imp {
// SAFETY: as host_credits_balance.
unsafe { credits_settle(player_idx as u64, payout) }
}

pub fn host_credits_buyback(player_idx: usize) -> i64 {
// SAFETY: as host_credits_balance.
unsafe { credits_buyback(player_idx as u64) }
}
}

// ---- native test host (cargo test) ---------------------------------------------
Expand Down Expand Up @@ -337,6 +345,24 @@ mod imp {
crate::wire::CREDITS_OK
})
}

pub fn host_credits_buyback(player_idx: usize) -> i64 {
with_test_host(|h| {
if h.credits_disabled {
return crate::wire::CREDITS_ERR_DISABLED;
}
// Broke-only, platform floor/amount defaults (100 / 1000); the
// test host does not model the per-day rebuy limit.
let bal = credits_balance_of(h, player_idx);
let stake = h.credits_stakes.get(&player_idx).copied().unwrap_or(0);
if bal + stake >= 100 {
return crate::wire::CREDITS_ERR_INSUFFICIENT;
}
let new_bal = bal.max(1000);
h.credits.insert(player_idx, new_bal);
new_bal
})
}
}

pub(crate) use imp::*;
Expand Down
10 changes: 5 additions & 5 deletions rust/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ fn decode_members(r: &mut Rd<'_>, n: usize, features: u32) -> Vec<Player> {
/// `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 = 7;
pub(crate) const WIRE_REVISION: u16 = 8;

/// Credits host-function status codes (ABI.md §3): `credits_balance` returns
/// the balance (>= 0) or one of the negatives; wager/settle return
Expand Down Expand Up @@ -635,7 +635,7 @@ mod tests {
],
..Meta::DEFAULT
};
let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000000000000000000070000000000000000";
let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000000000000000000080000000000000000";
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");
}
Expand All @@ -658,7 +658,7 @@ mod tests {
],
..Meta::DEFAULT
};
let golden = "040063746c67040043746c47000001000200000000000000000000000000000000000000070002000072000000060052455349474e01020400554e444f0000000000";
let golden = "040063746c67040043746c47000001000200000000000000000000000000000000000000080002000072000000060052455349474e01020400554e444f0000000000";
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");
}
Expand Down Expand Up @@ -790,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 7 LE
// trailer = u32 1 LE + u16 100 LE + u8 lifecycle + u16 revision 8 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..]);
assert!(got.ends_with("000001000000640000080000000000000000"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-36..]);
}
}

Expand Down
8 changes: 4 additions & 4 deletions rust/tests/golden/scalars.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +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 = 070064656661756c740000000001000100000000000000000000000000000000000000070000000000000000
meta_full = 0b00676f6c64656e2d66756c6c0b00476f6c64656e2046756c6c170065766572792073656374696f6e20706f70756c6174656402000800020005006d756c74690400636172640a004465616c206d6520696e080050726163746963650d004a6f696e206d79207461626c65010500636869707301010202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000001000000fa0001070002000072000000060052455349474e01020400554e444f0110270000
meta_default = 070064656661756c740000000001000100000000000000000000000000000000000000080000000000000000
meta_full = 0b00676f6c64656e2d66756c6c0b00476f6c64656e2046756c6c170065766572792073656374696f6e20706f70756c6174656402000800020005006d756c74690400636172640a004465616c206d6520696e080050726163746963650d004a6f696e206d79207461626c65010500636869707301010202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000001000000fa0001080002000072000000060052455349474e01020400554e444f0110270000
meta_trunc_pre_config = 05007472756e6305005472756e63000001000400000000000000000001050073636f7265010000
meta_trunc_pre_controls = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400010700
meta_trunc_pre_kind = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400010700010001020400554e444f
meta_trunc_pre_controls = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400010800
meta_trunc_pre_kind = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400010800010001020400554e444f
meta_trunc_pre_largeroom = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000
meta_trunc_pre_lifecycle = 05007472756e6305005472756e63000001000400000000000000000001050073636f72650100000000010000006400
meta_trunc_pre_revision = 05007472756e6305005472756e63000001000400000000000000000001050073636f7265010000000001000000640001
Expand Down
Loading
Loading