From 050c3a47db3bc1b47e1d9dc99b023f1f63799472 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Fri, 3 Jul 2026 19:02:19 +1000 Subject: [PATCH 1/4] blackjack: convert to casino-kind with platform Credits (kit v2.16.0) Declares Kind=casino, MaxPayoutMultiplier=26, CtxFeatCredits; removes the game's internal chip/KV wallet and wires bets through svc.Credits Wager/Settle/Balance with one open stake per round, a single stake-inclusive Settle, Settle-on-leave/abandon so escrow never leaks, and in-game rebuy via svc.Credits.Buyback when busted. Peak-Credits leaderboard. Side bets/double/split accumulate onto one grossThisRound, settled once. Verified: go build/vet/test, tinygo, and shellcade-kit check --require-leaderboard. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W7oJQSTva5xgbAZomdV5Jj --- games/bcook/blackjack/game.go | 24 +- games/bcook/blackjack/go.mod | 2 +- games/bcook/blackjack/go.sum | 4 +- games/bcook/blackjack/layout.go | 23 +- games/bcook/blackjack/main.go | 8 +- games/bcook/blackjack/room.go | 374 +++++++++++++++++------------ games/bcook/blackjack/room_test.go | 266 +++++++++++++------- 7 files changed, 429 insertions(+), 272 deletions(-) diff --git a/games/bcook/blackjack/game.go b/games/bcook/blackjack/game.go index 8412a46..49c2660 100644 --- a/games/bcook/blackjack/game.go +++ b/games/bcook/blackjack/game.go @@ -21,22 +21,32 @@ func (Game) Meta() kit.GameMeta { // no hibernation snapshot, no Resume-menu entry (kit v2.7.0). Lifecycle: kit.LifecycleEphemeral, + // A casino-kind game (kit v2.16.0): players gamble their account-wide + // platform Credits through the room's svc.Credits service — the host + // owns every balance. MaxPayoutMultiplier is the settlement ceiling: + // the top single-stake outcome is a Perfect Pairs "perfect" pair at + // 25:1, which returns stake×(25+1) = 26× on that side stake; the + // mandatory main bet only dilutes the per-seat aggregate, so 26 covers + // the largest honest payout. CtxFeatCredits is declared alongside so a + // credits-capable front end negotiates the encoding. + Kind: kit.GameKindCasino, + MaxPayoutMultiplier: 26, + // Per-member arcade characters (kit v2.9.0): every roster member // arrives with Player.Character populated, rendered as a one-cell // tile right before the player's name (seat rail + turn waits). - CtxFeatures: kit.CtxFeatCharacter, + CtxFeatures: kit.CtxFeatCharacter | kit.CtxFeatCredits, QuickModeLabel: "Join a table", SoloModeLabel: "Heads-up vs dealer", PrivateInviteLine: "Friends take a seat when they enter the code.", - // The native game is no-winner (it never End/Posts) and surfaced its - // board via a custom KV-backed peak provider. The lean ABI has no custom - // provider, so the board is declared here and fed with Post on a new - // personal peak — the same high-water-mark metric (Chips), keyed off the - // durable wallet's `peak` (see room.go persistWallet / postPeak). + // The board is a peak account-wide-credits metric: after each settle the + // seat's fresh platform balance (svc.Credits.Balance) is compared to its + // high-water mark and Posted on a new personal best (see room.go + // postPeak). BestResult keeps each account's highest observed balance. Leaderboard: &kit.LeaderboardSpec{ - MetricLabel: "Chips", + MetricLabel: "Credits", Direction: kit.HigherBetter, Aggregation: kit.BestResult, Format: kit.Integer, diff --git a/games/bcook/blackjack/go.mod b/games/bcook/blackjack/go.mod index 4d50e48..b8f9bbc 100644 --- a/games/bcook/blackjack/go.mod +++ b/games/bcook/blackjack/go.mod @@ -2,7 +2,7 @@ module shellcade.games/bcook/blackjack go 1.25.11 -require github.com/shellcade/kit/v2 v2.15.0 +require github.com/shellcade/kit/v2 v2.16.0 require ( github.com/extism/go-pdk v1.1.3 // indirect diff --git a/games/bcook/blackjack/go.sum b/games/bcook/blackjack/go.sum index d39f6ff..010ad51 100644 --- a/games/bcook/blackjack/go.sum +++ b/games/bcook/blackjack/go.sum @@ -1,7 +1,7 @@ github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= -github.com/shellcade/kit/v2 v2.15.0 h1:/C/xsAlteeTMbJ+i+1qOcWXECeib4+dduTyPpEGq9bU= -github.com/shellcade/kit/v2 v2.15.0/go.mod h1:EYbqrycZyGxzjUnklDlKBPgU8bCftTVJFKVCw/+gvdw= +github.com/shellcade/kit/v2 v2.16.0 h1:ZU3SCkLqmJMbKqxnBMfEocYsjLiGoHaq48pWrFIkV1s= +github.com/shellcade/kit/v2 v2.16.0/go.mod h1:EYbqrycZyGxzjUnklDlKBPgU8bCftTVJFKVCw/+gvdw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go index a0d216c..47207c4 100644 --- a/games/bcook/blackjack/layout.go +++ b/games/bcook/blackjack/layout.go @@ -61,6 +61,15 @@ func (rm *room) render(r kit.Room) { } func (rm *room) compose(f *kit.Frame, v kit.Player) { + // A casino game with no host economy renders out-of-service rather than + // trapping: the table cannot take a bet without the Credits service. + if rm.economyOff() { + f.Text(0, 1, "♠♥♦♣ BLACKJACK", stTitle) + drawFelt(f, feltTop, feltBottom) + center(f, (feltTop+feltBottom)/2, "table closed — credits unavailable", stDim) + return + } + active, _ := rm.firstUnresolved() // Title + phase/time above the felt. @@ -102,7 +111,7 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) { f.Text(kit.Rows-1, 1, "Esc leave", stDim) if s := rm.seats[v.AccountID]; s != nil { - f.TextRight(kit.Rows-1, kit.Cols-1, fmt.Sprintf("chips %d HI %d", s.chips, s.highScore), stDim) + f.TextRight(kit.Rows-1, kit.Cols-1, fmt.Sprintf("credits %d HI %d", s.bal, s.highScore), stDim) } } @@ -184,7 +193,7 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act status = fmt.Sprintf("bet %d?", s.bet) } centerSlot(f, seatCardRow+1, slot, status, stDim) - centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.chips), stDim) + centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.bal), stDim) return } // A seat with no hand sat the round out (it never placed a bet). This keys off @@ -194,7 +203,7 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act if len(s.hands) == 0 { centerSlot(f, seatCardRow+1, slot, "no bet", stDim) centerSlot(f, seatValRow, slot, "sat out", stDim) - centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.chips), stDim) + centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.bal), stDim) return } @@ -221,7 +230,7 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act if rm.phase == phResults && s.result != "" { centerSlot(f, seatChipRow, slot, s.result, resultStyle(s.result)) } else { - centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.chips), stDim) + centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.bal), stDim) } return } @@ -254,7 +263,7 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act if rm.phase == phResults && s.result != "" { centerSlot(f, seatChipRow, slot, s.result, resultStyle(s.result)) } else { - centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.chips), stDim) + centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.bal), stDim) } } @@ -476,10 +485,10 @@ func legalActions(s *seat, h *phand) string { } parts := []string{"[H]it", "[S]tand"} first := len(h.cards) == 2 && !h.doubled - if first && s.chips >= h.bet { + if first && s.bal >= h.bet { parts = append(parts, "[D]ouble") } - if first && h.cards[0].r == h.cards[1].r && s.chips >= h.bet && len(s.hands) < maxHands { + if first && h.cards[0].r == h.cards[1].r && s.bal >= h.bet && len(s.hands) < maxHands { parts = append(parts, "[P]split") } if first && len(s.hands) == 1 { diff --git a/games/bcook/blackjack/main.go b/games/bcook/blackjack/main.go index 76f8719..5a29a88 100644 --- a/games/bcook/blackjack/main.go +++ b/games/bcook/blackjack/main.go @@ -5,8 +5,10 @@ // Chips are integers: an odd bet's half-chip on the 3:2 payout and on a // surrender return rounds UP to the player; the insurance stake is half the // bet rounded down and pays exactly 2:1 on what was staked. Leaving with a -// live hand forfeits its stake. Each seat carries a durable wallet (start -// 1000, re-buy on bust) and the board ranks your high-water mark. +// live hand forfeits its stake. Stakes are the platform's account-wide Credits +// (kit v2.16.0 casino ABI): every bet Wagers onto the seat's single open stake +// and the round Settles that stake exactly once with the gross payout; the host +// owns every balance and the board ranks your peak credits. // // The wasm ABI has no timers, ticks, or phases: every "later…" here is a // deadline held in guest memory and checked against r.Now() inside OnWake (the @@ -18,7 +20,7 @@ // This is the native entry point; the wasm exports live in exports.go. The game // logic shares this package so `go run .` plays it. // -// Build (artifact): tinygo build -opt=1 -no-debug -gc=leaking \ +// Build (artifact): tinygo build -opt=1 -no-debug -gc=conservative \ // -o game.wasm -target wasip1 -buildmode=c-shared . package main diff --git a/games/bcook/blackjack/room.go b/games/bcook/blackjack/room.go index 1f01ee5..3d85510 100644 --- a/games/bcook/blackjack/room.go +++ b/games/bcook/blackjack/room.go @@ -1,10 +1,8 @@ package main import ( - "context" "sort" "strconv" - "strings" "time" kit "github.com/shellcade/kit/v2" @@ -21,9 +19,7 @@ const ( ) const ( - startChips = 1000 - rebuyChips = 1000 - maxHands = 4 // a seat may split up to four hands + maxHands = 4 // a seat may split up to four hands bettingDur = 15 * time.Second insuranceDur = 10 * time.Second @@ -34,11 +30,12 @@ const ( // and dealing, so an all-bets-in table deals early without feeling abrupt. gracePeriod = time.Second - // wallet KV keys + the first-ever stack, mirroring the native casino package - // (balance: merge sum, the carryable bankroll; peak: merge max, the - // high-water mark and the leaderboard metric). - keyBalance = "balance" - keyPeak = "peak" + // maxPayoutMult mirrors Meta().MaxPayoutMultiplier: the host clamps every + // Settle to the seat's open stake × this multiplier, so the game clamps its + // own displayed/settled gross to the same ceiling before Settle. 26 covers a + // Perfect Pairs "perfect" pair (25:1 -> stake×26); the mandatory main bet + // only dilutes the per-seat aggregate, so it is never reached in practice. + maxPayoutMult = 26 ) // betTiers are the selectable stakes, lowest first. The ×2.5/×2/×2 climb repeats @@ -76,10 +73,22 @@ type backBet struct { // seat is one player's place at the table. Keyed by account id in the room map // so it survives a hibernation freeze/thaw (connections change; accounts don't). type seat struct { - p kit.Player - chips int - highScore int - postedPeak int // last peak Posted to the board (post only on increase) + p kit.Player + // bal is the cached account-wide credits balance (svc.Credits.Balance), + // refreshed at join, after each Wager (decremented), and after each Settle / + // Buyback (re-read). It drives the HUD and every betting-affordability check; + // the platform owns the authoritative balance — this is only a per-tick cache + // so the render/clamp paths never call Balance in a hot loop. + bal int + highScore int // peak balance observed this session (the leaderboard metric) + postedPeak int // last peak Posted to the board (post only on increase) + // grossThisRound accumulates the seat's single open stake's GROSS payout + // (stake-inclusive): side-bet wins fold in as they resolve, hand credits at + // settle, and the whole thing Settles exactly once. roundStake is the total + // escrowed onto that open stake (sum of every Wager this round); >0 marks an + // open, unsettled stake and bounds the payout ceiling (roundStake×maxPayoutMult). + grossThisRound int64 + roundStake int64 bet int // currently selected/placed stake placed bool pairsBet int // Perfect Pairs side stake (0 = off), carried between rounds like bet @@ -143,84 +152,122 @@ type room struct { schedEnd time.Time frame *kit.Frame // reused render scratch (allocation-light steady state) - - // sk standardises the durable-wallet KV writes (PersistWallet), replacing - // the duplicated persistWallet helper. The leaderboard Post stays - // hand-rolled in postPeak because postedPeak is seeded from the durable peak - // at join — so a returning seat only posts on a NEW personal best, which - // ScoreKeeper.Record (always posts the first observed value) would not - // preserve. - sk *kit.ScoreKeeper } func newRoom(cfg kit.RoomConfig, svc kit.Services) *room { - return &room{cfg: cfg, svc: svc, seats: map[string]*seat{}, frame: kit.NewFrame(), sk: kit.NewScoreKeeper(kit.OnImprove)} + return &room{cfg: cfg, svc: svc, seats: map[string]*seat{}, frame: kit.NewFrame()} } +// economyOff reports whether the host has no credits economy (svc.Credits is +// nil). A casino game must render out-of-service rather than trap when the +// economy is disabled — every money path checks this first. +func (rm *room) economyOff() bool { return rm.svc.Credits == nil } + func (rm *room) OnStart(r kit.Room) { rm.sh = newShoe(r.Rand()) + if rm.economyOff() { + rm.render(r) // out-of-service: no economy, no betting + return + } rm.enterBetting(r) rm.render(r) } func (rm *room) OnClose(r kit.Room) { - // Persist every seat's durable wallet on the way out. Synchronous: the wasm - // sandbox has no goroutines, and the host bounds the KV call. + // Book every seat's open stake on the way out so no escrow leaks: a seat with + // an unsettled stake at close Settles its accumulated gross (forfeiting any + // still-unresolved hand as a loss). Synchronous — the wasm sandbox has no + // goroutines and the host bounds the call. for _, id := range rm.order { if s := rm.seats[id]; s != nil { - rm.persistWallet(r, s) + rm.settleOpenStake(s) } } } -// --- durable wallet (the casino pattern over kv) --------------------------- +// --- platform credits (the account-wide casino bankroll) ------------------- -func kvInt(store kit.KVStore, key string) (int, bool) { - v, ok, err := store.Get(context.Background(), key) - if err != nil || !ok { - return 0, false +// wager escrows amt onto the seat's SINGLE open stake via svc.Credits, updating +// the cached balance and the round's total stake. A non-positive amt is a no-op +// success (nothing to escrow). Returns false — the caller MUST reject the action +// and never proceed — when the economy is off or the balance can't cover it +// (mirrors the old `chips < bet` guards). Wager accumulates: repeated calls +// before one Settle all land on the same open stake. +func (rm *room) wager(s *seat, amt int) bool { + if amt <= 0 { + return true } - n, err := strconv.Atoi(strings.TrimSpace(string(v))) - if err != nil { - return 0, false + if rm.economyOff() { + return false + } + if err := rm.svc.Credits.Wager(s.p, int64(amt)); err != nil { + return false // ErrInsufficientCredits (or disabled): reject cleanly + } + s.roundStake += int64(amt) + s.bal -= amt + return true +} + +// refreshBal re-reads the seat's authoritative account-wide balance into the +// cache (after a Settle or Buyback changes it). A disabled/erroring economy +// leaves the cache untouched. +func (rm *room) refreshBal(s *seat) { + if rm.economyOff() { + return + } + if b, err := rm.svc.Credits.Balance(s.p); err == nil { + s.bal = int(b) } - return n, true } -// seedWallet returns the joining player's durable (balance, peak): balance -// defaults to startChips for a first-ever player, and peak is raised to at least -// the balance. A nil account (no durable storage) returns the defaults. -func (rm *room) seedWallet(r kit.Room, p kit.Player) (balance, peak int) { - acct := r.Services().Accounts.For(p) - if acct == nil { - return startChips, startChips +// settleOpenStake closes the seat's single open stake EXACTLY ONCE: it clamps +// the accumulated gross to the payout ceiling (roundStake×maxPayoutMult) and +// Settles it, then clears the round's stake/gross and re-reads the balance. +// Returns the round's net chip change (gross-stake) for the results summary. A +// no-op with no open stake (roundStake==0), so it is safe on the mid-round exit +// paths (OnLeave/OnClose) — a committed-but-unresolved bet Settles its resolved +// gross (0 for a bare main bet), never leaking escrow and never double-settling. +func (rm *room) settleOpenStake(s *seat) int { + if s.roundStake <= 0 { + s.grossThisRound = 0 + return 0 } - store := acct.Store() - bal, ok := kvInt(store, keyBalance) - if !ok { - bal = startChips - _ = store.Set(context.Background(), keyBalance, []byte(strconv.Itoa(bal)), kit.MergeSum) + gross := s.grossThisRound + if lim := s.roundStake * maxPayoutMult; gross > lim { + gross = lim // never settle (or show) more than the host will pay } - pk, _ := kvInt(store, keyPeak) - if pk < bal { - pk = bal - _ = store.Set(context.Background(), keyPeak, []byte(strconv.Itoa(pk)), kit.MergeMax) + net := int(gross - s.roundStake) + if !rm.economyOff() { + _ = rm.svc.Credits.Settle(s.p, gross) } - return bal, pk + s.grossThisRound = 0 + s.roundStake = 0 + rm.refreshBal(s) + return net } -// persistWallet writes the seat's balance and raises peak to >= balance, with -// the same keys/merge rules the native casino package used (sum for balance, max -// for peak — out-of-order or concurrent writes can never regress the metric). -// Delegates to the kit's ScoreKeeper.PersistWallet, which writes the identical -// keys + merge rules, replacing the duplicated casino-wallet helper. -func (rm *room) persistWallet(r kit.Room, s *seat) { - rm.sk.PersistWallet(r, s.p, keyBalance, s.chips, keyPeak, s.highScore) +// buyback triggers the platform broke-relief rebuy for a busted seat, updating +// the cached balance on success. Returns false when the host refuses (solvent or +// the daily rebuy limit reached — render it, do not retry) or the economy is off. +func (rm *room) buyback(s *seat) bool { + if rm.economyOff() { + return false + } + nb, err := rm.svc.Credits.Buyback(s.p) + if err != nil { + return false + } + s.bal = int(nb) + if s.bal > s.highScore { + s.highScore = s.bal + } + return true } // postPeak feeds the declared leaderboard with a new personal peak (the board -// keeps each account's best). KV is durable state; Post is what reaches the -// board. The native game surfaced the same peak via a custom KV provider. +// keeps each account's best). highScore tracks the peak account-wide credits +// balance observed this session (sourced from svc.Credits.Balance after each +// Settle); Post is what reaches the board, only on a new personal best. func (rm *room) postPeak(r kit.Room, s *seat) { if s.highScore <= s.postedPeak { return @@ -241,21 +288,24 @@ func (rm *room) OnJoin(r kit.Room, p kit.Player) { rm.render(r) return } - bal, peak := rm.seedWallet(r, p) - rm.seats[p.AccountID] = &seat{p: p, chips: bal, highScore: peak, postedPeak: peak, bet: betTiers[1], joinOrder: rm.joinSeq} + s := &seat{p: p, bet: betTiers[1], joinOrder: rm.joinSeq} + rm.refreshBal(s) // seed the cached balance from the platform bankroll + s.highScore = s.bal + s.postedPeak = s.bal + rm.seats[p.AccountID] = s rm.joinSeq++ rm.order = append(rm.order, p.AccountID) rm.render(r) } -// OnLeave persists the leaver's wallet and frees the seat. Leaving with a live -// hand forfeits the stake (it was deducted at deal and settle only credits -// seats still in rm.order) — abandoning a dealt hand is a loss, as at a real -// table. Chips are conserved; nothing is credited elsewhere. +// OnLeave books any open stake and frees the seat. Leaving mid-round Settles the +// seat's single open stake exactly once with its accumulated gross (0 for a bare, +// still-unresolved main bet) — abandoning a dealt hand forfeits it as a loss, as +// at a real table, and the escrow is released rather than leaked. func (rm *room) OnLeave(r kit.Room, p kit.Player) { active, _ := rm.firstUnresolved() if s := rm.seats[p.AccountID]; s != nil { - rm.persistWallet(r, s) + rm.settleOpenStake(s) } delete(rm.seats, p.AccountID) for i, id := range rm.order { @@ -339,10 +389,13 @@ func (rm *room) enterBetting(r kit.Room) { s.result = "" s.pairsKind = "" s.pairsWin = 0 - s.focus = "" // re-open editing on the seat's own bet - rm.carryBacks(s) // behind/their-pairs stakes are sticky between rounds - if s.bet > s.chips { - s.bet = clampBet(s.chips) + s.focus = "" // re-open editing on the seat's own bet + s.grossThisRound = 0 // no open stake carries into a fresh betting window + s.roundStake = 0 // + rm.refreshBal(s) // re-sync the cached balance before affordability clamps + rm.carryBacks(s) // behind/their-pairs stakes are sticky between rounds + if s.bet > s.bal { + s.bet = clampBet(s.bal) } rm.clampPairs(s) // a thinned stack may no longer afford the carried side bet rm.clampBacks(s) // nor its carried backs @@ -416,8 +469,8 @@ func (rm *room) adjustBet(s *seat, dir int) { i = len(betTiers) - 1 } s.bet = betTiers[i] - if s.bet > s.chips { - s.bet = clampBet(s.chips) + if s.bet > s.bal { + s.bet = clampBet(s.bal) } } @@ -433,14 +486,14 @@ func tierIndex(bet int) int { // cycleOwnPairs advances the seat's own Perfect Pairs stake one tier, wrapping // back to 0 past the top (and resetting to 0 if the next tier is unaffordable). func (rm *room) cycleOwnPairs(s *seat) { - s.pairsBet = loopTier(pairsTiers, s.pairsBet, s.chips-(s.committed()-s.pairsBet)) + s.pairsBet = loopTier(pairsTiers, s.pairsBet, s.bal-(s.committed()-s.pairsBet)) } // clampPairs lowers the side bet to the highest tier the seat can still afford // alongside its main bet (down to off), so a raised main bet or a thin stack can // never leave an unaffordable side bet placed. func (rm *room) clampPairs(s *seat) { - for s.pairsBet > 0 && s.bet+s.pairsBet > s.chips { + for s.pairsBet > 0 && s.bet+s.pairsBet > s.bal { s.pairsBet = pairsTiers[pairsTierIndex(s.pairsBet)-1] } } @@ -466,8 +519,8 @@ func (rm *room) carryBacks(s *seat) { func (rm *room) clampBacks(s *seat) { for _, tid := range sortedBackIDs(s) { b := s.backs[tid] - b.pairs = affordTier(pairsTiers, b.pairs, s.chips-(s.committed()-b.pairs)) - b.behind = affordTier(pairsTiers, b.behind, s.chips-(s.committed()-b.behind)) + b.pairs = affordTier(pairsTiers, b.pairs, s.bal-(s.committed()-b.pairs)) + b.behind = affordTier(pairsTiers, b.behind, s.bal-(s.committed()-b.behind)) } } @@ -556,12 +609,12 @@ func (s *seat) backOn(target string) *backBet { // other commitments (an unaffordable next tier also resets to 0). func (rm *room) cycleBackPairs(s *seat) { b := s.backOn(s.focus) - b.pairs = loopTier(pairsTiers, b.pairs, s.chips-(s.committed()-b.pairs)) + b.pairs = loopTier(pairsTiers, b.pairs, s.bal-(s.committed()-b.pairs)) } func (rm *room) cycleBackBehind(s *seat) { b := s.backOn(s.focus) - b.behind = loopTier(pairsTiers, b.behind, s.chips-(s.committed()-b.behind)) + b.behind = loopTier(pairsTiers, b.behind, s.bal-(s.committed()-b.behind)) } // loopTier returns the tier one step above cur, wrapping back to 0 (the "off" @@ -592,7 +645,13 @@ func (rm *room) deal(r kit.Room) { if s == nil || !s.placed { continue } - s.chips -= s.bet + // Open the seat's single stake with the main bet. A failed Wager + // (balance can't cover it — should not happen after the betting clamps) + // sits the seat out this round rather than dealing an unbacked hand. + if !rm.wager(s, s.bet) { + s.placed = false + continue + } h := &phand{cards: hand{rm.sh.draw(rng), rm.sh.draw(rng)}, bet: s.bet} if h.cards.isBlackjack() { h.resolved = true @@ -621,21 +680,22 @@ func (rm *room) deal(r kit.Room) { } // resolvePairs settles a seat's Perfect Pairs side bet against its dealt cards: -// the stake is deducted (the main bet was already taken above) and any winning -// pair is credited immediately — the casino way, where the side bet stands apart -// from how the hand goes on to play out. +// the stake is Wagered onto the seat's open stake and any winning pair folds its +// gross into grossThisRound immediately — the casino way, where the side bet +// stands apart from how the hand goes on to play out. A stake the balance can't +// cover is dropped (no side bet this round) rather than proceeding unpaid. func (rm *room) resolvePairs(s *seat, dealt hand) { if s.pairsBet <= 0 || len(dealt) < 2 { return } - if s.pairsBet > s.chips { - s.pairsBet = s.chips // defensive: never deduct more than the seat has left + if !rm.wager(s, s.pairsBet) { + s.pairsBet = 0 + return } - s.chips -= s.pairsBet kind, mult := perfectPairsOutcome(dealt[0], dealt[1]) s.pairsKind = kind s.pairsWin = pairsCreditFor(mult, s.pairsBet) - s.chips += s.pairsWin + s.grossThisRound += int64(s.pairsWin) } // sortedBackIDs returns a seat's back target ids in a deterministic (sorted) @@ -657,9 +717,11 @@ func sortedBackIDs(s *seat) []string { // resolveBackPairs settles the their-Perfect-Pairs side of every seat's backs, // once all hands are dealt: a back on a seat that didn't get dealt in is voided -// (never charged); otherwise the stake is deducted and any winning pair on the -// target's first two cards is credited to the backer immediately. The behind bet -// is committed here too (deducted, held) and settled later against the dealer. +// (never charged); otherwise each stake is Wagered onto the backer's open stake +// and any winning pair on the target's first two cards folds its gross into the +// backer's grossThisRound immediately. The behind bet is Wagered here too +// (committed, held) and settled later against the dealer. A stake the balance +// can't cover is dropped (voided) rather than proceeding unpaid. func (rm *room) resolveBackPairs() { for _, id := range rm.order { s := rm.seats[id] @@ -673,20 +735,19 @@ func (rm *room) resolveBackPairs() { b.behind, b.pairs = 0, 0 // target sat out / gone -> void the back continue } - if b.pairs > s.chips { - b.pairs = s.chips // defensive - } if b.pairs > 0 { - s.chips -= b.pairs - kind, mult := perfectPairsOutcome(t.hands[0].cards[0], t.hands[0].cards[1]) - b.pairsKind = kind - b.pairsWin = pairsCreditFor(mult, b.pairs) - s.chips += b.pairsWin + if rm.wager(s, b.pairs) { + kind, mult := perfectPairsOutcome(t.hands[0].cards[0], t.hands[0].cards[1]) + b.pairsKind = kind + b.pairsWin = pairsCreditFor(mult, b.pairs) + s.grossThisRound += int64(b.pairsWin) + } else { + b.pairs = 0 + } } - if b.behind > s.chips { - b.behind = s.chips // defensive + if b.behind > 0 && !rm.wager(s, b.behind) { + b.behind = 0 // committed now; the dealer comparison happens at settle } - s.chips -= b.behind // committed now; the dealer comparison happens at settle } } } @@ -818,10 +879,12 @@ func (rm *room) takeInsurance(s *seat, yes bool) { s.insuranceDecided = true if yes { ins := s.bet / 2 - if ins > s.chips { - ins = s.chips + if ins <= 0 { + return + } + if !rm.wager(s, ins) { + return // can't cover the insurance stake: decline it } - s.chips -= ins s.insurance = ins } } @@ -869,7 +932,7 @@ func (rm *room) resolveInsurance(r kit.Room) { continue } s.insuranceWin = insuranceCredit(dbj, s.insurance) - s.chips += s.insuranceWin + s.grossThisRound += int64(s.insuranceWin) } if dbj { rm.revealAndSettle(r) @@ -944,10 +1007,15 @@ func (rm *room) act(r kit.Room, p kit.Player, a rune) { h.resolved = true rm.beginTurn(r) case 'd': - if !first || s.chips < h.bet { + // The double's extra stake Wagers onto the seat's open stake; a failed + // Wager (can't cover it) rejects the action, exactly as the old + // `chips < bet` guard did — never proceed on a failed Wager. + if !first { + return + } + if !rm.wager(s, h.bet) { return } - s.chips -= h.bet h.bet *= 2 h.doubled = true h.cards = append(h.cards, rm.sh.draw(r.Rand())) @@ -965,10 +1033,10 @@ func (rm *room) act(r kit.Room, p kit.Player, a rune) { } h.surrendered = true h.resolved = true - // Return half; the bet was deducted at deal. An odd bet's half-chip - // rounds UP to the player (halfUp owns the policy, shared with the - // 3:2 payout in creditFor and the net accounting in settle). - s.chips += halfUp(h.bet) + // Half the stake is returned at settle: settle() folds halfUp(h.bet) into + // the seat's gross for a surrendered hand (the full bet is already escrowed + // on the open stake). An odd bet's half-chip rounds UP to the player + // (halfUp owns the policy, shared with the 3:2 payout in creditFor). rm.beginTurn(r) } } @@ -976,11 +1044,15 @@ func (rm *room) act(r kit.Room, p kit.Player, a rune) { // split turns a two-card equal-rank pair into two hands, each taking a new card, // reporting whether the split happened. Split aces take one card and stand. func (rm *room) split(r kit.Room, s *seat, h *phand) bool { - if len(h.cards) != 2 || h.cards[0].r != h.cards[1].r || s.chips < h.bet || len(s.hands) >= maxHands { + if len(h.cards) != 2 || h.cards[0].r != h.cards[1].r || len(s.hands) >= maxHands { + return false + } + // The new hand's stake Wagers onto the seat's open stake; a failed Wager + // (can't cover it) rejects the split, as the old `chips < bet` guard did. + if !rm.wager(s, h.bet) { return false } c0, c1 := h.cards[0], h.cards[1] - s.chips -= h.bet rng := r.Rand() nh := &phand{cards: hand{c1, rm.sh.draw(rng)}, bet: h.bet, fromSplit: true} h.cards = hand{c0, rm.sh.draw(rng)} @@ -1096,63 +1168,49 @@ func (rm *room) settle(r kit.Room) { for _, id := range rm.order { s := rm.seats[id] if s == nil || !s.placed { - continue + continue // skip seats that never opened a stake this round } - net := 0 + // Fold every resolved credit into the seat's single open-stake gross. for _, h := range s.hands { if h.surrendered { - net -= h.bet - halfUp(h.bet) // lost half (the same halfUp act credited) + s.grossThisRound += int64(halfUp(h.bet)) // half the stake returned continue } pbj := h.cards.isBlackjack() && !h.fromSplit o := settleHandEx(h.cards, pbj, rm.dealer, dbj) - credit := creditFor(o, h.bet) - s.chips += credit - net += credit - h.bet + s.grossThisRound += int64(creditFor(o, h.bet)) } - // The Perfect Pairs side bet was settled at deal (stake deducted, any win - // credited there); fold its delta into the round net so the seat's - // WIN/LOSE summary reconciles with the chips that actually changed hands. - net += s.pairsWin - s.pairsBet - // The insurance side bet resolved when the offer closed (stake deducted at - // takeInsurance, any 2:1 payout credited in resolveInsurance). Fold its - // delta in too, so a hand fully covered by insurance against a dealer - // blackjack reads as the PUSH it actually is rather than a phantom loss. - net += s.insuranceWin - s.insurance - net += rm.settleBacks(s, dbj) + // The Perfect Pairs and insurance side-bet wins already folded into gross + // as they resolved (deal / offer-close); settle the behind bets now. + rm.settleBacks(s, dbj) + // Close the seat's open stake with ONE Settle of the accumulated gross + // (clamped to the payout ceiling), then feed the board on a new peak. + net := rm.settleOpenStake(s) s.result = resultText(net) - // A seat that can no longer cover the minimum stake is staked back to the - // re-buy stack — otherwise it would be soft-locked in betting, unable to - // place any bet (Confirm requires chips >= betTiers[0]) and never reaching - // this re-buy path again. - if s.chips < betTiers[0] { - s.chips = rebuyChips - s.result = "BUST - re-buy" - } - if s.chips > s.highScore { - s.highScore = s.chips + if s.bal > s.highScore { + s.highScore = s.bal } - // Persist the durable wallet after each settled round, then feed the - // board on a new peak. Synchronous (no goroutines in wasm; KV is - // host-bounded). - rm.persistWallet(r, s) rm.postPeak(r, s) + // A seat that can no longer cover the minimum stake would be soft-locked in + // betting (Confirm requires bal >= betTiers[0]); trigger the platform + // broke-relief rebuy. A refusal (solvent/daily limit) leaves the LOSE + // summary and lets the seat sit out until it recovers. + if s.bal < betTiers[0] && rm.buyback(s) { + s.result = "BUST - re-buy" + } s.placed = false } rm.enterResults(r) } -// settleBacks settles seat s's behind bets and folds every back's delta into the -// round, returning the net chip change to add to s's summary. Their-pairs were -// settled at the deal (only their delta folds here); each behind bet is judged -// now against the target's first hand vs the dealer — even money on a win, 3:2 on -// a natural blackjack, push returned — and is refunded if the target has left -// (their hand can't be judged) or loses if that hand surrendered. -func (rm *room) settleBacks(s *seat, dealerBJ bool) int { - net := 0 +// settleBacks settles seat s's behind bets, folding each behind win into the +// seat's open-stake gross. Their-pairs already folded in at the deal; each behind +// bet is judged now against the target's first hand vs the dealer — even money on +// a win, 3:2 on a natural blackjack, push returned — refunded if the target has +// left (their hand can't be judged) or lost if that hand surrendered. +func (rm *room) settleBacks(s *seat, dealerBJ bool) { for _, tid := range sortedBackIDs(s) { b := s.backs[tid] - net += b.pairsWin - b.pairs // their-pairs delta (settled at the deal) if b.behind <= 0 { continue } @@ -1167,10 +1225,8 @@ func (rm *room) settleBacks(s *seat, dealerBJ bool) int { o := settleHandEx(h0.cards, h0.cards.isBlackjack() && !h0.fromSplit, rm.dealer, dealerBJ) b.behindWin = creditFor(o, b.behind) } - s.chips += b.behindWin - net += b.behindWin - b.behind + s.grossThisRound += int64(b.behindWin) } - return net } func (rm *room) enterResults(r kit.Room) { @@ -1232,9 +1288,9 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { case kit.ActRight: rm.cycleFocus(s, +1) case kit.ActConfirm: - if s.chips >= betTiers[0] { - if s.bet > s.chips { - s.bet = clampBet(s.chips) + if s.bal >= betTiers[0] { + if s.bet > s.bal { + s.bet = clampBet(s.bal) } rm.clampPairs(s) s.placed = true diff --git a/games/bcook/blackjack/room_test.go b/games/bcook/blackjack/room_test.go index 40121a0..b05c41b 100644 --- a/games/bcook/blackjack/room_test.go +++ b/games/bcook/blackjack/room_test.go @@ -13,15 +13,46 @@ func mkPlayer(h string) kit.Player { return kit.Player{AccountID: h, Handle: h, Kind: kit.KindMember, Conn: "conn-" + h} } -// newGame returns a started room driven by an in-memory kittest.Room. +// newGame returns a started room driven by an in-memory kittest.Room. The +// credits double is set to clamp at the game's declared MaxPayoutMultiplier so +// the settlement ceiling is exercised exactly as the host would apply it. func newGame(t *testing.T, players ...kit.Player) (*room, *kittest.Room) { t.Helper() tr := kittest.NewRoom(players...) + tr.CreditsMaxPayoutMultiplier = maxPayoutMult rm := newRoom(tr.Config(), tr.Services()) rm.OnStart(tr) return rm, tr } +// fund sets the seat's account-wide credits balance in the double AND the seat's +// cached balance to n, so a subsequent Wager (deal/double/split/insurance) draws +// against a known bankroll. +func fund(tr *kittest.Room, s *seat, n int) { + if tr.Credits == nil { + tr.Credits = map[string]int64{} + } + tr.Credits[s.p.AccountID] = int64(n) + s.bal = n +} + +// staked models a seat that has already opened its stake this round: `bal` is the +// bankroll left after `stake` credits were escrowed. It seeds both the double +// (balance + open stake) and the seat's cache (bal + roundStake) so a direct +// settle() call reproduces a post-deal position without replaying the deal. +func staked(tr *kittest.Room, s *seat, bal, stake int) { + if tr.Credits == nil { + tr.Credits = map[string]int64{} + } + if tr.CreditsStakes == nil { + tr.CreditsStakes = map[string]int64{} + } + tr.Credits[s.p.AccountID] = int64(bal) + tr.CreditsStakes[s.p.AccountID] = int64(stake) + s.bal = bal + s.roundStake = int64(stake) +} + // pump advances the virtual clock by d in heartbeat-sized steps, waking on each, // so deadlines land exactly as they would under the host heartbeat. func pump(rm *room, tr *kittest.Room, d time.Duration) { @@ -44,7 +75,7 @@ func TestPairsSideBetLoopsOnP(t *testing.T) { if s.pairsBet != 0 { t.Fatalf("pairs side bet defaults to %d, want 0 (off)", s.pairsBet) } - s.chips = 100000 // deep enough to afford every tier, so the loop wraps only at the top + s.bal = 100000 // deep enough to afford every tier, so the loop wraps only at the top rm.OnInput(tr, a, runeInput('p')) // P advances one tier if s.pairsBet != pairsTiers[1] { t.Fatalf("after P, pairsBet = %d, want %d", s.pairsBet, pairsTiers[1]) @@ -69,12 +100,12 @@ func TestPairsSideBetClampedToChips(t *testing.T) { rm.OnJoin(tr, a) s := rm.seats[a.AccountID] s.bet = 100 - s.chips = 105 // can afford the 100 main bet + at most a 5-chip side bet, so only "off" + s.bal = 105 // can afford the 100 main bet + at most a 5-chip side bet, so only "off" for i := 0; i < len(pairsTiers); i++ { rm.OnInput(tr, a, runeInput('p')) } - if s.bet+s.pairsBet > s.chips { - t.Fatalf("main %d + pairs %d exceeds chips %d (clamp failed)", s.bet, s.pairsBet, s.chips) + if s.bet+s.pairsBet > s.bal { + t.Fatalf("main %d + pairs %d exceeds chips %d (clamp failed)", s.bet, s.pairsBet, s.bal) } } @@ -124,7 +155,7 @@ func TestBackBetAdjustsWhenFocused(t *testing.T) { rm.OnJoin(tr, b) sa := rm.seats[a.AccountID] sa.bet = 25 - sa.chips = 1000 + sa.bal = 1000 rm.OnInput(tr, a, keyInput(kit.KeyRight)) // focus seat b rm.OnInput(tr, a, runeInput('b')) // behind loops 0 -> 10 rm.OnInput(tr, a, runeInput('p')) // their-pairs loops 0 -> 10 @@ -145,7 +176,7 @@ func TestBackBetBudgetClamped(t *testing.T) { rm.OnJoin(tr, b) sa := rm.seats[a.AccountID] sa.bet = 100 - sa.chips = 105 // only 5 chips beyond the main bet — no back tier fits + sa.bal = 105 // only 5 chips beyond the main bet — no back tier fits rm.OnInput(tr, a, keyInput(kit.KeyRight)) for i := 0; i < len(pairsTiers); i++ { rm.OnInput(tr, a, runeInput('b')) // try to raise the behind stake @@ -155,8 +186,8 @@ func TestBackBetBudgetClamped(t *testing.T) { if bb := sa.backs[b.AccountID]; bb != nil { committed += bb.behind + bb.pairs } - if committed > sa.chips { - t.Fatalf("total commitment %d exceeds chips %d (budget clamp failed)", committed, sa.chips) + if committed > sa.bal { + t.Fatalf("total commitment %d exceeds chips %d (budget clamp failed)", committed, sa.bal) } } @@ -169,7 +200,7 @@ func TestDealResolvesPerfectPairsSideBet(t *testing.T) { s.bet = 50 s.placed = true s.pairsBet = 10 - s.chips = 1000 + fund(tr, s, 1000) // Stack the shoe: dealer up + hole, then the seat's two cards — a mixed pair. rm.sh.cards = hand{ {10, suitClub}, {9, suitDiamond}, // dealer 19 @@ -186,9 +217,17 @@ func TestDealResolvesPerfectPairsSideBet(t *testing.T) { if s.pairsWin != 70 { // mixed 6:1 on 10 -> 10 + 60 t.Fatalf("pairsWin = %d, want 70", s.pairsWin) } - // 1000 - 50 (bet) - 10 (pairs stake) + 70 (mixed payout) = 1010. - if s.chips != 1010 { - t.Fatalf("chips = %d, want 1010 (bet + pairs deducted at deal, mixed pair paid 70)", s.chips) + // The deal Wagers the main bet (50) + the pairs stake (10) onto the seat's + // open stake: bankroll 1000 -> 940, roundStake 60. The mixed pair's 70 gross + // folds into grossThisRound (paid only at the single settle), NOT the balance. + if s.bal != 940 { + t.Fatalf("bal = %d, want 940 (bet + pairs Wagered at deal)", s.bal) + } + if s.roundStake != 60 { + t.Fatalf("roundStake = %d, want 60 (main 50 + pairs 10)", s.roundStake) + } + if s.grossThisRound != 70 { + t.Fatalf("grossThisRound = %d, want 70 (mixed pair folded into the open stake)", s.grossThisRound) } } @@ -199,8 +238,10 @@ func TestDealResolvesBackPairs(t *testing.T) { rm.OnJoin(tr, a) rm.OnJoin(tr, b) sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID] - sa.bet, sa.placed, sa.chips = 25, true, 1000 - sb.bet, sb.placed, sb.chips = 25, true, 1000 + sa.bet, sa.placed = 25, true + sb.bet, sb.placed = 25, true + fund(tr, sa, 1000) + fund(tr, sb, 1000) sa.backs = map[string]*backBet{b.AccountID: {pairs: 10}} // a backs b's pairs // dealer up+hole, then seat a's two cards, then seat b's two cards (a mixed pair). rm.sh.cards = hand{ @@ -216,9 +257,13 @@ func TestDealResolvesBackPairs(t *testing.T) { if bb.pairsKind != "mixed" || bb.pairsWin != 70 { t.Fatalf("back-pairs on b = kind %q win %d, want mixed/70", bb.pairsKind, bb.pairsWin) } - // a paid main 25 + back-pairs 10 and won 70: 1000 - 25 - 10 + 70 = 1035. - if sa.chips != 1035 { - t.Fatalf("a chips = %d, want 1035", sa.chips) + // a Wagered main 25 + back-pairs 10 (bal 1000 -> 965, roundStake 35); the 70 + // back-pairs gross folds into a's open stake, paid at settle not now. + if sa.bal != 965 { + t.Fatalf("a bal = %d, want 965 (main 25 + back-pairs 10 Wagered)", sa.bal) + } + if sa.grossThisRound != 70 { + t.Fatalf("a grossThisRound = %d, want 70 (back-pairs win folded in)", sa.grossThisRound) } } @@ -229,15 +274,19 @@ func TestDealVoidsBackOnSatOutTarget(t *testing.T) { rm.OnJoin(tr, a) rm.OnJoin(tr, b) sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID] - sa.bet, sa.placed, sa.chips = 25, true, 1000 + sa.bet, sa.placed = 25, true + fund(tr, sa, 1000) sb.placed = false // b sits this round out sa.backs = map[string]*backBet{b.AccountID: {behind: 50, pairs: 10}} rm.sh.cards = hand{{10, suitClub}, {9, suitDiamond}, {2, suitSpade}, {7, suitHeart}, {3, suitClub}, {4, suitClub}} rm.sh.pos, rm.sh.roundStart = 0, 0 rm.deal(tr) - if sa.chips != 975 { // only a's own 25 bet deducted; the back on a sat-out seat is voided - t.Fatalf("a chips = %d, want 975 (back on sat-out target voided, not deducted)", sa.chips) + if sa.bal != 975 { // only a's own 25 bet Wagered; the back on a sat-out seat is voided + t.Fatalf("a bal = %d, want 975 (back on sat-out target voided, not Wagered)", sa.bal) + } + if sa.roundStake != 25 { + t.Fatalf("a roundStake = %d, want 25 (only the main bet opened)", sa.roundStake) } if bb := sa.backs[b.AccountID]; bb != nil && (bb.behind != 0 || bb.pairs != 0) { t.Fatalf("back on sat-out target not voided: %+v", bb) @@ -250,7 +299,8 @@ func TestSettleBehindBetWinFolds(t *testing.T) { rm.OnJoin(tr, a) rm.OnJoin(tr, b) sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID] - sa.placed, sa.bet, sa.chips = true, 25, 1000 + sa.placed, sa.bet = true, 25 + staked(tr, sa, 925, 75) // a's open stake: main 25 + behind 50 escrowed sa.hands = []*phand{{cards: hand{{2, suitSpade}, {3, suitHeart}}, bet: 25}} // a: 5, loses sb.placed, sb.bet = true, 25 sb.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // b: 19, beats dealer @@ -263,7 +313,7 @@ func TestSettleBehindBetWinFolds(t *testing.T) { if bb.behindWin != 100 { // even money: 50 stake + 50 t.Fatalf("behindWin = %d, want 100 (behind paid even money on b's win)", bb.behindWin) } - // a's own hand loses 25; the behind nets +50: round net = +25. + // a's own hand loses (gross 0); the behind grosses 100 on a 75 stake: net +25. if sa.result != "WIN +25" { t.Fatalf("a result = %q, want WIN +25 (behind win folded into net)", sa.result) } @@ -275,7 +325,8 @@ func TestSettleBehindRefundsWhenTargetLeft(t *testing.T) { rm.OnJoin(tr, a) rm.OnJoin(tr, b) sa := rm.seats[a.AccountID] - sa.placed, sa.bet, sa.chips = true, 25, 900 // behind 50 already deducted at deal + sa.placed, sa.bet = true, 25 + staked(tr, sa, 900, 75) // main 25 + behind 50 escrowed; 900 bankroll left sa.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // 19, pushes sa.backs = map[string]*backBet{b.AccountID: {behind: 50}} delete(rm.seats, b.AccountID) // b left mid-round @@ -287,9 +338,10 @@ func TestSettleBehindRefundsWhenTargetLeft(t *testing.T) { if bb := sa.backs[b.AccountID]; bb.behindWin != 50 { t.Fatalf("behindWin = %d, want 50 (behind refunded when target left)", bb.behindWin) } - // own hand pushes (+25 returned), behind refunded (+50): 900 + 25 + 50 = 975. - if sa.chips != 975 { - t.Fatalf("a chips = %d, want 975 (push + behind refund)", sa.chips) + // own hand pushes (gross 25), behind refunded (gross 50): Settle(75) on a 75 + // stake tops the 900 bankroll back to 975. + if sa.bal != 975 { + t.Fatalf("a bal = %d, want 975 (push + behind refund settled once)", sa.bal) } } @@ -300,13 +352,14 @@ func TestSettleFoldsPairsResultIntoNet(t *testing.T) { s := rm.seats[a.AccountID] s.placed = true s.bet = 50 - s.chips = 1000 s.pairsBet = 10 - s.pairsWin = 70 // a mixed pair already paid at deal + staked(tr, s, 940, 60) // main 50 + pairs 10 escrowed + s.pairsWin = 70 // a mixed pair already resolved at deal... + s.grossThisRound = 70 // ...and folded into the open stake's gross s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}} // 19 rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // 19 -> hand pushes rm.settle(tr) - // Hand pushes (net 0); the pairs win folds in: net = (70 - 10) = +60. + // Hand pushes (gross 50) atop the pairs gross 70 = 120 on a 60 stake: net +60. if s.result != "WIN +60" { t.Fatalf("result = %q, want WIN +60 (pairs win folded into the round net)", s.result) } @@ -317,7 +370,9 @@ func TestJoinSeatsPlayer(t *testing.T) { rm, tr := newGame(t, p) rm.OnJoin(tr, p) s := rm.seats[p.AccountID] - if s == nil || s.chips != startChips || s.highScore != startChips || s.bet != betTiers[1] { + // A fresh account seeds the credits double's default balance (1000); the seat + // caches it and seeds its peak from it. + if s == nil || s.bal != 1000 || s.highScore != 1000 || s.bet != betTiers[1] { t.Fatalf("bad seat after join: %+v", s) } } @@ -335,8 +390,8 @@ func TestBettingDeductsAndSitsOut(t *testing.T) { if len(rm.seats[a.AccountID].hands) == 0 { t.Fatal("a placed a bet but was not dealt a hand") } - if rm.seats[a.AccountID].chips != startChips-50 { - t.Errorf("a chips = %d, want %d (bet deducted at deal)", rm.seats[a.AccountID].chips, startChips-50) + if rm.seats[a.AccountID].bal != 1000-50 { + t.Errorf("a bal = %d, want %d (bet Wagered at deal)", rm.seats[a.AccountID].bal, 1000-50) } if len(rm.seats[b.AccountID].hands) != 0 { t.Error("b placed no bet but was dealt in") @@ -416,12 +471,12 @@ func TestOnlyActiveSeatActs(t *testing.T) { func TestDoubleDrawsOneCardAndResolves(t *testing.T) { rm, tr, a, _ := turnsSetup(t, hand{{5, suitSpade}, {6, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}}) - rm.seats[a.AccountID].chips = 950 + fund(tr, rm.seats[a.AccountID], 950) rm.OnInput(tr, a, runeInput('d')) h := rm.seats[a.AccountID].hands[0] - if rm.seats[a.AccountID].chips != 900 { - t.Errorf("chips = %d, want 900 (second bet deducted)", rm.seats[a.AccountID].chips) + if rm.seats[a.AccountID].bal != 900 { + t.Errorf("bal = %d, want 900 (double Wagered the second bet)", rm.seats[a.AccountID].bal) } if h.bet != 100 || len(h.cards) != 3 || !h.resolved { t.Errorf("after double: bet=%d cards=%d resolved=%v, want 100/3/true", h.bet, len(h.cards), h.resolved) @@ -430,15 +485,15 @@ func TestDoubleDrawsOneCardAndResolves(t *testing.T) { func TestSplitFormsTwoHands(t *testing.T) { rm, tr, a, _ := turnsSetup(t, hand{{8, suitSpade}, {8, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}}) - rm.seats[a.AccountID].chips = 950 + fund(tr, rm.seats[a.AccountID], 950) rm.OnInput(tr, a, runeInput('p')) s := rm.seats[a.AccountID] if len(s.hands) != 2 { t.Fatalf("after split: %d hands, want 2", len(s.hands)) } - if s.chips != 900 { - t.Errorf("chips = %d, want 900 (second bet)", s.chips) + if s.bal != 900 { + t.Errorf("bal = %d, want 900 (split Wagered the second bet)", s.bal) } for i, h := range s.hands { if len(h.cards) != 2 { @@ -449,7 +504,7 @@ func TestSplitFormsTwoHands(t *testing.T) { func TestSplitAcesTakeOneCardEach(t *testing.T) { rm, tr, a, _ := turnsSetup(t, hand{{rankAce, suitSpade}, {rankAce, suitHeart}}, hand{{10, suitClub}, {6, suitDiamond}}) - rm.seats[a.AccountID].chips = 950 + fund(tr, rm.seats[a.AccountID], 950) rm.OnInput(tr, a, runeInput('p')) s := rm.seats[a.AccountID] @@ -480,15 +535,17 @@ func TestBustRebuysAndKeepsHighScore(t *testing.T) { rm.OnJoin(tr, a) s := rm.seats[a.AccountID] s.placed = true - s.chips = 0 // bet already deducted; this hand loses + staked(tr, s, 0, 50) // bet 50 already escrowed; broke bankroll; this hand loses s.highScore = 2500 s.hands = []*phand{{cards: hand{{10, suitSpade}, {10, suitHeart}, {5, suitClub}}, bet: 50}} // 25, bust rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} rm.settle(tr) - if s.chips != rebuyChips { - t.Errorf("chips = %d, want re-buy to %d", s.chips, rebuyChips) + // The losing stake Settles for 0, leaving the seat broke, so the platform + // broke-relief Buyback tops the balance up to the double's rebuy amount (1000). + if s.bal != 1000 { + t.Errorf("bal = %d, want re-buy to 1000", s.bal) } if s.highScore != 2500 { t.Errorf("highScore = %d, want 2500 (a bust must not lower it)", s.highScore) @@ -501,14 +558,16 @@ func TestRebuyWhenBelowMinimumBet(t *testing.T) { rm.OnJoin(tr, a) s := rm.seats[a.AccountID] s.placed = true - s.chips = 5 // above zero but can't cover the 10-chip minimum -> soft-lock without a re-buy + staked(tr, s, 5, 5) // 5 left, above zero but under the 10-credit minimum bet s.hands = []*phand{{cards: hand{{10, suitSpade}, {8, suitHeart}}, bet: 5}} rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // dealer 19 beats 18 rm.settle(tr) - if s.chips != rebuyChips { - t.Errorf("chips = %d, want re-buy to %d (a stack under the minimum bet must re-buy)", s.chips, rebuyChips) + // After the loss settles, 5 credits is under the minimum bet, so Buyback tops + // the balance up to the double's rebuy amount (1000) rather than soft-locking. + if s.bal != 1000 { + t.Errorf("bal = %d, want re-buy to 1000 (a stack under the minimum bet must re-buy)", s.bal) } } @@ -518,7 +577,7 @@ func TestBehindBetsAreStickyAcrossRounds(t *testing.T) { rm.OnJoin(tr, a) rm.OnJoin(tr, b) sa := rm.seats[a.AccountID] - sa.chips = 1000 + sa.bal = 1000 sa.backs = map[string]*backBet{b.AccountID: {behind: 50, pairs: 10}} rm.enterBetting(tr) // a fresh betting window must carry a's back on b @@ -538,7 +597,7 @@ func TestStickyBackPrunedWhenTargetLeaves(t *testing.T) { rm.OnJoin(tr, a) rm.OnJoin(tr, b) sa := rm.seats[a.AccountID] - sa.chips = 1000 + sa.bal = 1000 sa.backs = map[string]*backBet{b.AccountID: {behind: 50}} rm.OnLeave(tr, b) // b leaves the table @@ -566,15 +625,15 @@ func TestBlackjackPays3to2(t *testing.T) { rm.OnJoin(tr, a) s := rm.seats[a.AccountID] s.placed = true - s.chips = 950 // 100 already staked at deal + staked(tr, s, 950, 100) // 100 escrowed at deal, 950 bankroll left s.hands = []*phand{{cards: hand{{rankAce, suitSpade}, {rankKing, suitHeart}}, bet: 100}} rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // dealer 19, no blackjack rm.settle(tr) - // 3:2 blackjack credits stake (100) + 150 = 250, on top of the 950 left. - if s.chips != 1200 { - t.Errorf("chips = %d, want 1200 (blackjack pays 3:2)", s.chips) + // 3:2 blackjack grosses stake (100) + 150 = 250; Settle(250) tops 950 -> 1200. + if s.bal != 1200 { + t.Errorf("bal = %d, want 1200 (blackjack pays 3:2)", s.bal) } } @@ -585,18 +644,21 @@ func TestInsurancePaysOnDealerBlackjack(t *testing.T) { s := rm.seats[a.AccountID] s.placed = true s.bet = 50 - s.chips = 950 + fund(tr, s, 950) rm.dealer = hand{{rankAce, suitSpade}, {rankKing, suitHeart}} // dealer blackjack + rm.dealerHole = true - rm.takeInsurance(s, true) // insurance = 25, chips 925 - if s.insurance != 25 || s.chips != 925 { - t.Fatalf("after taking insurance: ins=%d chips=%d, want 25/925", s.insurance, s.chips) + rm.takeInsurance(s, true) // insurance = 25 Wagered onto the open stake -> bal 925 + if s.insurance != 25 || s.bal != 925 { + t.Fatalf("after taking insurance: ins=%d bal=%d, want 25/925", s.insurance, s.bal) } - rm.resolveInsurance(tr) // 2:1 -> +75, dealer BJ settles the round + rm.resolveInsurance(tr) // 2:1 -> +75 folds into gross, dealer BJ defers settle + pump(rm, tr, 5*time.Second) // let the reveal + settle play out - if s.chips != 1000 { - t.Errorf("chips = %d, want 1000 (insurance paid 2:1)", s.chips) + // The insurance 2:1 (75 gross) settles the 25 stake: 925 -> 1000. + if s.bal != 1000 { + t.Errorf("bal = %d, want 1000 (insurance paid 2:1)", s.bal) } if rm.dealerHole { t.Error("dealer hole card should be revealed after a dealer blackjack") @@ -635,37 +697,32 @@ func TestDealingOrderIsDeterministic(t *testing.T) { } } -// TestWalletSeedsPersistsAndPostsPeak covers the durable wallet (balance sum / -// peak max) and the leaderboard Post on a new personal peak. -func TestWalletSeedsPersistsAndPostsPeak(t *testing.T) { +// TestBalanceSeedsAndPostsPeak covers the account-wide credits path: a fresh +// seat caches the default balance, and a winning settle raises the peak +// (sourced from the post-Settle balance) and Posts it to the leaderboard. +func TestBalanceSeedsAndPostsPeak(t *testing.T) { a := mkPlayer("a") rm, tr := newGame(t, a) rm.OnJoin(tr, a) - // First seat seeds the default stack into KV. - if got := string(tr.KV[a.AccountID][keyBalance]); got != "1000" { - t.Fatalf("seeded balance KV = %q, want 1000", got) - } - if got := tr.KVRules[a.AccountID][keyBalance]; got != kit.MergeSum { - t.Errorf("balance merge rule = %v, want MergeSum", got) - } - if got := tr.KVRules[a.AccountID][keyPeak]; got != kit.MergeMax { - t.Errorf("peak merge rule = %v, want MergeMax", got) + // A fresh seat seeds the credits double's default balance into the cache. + s := rm.seats[a.AccountID] + if s.bal != 1000 || s.highScore != 1000 { + t.Fatalf("seeded seat: bal=%d high=%d, want 1000/1000", s.bal, s.highScore) } - // A winning settle raises the peak, persists it, and posts it to the board. - s := rm.seats[a.AccountID] + // A winning settle raises the peak from the fresh balance and posts it. s.placed = true - s.chips = 1900 // 100 staked at deal, hand will win 200 + staked(tr, s, 1900, 100) // 100 escrowed, hand will gross 200 s.hands = []*phand{{cards: hand{{rankKing, suitSpade}, {rankQueen, suitHeart}}, bet: 100}} rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // dealer 19, player 20 wins rm.settle(tr) - if s.chips != 2100 || s.highScore != 2100 { - t.Fatalf("after win: chips=%d high=%d, want 2100/2100", s.chips, s.highScore) + if s.bal != 2100 || s.highScore != 2100 { + t.Fatalf("after win: bal=%d high=%d, want 2100/2100", s.bal, s.highScore) } - if got := string(tr.KV[a.AccountID][keyPeak]); got != "2100" { - t.Errorf("persisted peak KV = %q, want 2100", got) + if got := tr.Credits[a.AccountID]; got != 2100 { + t.Errorf("settled account balance = %d, want 2100", got) } if len(tr.Posted) == 0 { t.Fatal("a new peak should Post to the leaderboard") @@ -676,6 +733,27 @@ func TestWalletSeedsPersistsAndPostsPeak(t *testing.T) { } } +// TestTopPrizeDoesNotClamp asserts the declared MaxPayoutMultiplier (26) covers +// the game's largest single-stake outcome: a Perfect Pairs "perfect" pair pays +// 25:1, grossing stake×(25+1) = stake×26 — exactly the ceiling, so it settles in +// full and is never clamped. +func TestTopPrizeDoesNotClamp(t *testing.T) { + a := mkPlayer("a") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + // A lone 100 Perfect Pairs stake hitting a "perfect" pair: gross 2600. + staked(tr, s, 0, 100) + s.grossThisRound = 100 * (25 + 1) // 2600, the top pairs payout + net := rm.settleOpenStake(s) + if net != 2500 { + t.Fatalf("net = %d, want 2500 (top pairs prize settles unclamped)", net) + } + if got := tr.Credits[a.AccountID]; got != 2600 { + t.Fatalf("settled balance = %d, want 2600 (full 26x top prize paid)", got) + } +} + // colIndex returns the COLUMN (rune) index of sub in row, or -1 — unlike // strings.Index, whose byte offsets drift once a character glyph (e.g. λ) // occupies a cell to the left. @@ -980,7 +1058,7 @@ func TestInsuranceSkipsTimerOnceAllDecided(t *testing.T) { s := rm.seats[p.AccountID] s.placed = true s.bet = 50 - s.chips = 950 + s.bal = 950 s.hands = []*phand{{cards: hand{{10, suitSpade}, {7, suitHeart}}, bet: 50}} } rm.dealer = hand{{rankAce, suitSpade}, {6, suitHeart}} // shows an Ace, no blackjack @@ -1006,7 +1084,7 @@ func TestResultLabelDoesNotLeakChips(t *testing.T) { rm.OnJoin(tr, a) s := rm.seats[a.AccountID] s.placed = true - s.chips = 1000 // "$1000" is wider than "PUSH" + s.bal = 1000 // "$1000" is wider than "PUSH" s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}} s.result = "PUSH" rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} @@ -1029,7 +1107,7 @@ func TestReadyUpSkipsTheResultsWait(t *testing.T) { rm.OnJoin(tr, a) s := rm.seats[a.AccountID] s.placed = true - s.chips = 950 + staked(tr, s, 950, 50) s.hands = []*phand{{cards: hand{{rankKing, suitSpade}, {rankQueen, suitHeart}}, bet: 50}} // 20 rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // 19, player wins rm.settle(tr) @@ -1163,10 +1241,11 @@ func TestResultsShowsPerfectPairsWin(t *testing.T) { s := rm.seats[a.AccountID] s.placed = true s.bet = 50 - s.chips = 1000 + staked(tr, s, 1000, 75) // main 50 + pairs 25 escrowed s.pairsBet = 25 s.pairsKind = "colored" s.pairsWin = 325 + s.grossThisRound = 325 // the colored pair already folded into the open stake s.hands = []*phand{{cards: hand{{8, suitHeart}, {8, suitDiamond}}, bet: 50}} rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} rm.settle(tr) // -> results phase @@ -1215,7 +1294,7 @@ func TestSplitSeatShowsEveryHandsCards(t *testing.T) { rm.OnJoin(tr, a) s := rm.seats[a.AccountID] s.placed = true - s.chips = 800 + s.bal = 800 s.hands = []*phand{ {cards: hand{{8, suitSpade}, {3, suitHeart}}, bet: 50, fromSplit: true}, {cards: hand{{8, suitClub}, {10, suitDiamond}}, bet: 50, fromSplit: true}, @@ -1251,7 +1330,7 @@ func TestReadyUpWaitsOnOtherPlayers(t *testing.T) { for _, p := range []kit.Player{a, b} { s := rm.seats[p.AccountID] s.placed = true - s.chips = 950 + staked(tr, s, 950, 50) s.hands = []*phand{{cards: hand{{rankKing, suitSpade}, {rankQueen, suitHeart}}, bet: 50}} } rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} @@ -1295,7 +1374,7 @@ func TestSplitAcesShowOneCardNote(t *testing.T) { rm.OnJoin(tr, a) s := rm.seats[a.AccountID] s.placed = true - s.chips = 900 + s.bal = 900 s.hands = []*phand{ {cards: hand{{rankAce, suitSpade}, {rankKing, suitHeart}}, bet: 50, fromSplit: true, resolved: true}, // 21 {cards: hand{{rankAce, suitClub}, {6, suitDiamond}}, bet: 50, fromSplit: true, resolved: true}, // soft 17 @@ -1331,7 +1410,7 @@ func TestPairsVerdictHeldUntilCardsLand(t *testing.T) { s := rm.seats[a.AccountID] s.placed = true s.bet = 50 - s.chips = 1000 + s.bal = 1000 s.pairsBet = 25 // A non-pair hand: the side bet loses, and that must stay hidden mid-deal. s.hands = []*phand{{cards: hand{{9, suitSpade}, {4, suitHeart}}, bet: 50}} @@ -1364,17 +1443,18 @@ func TestInsuranceFoldsIntoResultNet(t *testing.T) { s := rm.seats[a.AccountID] s.placed = true s.bet = 100 - s.chips = 900 // the main bet was already deducted at the deal + staked(tr, s, 900, 100) // main 100 escrowed at the deal s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 100}} // 19, loses to BJ rm.dealer = hand{{rankAce, suitSpade}, {rankKing, suitHeart}} // dealer blackjack rm.dealerHole = true - rm.takeInsurance(s, true) // stake 50 -> chips 850 - rm.resolveInsurance(tr) // dealer BJ pays 2:1 (+150) -> chips 1000, defers settle - pump(rm, tr, 5*time.Second) + rm.takeInsurance(s, true) // stake 50 Wagered -> bal 850, roundStake 150 + rm.resolveInsurance(tr) // dealer BJ pays 2:1 (+150 gross), defers settle + pump(rm, tr, 5*time.Second) // reveal + single Settle plays out - if s.chips != 1000 { - t.Fatalf("chips = %d, want 1000 (insured loss breaks even)", s.chips) + // Insurance gross 150 exactly offsets the lost main bet on a 150 stake: net 0. + if s.bal != 1000 { + t.Fatalf("bal = %d, want 1000 (insured loss breaks even)", s.bal) } if s.result != "PUSH" { t.Fatalf("result = %q, want PUSH (insurance offsets the main-bet loss in the net)", s.result) @@ -1392,7 +1472,7 @@ func TestManyCardHandStaysInItsSlot(t *testing.T) { rm.OnJoin(tr, a) s := rm.seats[a.AccountID] s.placed = true - s.chips = 800 + s.bal = 800 s.hands = []*phand{{cards: hand{{2, suitSpade}, {2, suitHeart}, {3, suitClub}, {4, suitDiamond}, {5, suitSpade}}, bet: 50}} rm.dealer = hand{{10, suitClub}, {7, suitDiamond}} rm.phase = phTurns From c084698ffacb4dfb76f6dd04f7265df989ecc489 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Fri, 3 Jul 2026 19:02:19 +1000 Subject: [PATCH 2/4] roulette: convert to casino-kind with platform Credits (kit v2.16.0) Declares Kind=casino, MaxPayoutMultiplier=36, CtxFeatCredits; removes the game's internal chip/KV wallet and wires bets through svc.Credits Wager/Settle/Balance with one open stake per round, a single stake-inclusive Settle, Settle-on-leave/abandon so escrow never leaks, and in-game rebuy via svc.Credits.Buyback when busted. Peak-Credits leaderboard. Whole board is one Wager at spin-lock; local-only chip placement pre-lock. Verified: go build/vet/test, tinygo, and shellcade-kit check --require-leaderboard. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W7oJQSTva5xgbAZomdV5Jj --- games/alan/roulette/game.go | 14 +- games/alan/roulette/go.mod | 2 +- games/alan/roulette/go.sum | 4 +- games/alan/roulette/main.go | 5 +- games/alan/roulette/render.go | 13 +- games/alan/roulette/room.go | 266 ++++++++++-------- games/alan/roulette/room_test.go | 208 ++++++++++---- .../roulette/roulette_leaderboard_test.go | 84 +++--- 8 files changed, 375 insertions(+), 221 deletions(-) diff --git a/games/alan/roulette/game.go b/games/alan/roulette/game.go index 36338dd..dfe437a 100644 --- a/games/alan/roulette/game.go +++ b/games/alan/roulette/game.go @@ -18,9 +18,15 @@ func (Game) Meta() kit.GameMeta { MaxPlayers: 6, Tags: []string{"roulette", "casino", "betting", "american"}, - // Player characters: each player's tile renders beside their name in - // the seat strip under the table. - CtxFeatures: kit.CtxFeatCharacter, + // A casino game: players wager their account-wide platform Credits. The + // richest wager is a straight-up (35:1), so a winning stake returns + // stake*(35+1) = stake*36 and no board can pay more than 36x its stake. + Kind: kit.GameKindCasino, + MaxPayoutMultiplier: 36, + + // Player characters (seat tiles) + Credits (the host owns every balance; + // this game calls Wager/Settle/Buyback/Balance). + CtxFeatures: kit.CtxFeatCharacter | kit.CtxFeatCredits, // A casual social table: when everyone leaves, the room closes — no // hibernation snapshot, no Resume-menu entry. @@ -31,7 +37,7 @@ func (Game) Meta() kit.GameMeta { PrivateInviteLine: "Friends pull up a chair when they enter the code.", Leaderboard: &kit.LeaderboardSpec{ - MetricLabel: "Chips", + MetricLabel: "Credits", Direction: kit.HigherBetter, Aggregation: kit.BestResult, Format: kit.Integer, diff --git a/games/alan/roulette/go.mod b/games/alan/roulette/go.mod index a3da528..1519112 100644 --- a/games/alan/roulette/go.mod +++ b/games/alan/roulette/go.mod @@ -2,7 +2,7 @@ module alan/roulette go 1.25.11 -require github.com/shellcade/kit/v2 v2.15.0 +require github.com/shellcade/kit/v2 v2.16.0 require ( github.com/extism/go-pdk v1.1.3 // indirect diff --git a/games/alan/roulette/go.sum b/games/alan/roulette/go.sum index d39f6ff..010ad51 100644 --- a/games/alan/roulette/go.sum +++ b/games/alan/roulette/go.sum @@ -1,7 +1,7 @@ github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= -github.com/shellcade/kit/v2 v2.15.0 h1:/C/xsAlteeTMbJ+i+1qOcWXECeib4+dduTyPpEGq9bU= -github.com/shellcade/kit/v2 v2.15.0/go.mod h1:EYbqrycZyGxzjUnklDlKBPgU8bCftTVJFKVCw/+gvdw= +github.com/shellcade/kit/v2 v2.16.0 h1:ZU3SCkLqmJMbKqxnBMfEocYsjLiGoHaq48pWrFIkV1s= +github.com/shellcade/kit/v2 v2.16.0/go.mod h1:EYbqrycZyGxzjUnklDlKBPgU8bCftTVJFKVCw/+gvdw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= diff --git a/games/alan/roulette/main.go b/games/alan/roulette/main.go index a3fbe34..e88a17c 100644 --- a/games/alan/roulette/main.go +++ b/games/alan/roulette/main.go @@ -9,8 +9,9 @@ // deceleration, and the results hold are deadlines held in guest memory and // landed in OnWake against CallContext time (no host timer survives a thaw); // the spin outcome is rolled up front from the room's seeded RNG so a seeded -// room reproduces every result; and the durable bankroll uses the casino kv -// pattern (balance summed, peak max-merged) feeding a peak-ranked leaderboard. +// room reproduces every result; and bankrolls are the platform's account-wide +// Credits (the host owns every balance) — the board is wagered as one escrow per +// seat at spin lock and Settled once at payout, feeding a peak-Credits leaderboard. // // This is the dual-target entrypoint: `go run .` plays it in your terminal with // normal Go tooling (and `go run . -smoke smoke.yaml -smoke-out shots/` writes diff --git a/games/alan/roulette/render.go b/games/alan/roulette/render.go index 0467d43..ec7ab14 100644 --- a/games/alan/roulette/render.go +++ b/games/alan/roulette/render.go @@ -612,6 +612,11 @@ func (rm *room) roundNet(pl *player) (won, staked int) { won += settleReturn(masterBets[b.master], b.stake, rm.result) staked += b.stake } + // Mirror the settlement clamp so the results panel never shows a gross bigger + // than the host will actually pay (stake*maxPayoutMult). + if cap := staked * maxPayoutMult; won > cap { + won = cap + } return won, staked } @@ -710,7 +715,7 @@ func (rm *room) drawSeats(f *kit.Frame, v kit.Player) { nameSt.Attr |= kit.AttrBold } f.Text(seatsRow, x+3, name, nameSt) // name - f.Text(seatsRow+1, x+1, strconv.Itoa(pl.balance), stHead) // chips, underneath + f.Text(seatsRow+1, x+1, strconv.FormatInt(pl.bal, 10), stHead) // credits, underneath rm.drawSeatStatus(f, seatsRow+2, x+1, pl) // status, under that } } @@ -814,7 +819,11 @@ func (rm *room) drawHelp(f *kit.Frame, pl *player) { f.Text(helpRow, 2, help, stDim) } if pl != nil { - f.TextRight(helpRow, kit.Cols-2, "BAL "+strconv.Itoa(pl.balance), stTitle) + bal := "BAL " + strconv.FormatInt(pl.bal, 10) + if rm.econOff { + bal = "CREDITS OFFLINE" + } + f.TextRight(helpRow, kit.Cols-2, bal, stTitle) } } diff --git a/games/alan/roulette/room.go b/games/alan/roulette/room.go index 5fd3964..a561ad8 100644 --- a/games/alan/roulette/room.go +++ b/games/alan/roulette/room.go @@ -1,9 +1,6 @@ package main import ( - "context" - "strconv" - "strings" "time" kit "github.com/shellcade/kit/v2" @@ -19,8 +16,16 @@ const ( ) const ( - startBalance = 1000 // chips a first-ever player sits down with - rebuyAmount = 1000 // balance restored on a bust + // maxPayoutMult is the declared per-seat gross ceiling (Meta.MaxPayoutMultiplier). + // The richest single wager is a straight-up (35:1): a winning stake returns + // stake*(35+1) = stake*36, and no combination of chips on the board can return + // more than 36x the seat's total stake — so a whole-board gross is a + // stake-weighted blend that is always <= 36x. This is the true top prize. + maxPayoutMult = 36 + + // minBet is the table minimum (the lowest chip tier); a seat whose account-wide + // balance falls below it after a Settle is offered the platform Buyback. + minBet = 5 bettingDur = 20 * time.Second // the open betting window spinAnimDur = 5 * time.Second // wheel deceleration @@ -35,21 +40,6 @@ const ( flashPeriod = 300 * time.Millisecond // half a blink cycle historyLen = 12 // recent winning numbers kept for the marquee - - // peakFlushInterval throttles the periodic leaderboard flush. roulette is a - // continuous table whose rounds loop forever; a player can be seated while - // the table is abandoned mid-spin and never hit a NEW peak (so postPeak never - // fires). To keep the board "constantly saved" with seated players' current - // peaks, OnWake re-posts every tracked player on this game-time cadence — - // cheap, deterministic, gated on r.Now() (no wall clock, no timer). 10s is - // well inside the betting window so the flush never collides with a round - // one-shot, yet frequent enough that an idle table stays fresh on the board. - peakFlushInterval = 10 * time.Second - - // wallet KV keys + merge rules, the casino pattern (balance: sum, the - // carryable bankroll; peak: max, the high-water mark + leaderboard metric). - keyBalance = "balance" - keyPeak = "peak" ) // stakeTiers are the selectable chip denominations, lowest first. The 5 chip is @@ -71,12 +61,12 @@ type placedBet struct { // hibernation freeze/thaw (connections change; accounts don't). type player struct { p kit.Player - balance int - peak int - postedPeak int // last peak Posted to the board (post only on increase) - stakeIdx int // index into stakeTiers + bal int64 // cached account-wide Credits balance (refreshed at money events) + postedPeak int64 // highest balance Posted to the board (post only on increase) + stakeIdx int // index into stakeTiers sel selection bets []placedBet + wagered bool // an escrow is open for this seat (Wagered this spin, not yet Settled) ready bool joinOrder int colorIdx int // index into the chip-colour palette (stable while seated) @@ -94,6 +84,11 @@ func (pl *player) staked() int { return t } +// avail is what this seat can still put on the felt: the cached account-wide +// balance less the chips already down (bets are LOCAL until the spin locks, so +// nothing is escrowed yet — affordability is purely bal minus staked). +func (pl *player) avail() int64 { return pl.bal - int64(pl.staked()) } + // pending names the deferred one-shot the room is waiting on — each a deadline // held in guest memory and landed in OnWake when r.Now() passes it (the wake // idiom; no host timer survives a thaw). @@ -133,11 +128,10 @@ type room struct { spunOnce bool history []int // recent winning numbers, newest last - // sk mirrors every posted peak so the periodic flush can re-post all seated - // players in deterministic order; lastFlush is the game-time instant of the - // last FlushAll, throttling it to peakFlushInterval. - sk *kit.ScoreKeeper - lastFlush time.Time + // econOff is set when the host has no Credits economy (svc.Credits nil, or a + // Balance call reports the economy disabled/unavailable). The table then + // renders "credits offline" instead of a bankroll rather than trapping. + econOff bool lastNow time.Time frame *kit.Frame @@ -155,7 +149,6 @@ func newRoom(cfg kit.RoomConfig, svc kit.Services) *room { cfg: cfg, svc: svc, players: map[string]*player{}, - sk: kit.NewScoreKeeper(kit.OnImprove), frame: kit.NewFrame(), chipBits: make([]uint8, len(masterBets)), } @@ -182,78 +175,71 @@ func (rm *room) freeColorIdx() int { func (rm *room) OnStart(r kit.Room) { rm.lastNow = r.Now() - rm.lastFlush = r.Now() rm.enterBetting(r) rm.render(r) } +// OnClose closes any still-open escrow before the room disappears. The balances +// themselves are the platform's — nothing local to persist — but a seat that +// was mid-spin (Wagered, not yet Settled) when the room tears down must be +// Settled or its escrow leaks. The result is already rolled, so book the fair +// gross. func (rm *room) OnClose(r kit.Room) { + if rm.svc.Credits == nil { + return + } for _, id := range rm.order { - if pl := rm.players[id]; pl != nil { - rm.persistWallet(r, pl) + if pl := rm.players[id]; pl != nil && pl.wagered { + _ = rm.svc.Credits.Settle(pl.p, rm.grossFor(pl)) + pl.wagered = false } } } -// --- durable wallet (the casino pattern over kv) --------------------------- +// --- account-wide Credits (the platform economy) --------------------------- -func kvInt(store kit.KVStore, key string) (int, bool) { - v, ok, err := store.Get(context.Background(), key) - if err != nil || !ok { - return 0, false +// refreshBal caches the seat's authoritative account-wide balance. Called only +// at money events (join, post-Wager, post-Settle, Buyback) — never per frame — +// so the hot render path reads the cached pl.bal. A nil economy or a +// disabled/unavailable host flips econOff and leaves the last cached value. +func (rm *room) refreshBal(pl *player) { + if rm.svc.Credits == nil { + rm.econOff = true + return } - n, err := strconv.Atoi(strings.TrimSpace(string(v))) + b, err := rm.svc.Credits.Balance(pl.p) if err != nil { - return 0, false + rm.econOff = true + return } - return n, true -} - -// seedWallet returns the joining player's durable (balance, peak): balance -// defaults to startBalance for a first-ever player (or a non-positive stored -// balance), and peak is raised to at least the balance. A nil/guest account -// returns the defaults. -func (rm *room) seedWallet(r kit.Room, p kit.Player) (balance, peak int) { - acct := r.Services().Accounts.For(p) - if acct == nil { - return startBalance, startBalance - } - store := acct.Store() - bal, ok := kvInt(store, keyBalance) - if !ok || bal <= 0 { - bal = startBalance - } - pk, ok := kvInt(store, keyPeak) - if !ok || pk < bal { - pk = bal - } - return bal, pk + rm.econOff = false + pl.bal = b } -// persistWallet writes the current balance (summed) and raises peak (max). The -// monotonic max-on-write means out-of-order or concurrent same-account writes -// can never regress the leaderboard metric. -func (rm *room) persistWallet(r kit.Room, pl *player) { - acct := r.Services().Accounts.For(pl.p) - if acct == nil { - return +// grossFor is the seat's clamped gross return for the current result: the stake +// back plus winnings on every covered bet, capped at stake*maxPayoutMult so the +// game never books (or shows) more than the host will actually pay. +func (rm *room) grossFor(pl *player) int64 { + var ret int64 + for _, b := range pl.bets { + ret += int64(settleReturn(masterBets[b.master], b.stake, rm.result)) + } + if cap := int64(pl.staked()) * maxPayoutMult; ret > cap { + ret = cap } - store := acct.Store() - _ = store.Set(context.Background(), keyBalance, []byte(strconv.Itoa(pl.balance)), kit.MergeSum) - _ = store.Set(context.Background(), keyPeak, []byte(strconv.Itoa(pl.peak)), kit.MergeMax) + return ret } -// postPeak feeds the declared leaderboard with a new personal peak (the board -// keeps each account's best). KV is durable state; Post is what reaches the -// board. +// postPeak feeds the declared leaderboard with the seat's account-wide balance +// whenever it sets a new personal high (the board keeps each account's best). func (rm *room) postPeak(r kit.Room, pl *player) { - if pl.peak <= pl.postedPeak { + if pl.bal <= pl.postedPeak { return } - pl.postedPeak = pl.peak - // Record (cadence OnImprove) posts the new high AND registers the player with - // the keeper so the periodic FlushAll re-posts them while seated. - rm.sk.Record(r, pl.p, pl.peak) + pl.postedPeak = pl.bal + r.Post(kit.Result{Rankings: []kit.PlayerResult{{ + Player: pl.p, Metric: int(pl.bal), Status: kit.StatusFinished, + }}}) } // --- roster ---------------------------------------------------------------- @@ -261,32 +247,41 @@ func (rm *room) postPeak(r kit.Room, pl *player) { func (rm *room) OnJoin(r kit.Room, p kit.Player) { if pl := rm.players[p.AccountID]; pl != nil { pl.p = p // refresh the token (handle/conn may have changed on rejoin) + rm.refreshBal(pl) rm.render(r) return } - bal, peak := rm.seedWallet(r, p) - rm.players[p.AccountID] = &player{ - p: p, balance: bal, peak: peak, postedPeak: peak, + pl := &player{ + p: p, stakeIdx: defaultStakeIdx, sel: newSelection(), joinOrder: rm.joinSeq, colorIdx: rm.freeColorIdx(), } + rm.refreshBal(pl) // read the account-wide balance the platform owns + pl.postedPeak = pl.bal + rm.clampStake(pl) // arm the highest chip this balance can cover + rm.players[p.AccountID] = pl rm.joinSeq++ rm.order = append(rm.order, p.AccountID) rm.render(r) } -// OnLeave persists the leaver's wallet and frees the seat. Bets still on the -// felt during the open betting window are refunded (the round hasn't resolved); -// bets locked in once the wheel is spinning are forfeit, as at a real table. +// OnLeave frees the seat. A seat that has an OPEN escrow (Wagered at spin lock, +// not yet Settled) must be Settled or the stake leaks: the result is already +// rolled, so book the fair gross. Chips merely resting on the felt during the +// open betting window are pre-Wager — a purely local refund, no escrow to close. func (rm *room) OnLeave(r kit.Room, p kit.Player) { pl := rm.players[p.AccountID] if pl == nil { return } - if rm.phase == phBetting { + if pl.wagered { + if rm.svc.Credits != nil { + _ = rm.svc.Credits.Settle(pl.p, rm.grossFor(pl)) + } + pl.wagered = false + } else if rm.phase == phBetting { rm.refundAll(pl) } - rm.persistWallet(r, pl) delete(rm.players, p.AccountID) for i, id := range rm.order { if id == p.AccountID { @@ -320,14 +315,6 @@ func (rm *room) OnWake(r kit.Room) { rm.enterBetting(r) } } - // Periodic peak flush: on a throttled game-time cadence, re-post every - // tracked (seated) player's current peak so an abandoned, still-ticking - // table keeps the board "constantly saved". Gated purely on r.Now() — no - // wall clock, no RNG — so it stays deterministic under freeze/thaw. - if rm.lastNow.Sub(rm.lastFlush) >= peakFlushInterval { - rm.lastFlush = rm.lastNow - rm.sk.FlushAll(r, kit.StatusFinished) - } rm.render(r) } @@ -422,8 +409,9 @@ func (rm *room) cancelEarlyClose() { // --- stakes & chips -------------------------------------------------------- func (rm *room) clampStake(pl *player) { - // Drop to the highest tier the balance can cover (at least the lowest index). - for pl.stakeIdx > 0 && stakeTiers[pl.stakeIdx] > pl.balance { + // Drop to the highest tier the still-available balance can cover (at least the + // lowest index). Bets are local until spin lock, so "available" is bal - staked. + for pl.stakeIdx > 0 && int64(stakeTiers[pl.stakeIdx]) > pl.avail() { pl.stakeIdx-- } } @@ -440,17 +428,19 @@ func (rm *room) adjustStake(pl *player, dir int) { rm.clampStake(pl) } -// placeBet puts the current stake on the armed bet, deducting immediately. +// placeBet puts the current stake on the armed bet. This is LOCAL bookkeeping +// only — nothing is escrowed until the spin locks (the ABI has no un-wager, so +// chips a player might still undo/clear cannot be Wagered yet); affordability +// gates on the cached balance less what is already down. func (rm *room) placeBet(pl *player) { mi := pl.sel.betIndex() if mi < 0 { return } stake := stakeTiers[pl.stakeIdx] - if stake > pl.balance { + if int64(stake) > pl.avail() { return // can't cover it } - pl.balance -= stake pl.bets = append(pl.bets, placedBet{master: mi, stake: stake}) // Placing a chip un-readies you — and if the table was already in the grace // beat, backs out of the early close too (same as un-readying with r), so a @@ -459,32 +449,57 @@ func (rm *room) placeBet(pl *player) { rm.cancelEarlyClose() } -// undoBet removes and refunds the last chip placed. +// undoBet removes the last chip placed (local only — pre-Wager). func (rm *room) undoBet(pl *player) { n := len(pl.bets) if n == 0 { return } - pl.balance += pl.bets[n-1].stake pl.bets = pl.bets[:n-1] } -// clearBets refunds every chip on the felt. +// clearBets drops every chip on the felt (local only — pre-Wager). func (rm *room) clearBets(pl *player) { rm.refundAll(pl) } -func (rm *room) refundAll(pl *player) { - pl.balance += pl.staked() - pl.bets = nil -} +func (rm *room) refundAll(pl *player) { pl.bets = nil } // --- spinning & settlement ------------------------------------------------- func (rm *room) startSpin(r kit.Room) { + // The outcome is rolled once, up front, from the seeded RNG so a seeded room + // reproduces every result and a later render never re-rolls it. + rm.result = r.Rand().Intn(pockets) + rm.spunOnce = true + + // Lock in ONE escrow per seat: the whole board is a single Wager, taken now. + // A seat is only in the spin if its Wager succeeds; the ABI has no un-wager, + // so this is the first and only escrow of the round. + n := 0 + for _, id := range rm.order { + pl := rm.players[id] + if pl == nil || len(pl.bets) == 0 { + continue + } + amt := int64(pl.staked()) + if rm.svc.Credits == nil { + pl.bets = nil // economy off: cannot take the bet, drop the chips + continue + } + if err := rm.svc.Credits.Wager(pl.p, amt); err != nil { + pl.bets = nil // couldn't escrow (insufficient/disabled) — sit them out + continue + } + pl.wagered = true + pl.bal -= amt // reflect the escrow in the cached HUD balance + n++ + } + if n == 0 { + rm.enterBetting(r) // nobody's stake could be taken — reopen the window + return + } rm.phase = phSpinning rm.closing = false rm.spinStart = r.Now() - rm.result = r.Rand().Intn(pockets) // the outcome, fixed up front - rm.spunOnce = true rm.deadline = rm.spinStart.Add(spinDur) rm.arm(pendSettle, rm.deadline) } @@ -495,24 +510,31 @@ func (rm *room) settle(r kit.Room) { if pl == nil { continue } + if !pl.wagered { // no escrow this round (sat out or Wager was refused) + pl.lastPlayed = false + continue + } staked := pl.staked() - ret := 0 - for _, b := range pl.bets { - ret += settleReturn(masterBets[b.master], b.stake, rm.result) + ret := rm.grossFor(pl) // clamped gross (0 on a total loss) + // Close the ONE open stake with its gross exactly once. + if rm.svc.Credits != nil { + _ = rm.svc.Credits.Settle(pl.p, ret) } - pl.balance += ret - pl.lastPlayed = len(pl.bets) > 0 - pl.lastNet = ret - staked + pl.wagered = false + pl.lastPlayed = staked > 0 + pl.lastNet = int(ret) - staked // The chips stay on the felt through the results screen so players can // see them against the winning number; enterBetting clears them when the // next window opens. - if pl.balance <= 0 { - pl.balance = rebuyAmount - } - if pl.balance > pl.peak { - pl.peak = pl.balance + rm.refreshBal(pl) // authoritative post-settle account-wide balance + // Broke-relief: a seat wiped below the table minimum tops up from the + // platform Buyback (solvent/limit-reached returns ErrInsufficientCredits, + // which we surface by simply leaving the balance as-is — no retry). + if pl.bal < minBet && rm.svc.Credits != nil { + if nb, err := rm.svc.Credits.Buyback(pl.p); err == nil { + pl.bal = nb + } } - rm.persistWallet(r, pl) rm.postPeak(r, pl) } // Record the winning number for the marquee. diff --git a/games/alan/roulette/room_test.go b/games/alan/roulette/room_test.go index 9fd60fa..13bf9b0 100644 --- a/games/alan/roulette/room_test.go +++ b/games/alan/roulette/room_test.go @@ -17,6 +17,9 @@ func newGame(t *testing.T, ids ...string) (*kittest.Room, *room) { players[i] = kittest.Player(id) } r := kittest.NewRoom(players...) + // Mirror the declared per-seat payout ceiling so the double exercises the + // same clamp the host applies. + r.CreditsMaxPayoutMultiplier = maxPayoutMult h := Game{}.NewRoom(r.Config(), r.Services()) rm, ok := h.(*room) if !ok { @@ -35,51 +38,70 @@ func setCursorNumber(rm *room, id string, n int) { rm.players[id].sel = selection{spot: n} } -func TestJoinSeedsWallet(t *testing.T) { - _, rm := newGame(t, "p1") - if rm.players["p1"].balance != startBalance { - t.Errorf("fresh player balance = %d, want %d", rm.players["p1"].balance, startBalance) +// seedBal is the kittest credits double's first-touch balance (its default). +const seedBal = 1000 + +func TestJoinSeedsBalance(t *testing.T) { + r, rm := newGame(t, "p1") + if rm.players["p1"].bal != seedBal { + t.Errorf("fresh player balance = %d, want %d", rm.players["p1"].bal, seedBal) + } + if got := r.Credits["p1"]; got != seedBal { + t.Errorf("credits ledger = %d, want %d", got, seedBal) } if rm.phase != phBetting { t.Errorf("phase = %q, want betting", rm.phase) } } +// TestPlaceUndoClear verifies chip placement is LOCAL bookkeeping only: nothing +// is escrowed during betting (the ledger never moves), and undo/clear are pure +// list edits — availability tracks bal minus staked. func TestPlaceUndoClear(t *testing.T) { - _, rm := newGame(t, "p1") + r, rm := newGame(t, "p1") pl := rm.players["p1"] setCursorNumber(rm, "p1", 17) // straight on 17 is the armed bet (chip 10) rm.placeBet(pl) - if pl.balance != startBalance-10 || len(pl.bets) != 1 { - t.Fatalf("after place: balance=%d bets=%d", pl.balance, len(pl.bets)) + if pl.staked() != 10 || len(pl.bets) != 1 { + t.Fatalf("after place: staked=%d bets=%d", pl.staked(), len(pl.bets)) } rm.adjustStake(pl, +1) // chip 25 rm.placeBet(pl) - if pl.balance != startBalance-35 || pl.staked() != 35 { - t.Fatalf("after second place: balance=%d staked=%d", pl.balance, pl.staked()) + if pl.staked() != 35 || pl.avail() != seedBal-35 { + t.Fatalf("after second place: staked=%d avail=%d", pl.staked(), pl.avail()) + } + rm.undoBet(pl) // drop the 25 + if pl.staked() != 10 || len(pl.bets) != 1 { + t.Fatalf("after undo: staked=%d bets=%d", pl.staked(), len(pl.bets)) + } + rm.clearBets(pl) // drop the rest + if pl.staked() != 0 || len(pl.bets) != 0 { + t.Fatalf("after clear: staked=%d bets=%d", pl.staked(), len(pl.bets)) } - rm.undoBet(pl) // refund the 25 - if pl.balance != startBalance-10 || len(pl.bets) != 1 { - t.Fatalf("after undo: balance=%d bets=%d", pl.balance, len(pl.bets)) + // The ledger never moved through any of the betting-phase edits — no Wager + // was taken and no escrow was opened. + if got := r.Credits["p1"]; got != seedBal { + t.Errorf("betting-phase edits touched the ledger: %d, want %d", got, seedBal) } - rm.clearBets(pl) // refund the rest - if pl.balance != startBalance || len(pl.bets) != 0 { - t.Fatalf("after clear: balance=%d bets=%d", pl.balance, len(pl.bets)) + if len(r.CreditsStakes) != 0 { + t.Errorf("betting-phase edits opened an escrow: %+v", r.CreditsStakes) } } func TestStakeClamp(t *testing.T) { _, rm := newGame(t, "p1") pl := rm.players["p1"] - pl.balance = 30 // can cover tier 10 and 25, not 50/100 + pl.bal = 30 // can cover tier 10 and 25, not 50/100 pl.stakeIdx = len(stakeTiers) - 1 rm.clampStake(pl) - if stakeTiers[pl.stakeIdx] > pl.balance { - t.Errorf("clamped stake %d exceeds balance %d", stakeTiers[pl.stakeIdx], pl.balance) + if int64(stakeTiers[pl.stakeIdx]) > pl.avail() { + t.Errorf("clamped stake %d exceeds available %d", stakeTiers[pl.stakeIdx], pl.avail()) } // Cannot place a bet you can't afford. - pl.balance = 5 + pl.bal = 5 + pl.bets = nil + pl.stakeIdx = defaultStakeIdx // chip 10 > 5 available setCursorNumber(rm, "p1", 1) rm.placeBet(pl) if len(pl.bets) != 0 { @@ -93,15 +115,15 @@ func TestStakeClamp(t *testing.T) { func TestFiveDollarFloor(t *testing.T) { _, rm := newGame(t, "p1") pl := rm.players["p1"] - pl.balance = 5 + pl.bal = 5 rm.clampStake(pl) // every betting window re-clamps the chip to the balance if got := stakeTiers[pl.stakeIdx]; got != 5 { t.Fatalf("chip clamped to %d on a 5 balance, want the 5 floor", got) } setCursorNumber(rm, "p1", 17) rm.placeBet(pl) - if len(pl.bets) != 1 || pl.balance != 0 { - t.Fatalf("could not bet the last 5: bets=%d balance=%d", len(pl.bets), pl.balance) + if len(pl.bets) != 1 || pl.avail() != 0 { + t.Fatalf("could not bet the last 5: bets=%d avail=%d", len(pl.bets), pl.avail()) } } @@ -121,9 +143,7 @@ func TestRoundSettles(t *testing.T) { rm.placeBet(p2) // chip 10 p2Bet := masterBets[p2.bets[0].master] - bal1, bal2 := p1.balance, p2.balance // already net of stakes - - // Both ready up -> grace -> spin. + // Both ready up -> grace -> spin. The single per-seat Wager is taken now. rm.toggleReady(r, p1) rm.toggleReady(r, p2) if !rm.closing { @@ -135,6 +155,14 @@ func TestRoundSettles(t *testing.T) { t.Fatalf("phase = %q, want spinning", rm.phase) } result := rm.result + // Exactly one open escrow per seat, equal to the whole board staked. + if r.CreditsStakes["p1"] != 25 || r.CreditsStakes["p2"] != 10 { + t.Fatalf("open stakes = %+v, want p1:25 p2:10", r.CreditsStakes) + } + // The escrow left the wagerable balance. + if r.Credits["p1"] != seedBal-25 || r.Credits["p2"] != seedBal-10 { + t.Fatalf("post-wager ledger = p1:%d p2:%d", r.Credits["p1"], r.Credits["p2"]) + } // Let the wheel finish; settle. r.Advance(spinDur + 100*time.Millisecond) @@ -143,23 +171,24 @@ func TestRoundSettles(t *testing.T) { t.Fatalf("phase = %q after spin, want results", rm.phase) } - wantBal1 := bal1 + settleReturn(p1Bet, 25, result) - wantBal2 := bal2 + settleReturn(p2Bet, 10, result) - if p1.balance != wantBal1 { - t.Errorf("p1 balance = %d, want %d (result %d, RED bet)", p1.balance, wantBal1, result) + // Each seat's stake is closed exactly once with its gross; the escrow clears. + if len(r.CreditsStakes) != 0 { + t.Errorf("escrow leaked after settle: %+v", r.CreditsStakes) + } + wantBal1 := int64(seedBal - 25 + settleReturn(p1Bet, 25, result)) + wantBal2 := int64(seedBal - 10 + settleReturn(p2Bet, 10, result)) + if r.Credits["p1"] != wantBal1 { + t.Errorf("p1 ledger = %d, want %d (result %d, RED bet)", r.Credits["p1"], wantBal1, result) } - if p2.balance != wantBal2 { - t.Errorf("p2 balance = %d, want %d (result %d, straight 7)", p2.balance, wantBal2, result) + if r.Credits["p2"] != wantBal2 { + t.Errorf("p2 ledger = %d, want %d (result %d, straight 7)", r.Credits["p2"], wantBal2, result) } if len(rm.history) != 1 || rm.history[0] != result { t.Errorf("history = %v, want [%d]", rm.history, result) } - // Durable wallet persisted, and a peak gain reaches the leaderboard. - if got, _ := kvInt(walletStore(r, "p1"), keyBalance); got != p1.balance { - t.Errorf("persisted balance = %d, want %d", got, p1.balance) - } - if p1.balance > startBalance { // p1 won on RED this round + // A peak gain reaches the leaderboard. + if r.Credits["p1"] > seedBal { // p1 won on RED this round if len(r.Posted) == 0 { t.Error("a new peak did not post to the leaderboard") } @@ -209,11 +238,14 @@ func TestPlacingChipCancelsEarlyClose(t *testing.T) { } } -// TestRebuyOnBust confirms a wiped-out player is staked again. +// TestRebuyOnBust confirms a wiped-out player is topped up by the platform +// Buyback after settlement (default rebuy amount 1000). func TestRebuyOnBust(t *testing.T) { r, rm := newGame(t, "p1") pl := rm.players["p1"] - pl.balance = 100 + // Start this seat at exactly 100 in the ledger and cache. + r.Credits["p1"] = 100 + pl.bal = 100 // Stake the whole 100 on a single straight that will miss unless the wheel // lands on it; then resolve and check either a clean win or a re-buy. setCursorNumber(rm, "p1", 33) @@ -221,44 +253,112 @@ func TestRebuyOnBust(t *testing.T) { for i := 0; i < 10; i++ { // 10 x 10 = the whole 100 on straight 33 rm.placeBet(pl) } - if pl.balance != 0 { - t.Fatalf("expected balance 0 after staking all, got %d", pl.balance) + if pl.staked() != 100 { + t.Fatalf("expected 100 staked, got %d", pl.staked()) } rm.toggleReady(r, pl) r.Advance(gracePeriod + 100*time.Millisecond) rm.OnWake(r) r.Advance(spinDur + 100*time.Millisecond) rm.OnWake(r) + if len(r.CreditsStakes) != 0 { + t.Fatalf("escrow leaked: %+v", r.CreditsStakes) + } if rm.result == 33 { - if pl.balance != 100*36 { - t.Errorf("won straight 33 but balance = %d, want %d", pl.balance, 100*36) + if pl.bal != 100*36 { // 3600 gross, no rebuy + t.Errorf("won straight 33 but balance = %d, want %d", pl.bal, 100*36) + } + if r.CreditsRebuys["p1"] != 0 { + t.Errorf("a winner should not rebuy: %d", r.CreditsRebuys["p1"]) + } + } else { + // Busted to 0 -> Buyback tops up to the default rebuy amount (1000). + if pl.bal != 1000 { + t.Errorf("busted but balance = %d, want re-buy 1000", pl.bal) + } + if r.CreditsRebuys["p1"] != 1 { + t.Errorf("expected exactly one Buyback, got %d", r.CreditsRebuys["p1"]) } - } else if pl.balance != rebuyAmount { - t.Errorf("busted but balance = %d, want re-buy %d", pl.balance, rebuyAmount) } } -// TestLeaveRefundsDuringBetting checks an open-window departure gets its chips -// back (the round hasn't resolved). +// TestLeaveRefundsDuringBetting checks an open-window departure drops its chips +// with no ledger movement (pre-Wager, so there is no escrow to close). func TestLeaveRefundsDuringBetting(t *testing.T) { r, rm := newGame(t, "p1", "p2") pl := rm.players["p1"] setCursorNumber(rm, "p1", 5) rm.placeBet(pl) rm.placeBet(pl) - if pl.balance != startBalance-20 { - t.Fatalf("balance before leave = %d", pl.balance) - } rm.OnLeave(r, pl.p) - // Persisted balance should reflect the refund (back to the full start). - if got, _ := kvInt(walletStore(r, "p1"), keyBalance); got != startBalance { - t.Errorf("persisted balance after refunded leave = %d, want %d", got, startBalance) + // No Wager was taken, so the ledger is untouched at the seed and no stake + // is left open. + if got := r.Credits["p1"]; got != seedBal { + t.Errorf("ledger after refunded leave = %d, want %d", got, seedBal) + } + if len(r.CreditsStakes) != 0 { + t.Errorf("betting-phase leave left an escrow: %+v", r.CreditsStakes) } if _, ok := rm.players["p1"]; ok { t.Error("player not removed on leave") } } +// TestLeaveMidSpinSettlesEscrow guards the money path: a seat that Wagered and +// then leaves while the wheel is spinning (open escrow) must be Settled so the +// stake never leaks. +func TestLeaveMidSpinSettlesEscrow(t *testing.T) { + r, rm := newGame(t, "p1", "p2") // p2 keeps the table from closing on p1 leave + pl := rm.players["p1"] + setCursorNumber(rm, "p1", 7) + rm.placeBet(pl) // 10 on straight 7 + rm.toggleReady(r, pl) + rm.toggleReady(r, rm.players["p2"]) // both ready -> grace -> spin + r.Advance(gracePeriod + 100*time.Millisecond) + rm.OnWake(r) + if rm.phase != phSpinning || !pl.wagered { + t.Fatalf("setup: phase=%q wagered=%v", rm.phase, pl.wagered) + } + if r.CreditsStakes["p1"] != 10 { + t.Fatalf("open escrow = %d, want 10", r.CreditsStakes["p1"]) + } + // Leave mid-spin: the open escrow is Settled at the fair gross for the rolled + // result (the win pays 10*36; a miss books the loss). Either way it clears. + want := int64(seedBal - 10 + settleReturn(masterBets[7], 10, rm.result)) + rm.OnLeave(r, pl.p) + if _, open := r.CreditsStakes["p1"]; open { + t.Errorf("escrow leaked on mid-spin leave: %+v", r.CreditsStakes) + } + if got := r.Credits["p1"]; got != want { + t.Errorf("mid-spin leave ledger = %d, want %d (result %d)", got, want, rm.result) + } +} + +// TestTopPrizeDoesNotClamp proves the declared MaxPayoutMultiplier (36) covers +// the richest wager: a whole-stake straight-up that hits pays the full stake*36 +// with no truncation by either the game clamp or the host double. +func TestTopPrizeDoesNotClamp(t *testing.T) { + r, rm := newGame(t, "p1") + pl := rm.players["p1"] + setCursorNumber(rm, "p1", 20) + pl.stakeIdx = defaultStakeIdx + for i := 0; i < 10; i++ { // 100 on straight 20 + rm.placeBet(pl) + } + staked := int64(pl.staked()) + // Take the one escrow, force the wheel onto 20 (the 35:1 jackpot), settle. + if err := r.Services().Credits.Wager(pl.p, staked); err != nil { + t.Fatalf("wager: %v", err) + } + pl.wagered = true + rm.result = 20 + rm.settle(r) + want := seedBal - staked + staked*36 // full 36x, un-clamped + if got := r.Credits["p1"]; got != want { + t.Fatalf("top prize clamped: ledger = %d, want %d", got, want) + } +} + // --- helpers --------------------------------------------------------------- func setCursorOutside(rm *room, id string, k betKind) { @@ -269,7 +369,3 @@ func setCursorOutside(rm *room, id string, k betKind) { } } } - -func walletStore(r *kittest.Room, id string) kit.KVStore { - return r.Services().Accounts.For(kittest.Player(id)).Store() -} diff --git a/games/alan/roulette/roulette_leaderboard_test.go b/games/alan/roulette/roulette_leaderboard_test.go index 9a3982d..dbd1867 100644 --- a/games/alan/roulette/roulette_leaderboard_test.go +++ b/games/alan/roulette/roulette_leaderboard_test.go @@ -2,13 +2,13 @@ package main import ( "testing" - "time" kit "github.com/shellcade/kit/v2" + "github.com/shellcade/kit/v2/kittest" ) -// peakOf returns the metric of the most recent peak post for the given account -// in r.Posted, or (0, false) if the player has no post. +// lastPostedMetric returns the metric of the most recent leaderboard post for +// the given account in r.Posted, or (0, false) if the player has no post. func lastPostedMetric(posted []kit.Result, id string) (int, bool) { metric, ok := 0, false for _, res := range posted { @@ -21,40 +21,60 @@ func lastPostedMetric(posted []kit.Result, id string) (int, bool) { return metric, ok } -// TestPeriodicPeakFlush locks in the "constantly saved" guarantee for an -// abandoned table: a seated player whose peak does NOT change is still re-posted -// to the leaderboard on the throttled OnWake interval, so the board reflects a -// still-seated player even when the table is idle mid-round. A wake BEFORE the -// interval elapses must not re-post. -func TestPeriodicPeakFlush(t *testing.T) { - r, rm := newGame(t, "p1") - pl := rm.players["p1"] - - // Establish a peak via the normal increase path so the keeper is tracking - // the player, then clear the recorded posts. - pl.peak = 4242 - rm.postPeak(r, pl) - if _, ok := lastPostedMetric(r.Posted, "p1"); !ok { - t.Fatal("postPeak did not record an initial leaderboard post") +// settleStraight drives one seat through a single Wager -> forced result -> +// Settle for a whole-stake straight bet on number n, returning the seat. +func settleStraight(t *testing.T, r *kittest.Room, rm *room, id string, n int, chips int) *player { + t.Helper() + pl := rm.players[id] + setCursorNumber(rm, id, n) + pl.stakeIdx = defaultStakeIdx // chip 10 + pl.bets = nil + for i := 0; i < chips; i++ { + rm.placeBet(pl) } - r.Posted = nil - - // A wake BEFORE the interval elapses must NOT re-post (still inside the - // open betting window, so no round one-shot fires either). - r.Advance(peakFlushInterval - time.Second) - rm.OnWake(r) - if _, ok := lastPostedMetric(r.Posted, "p1"); ok { - t.Fatalf("re-posted before the flush interval elapsed: %+v", r.Posted) + if err := r.Services().Credits.Wager(pl.p, int64(pl.staked())); err != nil { + t.Fatalf("wager: %v", err) } + pl.wagered = true + rm.result = n // force the outcome so we control win/loss deterministically + rm.settle(r) + return pl +} - // Crossing the interval (with NO new peak/bet) must re-post the current peak. - r.Advance(2 * time.Second) // now past peakFlushInterval since last flush - rm.OnWake(r) +// TestLeaderboardPostsCreditsPeak locks in the converted board: the metric is +// the account-wide Credits balance, posted only when it sets a new personal high +// after a Settle; a losing round (a lower balance) never regresses the board. +func TestLeaderboardPostsCreditsPeak(t *testing.T) { + r, rm := newGame(t, "p1") + + // A winning round: whole 100 on straight 20 that hits -> balance jumps to + // seed - 100 + 100*36, which posts as the new peak. + settleStraight(t, r, rm, "p1", 20, 10) + wantPeak := int64(seedBal - 100 + 100*36) got, ok := lastPostedMetric(r.Posted, "p1") if !ok { - t.Fatal("periodic flush did not re-post the seated player's peak") + t.Fatal("winning round did not post a leaderboard peak") + } + if int64(got) != wantPeak { + t.Errorf("posted peak = %d, want %d (account-wide credits)", got, wantPeak) } - if got != pl.peak { - t.Errorf("periodic flush posted metric %d, want current peak %d", got, pl.peak) + + // A losing round afterwards drops the balance well below the peak; it must + // NOT post a regressed metric. + r.Posted = nil + // Advance into a fresh betting window then bet-and-miss. + rm.enterBetting(r) + pl := rm.players["p1"] + setCursorNumber(rm, "p1", 5) + pl.stakeIdx = defaultStakeIdx + rm.placeBet(pl) // 10 on straight 5 + if err := r.Services().Credits.Wager(pl.p, int64(pl.staked())); err != nil { + t.Fatalf("wager: %v", err) + } + pl.wagered = true + rm.result = 6 // 5 loses + rm.settle(r) + if _, ok := lastPostedMetric(r.Posted, "p1"); ok { + t.Errorf("a losing round regressed the leaderboard: %+v", r.Posted) } } From 1980c64eaddd3c0512344c181c398aec06a0202c Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Fri, 3 Jul 2026 19:02:19 +1000 Subject: [PATCH 3/4] pokies: convert to casino-kind with platform Credits (kit v2.16.0) Declares Kind=casino, MaxPayoutMultiplier=10000, CtxFeatCredits; removes the game's internal chip/KV wallet and wires bets through svc.Credits Wager/Settle/Balance with one open stake per round, a single stake-inclusive Settle, Settle-on-leave/abandon so escrow never leaks, and in-game rebuy via svc.Credits.Buyback when busted. Peak-Credits leaderboard. Lifecycle Resident->Resumable (casino+resident is unsupported); gamble MaxWin and free-spin accumulation made stake-relative, gross capped at bet*10000. Verified: go build/vet/test, tinygo, and shellcade-kit check --require-leaderboard. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W7oJQSTva5xgbAZomdV5Jj --- games/bcook/pokies/alloc_test.go | 2 +- games/bcook/pokies/freespins.go | 25 +- games/bcook/pokies/freespins_test.go | 37 ++- games/bcook/pokies/gamble.go | 35 ++- games/bcook/pokies/gamble_test.go | 25 +- games/bcook/pokies/go.mod | 2 +- games/bcook/pokies/go.sum | 4 +- games/bcook/pokies/layout.go | 21 +- games/bcook/pokies/main.go | 10 +- games/bcook/pokies/pokies_test.go | 201 +++++++++++----- games/bcook/pokies/room.go | 344 +++++++++++++++++---------- 11 files changed, 474 insertions(+), 232 deletions(-) diff --git a/games/bcook/pokies/alloc_test.go b/games/bcook/pokies/alloc_test.go index 8dc9fb8..84c23bc 100644 --- a/games/bcook/pokies/alloc_test.go +++ b/games/bcook/pokies/alloc_test.go @@ -29,7 +29,7 @@ func TestDrawPaytableAllocFree(t *testing.T) { func TestDrawGambleAllocBudget(t *testing.T) { p := kittest.Player("alice") rm, _ := newGame(t, p) - m := &machine{balance: 1000, gamble: &gambleState{atRisk: 12345, sel: selRed, card: suitHearts}} + m := &machine{credits: 1000, gamble: &gambleState{atRisk: 12345, sel: selRed, card: suitHearts}} rm.machines[p.AccountID] = m f := kit.NewFrame() if n := testing.AllocsPerRun(100, func() { rm.drawGamble(f, 0, 0, m, true) }); n > 2 { diff --git a/games/bcook/pokies/freespins.go b/games/bcook/pokies/freespins.go index b50506c..c26fad3 100644 --- a/games/bcook/pokies/freespins.go +++ b/games/bcook/pokies/freespins.go @@ -28,15 +28,17 @@ func (rm *room) scatterCount(m *machine) int { } // triggerFreeSpins awards free spins from the just-settled window under variant v -// (the spin's pinned variant), returning the spins awarded (0 if none). On a -// fresh feature it locks the bet and variant; a trigger during free spins -// retriggers, adding to the running count. -func (rm *room) triggerFreeSpins(m *machine, v *variant, bet int) int { +// (the spin's pinned variant), returning the spins awarded (0 if none). When +// fresh (a base-game trigger) it locks the bet and variant and zeroes the win +// accumulator; a retrigger (fresh=false, called from inside the feature) only +// adds spins and MUST keep the accumulator — the caller owns the fresh/retrigger +// distinction so a retrigger on the last spin never wipes the pending settle. +func (rm *room) triggerFreeSpins(m *machine, v *variant, bet int, fresh bool) int { award := v.scatterAward(scatterWindow(m.lastStrip, m.lastIdx)) if award == 0 { return 0 } - if m.freeSpins == 0 { // fresh feature + if fresh { m.freeBet = bet m.freeVar = v m.freeWin = 0 @@ -50,18 +52,23 @@ func (rm *room) scheduleNextFree(r kit.Room, m *machine) { m.nextFree = r.Now().Add(freeSpinGap) } -// endFreeSpins finalizes a feature: flash the accumulated total and release the -// pinned variant. +// endFreeSpins finalizes a feature: it is the SINGLE Settle for the whole +// feature, closing the one open stake with the accumulated (stake-clamped) +// win — the triggering line win folded in plus every free-spin win. Flashes +// the total and releases the pinned variant. func (rm *room) endFreeSpins(r kit.Room, id string) { m := rm.machines[id] if m == nil { return } - if m.freeWin > 0 { - m.flash = fmt.Sprintf("FEATURE +%d", m.freeWin) + gross := capGross(m.freeWin, m.stake) + if gross > 0 { + m.flash = fmt.Sprintf("FEATURE +%d", gross) m.flashUntil = r.Now().Add(flashDur) } m.freeVar = nil + rm.settle(r, id, gross) + m.freeWin = 0 } // autoFreeSpin rolls one free spin (no bet charged) under the pinned free-spin diff --git a/games/bcook/pokies/freespins_test.go b/games/bcook/pokies/freespins_test.go index e669e10..cb0c98f 100644 --- a/games/bcook/pokies/freespins_test.go +++ b/games/bcook/pokies/freespins_test.go @@ -38,7 +38,8 @@ func TestFreeSpinsAwardedDeterministically(t *testing.T) { }) h.OnJoin(r, p) m := h.machines[p.AccountID] - m.bet, m.balance = 10, startBalance-10 + m.bet = 10 + openStake(h, r, p) // wager the base bet; the trigger keeps this one stake open idx := firstIdx(t, h.variant.strip, symScatter) m.spin = &spinState{startedAt: r.Now(), variant: h.variant, stopIdx: allReels(idx), final: faceRow(symScatter)} @@ -49,31 +50,41 @@ func TestFreeSpinsAwardedDeterministically(t *testing.T) { if m.freeBet != 10 { t.Fatalf("freeBet = %d, want the triggering bet 10", m.freeBet) } + if m.stake != 10 { + t.Fatalf("stake = %d, want the base stake held open through the feature", m.stake) + } } -func TestFreeSpinWinCreditsAtLockedBetNoCharge(t *testing.T) { +// A free spin never wagers and never settles mid-feature: it only accumulates +// its win onto the one open stake (balance unchanged until the feature ends). +func TestFreeSpinAccumulatesNoMidCharge(t *testing.T) { p := kittest.Player("alice") r := kittest.NewRoom(p) + r.CreditsMaxPayoutMultiplier = maxPayoutMult h := Game{}.NewRoom(r.Config(), r.Services()).(*room) h.OnStart(r) - // No scatter on this strip: the 7-run free spin credits without a retrigger. + // No scatter on this strip: the 7-run free spin pays without a retrigger. h.variant = mustCompile(t, oddsVariant{ Name: "fs", Weights: map[string]int{"7": 3, "C": 30}, Paytable: []payEntry{{Faces: "7", Pay3: 50, Pay4: 150, Pay5: 500}}, }) h.OnJoin(r, p) m := h.machines[p.AccountID] - m.bet = 10 + m.bet = 50 + openStake(h, r, p) // the original base stake, held open through the feature m.freeSpins, m.freeBet, m.freeVar = 3, 50, h.variant - m.balance = 1000 + before := m.credits i7 := firstIdx(t, h.variant.strip, sym7) m.spin = &spinState{startedAt: r.Now(), variant: h.variant, stopIdx: allReels(i7), final: faceRow(sym7)} - want := 1000 + 50*h.variant.waysPayout(scatterWindow(h.variant.strip, allReels(i7)))/wayScale + win := 50 * h.variant.waysPayout(scatterWindow(h.variant.strip, allReels(i7))) / wayScale h.settleSpin(r, p.AccountID) - if m.balance != want { - t.Fatalf("balance = %d, want %d (credited at the locked bet, no deduction)", m.balance, want) + if m.credits != before { + t.Fatalf("balance changed mid-feature: %d, want unchanged %d (no per-spin settle)", m.credits, before) + } + if m.freeWin != win { + t.Fatalf("freeWin = %d, want the accumulated %d", m.freeWin, win) } if m.freeSpins != 2 { t.Fatalf("freeSpins = %d, want 2 (decremented)", m.freeSpins) @@ -86,6 +97,7 @@ func TestFreeSpinWinCreditsAtLockedBetNoCharge(t *testing.T) { func TestFreeSpinsAutoPlayToCompletion(t *testing.T) { p := kittest.Player("alice") r := kittest.NewRoom(p) + r.CreditsMaxPayoutMultiplier = maxPayoutMult h := Game{}.NewRoom(r.Config(), r.Services()).(*room) h.OnStart(r) h.variant = mustCompile(t, oddsVariant{ @@ -95,8 +107,9 @@ func TestFreeSpinsAutoPlayToCompletion(t *testing.T) { h.OnJoin(r, p) seatAt0(t, h, p) m := h.machines[p.AccountID] + m.bet = 10 + openStake(h, r, p) // the base stake, held open until the feature settles m.freeSpins, m.freeBet, m.freeVar = 3, 10, h.variant - m.balance = 1000 for i := 0; i < 40; i++ { r.Advance(300 * time.Millisecond) @@ -108,6 +121,9 @@ func TestFreeSpinsAutoPlayToCompletion(t *testing.T) { if m.spin != nil { t.Fatal("no spin should be in flight after the feature ends") } + if m.stake != 0 { + t.Fatalf("stake = %d, want 0 (the feature settled the one open stake exactly once)", m.stake) + } } func TestFreeSpinTriggerAnnouncesRoomWide(t *testing.T) { @@ -122,7 +138,8 @@ func TestFreeSpinTriggerAnnouncesRoomWide(t *testing.T) { }) h.OnJoin(r, p) m := h.machines[p.AccountID] - m.bet, m.balance = 10, 990 + m.bet = 10 + openStake(h, r, p) idx := firstIdx(t, h.variant.strip, symScatter) m.spin = &spinState{startedAt: r.Now(), variant: h.variant, stopIdx: allReels(idx), final: faceRow(symScatter)} diff --git a/games/bcook/pokies/gamble.go b/games/bcook/pokies/gamble.go index 96c5649..40d42cc 100644 --- a/games/bcook/pokies/gamble.go +++ b/games/bcook/pokies/gamble.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "math/rand" kit "github.com/shellcade/kit/v2" @@ -54,9 +55,11 @@ func suitIsRed(s int) bool { return s == suitHearts || s == suitDiamonds } func suitOf(sel int) int { return sel - selSpades } // enterGamble holds a base-game win at risk and opens the ladder, highlighting -// TAKE so an accidental confirm banks the win rather than risking it. +// TAKE so an accidental confirm banks the win rather than risking it. The held +// win is clamped stake-relative up front so the ladder never starts above the +// payout ceiling. func (rm *room) enterGamble(r kit.Room, m *machine, win int) { - m.gamble = &gambleState{atRisk: win, sel: selTake, card: -1} + m.gamble = &gambleState{atRisk: capGross(win, m.stake), sel: selTake, card: -1} } // gambleInput moves the selector or confirms (called from OnInput while a gamble @@ -111,30 +114,46 @@ func (rm *room) resolveGuess(r kit.Room, id string, suit int) { } g.last = win if !win { + // Wrong guess: forfeit the held win and settle the open stake as a loss. m.gamble = nil m.flash = "GAMBLED AWAY" m.flashUntil = r.Now().Add(flashDur) - rm.creditWin(r, id, 0, true) // rebuy check; nothing credited + rm.settle(r, id, 0) return } g.atRisk *= mult + // Hold the at-risk win stake-relative: cap it (and the effective auto-take + // ceiling) at stake*maxPayoutMult so the gross can never exceed what the host + // will pay, and a run at the ceiling auto-takes rather than risking a win it + // cannot grow. + cap := m.stake * maxPayoutMult + if m.stake > 0 && g.atRisk > cap { + g.atRisk = cap + } g.rungs++ gc := rm.gambleCap(m) - if g.rungs >= gc.MaxRungs || g.atRisk >= gc.MaxWin { + maxWin := gc.MaxWin + if m.stake > 0 && cap < maxWin { + maxWin = cap + } + if g.rungs >= gc.MaxRungs || g.atRisk >= maxWin { rm.takeWin(r, id) } } -// takeWin banks the at-risk win through the normal credit path (peak, leaderboard, -// big-win ticker), then clears the gamble. +// takeWin banks the at-risk win: it is the single Settle for the gamble path, +// closing the one open stake with the clamped gross, then fires the big-win +// ticker and clears the gamble. func (rm *room) takeWin(r kit.Room, id string) { m := rm.machines[id] if m == nil || m.gamble == nil { return } - win := m.gamble.atRisk + win := capGross(m.gamble.atRisk, m.stake) m.gamble = nil - rm.creditWin(r, id, win, true) + m.flash = fmt.Sprintf("WIN! +%d", win) + m.flashUntil = r.Now().Add(flashDur) + rm.settle(r, id, win) if win >= m.bet*tickerMult { rm.announce(r, id, win) } diff --git a/games/bcook/pokies/gamble_test.go b/games/bcook/pokies/gamble_test.go index 38cb510..5ea6eba 100644 --- a/games/bcook/pokies/gamble_test.go +++ b/games/bcook/pokies/gamble_test.go @@ -37,7 +37,6 @@ func TestGambleWinDoublesAndLadders(t *testing.T) { rm, r := newGame(t, p) rm.OnJoin(r, p) m := rm.machines[p.AccountID] - m.balance = 1000 rm.enterGamble(r, m, 100) if m.gamble == nil || m.gamble.atRisk != 100 { t.Fatalf("enterGamble did not hold the win: %+v", m.gamble) @@ -59,15 +58,16 @@ func TestGambleTakeBanksWin(t *testing.T) { rm, r := newGame(t, p) rm.OnJoin(r, p) m := rm.machines[p.AccountID] - m.balance = 1000 + openStake(rm, r, p) // one open stake for the take to settle + before := m.credits rm.enterGamble(r, m, 250) m.gamble.sel = selTake rm.gambleConfirm(r, p.AccountID) if m.gamble != nil { t.Fatal("take should clear the gamble") } - if m.balance != 1250 { - t.Fatalf("balance = %d, want 1250 (win banked)", m.balance) + if m.credits != before+250 { + t.Fatalf("balance = %d, want %d (win banked)", m.credits, before+250) } } @@ -76,15 +76,19 @@ func TestGambleLossForfeits(t *testing.T) { rm, r := newGame(t, p) rm.OnJoin(r, p) m := rm.machines[p.AccountID] - m.balance = 1000 + openStake(rm, r, p) + before := m.credits // already down the wagered stake rm.enterGamble(r, m, 100) m.gamble.sel = selRed rm.resolveGuess(r, p.AccountID, suitSpades) // spades = black -> RED loses if m.gamble != nil { t.Fatal("a loss should clear the gamble") } - if m.balance != 1000 { - t.Fatalf("balance = %d, want 1000 (win forfeited, nothing credited)", m.balance) + if m.credits != before { + t.Fatalf("balance = %d, want %d (held win forfeited, settle 0 credits nothing)", m.credits, before) + } + if r.CreditsStakes[p.AccountID] != 0 { + t.Fatalf("open stake = %d, want 0 (the loss must settle the stake)", r.CreditsStakes[p.AccountID]) } } @@ -93,7 +97,8 @@ func TestGambleAutoTakesAtRungCap(t *testing.T) { rm, r := newGame(t, p) rm.OnJoin(r, p) m := rm.machines[p.AccountID] - m.balance = 1000 + openStake(rm, r, p) + before := m.credits // lastVar drives the cap; default is 5 rungs. m.lastVar = defaultVariant() rm.enterGamble(r, m, 1) @@ -107,7 +112,7 @@ func TestGambleAutoTakesAtRungCap(t *testing.T) { if m.gamble != nil { t.Fatal("ladder should auto-take at the rung cap (5)") } - if m.balance != 1000+32 { // 1 doubled five times = 32 - t.Fatalf("balance = %d, want %d", m.balance, 1000+32) + if m.credits != before+32 { // 1 doubled five times = 32 + t.Fatalf("balance = %d, want %d", m.credits, before+32) } } diff --git a/games/bcook/pokies/go.mod b/games/bcook/pokies/go.mod index 96aca2a..0c3bca7 100644 --- a/games/bcook/pokies/go.mod +++ b/games/bcook/pokies/go.mod @@ -4,7 +4,7 @@ go 1.25.11 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - github.com/shellcade/kit/v2 v2.15.0 + github.com/shellcade/kit/v2 v2.16.0 ) require ( diff --git a/games/bcook/pokies/go.sum b/games/bcook/pokies/go.sum index 29c49a8..c09ac5d 100644 --- a/games/bcook/pokies/go.sum +++ b/games/bcook/pokies/go.sum @@ -4,8 +4,8 @@ github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/shellcade/kit/v2 v2.15.0 h1:/C/xsAlteeTMbJ+i+1qOcWXECeib4+dduTyPpEGq9bU= -github.com/shellcade/kit/v2 v2.15.0/go.mod h1:EYbqrycZyGxzjUnklDlKBPgU8bCftTVJFKVCw/+gvdw= +github.com/shellcade/kit/v2 v2.16.0 h1:ZU3SCkLqmJMbKqxnBMfEocYsjLiGoHaq48pWrFIkV1s= +github.com/shellcade/kit/v2 v2.16.0/go.mod h1:EYbqrycZyGxzjUnklDlKBPgU8bCftTVJFKVCw/+gvdw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= diff --git a/games/bcook/pokies/layout.go b/games/bcook/pokies/layout.go index f2c3ac5..b929196 100644 --- a/games/bcook/pokies/layout.go +++ b/games/bcook/pokies/layout.go @@ -133,9 +133,13 @@ func (rm *room) composeFloor(f *kit.Frame, v kit.Player) { rm.drawTicker(f) } rm.drawFloor(f, v) - f.Text(kit.Rows-1, 2, "Arrows move SPACE sit Esc leave", stDim) + controls := "Arrows move SPACE sit Esc leave" + if rm.economyOff { + controls = "CREDITS OUT OF SERVICE" + } + f.Text(kit.Rows-1, 2, controls, stDim) if m := rm.machines[v.AccountID]; m != nil { - f.TextRight(kit.Rows-1, kit.Cols-2, fmt.Sprintf("BAL %d HI %d", m.balance, m.highScore), stDim) + f.TextRight(kit.Rows-1, kit.Cols-2, fmt.Sprintf("BAL %d HI %d", m.credits, m.peak), stDim) } } @@ -164,7 +168,10 @@ func (rm *room) composeSeated(f *kit.Frame, v kit.Player) { case m.freeSpins > 0: controls = "FREE SPINS auto-playing... Esc stand" } - f.TextRight(kit.Rows-1, kit.Cols-2, fmt.Sprintf("BAL %d HI %d", m.balance, m.highScore), stDim) + f.TextRight(kit.Rows-1, kit.Cols-2, fmt.Sprintf("BAL %d HI %d", m.credits, m.peak), stDim) + } + if rm.economyOff { + controls = "CREDITS OUT OF SERVICE" } f.Text(kit.Rows-1, 2, controls, stDim) } @@ -255,10 +262,10 @@ func (rm *room) drawSeated(f *kit.Frame, m *machine) { } // Readout line: BET (or FREE during a feature), BAL, HI. - info := fmt.Sprintf("BET %d BAL %d HI %d", m.bet, m.balance, m.highScore) + info := fmt.Sprintf("BET %d BAL %d HI %d", m.bet, m.credits, m.peak) st := stTitle if m.freeSpins > 0 { - info = fmt.Sprintf("FREE %d BAL %d HI %d", m.freeSpins, m.balance, m.highScore) + info = fmt.Sprintf("FREE %d BAL %d HI %d", m.freeSpins, m.credits, m.peak) st = stWin } f.Text(seatTop+visRows+3, (kit.Cols-len(info))/2, info, st) @@ -283,8 +290,8 @@ func (rm *room) drawGamble(f *kit.Frame, col, top int, m *machine, own bool) { f.Text(top+2, col+2, "GAMBLE", stGamOpt) f.SetGraphemeWide(top+4, col+3, "\U0001F3B2", stGamble) // 🎲 f.Text(top+4, col+6, risk, stGamble) - f.Text(top+6, col+2, fmt.Sprintf("HI %d", m.highScore), stLabel) - f.Text(top+7, col+2, fmt.Sprintf("BAL %d", m.balance), stLabel) + f.Text(top+6, col+2, fmt.Sprintf("HI %d", m.peak), stLabel) + f.Text(top+7, col+2, fmt.Sprintf("BAL %d", m.credits), stLabel) return } f.Text(top+1, col+2, "GAMBLE", stGamble) diff --git a/games/bcook/pokies/main.go b/games/bcook/pokies/main.go index 07bd344..57909f3 100644 --- a/games/bcook/pokies/main.go +++ b/games/bcook/pokies/main.go @@ -3,11 +3,13 @@ // fixed 80x24 canvas. A wake-driven port of the native pokies game to the // shellcade wasm ABI: the reel animation is a clock derived from CallContext // time, reel landings are one-shot deadlines held in guest memory, odds are -// admin-tunable via config, and the durable wallet uses the casino kv pattern -// (balance summed, peak max-merged) with a peak-ranked leaderboard. +// admin-tunable via config, and every bet wagers account-wide platform Credits +// through the casino Credits ABI (kit v2.16.0) with a peak-credits leaderboard. // -// The machine is a 5-reel, 243-ways pokie (a shared resident lounge floor; sit -// at one of six themed machines). Features layer onto a single weighted strip: +// The machine is a 5-reel, 243-ways pokie played for account-wide platform +// Credits (casino-kind; the host owns the wallet). Players enter a resumable +// lounge session and sit at one of six themed machines. Features layer onto a +// single weighted strip: // - 243 WAYS: a symbol pays its left-aligned run (adjacent reels from reel 0, // any rows), credited pays[len] × the product of per-reel counts. // - WILD (👑) substitutes for any paying symbol within a run. diff --git a/games/bcook/pokies/pokies_test.go b/games/bcook/pokies/pokies_test.go index 3dfdd77..1c801d4 100644 --- a/games/bcook/pokies/pokies_test.go +++ b/games/bcook/pokies/pokies_test.go @@ -18,15 +18,34 @@ func keyUp() kit.Input { return kit.Input{Kind: kit.InputKey, Key: kit.KeyUp} func keyDown() kit.Input { return kit.Input{Kind: kit.InputKey, Key: kit.KeyDown} } func keyRight() kit.Input { return kit.Input{Kind: kit.InputKey, Key: kit.KeyRight} } -// newGame builds a started room handler plus its driving kittest.Room. +// seedCredits is the per-account starting balance the credits double seeds on +// first touch (kittest default). Tests reason about balances relative to it. +const seedCredits = 1000 + +// newGame builds a started room handler plus its driving kittest.Room. The +// credits double is seeded and its per-hand payout clamp is set to the game's +// declared MaxPayoutMultiplier so tests exercise the same ceiling as the host. func newGame(t *testing.T, players ...kit.Player) (*room, *kittest.Room) { t.Helper() r := kittest.NewRoom(players...) + r.CreditsSeed = seedCredits + r.CreditsMaxPayoutMultiplier = maxPayoutMult h := Game{}.NewRoom(r.Config(), r.Services()).(*room) h.OnStart(r) return h, r } +// openStake wagers the machine's current bet through the credits double and +// records the open stake, exactly as startSpin does — so a test that drives +// settleSpin/enterGamble directly (bypassing the animation) still has the one +// open stake that Settle requires. Refreshes the cached balance afterwards. +func openStake(rm *room, r *kittest.Room, p kit.Player) { + m := rm.machines[p.AccountID] + _ = r.Services().Credits.Wager(p, int64(m.bet)) + m.stake = m.bet + rm.refreshBalance(r, p.AccountID) +} + // seatAt0 seats player p at machine 0 so the cabinet renders and the machine // ticks. Returns the player's machine. func seatAt0(t *testing.T, rm *room, p kit.Player) *machine { @@ -96,6 +115,7 @@ func forceWin5(t *testing.T, rm *room, r *kittest.Room, p kit.Player, s symbol) v := rm.variant idx := pureIdx(t, v, s) // all-s window: max ways, scatter-free win := m.bet * v.waysPayout(scatterWindow(v.strip, allReels(idx))) / wayScale + openStake(rm, r, p) // wager the bet as startSpin would (opens the one stake) m.spin = &spinState{startedAt: r.Now(), variant: v, stopIdx: allReels(idx), final: faceRow(s)} rm.settleSpin(r, p.AccountID) return win @@ -111,6 +131,7 @@ func forceLoss5(t *testing.T, rm *room, r *kittest.Room, p kit.Player) { i7, iD, iS := pureIdx(t, v, sym7), pureIdx(t, v, symDollar), pureIdx(t, v, symStar) idx := [numReels]int{i7, iD, iS, i7, iD} fin := [numReels]symbol{sym7, symDollar, symStar, sym7, symDollar} + openStake(rm, r, p) // wager the bet as startSpin would (opens the one stake) m.spin = &spinState{startedAt: r.Now(), variant: v, stopIdx: idx, final: fin} rm.settleSpin(r, p.AccountID) } @@ -131,7 +152,7 @@ func frameContains(r *kittest.Room, p kit.Player, s string) bool { // --- meta + context ---------------------------------------------------------- -func TestMetaIsResidentLounge(t *testing.T) { +func TestMetaIsResumableCasino(t *testing.T) { m := Game{}.Meta() if m.Slug != "pokies" { t.Errorf("slug = %q, want pokies", m.Slug) @@ -139,8 +160,19 @@ func TestMetaIsResidentLounge(t *testing.T) { if m.MinPlayers != 1 || m.MaxPlayers != 32 { t.Errorf("players = %d..%d, want 1..32", m.MinPlayers, m.MaxPlayers) } - if m.Lifecycle != kit.LifecycleResident { - t.Errorf("lifecycle = %v, want resident", m.Lifecycle) + // Casino-kind must be RESUMABLE, never resident (a resident casino room gets + // no wallet, so every Wager/Settle/Balance would be denied). + if m.Lifecycle != kit.LifecycleResumable { + t.Errorf("lifecycle = %v, want resumable", m.Lifecycle) + } + if m.Kind != kit.GameKindCasino { + t.Errorf("kind = %v, want casino", m.Kind) + } + if m.MaxPayoutMultiplier != maxPayoutMult { + t.Errorf("MaxPayoutMultiplier = %d, want %d", m.MaxPayoutMultiplier, maxPayoutMult) + } + if m.CtxFeatures&kit.CtxFeatCredits == 0 { + t.Errorf("ctx features = %d, want the credits declaration bit set", m.CtxFeatures) } if m.CtxFeatures&kit.CtxFeatCharacter == 0 || m.CtxFeatures&kit.CtxFeatRosterEpoch == 0 { t.Errorf("ctx features = %d, want character + roster-epoch", m.CtxFeatures) @@ -168,32 +200,31 @@ func TestJoinSeedsMachine(t *testing.T) { if m == nil { t.Fatal("join did not create a machine") } - if m.balance != startBalance { - t.Errorf("balance = %d, want %d", m.balance, startBalance) + if m.credits != seedCredits { + t.Errorf("credits = %d, want %d", m.credits, seedCredits) } - if m.highScore != startBalance { - t.Errorf("highScore = %d, want %d", m.highScore, startBalance) + if m.peak != seedCredits { + t.Errorf("peak = %d, want %d", m.peak, seedCredits) } if m.bet != betTiers[0] { t.Errorf("bet = %d, want %d", m.bet, betTiers[0]) } } -func TestJoinResumesDurableWallet(t *testing.T) { +// Join reads the player's account-wide balance from the host (the platform owns +// it now — there is no game-side durable wallet to resume). +func TestJoinReadsHostBalance(t *testing.T) { p := kittest.Player("alice") rm, r := newGame(t, p) - // Pre-seed the durable wallet as if a prior session persisted it. - store := r.Services().Accounts.For(p).Store() - _ = store.Set(nil, keyBalance, []byte("2000"), kit.MergeSum) - _ = store.Set(nil, keyPeak, []byte("3000"), kit.MergeMax) + r.Credits = map[string]int64{p.AccountID: 2500} // host already holds a balance rm.OnJoin(r, p) m := rm.machines[p.AccountID] - if m.balance != 2000 { - t.Errorf("balance = %d, want resumed 2000", m.balance) + if m.credits != 2500 { + t.Errorf("credits = %d, want host balance 2500", m.credits) } - if m.highScore != 3000 { - t.Errorf("highScore = %d, want resumed peak 3000", m.highScore) + if m.peak != 2500 { + t.Errorf("peak = %d, want 2500", m.peak) } } @@ -223,7 +254,7 @@ func TestBetClampedToBalance(t *testing.T) { rm.OnJoin(r, p) seatAt0(t, rm, p) m := rm.machines[p.AccountID] - m.balance = 70 + m.credits = 70 // cached balance the bet clamps against rm.OnInput(r, p, keyUp()) // 50, ok rm.OnInput(r, p, keyUp()) // 100 > 70, clamp back to 50 @@ -243,15 +274,18 @@ func TestSpinDeductsBetAndIgnoresReentry(t *testing.T) { m.bet = 50 rm.OnInput(r, p, space()) - if m.balance != startBalance-50 { - t.Fatalf("balance after spin = %d, want %d", m.balance, startBalance-50) + if m.credits != seedCredits-50 { + t.Fatalf("balance after spin = %d, want %d", m.credits, seedCredits-50) + } + if m.stake != 50 { + t.Fatalf("open stake = %d, want the wagered 50", m.stake) } if m.spin == nil { t.Fatal("expected machine to be spinning") } - rm.OnInput(r, p, space()) // must be ignored mid-spin - if m.balance != startBalance-50 { - t.Fatalf("re-entry deducted again: balance = %d", m.balance) + rm.OnInput(r, p, space()) // must be ignored mid-spin (no second wager) + if m.credits != seedCredits-50 { + t.Fatalf("re-entry wagered again: balance = %d", m.credits) } } @@ -279,9 +313,9 @@ func TestSpinSettlesToPayoutOverWake(t *testing.T) { if m.freeSpins > 0 { t.Skip("seeded first spin triggered free spins; payout path covered elsewhere") } - want := (startBalance - 10) + 10*rm.variant.waysPayout(scatterWindow(rm.variant.strip, m.lastIdx))/wayScale - if m.balance != want { - t.Fatalf("balance = %d, want %d (ways over %v)", m.balance, want, m.lastIdx) + want := int64((seedCredits - 10) + 10*rm.variant.waysPayout(scatterWindow(rm.variant.strip, m.lastIdx))/wayScale) + if m.credits != want { + t.Fatalf("balance = %d, want %d (ways over %v)", m.credits, want, m.lastIdx) } } @@ -320,19 +354,18 @@ func TestSettleCreditsJackpot(t *testing.T) { rm.OnJoin(r, p) m := rm.machines[p.AccountID] m.bet = 50 - m.balance = startBalance - 50 // bet already deducted at spin start - win := forceWin5(t, rm, r, p, symStar) + win := forceWin5(t, rm, r, p, symStar) // wagers 50, then lands the 5-of-a-kind takeIfGambling(rm, r, p.AccountID) if win <= 0 { t.Fatalf("expected a paying 5-of-a-kind, got %d", win) } - if m.balance != startBalance-50+win { - t.Errorf("balance = %d, want %d", m.balance, startBalance-50+win) + if m.credits != int64(seedCredits-50+win) { + t.Errorf("balance = %d, want %d", m.credits, seedCredits-50+win) } - if m.highScore != m.balance { - t.Errorf("highScore = %d, want %d", m.highScore, m.balance) + if m.peak != m.credits { + t.Errorf("peak = %d, want %d", m.peak, m.credits) } if m.reels != faceRow(symStar) { t.Errorf("reels = %v, want all stars", m.reels) @@ -351,15 +384,17 @@ func TestBustRebuysPreservingHighScore(t *testing.T) { rm.OnJoin(r, p) m := rm.machines[p.AccountID] m.bet = 10 - m.highScore = 2500 - m.balance = 0 // bet already deducted; this spin loses - forceLoss5(t, rm, r, p) + m.peak, m.postedPeak = 2500, 2500 // a prior high-water; a bust must not lower it + r.Credits[p.AccountID] = 10 // exactly one min bet left, then bust to zero + r.CreditsBuybackAmount = 1000 // broke-relief tops a busted seat up to this - if m.balance != rebuyAmount { - t.Errorf("balance = %d, want re-buy to %d", m.balance, rebuyAmount) + forceLoss5(t, rm, r, p) // wagers 10 (host -> 0), settles the loss, then rebuys + + if m.credits != 1000 { + t.Errorf("balance = %d, want re-buy amount 1000", m.credits) } - if m.highScore != 2500 { - t.Errorf("highScore = %d, want 2500 (bust must not lower it)", m.highScore) + if m.peak != 2500 { + t.Errorf("peak = %d, want 2500 (bust/rebuy must not lower it)", m.peak) } if !strings.Contains(m.flash, "RE-BUY") { t.Errorf("flash = %q, want RE-BUY", m.flash) @@ -374,7 +409,6 @@ func TestBigWinPushesTicker(t *testing.T) { rm.OnJoin(r, p) m := rm.machines[p.AccountID] m.bet = 100 - m.balance = startBalance - 100 forceWin5(t, rm, r, p, symStar) // a big 5-of-a-kind at bet 100 takeIfGambling(rm, r, p.AccountID) @@ -393,7 +427,8 @@ func TestSmallWinDoesNotPushTicker(t *testing.T) { m := rm.machines[p.AccountID] m.bet = 50 // A win below bet*tickerMult (600) must not announce. Bank a held 100 via the - // gamble path (the credit/announce path) and check the ticker stays quiet. + // gamble path (the settle/announce path) and check the ticker stays quiet. + openStake(rm, r, p) // one open stake for the take to settle rm.enterGamble(r, m, 100) m.gamble.sel = selTake rm.gambleConfirm(r, p.AccountID) @@ -409,7 +444,6 @@ func TestTickerExpiresOnWake(t *testing.T) { rm.OnJoin(r, p) m := rm.machines[p.AccountID] m.bet = 100 - m.balance = startBalance - 100 forceWin5(t, rm, r, p, symStar) takeIfGambling(rm, r, p.AccountID) if !rm.tickerActive(r.Now()) { @@ -429,16 +463,15 @@ func TestNewPeakPostsToLeaderboard(t *testing.T) { rm.OnJoin(r, p) m := rm.machines[p.AccountID] m.bet = 50 - m.balance = startBalance - 50 - forceWin5(t, rm, r, p, symStar) // a win → new peak + forceWin5(t, rm, r, p, symStar) // a win → new credits peak takeIfGambling(rm, r, p.AccountID) if len(r.Posted) != 1 { t.Fatalf("posts = %d, want exactly 1 on a new peak", len(r.Posted)) } got := r.Posted[0].Rankings[0] - if got.Metric != m.highScore || got.Status != kit.StatusFinished { - t.Errorf("posted = %+v, want metric %d finished", got, m.highScore) + if got.Metric != int(m.peak) || got.Status != kit.StatusFinished { + t.Errorf("posted = %+v, want metric %d finished", got, m.peak) } } @@ -448,7 +481,6 @@ func TestNoPeakDoesNotPost(t *testing.T) { rm.OnJoin(r, p) m := rm.machines[p.AccountID] m.bet = 10 - m.balance = startBalance - 10 forceLoss5(t, rm, r, p) // no win → no new peak if len(r.Posted) != 0 { @@ -475,19 +507,28 @@ func TestLeaveRemovesAndKeepsJoinOrder(t *testing.T) { } } -func TestLeavePersistsWallet(t *testing.T) { +// Leaving mid-spin (after the wager, before the settle) MUST settle the open +// stake so escrow never leaks and a losing bet is not silently cancelled. +func TestLeaveSettlesOpenStake(t *testing.T) { p := kittest.Player("alice") rm, r := newGame(t, p) rm.OnJoin(r, p) + seatAt0(t, rm, p) m := rm.machines[p.AccountID] - m.balance = 2500 - m.highScore = 2500 + m.bet = 50 + + rm.OnInput(r, p, space()) // wager 50, reels spinning, one stake open + if m.stake != 50 { + t.Fatalf("open stake = %d, want the wagered 50", m.stake) + } - rm.OnLeave(r, p) + rm.OnLeave(r, p) // leave mid-spin: the committed bet books as a loss - store := r.Services().Accounts.For(p).Store() - if v, ok, _ := store.Get(nil, keyPeak); !ok || strings.TrimSpace(string(v)) != "2500" { - t.Errorf("persisted peak = %q (ok=%v), want 2500", v, ok) + if got := r.CreditsStakes[p.AccountID]; got != 0 { + t.Errorf("open stake after leave = %d, want 0 (escrow must not leak)", got) + } + if got := r.Credits[p.AccountID]; got != seedCredits-50 { + t.Errorf("balance = %d, want %d (the committed bet is lost, not refunded)", got, seedCredits-50) } } @@ -527,7 +568,6 @@ func TestGridCenterRowIsThePayline(t *testing.T) { rm.OnJoin(r, p) m := rm.machines[p.AccountID] m.bet = 10 - m.balance = startBalance - 10 strip := rm.variant.strip var idx [numReels]int var fin [numReels]symbol @@ -535,9 +575,11 @@ func TestGridCenterRowIsThePayline(t *testing.T) { idx[i] = i fin[i] = strip[i] } + openStake(rm, r, p) m.spin = &spinState{startedAt: r.Now(), variant: rm.variant, stopIdx: idx, final: fin} rm.settleSpin(r, p.AccountID) + takeIfGambling(rm, r, p.AccountID) g := rm.grid(m) for reel := 0; reel < numReels; reel++ { @@ -592,12 +634,12 @@ func settleKnownFaces(t *testing.T, rm *room, r *kittest.Room, p kit.Player) { seatAt0(t, rm, p) m := rm.machines[p.AccountID] m.bet = 10 - m.balance = startBalance - 10 strip := rm.variant.strip var idx [numReels]int for i, s := range knownFaces { idx[i] = firstIdx(t, strip, s) } + openStake(rm, r, p) m.spin = &spinState{startedAt: r.Now(), variant: rm.variant, stopIdx: idx, final: knownFaces} rm.settleSpin(r, p.AccountID) takeIfGambling(rm, r, p.AccountID) @@ -739,7 +781,7 @@ func TestGambleOwnerSeesSelectorOthersSeeIndicator(t *testing.T) { rm.OnJoin(r, b) seatAt0(t, rm, a) ma := rm.machines[a.AccountID] - ma.balance = 1000 + openStake(rm, r, a) rm.enterGamble(r, ma, 150) rm.render(r) if !frameContains(r, a, "TAKE") || !frameContains(r, a, "RED") { @@ -966,9 +1008,9 @@ func TestMidSpinVariantStability(t *testing.T) { h.settleSpin(r, p.AccountID) takeIfGambling(h, r, p.AccountID) - if m.balance != (startBalance-50)+wantWin { + if m.credits != int64((seedCredits-50)+wantWin) { t.Fatalf("balance = %d, want %d (settled under the starting variant)", - m.balance, (startBalance-50)+wantWin) + m.credits, (seedCredits-50)+wantWin) } } @@ -1014,6 +1056,47 @@ func TestSeededDeterminismPerVariant(t *testing.T) { } } +// --- payout ceiling ---------------------------------------------------------- + +// A gross exactly at the ceiling (stake x MaxPayoutMultiplier) settles in full — +// neither the game's own clamp nor the host's shaves the top prize. +func TestTopPrizeSettlesWithoutClamp(t *testing.T) { + p := kittest.Player("alice") + rm, r := newGame(t, p) + rm.OnJoin(r, p) + m := rm.machines[p.AccountID] + m.bet = 10 + openStake(rm, r, p) // wager 10, one open stake + + top := 10 * maxPayoutMult // the exact ceiling for this stake + rm.settle(r, p.AccountID, top) + + if got := r.Credits[p.AccountID]; got != int64(seedCredits-10+top) { + t.Fatalf("balance = %d, want %d (top prize must settle unclamped)", got, seedCredits-10+top) + } + if m.credits != int64(seedCredits-10+top) { + t.Fatalf("cached balance = %d, want %d", m.credits, seedCredits-10+top) + } +} + +// A gross above the ceiling is clamped to stake x MaxPayoutMultiplier BEFORE the +// settle, so the credited amount (and the UI) never exceed what the host pays. +func TestOverCeilingClampsToMax(t *testing.T) { + p := kittest.Player("alice") + rm, r := newGame(t, p) + rm.OnJoin(r, p) + m := rm.machines[p.AccountID] + m.bet = 10 + openStake(rm, r, p) + + ceiling := 10 * maxPayoutMult + rm.settle(r, p.AccountID, ceiling+5000) // ask for more than the ceiling + + if got := r.Credits[p.AccountID]; got != int64(seedCredits-10+ceiling) { + t.Fatalf("balance = %d, want %d (clamped to the ceiling)", got, seedCredits-10+ceiling) + } +} + func TestFloorRendersCharacterAvatars(t *testing.T) { a := kittest.Player("anna") a.Character = kit.Character{Glyph: "@"} diff --git a/games/bcook/pokies/room.go b/games/bcook/pokies/room.go index fcc3013..a4052d7 100644 --- a/games/bcook/pokies/room.go +++ b/games/bcook/pokies/room.go @@ -2,9 +2,8 @@ package main import ( "context" + "errors" "fmt" - "strconv" - "strings" "time" kit "github.com/shellcade/kit/v2" @@ -25,13 +24,24 @@ func (Game) Meta() kit.GameMeta { MaxPlayers: 32, Tags: []string{"slots", "casual", "social"}, - // A resident social lounge: the room persists when players leave, - // offering Resume-menu entry for returning players (kit v2.7.0+). - Lifecycle: kit.LifecycleResident, + // Casino-kind: the machine wagers account-wide platform Credits + // (kit v2.16.0). The host owns every balance; the game holds no + // wallet of its own. MaxPayoutMultiplier caps a settled gross to + // stake x this value (see the stake-relative gamble/free-spin caps). + Kind: kit.GameKindCasino, + MaxPayoutMultiplier: maxPayoutMult, + + // A resumable session (NOT resident): a casino room hibernates on + // abandon and resumes for a returning player. Resident is unsupported + // for casino-kind — a resident room gets no per-seat wallet, so every + // Wager/Settle/Balance would be denied. The floor/pawn lounge still + // runs; players enter a session rather than an always-on lounge. + Lifecycle: kit.LifecycleResumable, // Per-member arcade characters (kit v2.9.0) + roster epoch tracking - // for multiplayer awareness (kit v2.11.0+). - CtxFeatures: kit.CtxFeatCharacter | kit.CtxFeatRosterEpoch, + // for multiplayer awareness (kit v2.11.0+), and the Credits declaration + // bit (kit v2.16.0) so the guest may call the credits host functions. + CtxFeatures: kit.CtxFeatCharacter | kit.CtxFeatRosterEpoch | kit.CtxFeatCredits, QuickModeLabel: "Quick spin", SoloModeLabel: "Solo spin", @@ -56,9 +66,14 @@ func (Game) NewRoom(cfg kit.RoomConfig, svc kit.Services) kit.Handler { } const ( - startBalance = 1000 // credits a fresh machine starts with - rebuyAmount = 1000 // balance restored on a bust - tickerMult = 12 // a win at this multiplier or above announces room-wide + // maxPayoutMult is the declared casino MaxPayoutMultiplier: a settled gross + // is capped to stake x this value. Both the gamble ladder's at-risk win and + // the free-spin accumulation are held stake-relative to it, and every gross + // is clamped to stake*maxPayoutMult before Settle so the UI never shows a + // number bigger than the host will pay. + maxPayoutMult = 10000 + + tickerMult = 12 // a win at this multiplier or above announces room-wide cycleRate = 80 * time.Millisecond // reel-cycling animation step reelStopBase = 150 * time.Millisecond // when the first reel settles @@ -100,8 +115,17 @@ func (s *spinState) cycle(now time.Time) int { // machine is one player's slot machine. The visible reel area is a 3x3 window; // the center row is the payline. reels holds the settled center faces. type machine struct { - balance int - highScore int + // credits is the per-render cache of the player's account-wide platform + // balance (svc.Credits.Balance); peak is its high-water mark, the leaderboard + // metric. The host owns the real balance — these are read-through caches, + // refreshed after each money event (Wager/Settle/Buyback), never per frame. + credits int64 + peak int64 + postedPeak int64 // last peak posted to the leaderboard (post only on increase) + // stake is the single open wagered stake for this seat (0 = none open). Set + // at the Wager in startSpin, cleared at the one Settle that closes it; it + // bounds the stake-relative payout caps and flags an unsettled bet on exit. + stake int bet int reels [numReels]symbol // last settled center faces lastIdx [numReels]int // last settled landing index per reel (for the idle window) @@ -110,7 +134,6 @@ type machine struct { spin *spinState flash string // transient status line: "WIN! +N" / "RE-BUY" flashUntil time.Time // when the flash clears (deadline held in guest memory) - postedPeak int // last peak posted to the leaderboard (post only on increase) lastVar *variant // variant the last spin settled under (for the gamble caps) seatVar *variant // variant bound when the player sits at a floor machine (nil = room default) @@ -152,9 +175,10 @@ type room struct { occupied map[int]string // machine id -> account id (exclusive seat) themes []*variant // machine id -> bound variant (PR2: all default) - // sk standardises the durable-wallet KV writes (PersistWallet) and the - // new-peak leaderboard posts, replacing the duplicated persistWallet helper. - sk *kit.ScoreKeeper + // economyOff latches when the host reports the credits economy disabled + // (Credits nil, or a call returns ErrEconomyDisabled): the cabinet renders + // out-of-service and refuses spins rather than trapping. + economyOff bool } func newRoom(cfg kit.RoomConfig, svc kit.Services) *room { @@ -174,7 +198,6 @@ func newRoom(cfg kit.RoomConfig, svc kit.Services) *room { pawns: map[string]*pawn{}, occupied: map[int]string{}, themes: themes, - sk: kit.NewScoreKeeper(kit.OnImprove), } } @@ -215,56 +238,150 @@ func (rm *room) loadVariant(r kit.Room) { } } -// --- durable wallet ---------------------------------------------------------- +// --- platform credits --------------------------------------------------------- // -// The casino pattern over kv: balance (merge rule sum, the carryable bankroll) -// and peak (merge rule max, the high-water mark and leaderboard metric) — the -// same keys and merge rules the native casino package used. +// The platform owns every balance: a Wager escrows the stake into the seat's +// single open stake, and Settle closes it with the GROSS (stake-inclusive) +// payout. The game persists no wallet of its own; it caches the balance per +// money event for the HUD and posts a peak-credits leaderboard metric. + +// refreshBalance re-reads the player's account-wide balance into the machine +// cache (called after every money event, never per frame) and tracks the peak. +// A disabled economy latches economyOff and leaves the cache untouched. +func (rm *room) refreshBalance(r kit.Room, id string) { + m := rm.machines[id] + if m == nil { + return + } + c := r.Services().Credits + p, ok := rm.names[id] + if c == nil || !ok { + if c == nil { + rm.economyOff = true + } + return + } + bal, err := c.Balance(p) + if err != nil { + if errors.Is(err, kit.ErrEconomyDisabled) { + rm.economyOff = true + } + return + } + rm.economyOff = false + m.credits = bal + if bal > m.peak { + m.peak = bal + } +} -const ( - keyBalance = "balance" - keyPeak = "peak" -) +// capGross clamps a gross payout to stake x maxPayoutMult (rule 6: the UI must +// never show a number bigger than the host will pay). A zero stake leaves it +// unclamped — the host still refuses a settle with no open stake. +func capGross(gross, stake int) int { + if stake <= 0 { + return gross + } + if lim := stake * maxPayoutMult; gross > lim { + return lim + } + return gross +} -func kvInt(store kit.KVStore, key string) (int, bool) { - v, ok, err := store.Get(context.Background(), key) - if err != nil || !ok { - return 0, false +// settle closes the seat's single open stake EXACTLY ONCE with the clamped +// gross, then refreshes the cache, posts a new credits peak, and rebuys a +// busted seat. Every round-ending path funnels through here. +func (rm *room) settle(r kit.Room, id string, gross int) { + m := rm.machines[id] + if m == nil { + return } - n, err := strconv.Atoi(strings.TrimSpace(string(v))) - if err != nil { - return 0, false + gross = capGross(gross, m.stake) + c := r.Services().Credits + p, ok := rm.names[id] + if c == nil || !ok { + if c == nil { + rm.economyOff = true + } + m.stake = 0 + return + } + if err := c.Settle(p, int64(gross)); err != nil { + if errors.Is(err, kit.ErrEconomyDisabled) { + rm.economyOff = true + } + r.Log("pokies: settle failed: " + err.Error()) + m.stake = 0 + rm.refreshBalance(r, id) + return } - return n, true + m.stake = 0 + rm.refreshBalance(r, id) + rm.postPeak(r, id) + rm.maybeRebuy(r, id) } -// seedWallet returns the joining player's durable (balance, peak): balance -// defaults to startBalance for a first-ever player (or a non-positive stored -// balance), and peak is raised to at least the balance. A nil/guest account -// returns the defaults. -func (rm *room) seedWallet(r kit.Room, p kit.Player) (int, int) { - acct := r.Services().Accounts.For(p) - if acct == nil { - return startBalance, startBalance - } - store := acct.Store() - bal, ok := kvInt(store, keyBalance) - if !ok || bal <= 0 { - bal = startBalance - } - peak, ok := kvInt(store, keyPeak) - if !ok || peak < bal { - peak = bal - } - return bal, peak +// postPeak posts a new personal credits peak to the leaderboard (the board keeps +// each account's best). Posts only on improvement over the last posted value. +func (rm *room) postPeak(r kit.Room, id string) { + m := rm.machines[id] + if m == nil { + return + } + p, ok := rm.names[id] + if !ok || m.peak <= m.postedPeak { + return + } + m.postedPeak = m.peak + r.Post(kit.Result{Rankings: []kit.PlayerResult{{ + Player: p, Metric: int(m.peak), Status: kit.StatusFinished, + }}}) } -// persistWallet writes the current balance (summed) and raises peak (max). peak -// uses a monotonic max-on-write, so out-of-order or concurrent same-account -// writes can never regress the leaderboard metric. Delegates to the kit's -// ScoreKeeper.PersistWallet, which writes the identical keys + merge rules. -func (rm *room) persistWallet(r kit.Room, p kit.Player, bal, peak int) { - rm.sk.PersistWallet(r, p, keyBalance, bal, keyPeak, peak) +// maybeRebuy triggers the platform broke-relief rebuy when the post-settle +// balance can no longer cover the lowest bet. A refusal (still solvent, or the +// daily limit reached) is rendered, not retried; the rebuy is not a peak. +func (rm *room) maybeRebuy(r kit.Room, id string) { + m := rm.machines[id] + if m == nil || m.credits >= int64(betTiers[0]) { + return + } + c := r.Services().Credits + p, ok := rm.names[id] + if c == nil || !ok { + return + } + bal, err := c.Buyback(p) + if err != nil { + return // ErrInsufficientCredits: solvent or daily limit — do not retry + } + m.credits = bal + m.flash = "RE-BUY" + m.flashUntil = r.Now().Add(flashDur) + rm.clampBet(m) +} + +// forceSettle settles any open stake for a seat leaving mid-round (voluntary +// leave, abandon, or room close): banks the known gross (the gamble at-risk take +// or the accumulated free-spin win) and otherwise books the committed bet as a +// loss with Settle(0), so escrow never leaks and a losing bet is never a free +// cancel. A no-op when no stake is open. +func (rm *room) forceSettle(r kit.Room, id string) { + m := rm.machines[id] + if m == nil || m.stake == 0 { + return + } + gross := 0 + switch { + case m.gamble != nil: + gross = m.gamble.atRisk // the "take" value is the known gross + case m.freeSpins > 0: + gross = m.freeWin // free winnings accrued so far (a free spin has no risk) + } + rm.settle(r, id, gross) + m.gamble = nil + m.freeSpins = 0 + m.spin = nil } func (rm *room) OnJoin(r kit.Room, p kit.Player) { @@ -273,9 +390,12 @@ func (rm *room) OnJoin(r kit.Room, p kit.Player) { rm.render(r) return } - // Seed the machine balance from the player's durable wallet (default first time). - bal, peak := rm.seedWallet(r, p) - rm.machines[p.AccountID] = &machine{balance: bal, highScore: peak, bet: betTiers[0], postedPeak: peak} + m := &machine{bet: betTiers[0]} + rm.machines[p.AccountID] = m + // Seed the HUD from the player's account-wide balance; the posted-peak + // watermark starts at the join balance so it is never itself posted. + rm.refreshBalance(r, p.AccountID) + m.postedPeak = m.peak rm.order = append(rm.order, p.AccountID) sx, sy := loungeSpawn() rm.pawns[p.AccountID] = &pawn{x: sx, y: sy, seat: -1} @@ -287,7 +407,7 @@ func (rm *room) OnLeave(r kit.Room, p kit.Player) { if m == nil { return } - rm.persistWallet(r, p, m.balance, m.highScore) + rm.forceSettle(r, p.AccountID) // settle any open stake before the seat vanishes delete(rm.machines, p.AccountID) delete(rm.names, p.AccountID) for i, id := range rm.order { @@ -404,14 +524,9 @@ func (rm *room) OnWake(r kit.Room) { } func (rm *room) OnClose(r kit.Room) { + // Settle any open stakes so a room teardown never leaks escrow. for _, id := range rm.order { - m := rm.machines[id] - if m == nil { - continue - } - if p, ok := rm.names[id]; ok { - rm.persistWallet(r, p, m.balance, m.highScore) - } + rm.forceSettle(r, id) } } @@ -438,9 +553,9 @@ func (rm *room) adjustBet(m *machine, dir int) { rm.clampBet(m) } -// clampBet drops the bet to the highest tier the balance can cover. +// clampBet drops the bet to the highest tier the cached balance can cover. func (rm *room) clampBet(m *machine) { - for m.bet > m.balance && tierIndex(m.bet) > 0 { + for int64(m.bet) > m.credits && tierIndex(m.bet) > 0 { m.bet = betTiers[tierIndex(m.bet)-1] } } @@ -452,12 +567,27 @@ func (rm *room) startSpin(r kit.Room, p kit.Player) { if m == nil || m.spin != nil || m.freeSpins > 0 || m.gamble != nil { return // auto-play owns the reels during a feature / gamble holds the win } + c := r.Services().Credits + if c == nil { + rm.economyOff = true + return // no economy: cabinet is out of service + } rm.clampBet(m) - if m.bet > m.balance { - return // can't afford the lowest tier + // Wager the bet: escrow it into the seat's single open stake. Only start the + // reels on success; a refusal (insufficient credits) tries the broke-relief + // rebuy so a busted seat can spin on the next press, never leaving a stake open. + if err := c.Wager(p, int64(m.bet)); err != nil { + if errors.Is(err, kit.ErrInsufficientCredits) { + rm.maybeRebuy(r, p.AccountID) + } else if errors.Is(err, kit.ErrEconomyDisabled) { + rm.economyOff = true + } + rm.refreshBalance(r, p.AccountID) + return } - m.balance -= m.bet + m.stake = m.bet m.flash = "" + rm.refreshBalance(r, p.AccountID) // reflect the escrow debit in the HUD // Pin the variant this spin starts under: a later config refresh never // re-evaluates an in-flight spin. The strip is its variant's strip, so a @@ -515,74 +645,46 @@ func (rm *room) settleSpin(r kit.Room, id string) { win := bet * v.waysPayout(scatterWindow(v.strip, m.lastIdx)) / wayScale if wasFree { - // Free spin: credit at the locked bet (no charge), retrigger, then advance - // the feature. Gamble is never offered inside a feature. + // Free spin: NEVER wager, NEVER settle mid-feature. Accumulate the win onto + // the ONE open stake (capped stake-relative), retrigger, and settle exactly + // once in endFreeSpins when the feature runs out. Gamble is never offered + // inside a feature. m.freeSpins-- - m.freeWin += win - rm.creditWin(r, id, win, false) - rm.triggerFreeSpins(m, v, bet) + m.freeWin = capGross(m.freeWin+win, m.stake) + if win > 0 { + m.flash = fmt.Sprintf("WIN! +%d", win) + m.flashUntil = r.Now().Add(flashDur) + } + rm.triggerFreeSpins(m, v, bet, false) // retrigger: keep the accumulator if win >= bet*tickerMult { rm.announce(r, id, win) } if m.freeSpins == 0 { - rm.endFreeSpins(r, id) + rm.endFreeSpins(r, id) // the single Settle for the whole feature } rm.scheduleNextFree(r, m) return } // Base game. A spin can both pay a line and trigger free spins; on a trigger - // credit any line win directly (no gamble) and start the feature. - if award := rm.triggerFreeSpins(m, v, bet); award > 0 { - rm.creditWin(r, id, win, false) + // the line win folds into the feature accumulation and the ONE open stake + // stays open — settled once at endFreeSpins, never here. + if award := rm.triggerFreeSpins(m, v, bet, true); award > 0 { + m.freeWin = capGross(m.freeWin+win, m.stake) rm.announce(r, id, 0) // "X hit FREE SPINS!" rm.scheduleNextFree(r, m) return } if win > 0 { - rm.enterGamble(r, m, win) // hold the win on the double-up ladder + rm.enterGamble(r, m, win) // hold the win on the double-up ladder (settles at take/loss) m.flash = "" return } - rm.creditWin(r, id, 0, true) // no win: rebuy check + clear flash -} - -// creditWin adds win to the balance, raises the peak, posts a new personal best -// to the leaderboard, sets the WIN flash, and (when allowZeroRebuy) re-buys a -// busted machine. It is the single credit path for taken base wins, free-spin -// wins, and the no-win settle. -func (rm *room) creditWin(r kit.Room, id string, win int, allowZeroRebuy bool) { - m := rm.machines[id] - if m == nil { - return - } - m.balance += win - if m.balance > m.highScore { - m.highScore = m.balance - } - switch { - case allowZeroRebuy && m.balance <= 0: - m.balance = rebuyAmount - m.flash = "RE-BUY" - case win > 0: - m.flash = fmt.Sprintf("WIN! +%d", win) - } - m.flashUntil = r.Now().Add(flashDur) - rm.clampBet(m) - if p, ok := rm.names[id]; ok { - // Persist the durable wallet (peak excludes the rebuy). - rm.persistWallet(r, p, m.balance, m.highScore) - // Leaderboard: Post feeds the board declared in GameMeta.Leaderboard. - // Post on a new personal peak — the board keeps each account's best. - if m.highScore > m.postedPeak { - m.postedPeak = m.highScore - r.Post(kit.Result{Rankings: []kit.PlayerResult{{ - Player: p, Metric: m.highScore, Status: kit.StatusFinished, - }}}) - } - } + // No win, no feature: settle the open stake as a loss (Settle 0). + m.flash = "" + rm.settle(r, id, 0) } // announce raises the room-wide ticker: a free-spin trigger banner when win == 0, From 08362911e4cd0cf379ecfac9ebe83b3ee7c94ecb Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Fri, 3 Jul 2026 19:02:19 +1000 Subject: [PATCH 4/4] scratchies: convert to casino-kind with platform Credits (kit v2.16.0) Declares Kind=casino, MaxPayoutMultiplier=30000, CtxFeatCredits; removes the game's internal chip/KV wallet and wires bets through svc.Credits Wager/Settle/Balance with one open stake per round, a single stake-inclusive Settle, Settle-on-leave/abandon so escrow never leaks, and in-game rebuy via svc.Credits.Buyback when busted. Peak-Credits leaderboard. One outcome drawn at purchase; one Wager + one gross Settle per ticket. Verified: go build/vet/test, tinygo, and shellcade-kit check --require-leaderboard. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W7oJQSTva5xgbAZomdV5Jj --- games/bcook/scratchies/card.go | 4 +- games/bcook/scratchies/game.go | 24 ++- games/bcook/scratchies/go.mod | 2 +- games/bcook/scratchies/go.sum | 4 +- games/bcook/scratchies/kv.go | 64 ------ games/bcook/scratchies/main.go | 7 +- games/bcook/scratchies/mech_bingo.go | 6 +- games/bcook/scratchies/mech_keynum.go | 4 +- games/bcook/scratchies/mech_lines.go | 4 +- games/bcook/scratchies/mech_mult.go | 6 +- games/bcook/scratchies/mech_showdown.go | 10 +- games/bcook/scratchies/room.go | 208 +++++++++++++++---- games/bcook/scratchies/room_test.go | 255 ++++++++++++++++++++++++ 13 files changed, 456 insertions(+), 142 deletions(-) delete mode 100644 games/bcook/scratchies/kv.go create mode 100644 games/bcook/scratchies/room_test.go diff --git a/games/bcook/scratchies/card.go b/games/bcook/scratchies/card.go index b95a537..b9304c2 100644 --- a/games/bcook/scratchies/card.go +++ b/games/bcook/scratchies/card.go @@ -132,8 +132,8 @@ func newGenericGridCard(t *Ticket, out Outcome, rng *rand.Rand) *genericGridCard } } -func (c *genericGridCard) Title() string { return c.title } -func (c *genericGridCard) Prompt() string { return c.prompt } +func (c *genericGridCard) Title() string { return c.title } +func (c *genericGridCard) Prompt() string { return c.prompt } func (c *genericGridCard) Move(dx, dy int) { c.grid.Move(dx, dy) } func (c *genericGridCard) Scratch() bool { return c.grid.Scratch() } func (c *genericGridCard) ScratchAll() { c.grid.ScratchAll() } diff --git a/games/bcook/scratchies/game.go b/games/bcook/scratchies/game.go index 1c772c4..2d3146c 100644 --- a/games/bcook/scratchies/game.go +++ b/games/bcook/scratchies/game.go @@ -8,17 +8,19 @@ type Game struct{} // Meta declares the game to the platform (see SPEC §12). func (Game) Meta() kit.GameMeta { return kit.GameMeta{ - Slug: "scratchies", - Name: "Scratchies", - ShortDescription: "Duck into the newsagent, buy an instant scratch-it, and peel the latex off panel by panel. 26 tickets, nine games, one dream jackpot.", - MinPlayers: 1, - MaxPlayers: 6, - Tags: []string{"casino", "luck", "scratch-card", "instant-win", "solo"}, - CtxFeatures: kit.CtxFeatCharacter, - Lifecycle: kit.LifecycleEphemeral, - QuickModeLabel: "Pop in", - SoloModeLabel: "Solo visit", - PrivateInviteLine: "Mates pull up to the same counter when they enter the code.", + Slug: "scratchies", + Name: "Scratchies", + ShortDescription: "Duck into the newsagent, buy an instant scratch-it, and peel the latex off panel by panel. 26 tickets, nine games, one dream jackpot.", + MinPlayers: 1, + MaxPlayers: 6, + Tags: []string{"casino", "luck", "scratch-card", "instant-win", "solo"}, + Kind: kit.GameKindCasino, + MaxPayoutMultiplier: maxPayoutMultiplier, + CtxFeatures: kit.CtxFeatCharacter | kit.CtxFeatCredits, + Lifecycle: kit.LifecycleEphemeral, + QuickModeLabel: "Pop in", + SoloModeLabel: "Solo visit", + PrivateInviteLine: "Mates pull up to the same counter when they enter the code.", Controls: []kit.ControlDecl{ kit.RuneControl(' ', "SCRATCH"), kit.RuneControl('a', "SCRATCH ALL"), diff --git a/games/bcook/scratchies/go.mod b/games/bcook/scratchies/go.mod index feb06c9..8182d35 100644 --- a/games/bcook/scratchies/go.mod +++ b/games/bcook/scratchies/go.mod @@ -2,7 +2,7 @@ module github.com/bcook/scratchies go 1.25.11 -require github.com/shellcade/kit/v2 v2.15.0 +require github.com/shellcade/kit/v2 v2.16.0 require ( github.com/extism/go-pdk v1.1.3 // indirect diff --git a/games/bcook/scratchies/go.sum b/games/bcook/scratchies/go.sum index d39f6ff..010ad51 100644 --- a/games/bcook/scratchies/go.sum +++ b/games/bcook/scratchies/go.sum @@ -1,7 +1,7 @@ github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= -github.com/shellcade/kit/v2 v2.15.0 h1:/C/xsAlteeTMbJ+i+1qOcWXECeib4+dduTyPpEGq9bU= -github.com/shellcade/kit/v2 v2.15.0/go.mod h1:EYbqrycZyGxzjUnklDlKBPgU8bCftTVJFKVCw/+gvdw= +github.com/shellcade/kit/v2 v2.16.0 h1:ZU3SCkLqmJMbKqxnBMfEocYsjLiGoHaq48pWrFIkV1s= +github.com/shellcade/kit/v2 v2.16.0/go.mod h1:EYbqrycZyGxzjUnklDlKBPgU8bCftTVJFKVCw/+gvdw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= diff --git a/games/bcook/scratchies/kv.go b/games/bcook/scratchies/kv.go deleted file mode 100644 index 1e48b81..0000000 --- a/games/bcook/scratchies/kv.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "context" - "strconv" - "strings" - - kit "github.com/shellcade/kit/v2" -) - -// --- durable wallet ---------------------------------------------------------- -// -// The casino pattern over kv (shared with pokies): balance (merge rule sum, the -// carryable bankroll) and peak (merge rule max, the high-water mark and -// leaderboard metric). - -const ( - keyBalance = "balance" - keyPeak = "peak" - startBalance = 1000 // credits a fresh wallet starts with - rebuyAmount = 1000 // balance restored on a bust -) - -func kvInt(store kit.KVStore, key string) (int, bool) { - v, ok, err := store.Get(context.Background(), key) - if err != nil || !ok { - return 0, false - } - n, err := strconv.Atoi(strings.TrimSpace(string(v))) - if err != nil { - return 0, false - } - return n, true -} - -// seedWallet returns the joining player's durable (balance, peak): balance -// defaults to startBalance for a first-ever player (or a non-positive stored -// balance), and peak is raised to at least the balance. A nil/guest account -// returns the defaults. -func seedWallet(r kit.Room, p kit.Player) (int, int) { - acct := r.Services().Accounts.For(p) - if acct == nil { - return startBalance, startBalance - } - store := acct.Store() - bal, ok := kvInt(store, keyBalance) - if !ok || bal <= 0 { - bal = startBalance - } - peak, ok := kvInt(store, keyPeak) - if !ok || peak < bal { - peak = bal - } - return bal, peak -} - -// persistWallet writes the current balance (summed) and raises peak (max). peak -// uses a monotonic max-on-write, so out-of-order or concurrent same-account -// writes can never regress the leaderboard metric. Delegates to the kit's -// ScoreKeeper.PersistWallet, which writes the identical keys + merge rules, -// replacing the duplicated casino-wallet helper. -func (rm *room) persistWallet(r kit.Room, p kit.Player, bal, peak int) { - rm.sk.PersistWallet(r, p, keyBalance, bal, keyPeak, peak) -} diff --git a/games/bcook/scratchies/main.go b/games/bcook/scratchies/main.go index 049c1ab..84ae56e 100644 --- a/games/bcook/scratchies/main.go +++ b/games/bcook/scratchies/main.go @@ -3,9 +3,10 @@ // buys a themed ticket, and scratches the latex off panel by panel. Sixteen // tickets ride on four reusable mechanic engines (match-3, key-number, // multiplier, find-the-symbol); every card's outcome is drawn at purchase from -// the ticket's prize table, so scratching is honest reveal theatre. The durable -// wallet uses the casino kv pattern (balance summed, peak max-merged) with a -// peak-ranked "Credits" leaderboard and a rebuy-to-1000 safety net. +// the ticket's prize table, so scratching is honest reveal theatre. Money runs +// on the platform casino Credits ABI: one Wager per ticket, one gross-inclusive +// Settle when it resolves, Buyback for the broke-relief rebuy, and a peak +// account-wide "Credits" leaderboard. // // Build (dev profile): // diff --git a/games/bcook/scratchies/mech_bingo.go b/games/bcook/scratchies/mech_bingo.go index 680126b..de91a22 100644 --- a/games/bcook/scratchies/mech_bingo.go +++ b/games/bcook/scratchies/mech_bingo.go @@ -61,9 +61,9 @@ func bingoLines(cols, rows int) [][]int { type bingoCard struct { t *Ticket out Outcome - calledNums []int // the pre-revealed "called" numbers (length == t.WinNumbers) + calledNums []int // the pre-revealed "called" numbers (length == t.WinNumbers) calledSet map[int]bool // O(1) membership test - cardNums []int // card number at each grid cell index + cardNums []int // card number at each grid cell index grid *Grid view int resolved bool @@ -349,7 +349,7 @@ func (c *bingoCard) Prompt() string { return fmt.Sprintf("%d called numbers revealed - complete a line to win", matchRevealed) } -func (c *bingoCard) Move(dx, dy int) { c.grid.Move(dx, dy) } +func (c *bingoCard) Move(dx, dy int) { c.grid.Move(dx, dy) } func (c *bingoCard) Scratch() (revealed bool) { revealed = c.grid.Scratch() if revealed && c.bingoHasCompletedLine() { diff --git a/games/bcook/scratchies/mech_keynum.go b/games/bcook/scratchies/mech_keynum.go index e855ccc..8cdff9d 100644 --- a/games/bcook/scratchies/mech_keynum.go +++ b/games/bcook/scratchies/mech_keynum.go @@ -23,8 +23,8 @@ func init() { type keynumCard struct { t *Ticket out Outcome - winNums []int // the winning numbers (length == t.WinNumbers), always visible - prizes []int // per-cell prize, parallel to grid.Panels + winNums []int // the winning numbers (length == t.WinNumbers), always visible + prizes []int // per-cell prize, parallel to grid.Panels grid *Grid view int } diff --git a/games/bcook/scratchies/mech_lines.go b/games/bcook/scratchies/mech_lines.go index 32281e4..058491d 100644 --- a/games/bcook/scratchies/mech_lines.go +++ b/games/bcook/scratchies/mech_lines.go @@ -194,8 +194,8 @@ func linesBuild(t *Ticket, out Outcome, rng *rand.Rand) *linesCard { decoyPool = linesDecoyPool(out.Win) } - var winLineIdx int // index into allLines of the planted winning run - var winLabel string // formatted winning amount + var winLineIdx int // index into allLines of the planted winning run + var winLabel string // formatted winning amount if out.Win > 0 { winLabel = linesFmt(out.Win) diff --git a/games/bcook/scratchies/mech_mult.go b/games/bcook/scratchies/mech_mult.go index 0cfffaf..c96311a 100644 --- a/games/bcook/scratchies/mech_mult.go +++ b/games/bcook/scratchies/mech_mult.go @@ -109,9 +109,9 @@ func (c *multCard) Move(dx, dy int) { } } } -func (c *multCard) Scratch() bool { return c.grid.Scratch() } -func (c *multCard) ScratchAll() { c.grid.ScratchAll() } -func (c *multCard) Resolved() bool { return c.grid.AllRevealed() } +func (c *multCard) Scratch() bool { return c.grid.Scratch() } +func (c *multCard) ScratchAll() { c.grid.ScratchAll() } +func (c *multCard) Resolved() bool { return c.grid.AllRevealed() } func (c *multCard) Win() int { if !c.Resolved() { return 0 diff --git a/games/bcook/scratchies/mech_showdown.go b/games/bcook/scratchies/mech_showdown.go index 9120f33..d12e75f 100644 --- a/games/bcook/scratchies/mech_showdown.go +++ b/games/bcook/scratchies/mech_showdown.go @@ -27,11 +27,11 @@ import ( type sdCard struct { ticket *Ticket out Outcome - grid *Grid // Cols × 2; row 0 = house (pre-revealed), row 1 = you (hidden) - houseValues []int // per-column house value [2..99] - yourValues []int // per-column your value [2..99] - prizes []int // per-column prize (credits); 0 for non-winning columns - winCol int // index of the single winning column; -1 on a loss + grid *Grid // Cols × 2; row 0 = house (pre-revealed), row 1 = you (hidden) + houseValues []int // per-column house value [2..99] + yourValues []int // per-column your value [2..99] + prizes []int // per-column prize (credits); 0 for non-winning columns + winCol int // index of the single winning column; -1 on a loss } func init() { diff --git a/games/bcook/scratchies/room.go b/games/bcook/scratchies/room.go index 529f082..e8d569d 100644 --- a/games/bcook/scratchies/room.go +++ b/games/bcook/scratchies/room.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" kit "github.com/shellcade/kit/v2" @@ -9,6 +10,13 @@ import ( // Price tiers, left-to-right along the counter. var standPrices = []int{1, 2, 5, 10} +// maxPayoutMultiplier is the declared casino payout ceiling (Meta. +// MaxPayoutMultiplier). The dearest headline jackpot is Cash Explosion's +// 300,000 gross on a $10 ticket = 30,000x, and every other ticket's top ratio +// is smaller (mega $5 24,000x, double $2 15,000x, tinnie $1 12,000x), so no +// honest jackpot is ever clamped. +const maxPayoutMultiplier = 30000 + // Patron states (the per-player state machine; see SPEC §3). const ( stateCounter = iota // browsing the four stands @@ -18,17 +26,21 @@ const ( stateBust // out of credits; rebuy beat ) -// patron is one player's view & wallet within the shared shop. +// patron is one player's view within the shared shop. The platform owns every +// balance now (svc.Credits); the fields below are display/leaderboard state +// only. type patron struct { p kit.Player - balance int - peak int - postedPeak int + balance int64 // cached account-wide credits, refreshed per credits op / render + postedPeak int64 // highest balance posted to the leaderboard state int standIdx int // 0..3 → standPrices ticketIdx int // index within the current stand's tickets card Card lastWin int + staked bool // an open Wager is awaiting Settle (leak guard) + notice string // transient one-line message (e.g. a refused rebuy) + oos bool // the host has no economy / it is switched off } // room is the shared newsagent floor. @@ -40,14 +52,6 @@ type room struct { patrons map[string]*patron order []string ticker []string // recent big wins, newest last - - // sk standardises the durable-wallet KV writes (PersistWallet), replacing - // the duplicated persistWallet helper. The leaderboard Post stays - // hand-rolled below because postedPeak is seeded from the durable peak at - // join — so a returning player only posts on a NEW personal best, which - // ScoreKeeper.Record (always posts the first observed value) would not - // preserve. - sk *kit.ScoreKeeper } func newRoom(cfg kit.RoomConfig, svc kit.Services) *room { @@ -56,7 +60,6 @@ func newRoom(cfg kit.RoomConfig, svc kit.Services) *room { svc: svc, frame: kit.NewFrame(), patrons: map[string]*patron{}, - sk: kit.NewScoreKeeper(kit.OnImprove), } } @@ -70,9 +73,11 @@ func (rm *room) OnJoin(r kit.Room, p kit.Player) { rm.render(r) return } - bal, peak := seedWallet(r, p) - rm.patrons[p.AccountID] = &patron{p: p, balance: bal, peak: peak, postedPeak: peak, state: stateCounter} + pt := &patron{p: p, state: stateCounter} + rm.patrons[p.AccountID] = pt rm.order = append(rm.order, p.AccountID) + rm.refreshBalance(pt) + pt.postedPeak = pt.balance rm.render(r) } @@ -81,7 +86,9 @@ func (rm *room) OnLeave(r kit.Room, p kit.Player) { if pt == nil { return } - rm.persistWallet(r, p, pt.balance, pt.peak) + // A player who walks out mid-scratch has a Wager still open; book it as a + // loss so the escrow never leaks (rule 3). + rm.abandonStake(pt) delete(rm.patrons, p.AccountID) for i, id := range rm.order { if id == p.AccountID { @@ -173,6 +180,9 @@ func (rm *room) inputCard(r kit.Room, pt *patron, in kit.Input) { case kit.ActRight: pt.card.Move(1, 0) case kit.ActBack: + // Walking away from an unscratched (or half-scratched) card abandons a + // live Wager: settle it as a loss before dropping the card (rule 3). + rm.abandonStake(pt) pt.card = nil pt.state = stateStand } @@ -184,54 +194,145 @@ func (rm *room) inputResult(r kit.Room, pt *patron, in kit.Input) { list := ticketsAtPrice(standPrices[pt.standIdx]) rm.buy(r, pt, list[pt.ticketIdx]) case kit.ActBack: + // The stake was already settled by maybeSettle on the way into + // stateResult; abandonStake is a no-op here (staked == false) and only + // guards against any future path that reaches result unsettled. + rm.abandonStake(pt) pt.card = nil pt.state = stateStand } } -// buy deducts the ticket price and opens a fresh card. If the patron can't -// afford even a $1 ticket, it triggers the rebuy beat instead. +// refreshBalance caches the player's account-wide credits for the HUD. It is +// the single read point (called after each credits op, and on join) so the hot +// render path never touches the host. A nil/disabled economy flips the patron +// to the out-of-service view instead of trapping. +func (rm *room) refreshBalance(pt *patron) { + cr := rm.svc.Credits + if cr == nil { + pt.oos = true + return + } + bal, err := cr.Balance(pt.p) + if err != nil { + if errors.Is(err, kit.ErrEconomyDisabled) { + pt.oos = true + } + return + } + pt.oos = false + pt.balance = bal +} + +// abandonStake settles any open Wager as a loss (rule 3): a card left +// unresolved on leave/quit would otherwise leak its escrow. It is idempotent — +// once the stake is closed, staked is false and this is a no-op. +func (rm *room) abandonStake(pt *patron) { + if !pt.staked { + return + } + pt.staked = false + if cr := rm.svc.Credits; cr != nil { + _ = cr.Settle(pt.p, 0) + } + rm.refreshBalance(pt) +} + +// buy opens ONE Wager for the ticket price and deals a fresh card. If the host +// refuses the stake for lack of funds, it routes to the rebuy beat and does NOT +// open a card (rule 1: exactly one open stake per ticket). func (rm *room) buy(r kit.Room, pt *patron, t *Ticket) { - if pt.balance < t.Price { - if pt.balance < standPrices[0] { - pt.balance = rebuyAmount - pt.state = stateBust + pt.notice = "" + cr := rm.svc.Credits + if cr == nil { + pt.oos = true + return + } + if err := cr.Wager(pt.p, int64(t.Price)); err != nil { + switch { + case errors.Is(err, kit.ErrInsufficientCredits): + rm.rebuy(pt) + case errors.Is(err, kit.ErrEconomyDisabled): + pt.oos = true + default: + // denied/temporarily-unavailable: the bet did not happen; stay put. + pt.notice = "the till's jammed - try again in a sec" } return } - pt.balance -= t.Price + pt.staked = true pt.card = buildCard(t, r.Rand()) pt.lastWin = 0 pt.state = stateCard + rm.refreshBalance(pt) } -// maybeSettle credits the wallet and advances to the result once the card has -// resolved. +// maybeSettle closes the ticket's single open stake with the GROSS payout once +// the card resolves, then advances to the result. Win() is already the +// stake-inclusive gross drawn at purchase, so it is Settled as-is (clamped to +// the declared ceiling for display honesty — no honest jackpot reaches it). func (rm *room) maybeSettle(r kit.Room, pt *patron) { - if pt.card == nil || !pt.card.Resolved() { + if pt.card == nil || !pt.card.Resolved() || !pt.staked { return } - win := pt.card.Win() - pt.lastWin = win - pt.balance += win - if pt.balance > pt.peak { - pt.peak = pt.balance + t := ticketsAtPrice(standPrices[pt.standIdx])[pt.ticketIdx] + gross := int64(pt.card.Win()) + if lim := int64(t.Price) * maxPayoutMultiplier; gross > lim { + gross = lim + } + cr := rm.svc.Credits + if cr == nil { + pt.oos = true + return } - if win > 0 { - t := ticketsAtPrice(standPrices[pt.standIdx])[pt.ticketIdx] - if isBigWin(win, t.Price) { - rm.pushTicker(fmt.Sprintf("%s scored %s on %s!", pt.p.Handle, commaInt(win), t.Name)) + if err := cr.Settle(pt.p, gross); err != nil { + if errors.Is(err, kit.ErrEconomyDisabled) { + pt.oos = true } + // A denied/unavailable Settle leaves the stake open; keep staked=true so + // a later abandon/leave still books it. Do not advance. + return + } + pt.staked = false + pt.lastWin = int(gross) + rm.refreshBalance(pt) + if gross > 0 && isBigWin(int(gross), t.Price) { + rm.pushTicker(fmt.Sprintf("%s scored %s on %s!", pt.p.Handle, commaInt(int(gross)), t.Name)) } - if pt.peak > pt.postedPeak { - pt.postedPeak = pt.peak + // Leaderboard: peak account-wide credits, posted only on a new high. + if pt.balance > pt.postedPeak { + pt.postedPeak = pt.balance r.Post(kit.Result{Rankings: []kit.PlayerResult{{ - Player: pt.p, Metric: pt.peak, Status: kit.StatusFinished, + Player: pt.p, Metric: int(pt.balance), Status: kit.StatusFinished, }}}) } pt.state = stateResult } +// rebuy triggers the platform broke-relief Buyback and only enters the bust +// celebration on success. A refusal (still solvent, or the daily limit reached) +// surfaces as a notice — never retried (rule 5). +func (rm *room) rebuy(pt *patron) { + cr := rm.svc.Credits + if cr == nil { + pt.oos = true + return + } + bal, err := cr.Buyback(pt.p) + if err != nil { + if errors.Is(err, kit.ErrEconomyDisabled) { + pt.oos = true + return + } + // ErrInsufficientCredits: render it, do not retry. + pt.balance = bal + pt.notice = "no rebuy available right now" + return + } + pt.balance = bal + pt.state = stateBust +} + // isBigWin reports whether a win clears the room-wide announce threshold. func isBigWin(win, price int) bool { return win >= 500 && win >= 50*price } @@ -286,6 +387,10 @@ func (rm *room) render(r kit.Room) { } func (rm *room) compose(f *Frame, pt *patron) { + if pt.oos { + rm.drawOOS(f) + return + } switch pt.state { case stateCounter: rm.drawCounter(f, pt) @@ -296,10 +401,25 @@ func (rm *room) compose(f *Frame, pt *patron) { case stateBust: rm.drawBust(f, pt) } + if pt.notice != "" { + f.Text(20, 3, "⚠ "+pt.notice, stBust) + } +} + +// drawOOS is the out-of-service screen shown when the host has no credits +// economy (svc.Credits == nil) or it is switched off (ErrEconomyDisabled). +func (rm *room) drawOOS(f *Frame) { + f.Text(0, 1, "THE CORNER NEWSAGENT", stTitle) + ruleRow(f, 1) + box(f, 7, 18, 15, 61, stBust) + f.Text(9, 24, "SHOP CLOSED", stBust) + f.Text(11, 24, "the credits till is offline right now.", stDim) + f.Text(13, 24, "pop back in a little while.", stDim) + f.Text(23, 1, "[q] leave the shop", stHint) } func (rm *room) drawCounter(f *Frame, pt *patron) { - drawChrome(f, "THE CORNER NEWSAGENT", pt.balance, rm.tickerLine(), + drawChrome(f, "THE CORNER NEWSAGENT", int(pt.balance), rm.tickerLine(), "◂ ▸ choose a stand [ENTER] step up to it [q] leave the shop") f.Text(3, 3, "★ INSTANT SCRATCH-ITS ★", stTitle) for i, price := range standPrices { @@ -318,7 +438,7 @@ func (rm *room) drawCounter(f *Frame, pt *patron) { func (rm *room) drawStand(f *Frame, pt *patron) { price := standPrices[pt.standIdx] - drawChrome(f, fmt.Sprintf("$%d STAND · pick a ticket", price), pt.balance, rm.tickerLine(), + drawChrome(f, fmt.Sprintf("$%d STAND · pick a ticket", price), int(pt.balance), rm.tickerLine(), fmt.Sprintf("▲ ▼ choose ticket [ENTER] buy $%d [q] back to counter", price)) list := ticketsAtPrice(price) for i, t := range list { @@ -346,7 +466,7 @@ func (rm *room) drawCard(f *Frame, pt *patron) { } hint = "[ENTER] buy another [q] back to the stand" } - drawChrome(f, title, pt.balance, rm.tickerLine(), hint) + drawChrome(f, title, int(pt.balance), rm.tickerLine(), hint) pt.card.Render(f, 3) if pt.state == stateResult { if pt.lastWin > 0 { @@ -358,12 +478,12 @@ func (rm *room) drawCard(f *Frame, pt *patron) { } func (rm *room) drawBust(f *Frame, pt *patron) { - drawChrome(f, "THE CORNER NEWSAGENT", pt.balance, rm.tickerLine(), + drawChrome(f, "THE CORNER NEWSAGENT", int(pt.balance), rm.tickerLine(), "[ENTER] back to the counter - have another crack") box(f, 7, 18, 15, 61, stBust) f.Text(9, 24, "OUT OF CREDITS", stBust) - f.Text(11, 24, "the newsagent slides you a fresh twenty.", stDim) - f.Text(13, 24, fmt.Sprintf("✦ + %s CREDITS ✦", commaInt(rebuyAmount)), stWin) + f.Text(11, 24, "the newsagent slides you a rebuy on the house.", stDim) + f.Text(13, 24, fmt.Sprintf("✦ BACK TO %s CREDITS ✦", commaInt(int(pt.balance))), stWin) } // mechanicBlurb is the one-line stand description per mechanic. diff --git a/games/bcook/scratchies/room_test.go b/games/bcook/scratchies/room_test.go new file mode 100644 index 0000000..375af40 --- /dev/null +++ b/games/bcook/scratchies/room_test.go @@ -0,0 +1,255 @@ +package main + +import ( + "testing" + + kit "github.com/shellcade/kit/v2" + "github.com/shellcade/kit/v2/kittest" +) + +// stubCard is a Card whose outcome is fixed, so credits-path tests don't have +// to fish a rare jackpot out of the seeded RNG. The ticket's outcome is drawn +// at purchase anyway; here we just pin what Win() reports. +type stubCard struct{ win int } + +func (c *stubCard) Title() string { return "STUB" } +func (c *stubCard) Prompt() string { return "" } +func (c *stubCard) Move(dx, dy int) {} +func (c *stubCard) Scratch() bool { return true } +func (c *stubCard) ScratchAll() {} +func (c *stubCard) Resolved() bool { return true } +func (c *stubCard) Win() int { return c.win } +func (c *stubCard) Render(f *Frame, top int) {} + +// newTestRoom spins up a one-player casino room wired to the kittest credits +// double, with the payout ceiling mirroring the declared MaxPayoutMultiplier. +func newTestRoom(t *testing.T) (*room, *kittest.Room, kit.Player) { + t.Helper() + r := kittest.NewRoom(kittest.Player("p1")) + r.CreditsSeed = 1000 + r.CreditsMaxPayoutMultiplier = maxPayoutMultiplier + h := Game{}.NewRoom(r.Config(), r.Services()) + rm := h.(*room) + rm.OnStart(r) + rm.OnJoin(r, r.Players[0]) + return rm, r, r.Players[0] +} + +// standAndIndex points a patron's cursor at the ticket with the given slug and +// returns its ticket pointer, so buy() computes the right stake. +func aimAt(pt *patron, slug string) *Ticket { + for si, price := range standPrices { + list := ticketsAtPrice(price) + for ti, tk := range list { + if tk.Slug == slug { + pt.standIdx, pt.ticketIdx = si, ti + return tk + } + } + } + return nil +} + +// TestWagerEscrowsAndSettleWins checks the full happy path: buy escrows exactly +// the ticket price, and a winning Settle credits the gross onto the balance. +func TestWagerEscrowsAndSettleWins(t *testing.T) { + rm, r, p := newTestRoom(t) + pt := rm.patrons[p.AccountID] + + tk := aimAt(pt, "lucky-7s") // $1 ticket + rm.buy(r, pt, tk) + if got := r.Credits[p.AccountID]; got != 999 { + t.Fatalf("after $1 wager balance = %d, want 999", got) + } + if r.CreditsStakes[p.AccountID] != 1 { + t.Fatalf("open stake = %d, want 1", r.CreditsStakes[p.AccountID]) + } + if !pt.staked { + t.Fatal("patron.staked should be true after a wager") + } + + pt.card = &stubCard{win: 5} // a $5 gross on the $1 ticket + rm.maybeSettle(r, pt) + if got := r.Credits[p.AccountID]; got != 1004 { + t.Fatalf("after settle balance = %d, want 1004 (999+5)", got) + } + if len(r.CreditsStakes) != 0 { + t.Fatalf("stake should be cleared after settle, got %v", r.CreditsStakes) + } + if pt.staked { + t.Fatal("patron.staked should be false after settle") + } + if pt.state != stateResult { + t.Fatalf("state = %d, want stateResult", pt.state) + } +} + +// TestSettleLossBooksZero checks a losing card settles 0 (stake forfeited). +func TestSettleLossBooksZero(t *testing.T) { + rm, r, p := newTestRoom(t) + pt := rm.patrons[p.AccountID] + tk := aimAt(pt, "gold-rush") // $2 + rm.buy(r, pt, tk) + pt.card = &stubCard{win: 0} + rm.maybeSettle(r, pt) + if got := r.Credits[p.AccountID]; got != 998 { + t.Fatalf("loss balance = %d, want 998", got) + } + if len(r.CreditsStakes) != 0 { + t.Fatalf("stake not cleared on loss: %v", r.CreditsStakes) + } +} + +// TestTopPrizeNotClamped is the guardrail: Cash Explosion's headline 300,000 on +// a $10 ticket is exactly 30,000x — the declared ceiling — so it must settle in +// full, un-clamped. +func TestTopPrizeNotClamped(t *testing.T) { + rm, r, p := newTestRoom(t) + pt := rm.patrons[p.AccountID] + tk := aimAt(pt, "cash-explosion") // $10, top 300,000 + if top := topPrize(tk); top != 300000 { + t.Fatalf("cash-explosion top prize = %d, want 300000", top) + } + rm.buy(r, pt, tk) + pt.card = &stubCard{win: 300000} + rm.maybeSettle(r, pt) + if pt.lastWin != 300000 { + t.Fatalf("lastWin = %d, want 300000 (unclamped)", pt.lastWin) + } + if got := r.Credits[p.AccountID]; got != 300990 { + t.Fatalf("balance = %d, want 300990 (990 + 300000)", got) + } +} + +// TestOverCeilingClamped is the defensive companion: a payout above stake x +// multiplier is clamped to the ceiling before Settle, so the UI never shows a +// number the host won't pay. +func TestOverCeilingClamped(t *testing.T) { + rm, r, p := newTestRoom(t) + pt := rm.patrons[p.AccountID] + tk := aimAt(pt, "cash-explosion") // $10 -> ceiling 300,000 + rm.buy(r, pt, tk) + pt.card = &stubCard{win: 400000} // impossible, but proves the clamp + rm.maybeSettle(r, pt) + if pt.lastWin != 300000 { + t.Fatalf("clamped lastWin = %d, want 300000", pt.lastWin) + } + if got := r.Credits[p.AccountID]; got != 300990 { + t.Fatalf("balance = %d, want 300990 (clamped)", got) + } +} + +// TestAbandonMidCardSettles is the escrow-leak guard: hitting q on an +// unresolved card after a Wager must Settle(0), not silently drop the stake. +func TestAbandonMidCardSettles(t *testing.T) { + rm, r, p := newTestRoom(t) + pt := rm.patrons[p.AccountID] + + // Drive it like a real player: Enter to the counter's $1 stand, Enter to buy. + rm.OnInput(r, p, kit.Input{Kind: kit.InputKey, Key: kit.KeyEnter}) // stand + rm.OnInput(r, p, kit.Input{Kind: kit.InputKey, Key: kit.KeyEnter}) // buy + if pt.state != stateCard || !pt.staked { + t.Fatalf("expected a staked card, state=%d staked=%v", pt.state, pt.staked) + } + if r.Credits[p.AccountID] != 999 { + t.Fatalf("post-buy balance = %d, want 999", r.Credits[p.AccountID]) + } + // Walk away with q while the card is still latex. + rm.OnInput(r, p, kit.Input{Kind: kit.InputRune, Rune: 'q'}) + if len(r.CreditsStakes) != 0 { + t.Fatalf("abandon leaked escrow: stakes=%v", r.CreditsStakes) + } + if r.Credits[p.AccountID] != 999 { + t.Fatalf("abandon balance = %d, want 999 (bet booked as loss)", r.Credits[p.AccountID]) + } + if pt.staked { + t.Fatal("staked should be false after abandon") + } +} + +// TestLeaveMidCardSettles covers the same leak on a hard disconnect. +func TestLeaveMidCardSettles(t *testing.T) { + rm, r, p := newTestRoom(t) + pt := rm.patrons[p.AccountID] + tk := aimAt(pt, "lucky-7s") + rm.buy(r, pt, tk) + if r.Credits[p.AccountID] != 999 { + t.Fatalf("post-buy balance = %d", r.Credits[p.AccountID]) + } + rm.OnLeave(r, p) + if len(r.CreditsStakes) != 0 { + t.Fatalf("leave leaked escrow: stakes=%v", r.CreditsStakes) + } + if r.Credits[p.AccountID] != 999 { + t.Fatalf("leave balance = %d, want 999", r.Credits[p.AccountID]) + } +} + +// TestRebuyWhenBroke checks that trying to buy with no money triggers Buyback +// and enters the bust screen only on success. +func TestRebuyWhenBroke(t *testing.T) { + r := kittest.NewRoom(kittest.Player("p1")) + r.Credits = map[string]int64{"p1": 0} // broke: below the buyback floor + r.CreditsMaxPayoutMultiplier = maxPayoutMultiplier + r.CreditsBuybackFloor = 100 + r.CreditsBuybackAmount = 1000 + h := Game{}.NewRoom(r.Config(), r.Services()) + rm := h.(*room) + rm.OnStart(r) + rm.OnJoin(r, r.Players[0]) + p := r.Players[0] + pt := rm.patrons[p.AccountID] + + tk := aimAt(pt, "lucky-7s") + rm.buy(r, pt, tk) // can't afford -> rebuy path + if pt.state != stateBust { + t.Fatalf("state = %d, want stateBust after successful rebuy", pt.state) + } + if r.Credits[p.AccountID] != 1000 { + t.Fatalf("rebuy balance = %d, want 1000", r.Credits[p.AccountID]) + } + if r.CreditsRebuys[p.AccountID] != 1 { + t.Fatalf("rebuy count = %d, want 1", r.CreditsRebuys[p.AccountID]) + } + if pt.staked { + t.Fatal("a refused buy must not leave a stake open") + } +} + +// TestLeaderboardPostsPeak checks a new balance high is posted to the board. +func TestLeaderboardPostsPeak(t *testing.T) { + rm, r, p := newTestRoom(t) + pt := rm.patrons[p.AccountID] + tk := aimAt(pt, "lucky-7s") + rm.buy(r, pt, tk) + pt.card = &stubCard{win: 500} // a real high over the 1000 seed + rm.maybeSettle(r, pt) + if len(r.Posted) == 0 { + t.Fatal("expected a leaderboard post on a new peak") + } + last := r.Posted[len(r.Posted)-1].Rankings[0] + if last.Metric != 1499 { // 999 + 500 + t.Fatalf("posted metric = %d, want 1499", last.Metric) + } +} + +// TestEconomyDisabledRendersOOS checks the game degrades to out-of-service +// rather than trapping when the host has no economy. +func TestEconomyDisabledRendersOOS(t *testing.T) { + r := kittest.NewRoom(kittest.Player("p1")) + r.CreditsDisabled = true + h := Game{}.NewRoom(r.Config(), r.Services()) + rm := h.(*room) + rm.OnStart(r) + rm.OnJoin(r, r.Players[0]) + pt := rm.patrons[r.Players[0].AccountID] + if !pt.oos { + t.Fatal("expected out-of-service when the economy is disabled") + } + // A buy attempt must not panic and must stay out-of-service. + tk := aimAt(pt, "lucky-7s") + rm.buy(r, pt, tk) + if pt.staked { + t.Fatal("no stake should open with the economy off") + } +}