diff --git a/.changeset/fix-guest-room-roster.md b/.changeset/fix-guest-room-roster.md new file mode 100644 index 0000000..53297f2 --- /dev/null +++ b/.changeset/fix-guest-room-roster.md @@ -0,0 +1,5 @@ +--- +"kit": patch +--- + +Fix casino games showing a 0 balance and refusing wagers. The guest runtime built a fresh `room` every callback but created the game's `Handler` (and its stored `Services`) only once at `OnStart`, so `svc.Credits` was permanently bound to the *start* callback's roster. `creditsSvc` resolves a player's host-side index against that roster's `ctx.members`, which is empty for a solo game (the player joins after start) — so every `Balance`/`Wager`/`Settle` was denied at the guest facade (`ErrCreditsDenied`) and never reached the host. The room is now a single persistent instance refreshed in place each callback, so the game's stored `Services` always resolve against the live roster. (The `kittest`/native/memsvc doubles resolve by account id, not roster index, which is why unit tests and conformance did not catch this — it only manifested on the real wasm ABI path.) Casino games must be rebuilt against this release to pick up the fix. diff --git a/internal/game/run.go b/internal/game/run.go index 574436c..348f490 100644 --- a/internal/game/run.go +++ b/internal/game/run.go @@ -18,6 +18,16 @@ var ( theGame Game handler Handler rng *rand.Rand + // theRoom is the single, persistent Room, refreshed in place each callback + // (its ctx is reassigned in decodeCall). The game's Services are built ONCE + // from it at NewRoom and stored by the game, so any roster-dependent surface + // they expose must see the LIVE roster: Credits resolves a player's host + // index against ctx.members, so a fresh room per callback would bind the + // game's stored svc.Credits to the START roster forever — denying credits to + // everyone who joins after start (a solo player joins after OnStart, so its + // start roster is empty and every Balance/Wager is denied). One reused room + // keeps the stored Services pointed at the current ctx. + theRoom = &room{} ) // Run installs the game. The instance's Handler is created lazily on start. @@ -68,7 +78,12 @@ func decodeCall() (*room, *wire.Rd) { if rosterChanged { invalidateBaselines() } - rm := &room{ctx: ctx, rng: rng} + // Refresh the single persistent room in place (NOT a fresh room each + // callback) so the game's once-built Services keep resolving against the + // live ctx — see theRoom. + theRoom.ctx = ctx + theRoom.rng = rng + rm := theRoom if epochMismatch && !epochMismatchLogged { // Host fault: an unchanged-form ctx carried an epoch we don't hold. // Degraded (cached roster kept, baselines invalidated) — warn once.